Commit 2dbdaf10 authored by Constance Okoghenun's avatar Constance Okoghenun

Merge branch 'master' of https://gitlab.com/gitlab-org/gitlab-ee into ee-boards-bundle-refactor

parents 3a858f9f d60a6d2f
Please view this file on the master branch, on stable branches it's out of date. Please view this file on the master branch, on stable branches it's out of date.
## 10.5.2 (2018-02-25)
- No changes.
## 10.5.1 (2018-02-22) ## 10.5.1 (2018-02-22)
- No changes. - No changes.
......
...@@ -2,6 +2,27 @@ ...@@ -2,6 +2,27 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 10.5.2 (2018-02-25)
### Fixed (7 changes)
- Fix single digit value clipping for stacked progress bar. !17217
- Fix issue with cache key being empty when variable used as the key. !17260
- Enable Legacy Authorization by default on Cluster creations. !17302
- Allow branch names to be named the same as the sha it points to.
- Fix 500 error when loading an invalid upload URL.
- Don't attempt to update user tracked fields if database is in read-only.
- Prevent MR Widget error when no CI configured.
### Performance (5 changes)
- Improve query performance for snippets dashboard. !17088
- Only check LFS integrity for first ref in a push to avoid timeout. !17098
- Improve query performance of MembersFinder. !17190
- Increase feature flag cache TTL to one hour.
- Improve performance of searching for and autocompleting of users.
## 10.5.1 (2018-02-22) ## 10.5.1 (2018-02-22)
- No changes. - No changes.
......
<script>
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import FilteredSearchTokenizer from '../filtered_search_tokenizer'; import FilteredSearchTokenizer from '../filtered_search_tokenizer';
export default { export default {
name: 'RecentSearchesDropdownContent', name: 'RecentSearchesDropdownContent',
props: { props: {
items: { items: {
type: Array, type: Array,
...@@ -19,7 +19,6 @@ export default { ...@@ -19,7 +19,6 @@ export default {
required: true, required: true,
}, },
}, },
computed: { computed: {
processedItems() { processedItems() {
return this.items.map((item) => { return this.items.map((item) => {
...@@ -42,7 +41,6 @@ export default { ...@@ -42,7 +41,6 @@ export default {
return this.items.length > 0; return this.items.length > 0;
}, },
}, },
methods: { methods: {
onItemActivated(text) { onItemActivated(text) {
eventHub.$emit('recentSearchesItemSelected', text); eventHub.$emit('recentSearchesItemSelected', text);
...@@ -54,49 +52,53 @@ export default { ...@@ -54,49 +52,53 @@ export default {
eventHub.$emit('requestClearRecentSearches'); eventHub.$emit('requestClearRecentSearches');
}, },
}, },
};
template: ` </script>
<div> <template>
<div <div>
v-if="!isLocalStorageAvailable" <div
class="dropdown-info-note"> v-if="!isLocalStorageAvailable"
This feature requires local storage to be enabled class="dropdown-info-note">
</div> This feature requires local storage to be enabled
<ul v-else-if="hasItems"> </div>
<li <ul v-else-if="hasItems">
v-for="(item, index) in processedItems" <li
:key="index"> v-for="(item, index) in processedItems"
<button :key="`processed-items-${index}`"
type="button" >
class="filtered-search-history-dropdown-item" <button
@click="onItemActivated(item.text)"> type="button"
<span> class="filtered-search-history-dropdown-item"
<span @click="onItemActivated(item.text)">
v-for="(token, tokenIndex) in item.tokens" <span>
class="filtered-search-history-dropdown-token"> <span
<span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span> class="filtered-search-history-dropdown-token"
</span> v-for="(token, index) in item.tokens"
</span> :key="`dropdown-token-${index}`"
<span class="filtered-search-history-dropdown-search-token"> >
{{ item.searchToken }} <span class="name">{{ token.prefix }}</span>
<span class="value">{{ token.suffix }}</span>
</span> </span>
</button> </span>
</li> <span class="filtered-search-history-dropdown-search-token">
<li class="divider"></li> {{ item.searchToken }}
<li> </span>
<button </button>
type="button" </li>
class="filtered-search-history-clear-button" <li class="divider"></li>
@click="onRequestClearRecentSearches($event)"> <li>
Clear recent searches <button
</button> type="button"
</li> class="filtered-search-history-clear-button"
</ul> @click="onRequestClearRecentSearches($event)">
<div Clear recent searches
v-else </button>
class="dropdown-info-note"> </li>
You don't have any recent searches </ul>
</div> <div
v-else
class="dropdown-info-note">
You don't have any recent searches
</div> </div>
`, </div>
}; </template>
import Vue from 'vue'; import Vue from 'vue';
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content'; import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content.vue';
import eventHub from './event_hub'; import eventHub from './event_hub';
class RecentSearchesRoot { class RecentSearchesRoot {
...@@ -33,7 +33,7 @@ class RecentSearchesRoot { ...@@ -33,7 +33,7 @@ class RecentSearchesRoot {
this.vm = new Vue({ this.vm = new Vue({
el: this.wrapperElement, el: this.wrapperElement,
components: { components: {
'recent-searches-dropdown-content': RecentSearchesDropdownContent, RecentSearchesDropdownContent,
}, },
data() { return state; }, data() { return state; },
template: ` template: `
......
<script>
import { mapState } from 'vuex';
import { sprintf, __ } from '../../../locale';
import * as consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
export default {
components: {
RadioGroup,
},
computed: {
...mapState([
'currentBranchId',
]),
newMergeRequestHelpText() {
return sprintf(
__('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
{ branchName: this.currentBranchId },
);
},
commitToCurrentBranchText() {
return sprintf(
__('Commit to %{branchName} branch'),
{ branchName: `<strong>${this.currentBranchId}</strong>` },
false,
);
},
commitToNewBranchText() {
return sprintf(
__('Creates a new branch from %{branchName}'),
{ branchName: this.currentBranchId },
);
},
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
};
</script>
<template>
<div class="append-bottom-15 ide-commit-radios">
<radio-group
:value="$options.commitToCurrentBranch"
:checked="true"
>
<span
v-html="commitToCurrentBranchText"
>
</span>
</radio-group>
<radio-group
:value="$options.commitToNewBranch"
:label="__('Create a new branch')"
:show-input="true"
:help-text="commitToNewBranchText"
/>
<radio-group
:value="$options.commitToNewBranchMR"
:label="__('Create a new branch and merge request')"
:show-input="true"
:help-text="newMergeRequestHelpText"
/>
</div>
</template>
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
value: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: null,
},
checked: {
type: Boolean,
required: false,
default: false,
},
showInput: {
type: Boolean,
required: false,
default: false,
},
helpText: {
type: String,
required: false,
default: null,
},
},
computed: {
...mapState('commit', [
'commitAction',
]),
...mapGetters('commit', [
'newBranchName',
]),
},
methods: {
...mapActions('commit', [
'updateCommitAction',
'updateBranchName',
]),
},
};
</script>
<template>
<fieldset>
<label>
<input
type="radio"
name="commit-action"
:value="value"
@change="updateCommitAction($event.target.value)"
:checked="checked"
v-once
/>
<span class="prepend-left-10">
<template v-if="label">
{{ label }}
</template>
<slot v-else></slot>
<span
v-if="helpText"
v-tooltip
class="help-block inline"
:title="helpText"
>
<i
class="fa fa-question-circle"
aria-hidden="true"
>
</i>
</span>
</span>
</label>
<div
v-if="commitAction === value && showInput"
class="ide-commit-new-branch"
>
<input
type="text"
class="form-control"
:placeholder="newBranchName"
@input="updateBranchName($event.target.value)"
/>
</div>
</fieldset>
</template>
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
}, },
data() { data() {
return { return {
width: 290, width: 340,
}; };
}, },
computed: { computed: {
...@@ -120,7 +120,7 @@ ...@@ -120,7 +120,7 @@
<panel-resizer <panel-resizer
:size.sync="width" :size.sync="width"
:enabled="!rightPanelCollapsed" :enabled="!rightPanelCollapsed"
:start-size="290" :start-size="340"
:min-size="200" :min-size="200"
:max-size="maxSize" :max-size="maxSize"
@resize-start="resizingStarted" @resize-start="resizingStarted"
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import * as consts from '../stores/modules/commit/constants';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import icon from '../../vue_shared/components/icon.vue'; import icon from '../../vue_shared/components/icon.vue';
import modal from '../../vue_shared/components/modal.vue'; import modal from '../../vue_shared/components/modal.vue';
import commitFilesList from './commit_sidebar/list.vue'; import commitFilesList from './commit_sidebar/list.vue';
import Actions from './commit_sidebar/actions.vue';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
export default { export default {
components: { components: {
modal, modal,
icon, icon,
commitFilesList, commitFilesList,
Actions,
LoadingButton,
}, },
directives: { directives: {
tooltip, tooltip,
...@@ -24,14 +29,6 @@ export default { ...@@ -24,14 +29,6 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
showNewBranchModal: false,
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
};
},
computed: { computed: {
...mapState([ ...mapState([
'currentProjectId', 'currentProjectId',
...@@ -40,74 +37,40 @@ export default { ...@@ -40,74 +37,40 @@ export default {
'lastCommitMsg', 'lastCommitMsg',
'changedFiles', 'changedFiles',
]), ]),
commitButtonDisabled() { ...mapState('commit', [
return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length; 'commitMessage',
}, 'submitCommitLoading',
commitMessageCount() { ]),
return this.commitMessage.length; ...mapGetters('commit', [
}, 'commitButtonDisabled',
'discardDraftButtonDisabled',
'branchName',
]),
statusSvg() { statusSvg() {
return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
}, },
}, },
methods: { methods: {
...mapActions([ ...mapActions([
'checkCommitStatus',
'commitChanges',
'getTreeData', 'getTreeData',
'setPanelCollapsedStatus', 'setPanelCollapsedStatus',
]), ]),
makeCommit(newBranch = false) { ...mapActions('commit', [
const createNewBranch = newBranch || this.startNewMR; 'updateCommitMessage',
'discardDraft',
const payload = { 'commitChanges',
branch: createNewBranch ? 'updateCommitAction',
`${this.currentBranchId}-${new Date().getTime().toString()}` : ]),
this.currentBranchId,
commit_message: this.commitMessage,
actions: this.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.content,
encoding: f.base64 ? 'base64' : 'text',
})),
start_branch: createNewBranch ? this.currentBranchId : undefined,
};
this.showNewBranchModal = false;
this.submitCommitsLoading = true;
this.commitChanges({ payload, newMr: this.startNewMR })
.then(() => {
this.submitCommitsLoading = false;
this.commitMessage = '';
this.startNewMR = false;
})
.catch(() => {
this.submitCommitsLoading = false;
});
},
tryCommit() {
this.submitCommitsLoading = true;
this.checkCommitStatus()
.then((branchChanged) => {
if (branchChanged) {
this.showNewBranchModal = true;
} else {
this.makeCommit();
}
})
.catch(() => {
this.submitCommitsLoading = false;
});
},
toggleCollapsed() { toggleCollapsed() {
this.setPanelCollapsedStatus({ this.setPanelCollapsedStatus({
side: 'right', side: 'right',
collapsed: !this.rightPanelCollapsed, collapsed: !this.rightPanelCollapsed,
}); });
}, },
forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH)
.then(() => this.commitChanges());
},
}, },
}; };
</script> </script>
...@@ -120,15 +83,17 @@ export default { ...@@ -120,15 +83,17 @@ export default {
}" }"
> >
<modal <modal
v-if="showNewBranchModal" id="ide-create-branch-modal"
:primary-button-label="__('Create new branch')" :primary-button-label="__('Create new branch')"
kind="primary" kind="success"
:title="__('Branch has changed')" :title="__('Branch has changed')"
:text="__(`This branch has changed since @submit="forceCreateNewBranch"
you started editing. Would you like to create a new branch?`)" >
@cancel="showNewBranchModal = false" <template slot="body">
@submit="makeCommit(true)" {{ __(`This branch has changed since you started editing.
/> Would you like to create a new branch?`) }}
</template>
</modal>
<commit-files-list <commit-files-list
title="Staged" title="Staged"
:file-list="changedFiles" :file-list="changedFiles"
...@@ -140,51 +105,36 @@ you started editing. Would you like to create a new branch?`)" ...@@ -140,51 +105,36 @@ you started editing. Would you like to create a new branch?`)"
> >
<form <form
class="form-horizontal multi-file-commit-form" class="form-horizontal multi-file-commit-form"
@submit.prevent="tryCommit" @submit.prevent.stop="commitChanges"
v-if="!rightPanelCollapsed" v-if="!rightPanelCollapsed"
> >
<div class="multi-file-commit-fieldset"> <div class="multi-file-commit-fieldset">
<textarea <textarea
class="form-control multi-file-commit-message ref-name" class="form-control multi-file-commit-message"
name="commit-message" name="commit-message"
v-model="commitMessage" :value="commitMessage"
placeholder="Commit message" :placeholder="__('Write a commit message...')"
@input="updateCommitMessage($event.target.value)"
> >
</textarea> </textarea>
</div> </div>
<div class="multi-file-commit-fieldset"> <div class="clearfix prepend-top-15">
<label <actions />
v-tooltip <loading-button
title="Create a new merge request with these changes" :loading="submitCommitLoading"
data-container="body"
data-placement="top"
>
<input
type="checkbox"
v-model="startNewMR"
/>
{{ __('Merge Request') }}
</label>
<button
type="submit"
:disabled="commitButtonDisabled" :disabled="commitButtonDisabled"
class="btn btn-default btn-sm append-right-10 prepend-left-10" container-class="btn btn-success btn-sm pull-left"
:class="{ disabled: submitCommitsLoading }" :label="__('Commit')"
@click="commitChanges"
/>
<button
v-if="!discardDraftButtonDisabled"
type="button"
class="btn btn-default btn-sm pull-right"
@click="discardDraft"
> >
<i {{ __('Discard draft') }}
v-if="submitCommitsLoading"
class="js-commit-loading-icon fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="loading"
>
</i>
{{ __('Commit') }}
</button> </button>
<div
class="multi-file-commit-message-count"
>
{{ commitMessageCount }}
</div>
</div> </div>
</form> </form>
</template> </template>
...@@ -216,8 +166,7 @@ you started editing. Would you like to create a new branch?`)" ...@@ -216,8 +166,7 @@ you started editing. Would you like to create a new branch?`)"
<h4> <h4>
{{ __('All changes are committed') }} {{ __('All changes are committed') }}
</h4> </h4>
<p> <p v-html="lastCommitMsg">
{{ lastCommitMsg }}
</p> </p>
</div> </div>
</div> </div>
......
import Vue from 'vue';
export default new Vue();
/* global monaco */ /* global monaco */
import Disposable from './disposable'; import Disposable from './disposable';
import eventHub from '../../eventhub';
export default class Model { export default class Model {
constructor(monaco, file) { constructor(monaco, file) {
...@@ -22,6 +23,9 @@ export default class Model { ...@@ -22,6 +23,9 @@ export default class Model {
); );
this.events = new Map(); this.events = new Map();
this.updateContent = this.updateContent.bind(this);
eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent);
} }
get url() { get url() {
...@@ -61,8 +65,15 @@ export default class Model { ...@@ -61,8 +65,15 @@ export default class Model {
); );
} }
updateContent(content) {
this.getModel().setValue(content);
this.getOriginalModel().setValue(content);
}
dispose() { dispose() {
this.disposable.dispose(); this.disposable.dispose();
this.events.clear(); this.events.clear();
eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent);
} }
} }
import Vue from 'vue'; import Vue from 'vue';
import { visitUrl } from '../../lib/utils/url_utility'; import { visitUrl } from '../../lib/utils/url_utility';
import flash from '../../flash';
import service from '../services';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { stripHtml } from '../../lib/utils/text_utility';
export const redirectToUrl = (_, url) => visitUrl(url); export const redirectToUrl = (_, url) => visitUrl(url);
...@@ -51,84 +48,6 @@ export const setResizingStatus = ({ commit }, resizing) => { ...@@ -51,84 +48,6 @@ export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing); commit(types.SET_RESIZING_STATUS, resizing);
}; };
export const checkCommitStatus = ({ state }) =>
service
.getBranchData(state.currentProjectId, state.currentBranchId)
.then(({ data }) => {
const { id } = data.commit;
const selectedBranch =
state.projects[state.currentProjectId].branches[state.currentBranchId];
if (selectedBranch.workingReference !== id) {
return true;
}
return false;
})
.catch(() => flash('Error checking branch data. Please try again.', 'alert', document, null, false, true));
export const commitChanges = (
{ commit, state, dispatch },
{ payload, newMr },
) =>
service
.commit(state.currentProjectId, payload)
.then(({ data }) => {
const { branch } = payload;
if (!data.short_id) {
flash(data.message, 'alert', document, null, false, true);
return;
}
const selectedProject = state.projects[state.currentProjectId];
const lastCommit = {
commit_path: `${selectedProject.web_url}/commit/${data.id}`,
commit: {
message: data.message,
authored_date: data.committed_date,
},
};
let commitMsg = `Your changes have been committed. Commit ${data.short_id}`;
if (data.stats) {
commitMsg += ` with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`;
}
commit(types.SET_LAST_COMMIT_MSG, commitMsg);
if (newMr) {
dispatch('discardAllChanges');
dispatch(
'redirectToUrl',
`${selectedProject.web_url}/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`,
);
} else {
commit(types.SET_BRANCH_WORKING_REFERENCE, {
projectId: state.currentProjectId,
branchId: state.currentBranchId,
reference: data.id,
});
state.changedFiles.forEach((entry) => {
commit(types.SET_LAST_COMMIT_DATA, {
entry,
lastCommit,
});
});
dispatch('discardAllChanges');
window.scrollTo(0, 0);
}
})
.catch((err) => {
let errMsg = 'Error committing changes. Please try again.';
if (err.response.data && err.response.data.message) {
errMsg += ` (${stripHtml(err.response.data.message)})`;
}
flash(errMsg, 'alert', document, null, false, true);
window.dispatchEvent(new Event('resize'));
});
export const createTempEntry = ( export const createTempEntry = (
{ state, dispatch }, { state, dispatch },
{ projectId, branchId, parent, name, type, content = '', base64 = false }, { projectId, branchId, parent, name, type, content = '', base64 = false },
......
...@@ -4,6 +4,7 @@ import state from './state'; ...@@ -4,6 +4,7 @@ import state from './state';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters'; import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import commitModule from './modules/commit';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -12,4 +13,7 @@ export default new Vuex.Store({ ...@@ -12,4 +13,7 @@ export default new Vuex.Store({
actions, actions,
mutations, mutations,
getters, getters,
modules: {
commit: commitModule,
},
}); });
import { sprintf, __ } from '../../../../locale';
import * as types from './mutation_types';
import * as consts from './constants';
import * as rootTypes from '../../mutation_types';
import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import router from '../../../ide_router';
import service from '../../../services';
import flash from '../../../../flash';
import { stripHtml } from '../../../../lib/utils/text_utility';
import eventHub from '../../../eventhub';
export const updateCommitMessage = ({ commit }, message) => {
commit(types.UPDATE_COMMIT_MESSAGE, message);
};
export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
export const updateCommitAction = ({ commit }, commitAction) => {
commit(types.UPDATE_COMMIT_ACTION, commitAction);
};
export const updateBranchName = ({ commit }, branchName) => {
commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
};
export const setLastCommitMessage = ({ rootState, commit }, data) => {
const currentProject = rootState.projects[rootState.currentProjectId];
const commitStats = data.stats ?
sprintf(
__('with %{additions} additions, %{deletions} deletions.'),
{ additions: data.stats.additions, deletions: data.stats.deletions },
)
: '';
const commitMsg = sprintf(
__('Your changes have been committed. Commit %{commitId} %{commitStats}'),
{
commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${data.short_id}</a>`,
commitStats,
},
false,
);
commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
};
export const checkCommitStatus = ({ rootState }) =>
service
.getBranchData(rootState.currentProjectId, rootState.currentBranchId)
.then(({ data }) => {
const { id } = data.commit;
const selectedBranch =
rootState.projects[rootState.currentProjectId].branches[rootState.currentBranchId];
if (selectedBranch.workingReference !== id) {
return true;
}
return false;
})
.catch(() => flash(__('Error checking branch data. Please try again.'), 'alert', document, null, false, true));
export const updateFilesAfterCommit = (
{ commit, dispatch, state, rootState, rootGetters },
{ data, branch },
) => {
const selectedProject = rootState.projects[rootState.currentProjectId];
const lastCommit = {
commit_path: `${selectedProject.web_url}/commit/${data.id}`,
commit: {
id: data.id,
message: data.message,
authored_date: data.committed_date,
author_name: data.committer_name,
},
};
commit(rootTypes.SET_BRANCH_WORKING_REFERENCE, {
projectId: rootState.currentProjectId,
branchId: rootState.currentBranchId,
reference: data.id,
}, { root: true });
rootState.changedFiles.forEach((entry) => {
commit(rootTypes.SET_LAST_COMMIT_DATA, {
entry,
lastCommit,
}, { root: true });
commit(rootTypes.SET_FILE_RAW_DATA, {
file: entry,
raw: entry.content,
}, { root: true });
eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.raw);
});
commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
router.push(`/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`);
}
dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
};
export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => {
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
const payload = createCommitPayload(getters.branchName, newBranch, state, rootState);
const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus');
commit(types.UPDATE_LOADING, true);
return getCommitStatus.then(branchChanged => new Promise((resolve) => {
if (branchChanged) {
// show the modal with a Bootstrap call
$('#ide-create-branch-modal').modal('show');
} else {
resolve();
}
}))
.then(() => service.commit(rootState.currentProjectId, payload))
.then(({ data }) => {
commit(types.UPDATE_LOADING, false);
if (!data.short_id) {
flash(data.message, 'alert', document, null, false, true);
return;
}
dispatch('setLastCommitMessage', data);
if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
dispatch(
'redirectToUrl',
createNewMergeRequestUrl(
rootState.projects[rootState.currentProjectId].web_url,
getters.branchName,
rootState.currentBranchId,
),
{ root: true },
);
} else {
dispatch('updateFilesAfterCommit', { data, branch: getters.branchName });
}
})
.catch((err) => {
let errMsg = __('Error committing changes. Please try again.');
if (err.response.data && err.response.data.message) {
errMsg += ` (${stripHtml(err.response.data.message)})`;
}
flash(errMsg, 'alert', document, null, false, true);
window.dispatchEvent(new Event('resize'));
commit(types.UPDATE_LOADING, false);
});
};
export const COMMIT_TO_CURRENT_BRANCH = '1';
export const COMMIT_TO_NEW_BRANCH = '2';
export const COMMIT_TO_NEW_BRANCH_MR = '3';
import * as consts from './constants';
export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
export const commitButtonDisabled = (state, getters, rootState) =>
getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
export const newBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
export const branchName = (state, getters, rootState) => {
if (
state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
) {
if (state.newBranchName === '') {
return getters.newBranchName;
}
return state.newBranchName;
}
return rootState.currentBranchId;
};
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default {
namespaced: true,
state: state(),
mutations,
actions,
getters,
};
export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
export const UPDATE_LOADING = 'UPDATE_LOADING';
import * as types from './mutation_types';
export default {
[types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
Object.assign(state, {
commitMessage,
});
},
[types.UPDATE_COMMIT_ACTION](state, commitAction) {
Object.assign(state, {
commitAction,
});
},
[types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
Object.assign(state, {
newBranchName,
});
},
[types.UPDATE_LOADING](state, submitCommitLoading) {
Object.assign(state, {
submitCommitLoading,
});
},
};
export default () => ({
commitMessage: '',
commitAction: '1',
newBranchName: '',
submitCommitLoading: false,
});
...@@ -187,3 +187,18 @@ export const createOrMergeEntry = ({ projectId, ...@@ -187,3 +187,18 @@ export const createOrMergeEntry = ({ projectId,
level, level,
}); });
}; };
export const createCommitPayload = (branch, newBranch, state, rootState) => ({
branch,
commit_message: state.commitMessage,
actions: rootState.changedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
content: f.content,
encoding: f.base64 ? 'base64' : 'text',
})),
start_branch: newBranch ? rootState.currentBranchId : undefined,
});
export const createNewMergeRequestUrl = (projectUrl, source, target) =>
`${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
...@@ -231,7 +231,8 @@ export default { ...@@ -231,7 +231,8 @@ export default {
@click="handleMergeButtonClick()" @click="handleMergeButtonClick()"
:disabled="isMergeButtonDisabled" :disabled="isMergeButtonDisabled"
:class="mergeButtonClass" :class="mergeButtonClass"
type="button"> type="button"
class="qa-merge-button">
<i <i
v-if="isMakingRequest" v-if="isMakingRequest"
class="fa fa-spinner fa-spin" class="fa fa-spinner fa-spin"
......
...@@ -111,7 +111,7 @@ js-toggle-container accept-action media space-children" ...@@ -111,7 +111,7 @@ js-toggle-container accept-action media space-children"
> >
<button <button
type="button" type="button"
class="btn btn-sm btn-reopen btn-success" class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
:disabled="isMakingRequest" :disabled="isMakingRequest"
@click="rebase" @click="rebase"
> >
......
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
.ide-view { .ide-view {
display: flex; display: flex;
height: calc(100vh - #{$header-height}); height: calc(100vh - #{$header-height});
margin-top: 40px;
color: $almost-black; color: $almost-black;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
...@@ -265,7 +266,7 @@ table.table tr td.multi-file-table-name { ...@@ -265,7 +266,7 @@ table.table tr td.multi-file-table-name {
display: flex; display: flex;
position: relative; position: relative;
flex-direction: column; flex-direction: column;
width: 290px; width: 340px;
padding: 0; padding: 0;
background-color: $gray-light; background-color: $gray-light;
padding-right: 3px; padding-right: 3px;
...@@ -465,20 +466,14 @@ table.table tr td.multi-file-table-name { ...@@ -465,20 +466,14 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-form { .multi-file-commit-form {
padding: $gl-padding; padding: $gl-padding;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
}
.multi-file-commit-fieldset {
display: flex;
align-items: center;
padding-bottom: 12px;
.btn { .btn {
flex: 1; font-size: $gl-font-size;
} }
} }
.multi-file-commit-message.form-control { .multi-file-commit-message.form-control {
height: 80px; height: 160px;
resize: none; resize: none;
} }
...@@ -639,3 +634,18 @@ table.table tr td.multi-file-table-name { ...@@ -639,3 +634,18 @@ table.table tr td.multi-file-table-name {
left: 0; left: 0;
} }
} }
.ide-commit-radios {
label {
font-weight: normal;
}
.help-block {
margin-top: 0;
line-height: 0;
}
}
.ide-commit-new-branch {
margin-left: 25px;
}
...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :require_pages_enabled! before_action :require_pages_enabled!
before_action :authorize_update_pages!, except: [:show] before_action :authorize_update_pages!, except: [:show]
before_action :domain, only: [:show, :destroy] before_action :domain, only: [:show, :destroy, :verify]
def show def show
end end
...@@ -12,11 +12,23 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -12,11 +12,23 @@ class Projects::PagesDomainsController < Projects::ApplicationController
@domain = @project.pages_domains.new @domain = @project.pages_domains.new
end end
def verify
result = VerifyPagesDomainService.new(@domain).execute
if result[:status] == :success
flash[:notice] = 'Successfully verified domain ownership'
else
flash[:alert] = 'Failed to verify domain ownership'
end
redirect_to project_pages_domain_path(@project, @domain)
end
def create def create
@domain = @project.pages_domains.create(pages_domain_params) @domain = @project.pages_domains.create(pages_domain_params)
if @domain.valid? if @domain.valid?
redirect_to project_pages_path(@project) redirect_to project_pages_domain_path(@project, @domain)
else else
render 'new' render 'new'
end end
...@@ -46,6 +58,6 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -46,6 +58,6 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end end
def domain def domain
@domain ||= @project.pages_domains.find_by(domain: params[:id].to_s) @domain ||= @project.pages_domains.find_by!(domain: params[:id].to_s)
end end
end end
...@@ -104,8 +104,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -104,8 +104,7 @@ class ProjectsController < Projects::ApplicationController
end end
def show def show
# If we're importing while we do have a repository, we're simply updating the mirror. if @project.import_in_progress?
if @project.import_in_progress? && !@project.updating_mirror?
redirect_to project_import_path(@project) redirect_to project_import_path(@project)
return return
end end
......
...@@ -200,6 +200,7 @@ module ApplicationSettingsHelper ...@@ -200,6 +200,7 @@ module ApplicationSettingsHelper
:metrics_port, :metrics_port,
:metrics_sample_interval, :metrics_sample_interval,
:metrics_timeout, :metrics_timeout,
:pages_domain_verification_enabled,
:password_authentication_enabled_for_web, :password_authentication_enabled_for_web,
:password_authentication_enabled_for_git, :password_authentication_enabled_for_git,
:performance_bar_allowed_group_id, :performance_bar_allowed_group_id,
......
...@@ -237,7 +237,7 @@ module IssuablesHelper ...@@ -237,7 +237,7 @@ module IssuablesHelper
data.merge!(updated_at_by(issuable)) data.merge!(updated_at_by(issuable))
data.to_json data
end end
def updated_at_by(issuable) def updated_at_by(issuable)
......
module Emails
module PagesDomains
def pages_domain_enabled_email(domain, recipient)
@domain = domain
@project = domain.project
mail(
to: recipient.notification_email,
subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
)
end
def pages_domain_disabled_email(domain, recipient)
@domain = domain
@project = domain.project
mail(
to: recipient.notification_email,
subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
)
end
def pages_domain_verification_succeeded_email(domain, recipient)
@domain = domain
@project = domain.project
mail(
to: recipient.notification_email,
subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
)
end
def pages_domain_verification_failed_email(domain, recipient)
@domain = domain
@project = domain.project
mail(
to: recipient.notification_email,
subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
)
end
end
end
...@@ -7,6 +7,7 @@ class Notify < BaseMailer ...@@ -7,6 +7,7 @@ class Notify < BaseMailer
include Emails::CsvExport include Emails::CsvExport
include Emails::MergeRequests include Emails::MergeRequests
include Emails::Notes include Emails::Notes
include Emails::PagesDomains
include Emails::Projects include Emails::Projects
include Emails::Profile include Emails::Profile
include Emails::Pipelines include Emails::Pipelines
......
class PagesDomain < ActiveRecord::Base class PagesDomain < ActiveRecord::Base
VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
VERIFICATION_THRESHOLD = 3.days.freeze
belongs_to :project belongs_to :project
validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false } validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, certificate: true, allow_nil: true, allow_blank: true validates :certificate, certificate: true, allow_nil: true, allow_blank: true
validates :key, certificate_key: true, allow_nil: true, allow_blank: true validates :key, certificate_key: true, allow_nil: true, allow_blank: true
validates :verification_code, presence: true, allow_blank: false
validate :validate_pages_domain validate :validate_pages_domain
validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? }
...@@ -16,10 +20,32 @@ class PagesDomain < ActiveRecord::Base ...@@ -16,10 +20,32 @@ class PagesDomain < ActiveRecord::Base
key: Gitlab::Application.secrets.db_key_base, key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
after_initialize :set_verification_code
after_create :update_daemon after_create :update_daemon
after_save :update_daemon after_update :update_daemon, if: :pages_config_changed?
after_destroy :update_daemon after_destroy :update_daemon
scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
scope :needs_verification, -> do
verified_at = arel_table[:verified_at]
enabled_until = arel_table[:enabled_until]
threshold = Time.now + VERIFICATION_THRESHOLD
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
def verified?
!!verified_at
end
def unverified?
!verified?
end
def enabled?
!Gitlab::CurrentSettings.pages_domain_verification_enabled? || enabled_until.present?
end
def to_param def to_param
domain domain
end end
...@@ -84,12 +110,49 @@ class PagesDomain < ActiveRecord::Base ...@@ -84,12 +110,49 @@ class PagesDomain < ActiveRecord::Base
@certificate_text ||= x509.try(:to_text) @certificate_text ||= x509.try(:to_text)
end end
# Verification codes may be TXT records for domain or verification_domain, to
# support the use of CNAME records on domain.
def verification_domain
return unless domain.present?
"_#{VERIFICATION_KEY}.#{domain}"
end
def keyed_verification_code
return unless verification_code.present?
"#{VERIFICATION_KEY}=#{verification_code}"
end
private private
def set_verification_code
return if self.verification_code.present?
self.verification_code = SecureRandom.hex(16)
end
def update_daemon def update_daemon
::Projects::UpdatePagesConfigurationService.new(project).execute ::Projects::UpdatePagesConfigurationService.new(project).execute
end end
def pages_config_changed?
project_id_changed? ||
domain_changed? ||
certificate_changed? ||
key_changed? ||
became_enabled? ||
became_disabled?
end
def became_enabled?
enabled_until.present? && !enabled_until_was.present?
end
def became_disabled?
!enabled_until.present? && enabled_until_was.present?
end
def validate_matching_key def validate_matching_key
unless has_matching_key? unless has_matching_key?
self.errors.add(:key, "doesn't match the certificate") self.errors.add(:key, "doesn't match the certificate")
......
...@@ -4,13 +4,33 @@ module Ci ...@@ -4,13 +4,33 @@ module Ci
return if job.job_artifacts_trace return if job.job_artifacts_trace
job.trace.read do |stream| job.trace.read do |stream|
if stream.file? break unless stream.file?
job.create_job_artifacts_trace!(
project: job.project, clone_file!(stream.path, JobArtifactUploader.workhorse_upload_path) do |clone_path|
file_type: :trace, create_job_trace!(job, clone_path)
file: stream) FileUtils.rm(stream.path)
end end
end end
end end
private
def create_job_trace!(job, path)
File.open(path) do |stream|
job.create_job_artifacts_trace!(
project: job.project,
file_type: :trace,
file: stream)
end
end
def clone_file!(src_path, temp_dir)
FileUtils.mkdir_p(temp_dir)
Dir.mktmpdir('tmp-trace', temp_dir) do |dir_path|
temp_path = File.join(dir_path, "job.log")
FileUtils.copy(src_path, temp_path)
yield(temp_path)
end
end
end end
end end
...@@ -362,6 +362,30 @@ class NotificationService ...@@ -362,6 +362,30 @@ class NotificationService
end end
end end
def pages_domain_verification_succeeded(domain)
recipients_for_pages_domain(domain).each do |user|
mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later
end
end
def pages_domain_verification_failed(domain)
recipients_for_pages_domain(domain).each do |user|
mailer.pages_domain_verification_failed_email(domain, user).deliver_later
end
end
def pages_domain_enabled(domain)
recipients_for_pages_domain(domain).each do |user|
mailer.pages_domain_enabled_email(domain, user).deliver_later
end
end
def pages_domain_disabled(domain)
recipients_for_pages_domain(domain).each do |user|
mailer.pages_domain_disabled_email(domain, user).deliver_later
end
end
protected protected
def new_resource_email(target, method) def new_resource_email(target, method)
...@@ -480,6 +504,14 @@ class NotificationService ...@@ -480,6 +504,14 @@ class NotificationService
private private
def recipients_for_pages_domain(domain)
project = domain.project
return [] unless project
notifiable_users(project.team.masters, :watch, target: project)
end
def notifiable?(*args) def notifiable?(*args)
NotificationRecipientService.notifiable?(*args) NotificationRecipientService.notifiable?(*args)
end end
......
...@@ -50,16 +50,7 @@ module Projects ...@@ -50,16 +50,7 @@ module Projects
return [] unless noteable&.is_a?(Issuable) return [] unless noteable&.is_a?(Issuable)
opts = { QuickActions::InterpretService.new(project, current_user).available_commands(noteable)
project: project,
issuable: noteable,
current_user: current_user
}
QuickActions::InterpretService.command_definitions.map do |definition|
next unless definition.available?(opts)
definition.to_h(opts)
end.compact
end end
end end
end end
...@@ -23,7 +23,7 @@ module Projects ...@@ -23,7 +23,7 @@ module Projects
end end
def pages_domains_config def pages_domains_config
project.pages_domains.map do |domain| enabled_pages_domains.map do |domain|
{ {
domain: domain.domain, domain: domain.domain,
certificate: domain.certificate, certificate: domain.certificate,
...@@ -32,6 +32,14 @@ module Projects ...@@ -32,6 +32,14 @@ module Projects
end end
end end
def enabled_pages_domains
if Gitlab::CurrentSettings.pages_domain_verification_enabled?
project.pages_domains.enabled
else
project.pages_domains
end
end
def reload_daemon def reload_daemon
# GitLab Pages daemon constantly watches for modification time of `pages.path` # GitLab Pages daemon constantly watches for modification time of `pages.path`
# It reloads configuration when `pages.path` is modified # It reloads configuration when `pages.path` is modified
......
...@@ -8,6 +8,18 @@ module QuickActions ...@@ -8,6 +8,18 @@ module QuickActions
SHRUG = '¯\\_(ツ)_/¯'.freeze SHRUG = '¯\\_(ツ)_/¯'.freeze
TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze
# Takes an issuable and returns an array of all the available commands
# represented with .to_h
def available_commands(issuable)
@issuable = issuable
self.class.command_definitions.map do |definition|
next unless definition.available?(self)
definition.to_h(self)
end.compact
end
# Takes a text and interprets the commands that are extracted from it. # Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record. # Returns the content without commands, and hash of changes to be applied to a record.
def execute(content, issuable) def execute(content, issuable)
...@@ -16,8 +28,8 @@ module QuickActions ...@@ -16,8 +28,8 @@ module QuickActions
@issuable = issuable @issuable = issuable
@updates = {} @updates = {}
content, commands = extractor.extract_commands(content, context) content, commands = extractor.extract_commands(content)
extract_updates(commands, context) extract_updates(commands)
[content, @updates] [content, @updates]
end end
...@@ -29,8 +41,8 @@ module QuickActions ...@@ -29,8 +41,8 @@ module QuickActions
@issuable = issuable @issuable = issuable
content, commands = extractor.extract_commands(content, context) content, commands = extractor.extract_commands(content)
commands = explain_commands(commands, context) commands = explain_commands(commands)
[content, commands] [content, commands]
end end
...@@ -158,11 +170,11 @@ module QuickActions ...@@ -158,11 +170,11 @@ module QuickActions
params '%"milestone"' params '%"milestone"'
condition do condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) && current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.milestones.active.any? find_milestones(project, state: 'active').any?
end end
parse_params do |milestone_param| parse_params do |milestone_param|
extract_references(milestone_param, :milestone).first || extract_references(milestone_param, :milestone).first ||
project.milestones.find_by(title: milestone_param.strip) find_milestones(project, title: milestone_param.strip).first
end end
command :milestone do |milestone| command :milestone do |milestone|
@updates[:milestone_id] = milestone.id if milestone @updates[:milestone_id] = milestone.id if milestone
...@@ -545,6 +557,10 @@ module QuickActions ...@@ -545,6 +557,10 @@ module QuickActions
users users
end end
def find_milestones(project, params = {})
MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute
end
def find_labels(labels_param) def find_labels(labels_param)
extract_references(labels_param, :label) | extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
...@@ -558,21 +574,21 @@ module QuickActions ...@@ -558,21 +574,21 @@ module QuickActions
find_labels(labels_param).map(&:id) find_labels(labels_param).map(&:id)
end end
def explain_commands(commands, opts) def explain_commands(commands)
commands.map do |name, arg| commands.map do |name, arg|
definition = self.class.definition_by_name(name) definition = self.class.definition_by_name(name)
next unless definition next unless definition
definition.explain(self, opts, arg) definition.explain(self, arg)
end.compact end.compact
end end
def extract_updates(commands, opts) def extract_updates(commands)
commands.each do |name, arg| commands.each do |name, arg|
definition = self.class.definition_by_name(name) definition = self.class.definition_by_name(name)
next unless definition next unless definition
definition.execute(self, opts, arg) definition.execute(self, arg)
end end
end end
...@@ -582,14 +598,5 @@ module QuickActions ...@@ -582,14 +598,5 @@ module QuickActions
ext.references(type) ext.references(type)
end end
def context
{
issuable: issuable,
current_user: current_user,
project: project,
params: params
}
end
end end
end end
require 'resolv'
class VerifyPagesDomainService < BaseService
# The maximum number of seconds to be spent on each DNS lookup
RESOLVER_TIMEOUT_SECONDS = 15
# How long verification lasts for
VERIFICATION_PERIOD = 7.days
attr_reader :domain
def initialize(domain)
@domain = domain
end
def execute
return error("No verification code set for #{domain.domain}") unless domain.verification_code.present?
if !verification_enabled? || dns_record_present?
verify_domain!
elsif expired?
disable_domain!
else
unverify_domain!
end
end
private
def verify_domain!
was_disabled = !domain.enabled?
was_unverified = domain.unverified?
# Prevent any pre-existing grace period from being truncated
reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
domain.update!(verified_at: Time.now, enabled_until: reverify)
if was_disabled
notify(:enabled)
elsif was_unverified
notify(:verification_succeeded)
end
success
end
def unverify_domain!
if domain.verified?
domain.update!(verified_at: nil)
notify(:verification_failed)
end
error("Couldn't verify #{domain.domain}")
end
def disable_domain!
domain.update!(verified_at: nil, enabled_until: nil)
notify(:disabled)
error("Couldn't verify #{domain.domain}. It is now disabled.")
end
# A domain is only expired until `disable!` has been called
def expired?
domain.enabled_until && domain.enabled_until < Time.now
end
def dns_record_present?
Resolv::DNS.open do |resolver|
resolver.timeouts = RESOLVER_TIMEOUT_SECONDS
check(domain.domain, resolver) || check(domain.verification_domain, resolver)
end
end
def check(domain_name, resolver)
records = parse(txt_records(domain_name, resolver))
records.any? do |record|
record == domain.keyed_verification_code || record == domain.verification_code
end
rescue => err
log_error("Failed to check TXT records on #{domain_name} for #{domain.domain}: #{err}")
false
end
def txt_records(domain_name, resolver)
resolver.getresources(domain_name, Resolv::DNS::Resource::IN::TXT)
end
def parse(records)
records.flat_map(&:strings).flat_map(&:split)
end
def verification_enabled?
Gitlab::CurrentSettings.pages_domain_verification_enabled?
end
def notify(type)
return unless verification_enabled?
Gitlab::AppLogger.info("Pages domain '#{domain.domain}' changed state to '#{type}'")
notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend
end
end
...@@ -272,6 +272,17 @@ ...@@ -272,6 +272,17 @@
.col-sm-10 .col-sm-10
= f.number_field :max_pages_size, class: 'form-control' = f.number_field :max_pages_size, class: 'form-control'
.help-block 0 for unlimited .help-block 0 for unlimited
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :pages_domain_verification_enabled do
= f.check_box :pages_domain_verification_enabled
Require users to prove ownership of custom domains
.help-block
Domain verification is an essential security measure for public GitLab
sites. Users are required to demonstrate they control a domain before
it is enabled
= link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
%fieldset %fieldset
%legend Continuous Integration and Deployment %legend Continuous Integration and Deployment
......
%p
Following a verification check, your GitLab Pages custom domain has been
%strong disabled.
This means that your content is no longer visible at #{link_to @domain.url, @domain.url}
%p
Project: #{link_to @project.human_name, project_url(@project)}
%p
Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
%p
If this domain has been disabled in error, please follow
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
to verify and re-enable your domain.
%p
If you no longer wish to use this domain with GitLab Pages, please remove it
from your GitLab project and delete any related DNS records.
Following a verification check, your GitLab Pages custom domain has been
**disabled**. This means that your content is no longer visible at #{@domain.url}
Project: #{@project.human_name} (#{project_url(@project)})
Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
If this domain has been disabled in error, please follow these instructions
to verify and re-enable your domain:
= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
If you no longer wish to use this domain with GitLab Pages, please remove it
from your GitLab project and delete any related DNS records.
%p
Following a verification check, your GitLab Pages custom domain has been
enabled. You should now be able to view your content at #{link_to @domain.url, @domain.url}
%p
Project: #{link_to @project.human_name, project_url(@project)}
%p
Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
%p
Please visit
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
Following a verification check, your GitLab Pages custom domain has been
enabled. You should now be able to view your content at #{@domain.url}
Project: #{@project.human_name} (#{project_url(@project)})
Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
Please visit
= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
%p
Verification has failed for one of your GitLab Pages custom domains!
%p
Project: #{link_to @project.human_name, project_url(@project)}
%p
Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
%p
Unless you take action, it will be disabled on
%strong= @domain.enabled_until.strftime('%F %T.')
Until then, you can view your content at #{link_to @domain.url, @domain.url}
%p
Please visit
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
%p
If you no longer wish to use this domain with GitLab Pages, please remove it
from your GitLab project and delete any related DNS records.
Verification has failed for one of your GitLab Pages custom domains!
Project: #{@project.human_name} (#{project_url(@project)})
Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
Unless you take action, it will be disabled on *#{@domain.enabled_until.strftime('%F %T')}*.
Until then, you can view your content at #{@domain.url}
Please visit
= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
If you no longer wish to use this domain with GitLab Pages, please remove it
from your GitLab project and delete any related DNS records.
%p
One of your GitLab Pages custom domains has been successfully verified!
%p
Project: #{link_to @project.human_name, project_url(@project)}
%p
Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)}
%p
This is a notification. No action is required on your part. You can view your
content at #{link_to @domain.url, @domain.url}
%p
Please visit
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
One of your GitLab Pages custom domains has been successfully verified!
Project: #{@project.human_name} (#{project_url(@project)})
Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)})
No action is required on your part. You can view your content at #{@domain.url}
Please visit
= help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
.radio .radio
= label_tag :project_merge_method_ff do = label_tag :project_merge_method_ff do
= form.radio_button :merge_method, :ff, class: "js-merge-method-radio" = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff"
%strong Fast-forward merge %strong Fast-forward merge
%br %br
%span.descr %span.descr
......
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f| = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f|
= render 'projects/ee/merge_request_settings', form: f = render 'projects/ee/merge_request_settings', form: f
= f.submit 'Save changes', class: "btn btn-save" = f.submit 'Save changes', class: "btn btn-save qa-save-merge-request-changes"
= render 'projects/ee/service_desk_settings' = render 'projects/ee/service_desk_settings'
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block .detail-page-description.content-block
%script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue) %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json
#js-issuable-app #js-issuable-app
%h2.title= markdown_field(@issue, :title) %h2.title= markdown_field(@issue, :title)
- if @issue.description.present? - if @issue.description.present?
......
...@@ -3,15 +3,26 @@ ...@@ -3,15 +3,26 @@
.panel-heading .panel-heading
Domains (#{@domains.count}) Domains (#{@domains.count})
%ul.well-list %ul.well-list
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- @domains.each do |domain| - @domains.each do |domain|
%li %li
.pull-right .pull-right
= link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped"
= link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
.clearfix .clearfix
%span= link_to domain.domain, domain.url - if verification_enabled
- tooltip, status = domain.unverified? ? ['Unverified', 'failed'] : ['Verified', 'success']
= link_to domain.url, title: tooltip, class: 'has-tooltip' do
= sprite_icon("status_#{status}", size: 16, css_class: "has-tooltip ci-status-icon ci-status-icon-#{status}")
= domain.domain
- else
= link_to domain.domain, domain.url
%p %p
- if domain.subject - if domain.subject
%span.label.label-gray Certificate: #{domain.subject} %span.label.label-gray Certificate: #{domain.subject}
- if domain.expired? - if domain.expired?
%span.label.label-danger Expired %span.label.label-danger Expired
- if verification_enabled && domain.unverified?
%li.warning-row
#{domain.domain} is not verified. To learn how to verify ownership, visit your
= link_to 'domain details', project_pages_domain_path(@project, domain)
- page_title "#{@domain.domain}", 'Pages Domains' - page_title "#{@domain.domain}", 'Pages Domains'
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
- if verification_enabled && @domain.unverified?
%p.alert.alert-warning
%strong
This domain is not verified. You will need to verify ownership before
access is enabled.
%h3.page-title %h3.page-title
Pages Domain Pages Domain
...@@ -15,9 +21,26 @@ ...@@ -15,9 +21,26 @@
DNS DNS
%td %td
%p %p
To access the domain create a new DNS record: To access this domain create a new DNS record:
%pre %pre
#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}. #{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}.
- if verification_enabled
%tr
%td
Verification status
%td
%p
- help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
To #{link_to 'verify ownership', help_link} of your domain, create
this DNS record:
%pre
#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}
%p
- if @domain.verified?
#{@domain.domain} has been successfully verified.
- else
= button_to 'Verify ownership', verify_project_pages_domain_path(@project, @domain), class: 'btn btn-save btn-sm'
%tr %tr
%td %td
Certificate Certificate
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
- cronjob:expire_build_artifacts - cronjob:expire_build_artifacts
- cronjob:gitlab_usage_ping - cronjob:gitlab_usage_ping
- cronjob:import_export_project_cleanup - cronjob:import_export_project_cleanup
- cronjob:pages_domain_verification_cron
- cronjob:pipeline_schedule - cronjob:pipeline_schedule
- cronjob:prune_old_events - cronjob:prune_old_events
- cronjob:remove_expired_group_links - cronjob:remove_expired_group_links
...@@ -82,6 +83,7 @@ ...@@ -82,6 +83,7 @@
- new_merge_request - new_merge_request
- new_note - new_note
- pages - pages
- pages_domain_verification
- post_receive - post_receive
- process_commit - process_commit
- project_cache - project_cache
......
class AuthorizedProjectsWorker class AuthorizedProjectsWorker
include ApplicationWorker include ApplicationWorker
prepend WaitableWorker
# Schedules multiple jobs and waits for them to be completed. def perform(user_id)
def self.bulk_perform_and_wait(args_list)
# Short-circuit: it's more efficient to do small numbers of jobs inline
return bulk_perform_inline(args_list) if args_list.size <= 3
waiter = Gitlab::JobWaiter.new(args_list.size)
# Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
# into [[1, "key"], [2, "key"], [3, "key"]]
waiting_args_list = args_list.map { |args| [*args, waiter.key] }
bulk_perform_async(waiting_args_list)
waiter.wait
end
# Performs multiple jobs directly. Failed jobs will be put into sidekiq so
# they can benefit from retries
def self.bulk_perform_inline(args_list)
failed = []
args_list.each do |args|
begin
new.perform(*args)
rescue
failed << args
end
end
bulk_perform_async(failed) if failed.present?
end
def perform(user_id, notify_key = nil)
user = User.find_by(id: user_id) user = User.find_by(id: user_id)
user&.refresh_authorized_projects user&.refresh_authorized_projects
ensure
Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
end end
end end
module WaitableWorker
extend ActiveSupport::Concern
module ClassMethods
# Schedules multiple jobs and waits for them to be completed.
def bulk_perform_and_wait(args_list, timeout: 10)
# Short-circuit: it's more efficient to do small numbers of jobs inline
return bulk_perform_inline(args_list) if args_list.size <= 3
waiter = Gitlab::JobWaiter.new(args_list.size)
# Point all the bulk jobs at the same JobWaiter. Converts, [[1], [2], [3]]
# into [[1, "key"], [2, "key"], [3, "key"]]
waiting_args_list = args_list.map { |args| [*args, waiter.key] }
bulk_perform_async(waiting_args_list)
waiter.wait(timeout)
end
# Performs multiple jobs directly. Failed jobs will be put into sidekiq so
# they can benefit from retries
def bulk_perform_inline(args_list)
failed = []
args_list.each do |args|
begin
new.perform(*args)
rescue
failed << args
end
end
bulk_perform_async(failed) if failed.present?
end
end
def perform(*args)
notify_key = args.pop if Gitlab::JobWaiter.key?(args.last)
super(*args)
ensure
Gitlab::JobWaiter.notify(notify_key, jid) if notify_key
end
end
class PagesDomainVerificationCronWorker
include ApplicationWorker
include CronjobQueue
def perform
PagesDomain.needs_verification.find_each do |domain|
PagesDomainVerificationWorker.perform_async(domain.id)
end
end
end
class PagesDomainVerificationWorker
include ApplicationWorker
def perform(domain_id)
domain = PagesDomain.find_by(id: domain_id)
return unless domain
VerifyPagesDomainService.new(domain).execute
end
end
...@@ -29,25 +29,23 @@ class StuckImportJobsWorker ...@@ -29,25 +29,23 @@ class StuckImportJobsWorker
end end
def mark_projects_with_jid_as_failed! def mark_projects_with_jid_as_failed!
completed_jids_count = 0 jids_and_ids = enqueued_projects_with_jid.pluck(:import_jid, :id).to_h
enqueued_projects_with_jid.find_in_batches(batch_size: 500) do |group| # Find the jobs that aren't currently running or that exceeded the threshold.
jids = group.map(&:import_jid) completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
return unless completed_jids.any?
# Find the jobs that aren't currently running or that exceeded the threshold. completed_project_ids = jids_and_ids.values_at(*completed_jids)
completed_jids = Gitlab::SidekiqStatus.completed_jids(jids).to_set
if completed_jids.any? # We select the projects again, because they may have transitioned from
completed_jids_count += completed_jids.count # scheduled/started to finished/failed while we were looking up their Sidekiq status.
group.each do |project| completed_projects = enqueued_projects_with_jid.where(id: completed_project_ids)
project.mark_import_as_failed(error_message) if completed_jids.include?(project.import_jid)
end
Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.to_a.join(', ')}") Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_projects.map(&:import_jid).join(', ')}")
end
end
completed_jids_count completed_projects.each do |project|
project.mark_import_as_failed(error_message)
end.count
end end
def enqueued_projects def enqueued_projects
......
---
title: Add verification for GitLab Pages custom domains
merge_request:
author:
type: security
---
title: Improve query performance of MembersFinder.
merge_request: 17190
author:
type: performance
---
title: Enable Legacy Authorization by default on Cluster creations
merge_request: 17302
author:
type: fixed
---
title: Allows the usage of /milestone quick action for group milestones
merge_request: 17239
author: Jacopo Beschi @jacopo-beschi
type: fixed
---
title: Improve query performance for snippets dashboard.
merge_request: 17088
author:
type: performance
---
title: Fix issue with cache key being empty when variable used as the key
merge_request: 17260
author:
type: fixed
--- ---
title: Prevent MR Widget error when no CI configured title: Verify project import status again before marking as failed
merge_request: merge_request:
author: author:
type: fixed type: fixed
---
title: Fix 500 error when loading an invalid upload URL
merge_request:
author:
type: fixed
---
title: Increase feature flag cache TTL to one hour
merge_request:
author:
type: performance
---
title: Only check LFS integrity for first ref in a push to avoid timeout
merge_request: 17098
author:
type: performance
---
title: Fix single digit value clipping for stacked progress bar
merge_request: 17217
author:
type: fixed
---
title: Prevent trace artifact migration to incur data loss
merge_request: 17313
author:
type: fixed
---
title: Don't attempt to update user tracked fields if database is in read-only
merge_request:
author:
type: fixed
---
title: Improve performance of searching for and autocompleting of users
merge_request:
author:
type: performance
...@@ -249,6 +249,10 @@ production: &base ...@@ -249,6 +249,10 @@ production: &base
repository_archive_cache_worker: repository_archive_cache_worker:
cron: "0 * * * *" cron: "0 * * * *"
# Verify custom GitLab Pages domains
pages_domain_verification_cron_worker:
cron: "*/15 * * * *"
## ##
# GitLab EE only jobs: # GitLab EE only jobs:
......
...@@ -517,6 +517,10 @@ Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({}) ...@@ -517,6 +517,10 @@ Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *' Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *'
Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker' Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker'
Settings.cron_jobs['pages_domain_verification_cron_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['pages_domain_verification_cron_worker']['cron'] ||= '*/15 * * * *'
Settings.cron_jobs['pages_domain_verification_cron_worker']['job_class'] = 'PagesDomainVerificationCronWorker'
# #
# GitLab Shell # GitLab Shell
# #
......
...@@ -55,7 +55,11 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -55,7 +55,11 @@ constraints(ProjectUrlConstrainer.new) do
end end
resource :pages, only: [:show, :destroy] do resource :pages, only: [:show, :destroy] do
resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } do
member do
post :verify
end
end
end end
resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do
......
...@@ -67,6 +67,7 @@ ...@@ -67,6 +67,7 @@
- [gcp_cluster, 1] - [gcp_cluster, 1]
- [project_migrate_hashed_storage, 1] - [project_migrate_hashed_storage, 1]
- [storage_migrator, 1] - [storage_migrator, 1]
- [pages_domain_verification, 1]
# EE-specific queues # EE-specific queues
- [ldap_group_sync, 2] - [ldap_group_sync, 2]
......
class AddPagesDomainVerification < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :pages_domains, :verified_at, :datetime_with_timezone
add_column :pages_domains, :verification_code, :string
end
end
class AddPagesDomainVerifiedAtIndex < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :pages_domains, :verified_at
end
def down
remove_concurrent_index :pages_domains, :verified_at
end
end
class AllowDomainVerificationToBeDisabled < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :application_settings, :pages_domain_verification_enabled, :boolean, default: true, null: false
end
end
class AddPagesDomainEnabledUntil < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :pages_domains, :enabled_until, :datetime_with_timezone
end
end
class AddPagesDomainEnabledUntilIndex < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :pages_domains, [:project_id, :enabled_until]
add_concurrent_index :pages_domains, [:verified_at, :enabled_until]
end
def down
remove_concurrent_index :pages_domains, [:verified_at, :enabled_until]
remove_concurrent_index :pages_domains, [:project_id, :enabled_until]
end
end
class PagesDomainsVerificationGracePeriod < ActiveRecord::Migration
DOWNTIME = false
class PagesDomain < ActiveRecord::Base
include EachBatch
end
# Allow this migration to resume if it fails partway through
disable_ddl_transaction!
def up
now = Time.now
grace = now + 30.days
PagesDomain.each_batch do |relation|
relation.update_all(verified_at: now, enabled_until: grace)
# Sleep 2 minutes between batches to not overload the DB with dead tuples
sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last
end
end
def down
# no-op
end
end
class FillPagesDomainVerificationCode < ActiveRecord::Migration
DOWNTIME = false
class PagesDomain < ActiveRecord::Base
include EachBatch
end
# Allow this migration to resume if it fails partway through
disable_ddl_transaction!
def up
PagesDomain.where(verification_code: [nil, '']).each_batch do |relation|
connection.execute(set_codes_sql(relation))
# Sleep 2 minutes between batches to not overload the DB with dead tuples
sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last
end
change_column_null(:pages_domains, :verification_code, false)
end
def down
change_column_null(:pages_domains, :verification_code, true)
end
private
def set_codes_sql(relation)
ids = relation.pluck(:id)
whens = ids.map { |id| "WHEN #{id} THEN '#{SecureRandom.hex(16)}'" }
<<~SQL
UPDATE pages_domains
SET verification_code =
CASE id
#{whens.join("\n")}
END
WHERE id IN(#{ids.join(',')})
SQL
end
end
class EnqueueVerifyPagesDomainWorkers < ActiveRecord::Migration
class PagesDomain < ActiveRecord::Base
include EachBatch
end
def up
PagesDomain.each_batch do |relation|
ids = relation.pluck(:id).map { |id| [id] }
PagesDomainVerificationWorker.bulk_perform_async(ids)
end
end
def down
# no-op
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180215181245) do ActiveRecord::Schema.define(version: 20180216121030) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -183,6 +183,7 @@ ActiveRecord::Schema.define(version: 20180215181245) do ...@@ -183,6 +183,7 @@ ActiveRecord::Schema.define(version: 20180215181245) do
t.boolean "external_authorization_service_enabled", default: false, null: false t.boolean "external_authorization_service_enabled", default: false, null: false
t.string "external_authorization_service_url" t.string "external_authorization_service_url"
t.string "external_authorization_service_default_label" t.string "external_authorization_service_default_label"
t.boolean "pages_domain_verification_enabled", default: true, null: false
end end
create_table "approvals", force: :cascade do |t| create_table "approvals", force: :cascade do |t|
...@@ -1714,10 +1715,16 @@ ActiveRecord::Schema.define(version: 20180215181245) do ...@@ -1714,10 +1715,16 @@ ActiveRecord::Schema.define(version: 20180215181245) do
t.string "encrypted_key_iv" t.string "encrypted_key_iv"
t.string "encrypted_key_salt" t.string "encrypted_key_salt"
t.string "domain" t.string "domain"
t.datetime_with_timezone "verified_at"
t.string "verification_code", null: false
t.datetime_with_timezone "enabled_until"
end end
add_index "pages_domains", ["domain"], name: "index_pages_domains_on_domain", unique: true, using: :btree add_index "pages_domains", ["domain"], name: "index_pages_domains_on_domain", unique: true, using: :btree
add_index "pages_domains", ["project_id", "enabled_until"], name: "index_pages_domains_on_project_id_and_enabled_until", using: :btree
add_index "pages_domains", ["project_id"], name: "index_pages_domains_on_project_id", using: :btree add_index "pages_domains", ["project_id"], name: "index_pages_domains_on_project_id", using: :btree
add_index "pages_domains", ["verified_at", "enabled_until"], name: "index_pages_domains_on_verified_at_and_enabled_until", using: :btree
add_index "pages_domains", ["verified_at"], name: "index_pages_domains_on_verified_at", using: :btree
create_table "path_locks", force: :cascade do |t| create_table "path_locks", force: :cascade do |t|
t.string "path", null: false t.string "path", null: false
......
...@@ -226,6 +226,18 @@ world. Custom domains and TLS are supported. ...@@ -226,6 +226,18 @@ world. Custom domains and TLS are supported.
1. [Reconfigure GitLab][reconfigure] 1. [Reconfigure GitLab][reconfigure]
### Custom domain verification
To prevent malicious users from hijacking domains that don't belong to them,
GitLab supports [custom domain verification](../../user/project/pages/getting_started_part_three.md#dns-txt-record).
When adding a custom domain, users will be required to prove they own it by
adding a GitLab-controlled verification code to the DNS records for that domain.
If your userbase is private or otherwise trusted, you can disable the
verification requirement. Navigate to `Admin area ➔ Settings` and uncheck
**Require users to prove ownership of custom domains** in the Pages section.
This setting is enabled by default.
## Change storage path ## Change storage path
Follow the steps below to change the default path where GitLab Pages' contents Follow the steps below to change the default path where GitLab Pages' contents
......
...@@ -62,7 +62,7 @@ for the most popular hosting services: ...@@ -62,7 +62,7 @@ for the most popular hosting services:
- [Microsoft](https://msdn.microsoft.com/en-us/library/bb727018.aspx) - [Microsoft](https://msdn.microsoft.com/en-us/library/bb727018.aspx)
If your hosting service is not listed above, you can just try to If your hosting service is not listed above, you can just try to
search the web for "how to add dns record on <my hosting service>". search the web for `how to add dns record on <my hosting service>`.
### DNS A record ### DNS A record
...@@ -95,12 +95,32 @@ without any `/project-name`. ...@@ -95,12 +95,32 @@ without any `/project-name`.
![DNS CNAME record pointing to GitLab.com project](img/dns_cname_record_example.png) ![DNS CNAME record pointing to GitLab.com project](img/dns_cname_record_example.png)
### TL;DR #### DNS TXT record
Unless your GitLab administrator has [disabled custom domain verification](../../../administration/pages/index.md#custom-domain-verification),
you'll have to prove that you own the domain by creating a `TXT` record
containing a verification code. The code will be displayed after you
[add your custom domain to GitLab Pages settings](#add-your-custom-domain-to-gitlab-pages-settings).
If using a [DNS A record](#dns-a-record), you can place the TXT record directly
under the domain. If using a [DNS CNAME record](#dns-cname-record), the two record types won't
co-exist, so you need to place the TXT record in a special subdomain of its own.
#### TL;DR
If the domain has multiple uses (e.g., you host email on it as well):
| From | DNS Record | To | | From | DNS Record | To |
| ---- | ---------- | -- | | ---- | ---------- | -- |
| domain.com | A | 52.167.214.135 | | domain.com | A | 52.167.214.135 |
| subdomain.domain.com | CNAME | namespace.gitlab.io | | domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff |
If the domain is dedicated to GitLab Pages use and no other services run on it:
| From | DNS Record | To |
| ---- | ---------- | -- |
| subdomain.domain.com | CNAME | gitlab.io |
| _gitlab-pages-verification-code.subdomain.domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff |
> **Notes**: > **Notes**:
> >
...@@ -121,6 +141,17 @@ your site will be accessible only via HTTP: ...@@ -121,6 +141,17 @@ your site will be accessible only via HTTP:
![Add new domain](img/add_certificate_to_pages.png) ![Add new domain](img/add_certificate_to_pages.png)
Once you have added a new domain, you will need to **verify your ownership**
(unless the GitLab administrator has disabled this feature). A verification code
will be shown to you; add it as a [DNS TXT record](#dns-txt-record), then press
the "Verify ownership" button to activate your new domain:
![Verify your domain](img/verify_your_domain.png)
Once your domain has been verified, leave the verification record in place -
your domain will be periodically reverified, and may be disabled if the record
is removed.
You can add more than one alias (custom domains and subdomains) to the same project. You can add more than one alias (custom domains and subdomains) to the same project.
An alias can be understood as having many doors leading to the same room. An alias can be understood as having many doors leading to the same room.
...@@ -128,8 +159,8 @@ All the aliases you've set to your site will be listed on **Setting > Pages**. ...@@ -128,8 +159,8 @@ All the aliases you've set to your site will be listed on **Setting > Pages**.
From that page, you can view, add, and remove them. From that page, you can view, add, and remove them.
Note that [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes), Note that [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes),
although it's usually a matter of minutes to complete. Until it does, visit attempts although it's usually a matter of minutes to complete. Until it does, verification
to your domain will respond with a 404. will fail and attempts to visit your domain will respond with a 404.
Read through the [general documentation on GitLab Pages](introduction.md#add-a-custom-domain-to-your-pages-website) to learn more about adding Read through the [general documentation on GitLab Pages](introduction.md#add-a-custom-domain-to-your-pages-website) to learn more about adding
custom domains to GitLab Pages sites. custom domains to GitLab Pages sites.
......
...@@ -29,6 +29,7 @@ export default { ...@@ -29,6 +29,7 @@ export default {
<input <input
type="checkbox" type="checkbox"
name="squash" name="squash"
class="qa-squash-checkbox"
:disabled="isMergeButtonDisabled" :disabled="isMergeButtonDisabled"
v-model="squashBeforeMerge" v-model="squashBeforeMerge"
@change="updateSquashModel"/> @change="updateSquashModel"/>
......
...@@ -63,5 +63,25 @@ module EE ...@@ -63,5 +63,25 @@ module EE
def has_dast_data? def has_dast_data?
dast_artifact&.success? dast_artifact&.success?
end end
def expose_performance_data?
project.feature_available?(:merge_request_performance_metrics) &&
has_performance_data?
end
def expose_sast_data?
project.feature_available?(:sast) &&
has_sast_data?
end
def expose_dast_data?
project.feature_available?(:dast) &&
has_dast_data?
end
def expose_sast_container_data?
project.feature_available?(:sast_container) &&
has_sast_container_data?
end
end end
end end
...@@ -120,16 +120,22 @@ module EE ...@@ -120,16 +120,22 @@ module EE
mirror? && !empty_repo? mirror? && !empty_repo?
end end
def scheduled_mirror? override :import_in_progress?
def import_in_progress?
# If we're importing while we do have a repository, we're simply updating the mirror.
super && !mirror_with_content?
end
def mirror_about_to_update?
return false unless mirror_with_content? return false unless mirror_with_content?
return false if mirror_hard_failed? return false if mirror_hard_failed?
return true if import_scheduled? return false if updating_mirror?
self.mirror_data.next_execution_timestamp <= Time.now self.mirror_data.next_execution_timestamp <= Time.now
end end
def updating_mirror? def updating_mirror?
mirror_with_content? && import_started? (import_scheduled? || import_started?) && mirror_with_content?
end end
def mirror_last_update_status def mirror_last_update_status
...@@ -232,7 +238,7 @@ module EE ...@@ -232,7 +238,7 @@ module EE
end end
def force_import_job! def force_import_job!
return if scheduled_mirror? || updating_mirror? return if mirror_about_to_update? || updating_mirror?
mirror_data = self.mirror_data mirror_data = self.mirror_data
......
...@@ -27,7 +27,7 @@ module EE ...@@ -27,7 +27,7 @@ module EE
end end
end end
expose :performance, if: -> (mr, _) { expose_performance_data?(mr) } do expose :performance, if: -> (mr, _) { mr.expose_performance_data? } do
expose :head_path, if: -> (mr, _) { can?(current_user, :read_build, mr.head_performance_artifact) } do |merge_request| expose :head_path, if: -> (mr, _) { can?(current_user, :read_build, mr.head_performance_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project, raw_project_build_artifacts_url(merge_request.source_project,
merge_request.head_performance_artifact, merge_request.head_performance_artifact,
...@@ -41,60 +41,35 @@ module EE ...@@ -41,60 +41,35 @@ module EE
end end
end end
expose :sast, if: -> (mr, _) { expose_sast_data?(mr, current_user) } do expose :sast, if: -> (mr, _) { mr.expose_sast_data? } do
expose :head_path do |merge_request| expose :head_path, if: -> (mr, _) { can?(current_user, :read_build, mr.head_sast_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project, raw_project_build_artifacts_url(merge_request.source_project,
merge_request.head_sast_artifact, merge_request.head_sast_artifact,
path: Ci::Build::SAST_FILE) path: Ci::Build::SAST_FILE)
end end
expose :base_path, if: -> (mr, _) { mr.has_base_sast_data? } do |merge_request| expose :base_path, if: -> (mr, _) { mr.has_base_sast_data? && can?(current_user, :read_build, mr.base_sast_artifact)} do |merge_request|
raw_project_build_artifacts_url(merge_request.target_project, raw_project_build_artifacts_url(merge_request.target_project,
merge_request.base_sast_artifact, merge_request.base_sast_artifact,
path: Ci::Build::SAST_FILE) path: Ci::Build::SAST_FILE)
end end
end end
expose :sast_container, if: -> (mr, _) { expose_sast_container_data?(mr, current_user) } do expose :sast_container, if: -> (mr, _) { mr.expose_sast_container_data? } do
expose :path do |merge_request| expose :path, if: -> (mr, _) { can?(current_user, :read_build, mr.sast_container_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project, raw_project_build_artifacts_url(merge_request.source_project,
merge_request.sast_container_artifact, merge_request.sast_container_artifact,
path: Ci::Build::SAST_CONTAINER_FILE) path: Ci::Build::SAST_CONTAINER_FILE)
end end
end end
expose :dast, if: -> (mr, _) { expose_dast_data?(mr, current_user) } do expose :dast, if: -> (mr, _) { mr.expose_dast_data? } do
expose :path do |merge_request| expose :path, if: -> (mr, _) { can?(current_user, :read_build, mr.dast_artifact) } do |merge_request|
raw_project_build_artifacts_url(merge_request.source_project, raw_project_build_artifacts_url(merge_request.source_project,
merge_request.dast_artifact, merge_request.dast_artifact,
path: Ci::Build::DAST_FILE) path: Ci::Build::DAST_FILE)
end end
end end
end end
private
def expose_sast_data?(mr, current_user)
mr.project.feature_available?(:sast) &&
mr.has_sast_data? &&
can?(current_user, :read_build, mr.head_sast_artifact)
end
def expose_performance_data?(mr)
mr.project.feature_available?(:merge_request_performance_metrics) &&
mr.has_performance_data?
end
def expose_sast_container_data?(mr, current_user)
mr.project.feature_available?(:sast_container) &&
mr.has_sast_container_data? &&
can?(current_user, :read_build, mr.sast_container_artifact)
end
def expose_dast_data?(mr, current_user)
mr.project.feature_available?(:dast) &&
mr.has_dast_data? &&
can?(current_user, :read_build, mr.dast_artifact)
end
end end
end end
...@@ -16,4 +16,4 @@ ...@@ -16,4 +16,4 @@
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'epic_show' = webpack_bundle_tag 'epic_show'
#epic-show-app{ data: { initial: issuable_initial_data(@epic), meta: epic_meta_data } } #epic-show-app{ data: { initial: issuable_initial_data(@epic).to_json, meta: epic_meta_data } }
- if @project.mirror? && can?(current_user, :push_code, @project) - if @project.mirror? && can?(current_user, :push_code, @project)
.append-bottom-default .append-bottom-default
- if @project.scheduled_mirror? - if @project.mirror_about_to_update?
%span.btn.disabled %span.btn.disabled
= icon("refresh spin") = icon("refresh spin")
Update Scheduled&hellip; Update Scheduled&hellip;
......
--- ---
title: Allow branch names to be named the same as the sha it points to title: Fix bug causing 'Import in progress' to be shown while a mirror is updating
merge_request: merge_request:
author: author:
type: fixed type: fixed
---
title: Move RecentSearchesDropdownContent vue component
merge_request: 16951
author: George Tsiolis
type: performance
...@@ -225,4 +225,17 @@ describe MergeRequest do ...@@ -225,4 +225,17 @@ describe MergeRequest do
describe '#dast_artifact' do describe '#dast_artifact' do
it { is_expected.to delegate_method(:dast_artifact).to(:head_pipeline) } it { is_expected.to delegate_method(:dast_artifact).to(:head_pipeline) }
end end
%w(sast dast performance sast_container).each do |type|
method = "expose_#{type}_data?"
describe "##{method}" do
before do
allow(merge_request).to receive(:"has_#{type}_data?").and_return(true)
allow(merge_request.project).to receive(:feature_available?).and_return(true)
end
it { expect(merge_request.send(method.to_sym)).to be_truthy }
end
end
end end
...@@ -349,7 +349,7 @@ describe Project do ...@@ -349,7 +349,7 @@ describe Project do
end end
end end
describe '#scheduled_mirror?' do describe '#mirror_about_to_update?' do
context 'when mirror is expected to run soon' do context 'when mirror is expected to run soon' do
it 'returns true' do it 'returns true' do
timestamp = Time.now timestamp = Time.now
...@@ -357,15 +357,15 @@ describe Project do ...@@ -357,15 +357,15 @@ describe Project do
project.mirror_last_update_at = timestamp - 3.minutes project.mirror_last_update_at = timestamp - 3.minutes
project.mirror_data.next_execution_timestamp = timestamp - 2.minutes project.mirror_data.next_execution_timestamp = timestamp - 2.minutes
expect(project.scheduled_mirror?).to be true expect(project.mirror_about_to_update?).to be true
end end
end end
context 'when mirror was scheduled' do context 'when mirror was scheduled' do
it 'returns true' do it 'returns false' do
project = create(:project, :mirror, :import_scheduled, :repository) project = create(:project, :mirror, :import_scheduled, :repository)
expect(project.scheduled_mirror?).to be true expect(project.mirror_about_to_update?).to be false
end end
end end
...@@ -373,7 +373,68 @@ describe Project do ...@@ -373,7 +373,68 @@ describe Project do
it 'returns false' do it 'returns false' do
project = create(:project, :mirror, :import_hard_failed) project = create(:project, :mirror, :import_hard_failed)
expect(project.scheduled_mirror?).to be false expect(project.mirror_about_to_update?).to be false
end
end
end
describe '#import_in_progress?' do
let(:traits) { [] }
let(:project) { create(:project, *traits, import_url: Project::UNKNOWN_IMPORT_URL) }
shared_examples 'import in progress' do
context 'when project is a mirror' do
before do
traits << :mirror
end
context 'when repository is empty' do
it 'returns true' do
expect(project.import_in_progress?).to be_truthy
end
end
context 'when repository is not empty' do
before do
traits << :repository
end
it 'returns false' do
expect(project.import_in_progress?).to be_falsey
end
end
end
context 'when project is not a mirror' do
it 'returns true' do
expect(project.import_in_progress?).to be_truthy
end
end
end
context 'when import status is scheduled' do
before do
traits << :import_scheduled
end
it_behaves_like 'import in progress'
end
context 'when import status is started' do
before do
traits << :import_started
end
it_behaves_like 'import in progress'
end
context 'when import status is finished' do
before do
traits << :import_finished
end
it 'returns false' do
expect(project.import_in_progress?).to be_falsey
end end
end end
end end
...@@ -395,13 +456,21 @@ describe Project do ...@@ -395,13 +456,21 @@ describe Project do
end end
end end
context 'when mirror is in progress' do context 'when mirror is started' do
it 'returns true' do it 'returns true' do
project = create(:project, :mirror, :import_started, :repository) project = create(:project, :mirror, :import_started, :repository)
expect(project.updating_mirror?).to be true expect(project.updating_mirror?).to be true
end end
end end
context 'when mirror is scheduled' do
it 'returns true' do
project = create(:project, :mirror, :import_scheduled, :repository)
expect(project.updating_mirror?).to be true
end
end
end end
describe '#mirror_last_update_status' do describe '#mirror_last_update_status' do
......
...@@ -7,6 +7,10 @@ describe MergeRequestWidgetEntity do ...@@ -7,6 +7,10 @@ describe MergeRequestWidgetEntity do
let(:request) { double('request', current_user: user) } let(:request) { double('request', current_user: user) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) } let(:pipeline) { create(:ci_empty_pipeline, project: project) }
before do
project.add_developer(user)
end
subject do subject do
described_class.new(merge_request, request: request) described_class.new(merge_request, request: request)
end end
...@@ -23,7 +27,7 @@ describe MergeRequestWidgetEntity do ...@@ -23,7 +27,7 @@ describe MergeRequestWidgetEntity do
it 'has performance data' do it 'has performance data' do
build = create(:ci_build, name: 'job') build = create(:ci_build, name: 'job')
allow(subject).to receive(:expose_performance_data?).and_return(true) allow(merge_request).to receive(:expose_performance_data?).and_return(true)
allow(merge_request).to receive(:base_performance_artifact).and_return(build) allow(merge_request).to receive(:base_performance_artifact).and_return(build)
allow(merge_request).to receive(:head_performance_artifact).and_return(build) allow(merge_request).to receive(:head_performance_artifact).and_return(build)
...@@ -31,9 +35,9 @@ describe MergeRequestWidgetEntity do ...@@ -31,9 +35,9 @@ describe MergeRequestWidgetEntity do
end end
it 'has sast data' do it 'has sast data' do
build = create(:ci_build, name: 'sast') build = create(:ci_build, name: 'sast', pipeline: pipeline)
allow(subject).to receive(:expose_sast_data?).and_return(true) allow(merge_request).to receive(:expose_sast_data?).and_return(true)
allow(merge_request).to receive(:has_base_sast_data?).and_return(true) allow(merge_request).to receive(:has_base_sast_data?).and_return(true)
allow(merge_request).to receive(:base_sast_artifact).and_return(build) allow(merge_request).to receive(:base_sast_artifact).and_return(build)
allow(merge_request).to receive(:head_sast_artifact).and_return(build) allow(merge_request).to receive(:head_sast_artifact).and_return(build)
...@@ -44,9 +48,9 @@ describe MergeRequestWidgetEntity do ...@@ -44,9 +48,9 @@ describe MergeRequestWidgetEntity do
end end
it 'has sast_container data' do it 'has sast_container data' do
build = create(:ci_build, name: 'sast:image') build = create(:ci_build, name: 'sast:image', pipeline: pipeline)
allow(subject).to receive(:expose_sast_container_data?).and_return(true) allow(merge_request).to receive(:expose_sast_container_data?).and_return(true)
allow(merge_request).to receive(:sast_container_artifact).and_return(build) allow(merge_request).to receive(:sast_container_artifact).and_return(build)
expect(subject.as_json).to include(:sast_container) expect(subject.as_json).to include(:sast_container)
......
...@@ -1438,6 +1438,10 @@ module API ...@@ -1438,6 +1438,10 @@ module API
expose :domain expose :domain
expose :url expose :url
expose :project_id expose :project_id
expose :verified?, as: :verified
expose :verification_code, as: :verification_code
expose :enabled_until
expose :certificate, expose :certificate,
as: :certificate_expiration, as: :certificate_expiration,
if: ->(pages_domain, _) { pages_domain.certificate? }, if: ->(pages_domain, _) { pages_domain.certificate? },
...@@ -1449,6 +1453,10 @@ module API ...@@ -1449,6 +1453,10 @@ module API
class PagesDomain < Grape::Entity class PagesDomain < Grape::Entity
expose :domain expose :domain
expose :url expose :url
expose :verified?, as: :verified
expose :verification_code, as: :verification_code
expose :enabled_until
expose :certificate, expose :certificate,
if: ->(pages_domain, _) { pages_domain.certificate? }, if: ->(pages_domain, _) { pages_domain.certificate? },
using: PagesDomainCertificate do |pages_domain| using: PagesDomainCertificate do |pages_domain|
......
...@@ -15,16 +15,22 @@ module Gitlab ...@@ -15,16 +15,22 @@ module Gitlab
# push to that array when done. Once the waiter has popped `count` items, it # push to that array when done. Once the waiter has popped `count` items, it
# knows all the jobs are done. # knows all the jobs are done.
class JobWaiter class JobWaiter
KEY_PREFIX = "gitlab:job_waiter".freeze
def self.notify(key, jid) def self.notify(key, jid)
Gitlab::Redis::SharedState.with { |redis| redis.lpush(key, jid) } Gitlab::Redis::SharedState.with { |redis| redis.lpush(key, jid) }
end end
def self.key?(key)
key.is_a?(String) && key =~ /\A#{KEY_PREFIX}:\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
end
attr_reader :key, :finished attr_reader :key, :finished
attr_accessor :jobs_remaining attr_accessor :jobs_remaining
# jobs_remaining - the number of jobs left to wait for # jobs_remaining - the number of jobs left to wait for
# key - The key of this waiter. # key - The key of this waiter.
def initialize(jobs_remaining = 0, key = "gitlab:job_waiter:#{SecureRandom.uuid}") def initialize(jobs_remaining = 0, key = "#{KEY_PREFIX}:#{SecureRandom.uuid}")
@key = key @key = key
@jobs_remaining = jobs_remaining @jobs_remaining = jobs_remaining
@finished = [] @finished = []
......
...@@ -24,15 +24,14 @@ module Gitlab ...@@ -24,15 +24,14 @@ module Gitlab
action_block.nil? action_block.nil?
end end
def available?(opts) def available?(context)
return true unless condition_block return true unless condition_block
context = OpenStruct.new(opts)
context.instance_exec(&condition_block) context.instance_exec(&condition_block)
end end
def explain(context, opts, arg) def explain(context, arg)
return unless available?(opts) return unless available?(context)
if explanation.respond_to?(:call) if explanation.respond_to?(:call)
execute_block(explanation, context, arg) execute_block(explanation, context, arg)
...@@ -41,15 +40,13 @@ module Gitlab ...@@ -41,15 +40,13 @@ module Gitlab
end end
end end
def execute(context, opts, arg) def execute(context, arg)
return if noop? || !available?(opts) return if noop? || !available?(context)
execute_block(action_block, context, arg) execute_block(action_block, context, arg)
end end
def to_h(opts) def to_h(context)
context = OpenStruct.new(opts)
desc = description desc = description
if desc.respond_to?(:call) if desc.respond_to?(:call)
desc = context.instance_exec(&desc) rescue '' desc = context.instance_exec(&desc) rescue ''
......
...@@ -62,9 +62,8 @@ module Gitlab ...@@ -62,9 +62,8 @@ module Gitlab
# Allows to define conditions that must be met in order for the command # Allows to define conditions that must be met in order for the command
# to be returned by `.command_names` & `.command_definitions`. # to be returned by `.command_names` & `.command_definitions`.
# It accepts a block that will be evaluated with the context given to # It accepts a block that will be evaluated with the context
# `CommandDefintion#to_h`. # of a QuickActions::InterpretService instance
#
# Example: # Example:
# #
# condition do # condition do
......
...@@ -29,7 +29,7 @@ module Gitlab ...@@ -29,7 +29,7 @@ module Gitlab
# commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']]
# msg #=> "hello\nworld" # msg #=> "hello\nworld"
# ``` # ```
def extract_commands(content, opts = {}) def extract_commands(content)
return [content, []] unless content return [content, []] unless content
content = content.dup content = content.dup
...@@ -37,7 +37,7 @@ module Gitlab ...@@ -37,7 +37,7 @@ module Gitlab
commands = [] commands = []
content.delete!("\r") content.delete!("\r")
content.gsub!(commands_regex(opts)) do content.gsub!(commands_regex) do
if $~[:cmd] if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?) commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
'' ''
...@@ -60,8 +60,8 @@ module Gitlab ...@@ -60,8 +60,8 @@ module Gitlab
# It looks something like: # It looks something like:
# #
# /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/ # /^\/(?<cmd>close|reopen|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/
def commands_regex(opts) def commands_regex
names = command_names(opts).map(&:to_s) names = command_names.map(&:to_s)
@commands_regex ||= %r{ @commands_regex ||= %r{
(?<code> (?<code>
...@@ -133,7 +133,7 @@ module Gitlab ...@@ -133,7 +133,7 @@ module Gitlab
[content, commands] [content, commands]
end end
def command_names(opts) def command_names
command_definitions.flat_map do |command| command_definitions.flat_map do |command|
next if command.noop? next if command.noop?
......
...@@ -130,6 +130,7 @@ module QA ...@@ -130,6 +130,7 @@ module QA
autoload :DeployKeys, 'qa/page/project/settings/deploy_keys' autoload :DeployKeys, 'qa/page/project/settings/deploy_keys'
autoload :SecretVariables, 'qa/page/project/settings/secret_variables' autoload :SecretVariables, 'qa/page/project/settings/secret_variables'
autoload :Runners, 'qa/page/project/settings/runners' autoload :Runners, 'qa/page/project/settings/runners'
autoload :MergeRequest, 'qa/page/project/settings/merge_request'
end end
module Issue module Issue
...@@ -145,6 +146,7 @@ module QA ...@@ -145,6 +146,7 @@ module QA
module MergeRequest module MergeRequest
autoload :New, 'qa/page/merge_request/new' autoload :New, 'qa/page/merge_request/new'
autoload :Show, 'qa/page/merge_request/show'
end end
module Admin module Admin
......
...@@ -18,6 +18,10 @@ module QA ...@@ -18,6 +18,10 @@ module QA
end end
end end
end end
module MergeRequest
autoload :Show, 'qa/ee/page/merge_request/show'
end
end end
module Factory module Factory
......
module QA
module EE
module Page
module MergeRequest
module Show
def self.prepended(page)
page.module_eval do
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js' do
element :head_mismatch, "The source branch HEAD has recently changed."
end
view 'ee/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js' do
element :squash_checkbox
end
end
end
def mark_to_squash
wait(reload: true) do
has_css?(element_selector_css(:squash_checkbox))
end
click_element :squash_checkbox
end
end
end
end
end
end
...@@ -22,7 +22,7 @@ module QA ...@@ -22,7 +22,7 @@ module QA
factory.fabricate!(*args) factory.fabricate!(*args)
return Factory::Product.populate!(self) return Factory::Product.populate!(factory)
end end
end end
......
...@@ -17,8 +17,9 @@ module QA ...@@ -17,8 +17,9 @@ module QA
def self.populate!(factory) def self.populate!(factory)
new.tap do |product| new.tap do |product|
factory.attributes.each_value do |attribute| factory.class.attributes.each_value do |attribute|
product.instance_exec(&attribute.block).tap do |value| product.instance_exec(factory, attribute.block) do |factory, block|
value = block.call(factory)
product.define_singleton_method(attribute.name) { value } product.define_singleton_method(attribute.name) { value }
end end
end end
......
...@@ -2,7 +2,7 @@ module QA ...@@ -2,7 +2,7 @@ module QA
module Factory module Factory
module Repository module Repository
class Push < Factory::Base class Push < Factory::Base
attr_writer :file_name, :file_content, :commit_message, :branch_name attr_writer :file_name, :file_content, :commit_message, :branch_name, :new_branch
dependency Factory::Resource::Project, as: :project do |project| dependency Factory::Resource::Project, as: :project do |project|
project.name = 'project-with-code' project.name = 'project-with-code'
...@@ -14,6 +14,7 @@ module QA ...@@ -14,6 +14,7 @@ module QA
@file_content = '# This is test project' @file_content = '# This is test project'
@commit_message = "Add #{@file_name}" @commit_message = "Add #{@file_name}"
@branch_name = 'master' @branch_name = 'master'
@new_branch = true
end end
def fabricate! def fabricate!
...@@ -29,6 +30,7 @@ module QA ...@@ -29,6 +30,7 @@ module QA
repository.clone repository.clone
repository.configure_identity('GitLab QA', 'root@gitlab.com') repository.configure_identity('GitLab QA', 'root@gitlab.com')
repository.checkout(@branch_name) unless @new_branch
repository.add_file(@file_name, @file_content) repository.add_file(@file_name, @file_content)
repository.commit(@commit_message) repository.commit(@commit_message)
repository.push_changes(@branch_name) repository.push_changes(@branch_name)
......
...@@ -9,11 +9,20 @@ module QA ...@@ -9,11 +9,20 @@ module QA
:source_branch, :source_branch,
:target_branch :target_branch
product :project do |factory|
factory.project
end
product :source_branch do |factory|
factory.source_branch
end
dependency Factory::Resource::Project, as: :project do |project| dependency Factory::Resource::Project, as: :project do |project|
project.name = 'project-with-merge-request' project.name = 'project-with-merge-request'
end end
dependency Factory::Repository::Push, as: :target do |push, factory| dependency Factory::Repository::Push, as: :target do |push, factory|
factory.project.visit!
push.project = factory.project push.project = factory.project
push.branch_name = "master:#{factory.target_branch}" push.branch_name = "master:#{factory.target_branch}"
end end
......
...@@ -36,6 +36,10 @@ module QA ...@@ -36,6 +36,10 @@ module QA
`git clone #{opts} #{@uri.to_s} ./ #{suppress_output}` `git clone #{opts} #{@uri.to_s} ./ #{suppress_output}`
end end
def checkout(branch_name)
`git checkout "#{branch_name}"`
end
def shallow_clone def shallow_clone
clone('--depth 1') clone('--depth 1')
end end
......
module QA
module Page
module MergeRequest
class Show < Page::Base
prepend QA::EE::Page::MergeRequest::Show
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js' do
element :merge_button
end
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue' do
element :merged_status, 'The changes were merged into'
end
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue' do
element :mr_rebase_button
element :fast_forward_nessage, "Fast-forward merge is not possible"
end
def rebase!
wait(reload: false) do
click_element :mr_rebase_button
has_text?("The source branch HEAD has recently changed.")
end
end
def fast_forward_possible?
!has_text?("Fast-forward merge is not possible")
end
def has_merge_button?
refresh
has_selector?('.accept-merge-request')
end
def merge!
wait(reload: false) do
click_element :merge_button
has_text?("The changes were merged into")
end
end
end
end
end
end
module QA
module Page
module Project
module Settings
class MergeRequest < QA::Page::Base
include Common
view 'app/views/projects/_merge_request_fast_forward_settings.html.haml' do
element :radio_button_merge_ff
end
view 'app/views/projects/edit.html.haml' do
element :merge_request_settings, 'Merge request settings'
element :save_merge_request_changes
end
def enable_ff_only
expand_section('Merge request settings') do
click_element :radio_button_merge_ff
click_element :save_merge_request_changes
end
end
end
end
end
end
end
module QA
feature 'merge request squash commits', :core do
scenario 'when squash commits is marked before merge' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
project = Factory::Resource::Project.fabricate! do |project|
project.name = "squash-before-merge"
end
merge_request = Factory::Resource::MergeRequest.fabricate! do |merge_request|
merge_request.project = project
merge_request.title = 'Squashing commits'
end
Factory::Repository::Push.fabricate! do |push|
push.project = project
push.commit_message = 'to be squashed'
push.branch_name = merge_request.source_branch
push.new_branch = false
push.file_name = 'other.txt'
push.file_content = "Test with unicode characters ❤✓€❄"
end
merge_request.visit!
Page::MergeRequest::Show.perform do |merge_request_page|
merge_request_page.mark_to_squash
merge_request_page.merge!
merge_request.project.visit!
Git::Repository.perform do |repository|
repository.location = Page::Project::Show.act do
choose_repository_clone_http
repository_location
end
repository.use_default_credentials
repository.act { clone }
expect(repository.commits.size).to eq 3
end
end
end
end
end
module QA
feature 'merge request rebase', :core do
scenario 'rebases source branch of merge request' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
project = Factory::Resource::Project.fabricate! do |project|
project.name = "only-fast-forward"
end
Page::Menu::Side.act { go_to_settings }
Page::Project::Settings::MergeRequest.act { enable_ff_only }
merge_request = Factory::Resource::MergeRequest.fabricate! do |merge_request|
merge_request.project = project
merge_request.title = 'Needs rebasing'
end
Factory::Repository::Push.fabricate! do |push|
push.project = project
push.file_name = "other.txt"
push.file_content = "New file added!"
end
merge_request.visit!
Page::MergeRequest::Show.perform do |merge_request|
expect(merge_request).to have_content('Needs rebasing')
expect(merge_request).not_to be_fast_forward_possible
expect(merge_request).not_to have_merge_button
merge_request.rebase!
expect(merge_request).to have_merge_button
expect(merge_request.fast_forward_possible?).to be_truthy
end
end
end
end
...@@ -7,6 +7,7 @@ describe QA::Factory::Base do ...@@ -7,6 +7,7 @@ describe QA::Factory::Base do
before do before do
allow(QA::Factory::Product).to receive(:new).and_return(product) allow(QA::Factory::Product).to receive(:new).and_return(product)
allow(QA::Factory::Product).to receive(:populate!).and_return(product)
end end
it 'instantiates the factory and calls factory method' do it 'instantiates the factory and calls factory method' do
...@@ -76,6 +77,7 @@ describe QA::Factory::Base do ...@@ -76,6 +77,7 @@ describe QA::Factory::Base do
allow(subject).to receive(:new).and_return(instance) allow(subject).to receive(:new).and_return(instance)
allow(instance).to receive(:mydep).and_return(nil) allow(instance).to receive(:mydep).and_return(nil)
allow(QA::Factory::Product).to receive(:new) allow(QA::Factory::Product).to receive(:new)
allow(QA::Factory::Product).to receive(:populate!)
end end
it 'builds all dependencies first' do it 'builds all dependencies first' do
...@@ -89,8 +91,16 @@ describe QA::Factory::Base do ...@@ -89,8 +91,16 @@ describe QA::Factory::Base do
describe '.product' do describe '.product' do
subject do subject do
Class.new(described_class) do Class.new(described_class) do
def fabricate!
"any"
end
# Defined only to be stubbed
def self.find_page
end
product :token do product :token do
page.do_something_on_page! find_page.do_something_on_page!
'resulting value' 'resulting value'
end end
end end
...@@ -105,16 +115,17 @@ describe QA::Factory::Base do ...@@ -105,16 +115,17 @@ describe QA::Factory::Base do
let(:page) { spy('page') } let(:page) { spy('page') }
before do before do
allow(subject).to receive(:new).and_return(factory) allow(factory).to receive(:class).and_return(subject)
allow(QA::Factory::Product).to receive(:new).and_return(product) allow(QA::Factory::Product).to receive(:new).and_return(product)
allow(product).to receive(:page).and_return(page) allow(product).to receive(:page).and_return(page)
allow(subject).to receive(:find_page).and_return(page)
end end
it 'populates product after fabrication' do it 'populates product after fabrication' do
subject.fabricate! subject.fabricate!
expect(page).to have_received(:do_something_on_page!)
expect(product.token).to eq 'resulting value' expect(product.token).to eq 'resulting value'
expect(page).to have_received(:do_something_on_page!)
end end
end end
end end
......
describe QA::Factory::Product do describe QA::Factory::Product do
let(:factory) { spy('factory') } let(:factory) do
QA::Factory::Base.new
end
let(:attributes) do
{ test: QA::Factory::Product::Attribute.new(:test, proc { 'returned' }) }
end
let(:product) { spy('product') } let(:product) { spy('product') }
before do
allow(QA::Factory::Base).to receive(:attributes).and_return(attributes)
end
describe '.populate!' do describe '.populate!' do
it 'returns a fabrication product' do it 'returns a fabrication product and define factory attributes as its methods' do
expect(described_class).to receive(:new).and_return(product) expect(described_class).to receive(:new).and_return(product)
result = described_class.populate!(factory) do |instance| result = described_class.populate!(factory) do |instance|
...@@ -11,6 +22,7 @@ describe QA::Factory::Product do ...@@ -11,6 +22,7 @@ describe QA::Factory::Product do
end end
expect(result).to be product expect(result).to be product
expect(result.test).to eq('returned')
end end
end end
......
...@@ -46,7 +46,46 @@ describe Projects::PagesDomainsController do ...@@ -46,7 +46,46 @@ describe Projects::PagesDomainsController do
post(:create, request_params.merge(pages_domain: pages_domain_params)) post(:create, request_params.merge(pages_domain: pages_domain_params))
end.to change { PagesDomain.count }.by(1) end.to change { PagesDomain.count }.by(1)
expect(response).to redirect_to(project_pages_path(project)) created_domain = PagesDomain.reorder(:id).last
expect(created_domain).to be_present
expect(response).to redirect_to(project_pages_domain_path(project, created_domain))
end
end
describe 'POST verify' do
let(:params) { request_params.merge(id: pages_domain.domain) }
def stub_service
service = double(:service)
expect(VerifyPagesDomainService).to receive(:new) { service }
service
end
it 'handles verification success' do
expect(stub_service).to receive(:execute).and_return(status: :success)
post :verify, params
expect(response).to redirect_to project_pages_domain_path(project, pages_domain)
expect(flash[:notice]).to eq('Successfully verified domain ownership')
end
it 'handles verification failure' do
expect(stub_service).to receive(:execute).and_return(status: :failed)
post :verify, params
expect(response).to redirect_to project_pages_domain_path(project, pages_domain)
expect(flash[:alert]).to eq('Failed to verify domain ownership')
end
it 'returns a 404 response for an unknown domain' do
post :verify, request_params.merge(id: 'unknown-domain')
expect(response).to have_gitlab_http_status(404)
end end
end end
......
FactoryBot.define do FactoryBot.define do
factory :pages_domain, class: 'PagesDomain' do factory :pages_domain, class: 'PagesDomain' do
domain 'my.domain.com' sequence(:domain) { |n| "my#{n}.domain.com" }
verified_at { Time.now }
enabled_until { 1.week.from_now }
trait :disabled do
verified_at nil
enabled_until nil
end
trait :unverified do
verified_at nil
end
trait :reverify do
enabled_until { 1.hour.from_now }
end
trait :expired do
enabled_until { 1.hour.ago }
end
trait :with_certificate do trait :with_certificate do
certificate '-----BEGIN CERTIFICATE----- certificate '-----BEGIN CERTIFICATE-----
......
...@@ -39,8 +39,8 @@ describe 'Recent searches', :js do ...@@ -39,8 +39,8 @@ describe 'Recent searches', :js do
items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
expect(items[0].text).to eq('label:~qux garply') expect(items[0].text).to eq('label: ~qux garply')
expect(items[1].text).to eq('label:~foo bar') expect(items[1].text).to eq('label: ~foo bar')
end end
it 'saved recent searches are restored last on the list' do it 'saved recent searches are restored last on the list' do
......
...@@ -60,7 +60,6 @@ feature 'Pages' do ...@@ -60,7 +60,6 @@ feature 'Pages' do
fill_in 'Domain', with: 'my.test.domain.com' fill_in 'Domain', with: 'my.test.domain.com'
click_button 'Create New Domain' click_button 'Create New Domain'
expect(page).to have_content('Domains (1)')
expect(page).to have_content('my.test.domain.com') expect(page).to have_content('my.test.domain.com')
end end
end end
...@@ -159,7 +158,6 @@ feature 'Pages' do ...@@ -159,7 +158,6 @@ feature 'Pages' do
fill_in 'Key (PEM)', with: certificate_key fill_in 'Key (PEM)', with: certificate_key
click_button 'Create New Domain' click_button 'Create New Domain'
expect(page).to have_content('Domains (1)')
expect(page).to have_content('my.test.domain.com') expect(page).to have_content('my.test.domain.com')
end end
end end
......
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
"domain": { "type": "string" }, "domain": { "type": "string" },
"url": { "type": "uri" }, "url": { "type": "uri" },
"project_id": { "type": "integer" }, "project_id": { "type": "integer" },
"verified": { "type": "boolean" },
"verification_code": { "type": ["string", "null"] },
"enabled_until": { "type": ["date", "null"] },
"certificate_expiration": { "certificate_expiration": {
"type": "object", "type": "object",
"properties": { "properties": {
...@@ -14,6 +17,6 @@ ...@@ -14,6 +17,6 @@
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": ["domain", "url", "project_id"], "required": ["domain", "url", "project_id", "verified", "verification_code", "enabled_until"],
"additionalProperties": false "additionalProperties": false
} }
...@@ -3,6 +3,9 @@ ...@@ -3,6 +3,9 @@
"properties": { "properties": {
"domain": { "type": "string" }, "domain": { "type": "string" },
"url": { "type": "uri" }, "url": { "type": "uri" },
"verified": { "type": "boolean" },
"verification_code": { "type": ["string", "null"] },
"enabled_until": { "type": ["date", "null"] },
"certificate": { "certificate": {
"type": "object", "type": "object",
"properties": { "properties": {
...@@ -15,6 +18,6 @@ ...@@ -15,6 +18,6 @@
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": ["domain", "url"], "required": ["domain", "url", "verified", "verification_code", "enabled_until"],
"additionalProperties": false "additionalProperties": false
} }
...@@ -168,54 +168,54 @@ describe IssuablesHelper do ...@@ -168,54 +168,54 @@ describe IssuablesHelper do
allow(helper).to receive(:can?).and_return(true) allow(helper).to receive(:can?).and_return(true)
end end
it 'returns the correct json for an issue' do it 'returns the correct data for an issue' do
issue = create(:issue, author: user, description: 'issue text') issue = create(:issue, author: user, description: 'issue text')
@project = issue.project @project = issue.project
expected_data = { expected_data = {
'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}", endpoint: "/#{@project.full_path}/issues/#{issue.iid}",
'updateEndpoint' => "/#{@project.full_path}/issues/#{issue.iid}.json", updateEndpoint: "/#{@project.full_path}/issues/#{issue.iid}.json",
'canUpdate' => true, canUpdate: true,
'canDestroy' => true, canDestroy: true,
'canAdmin' => true, canAdmin: true,
'issuableRef' => "##{issue.iid}", issuableRef: "##{issue.iid}",
'markdownPreviewPath' => "/#{@project.full_path}/preview_markdown", markdownPreviewPath: "/#{@project.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown', markdownDocsPath: '/help/user/markdown',
'issuableTemplates' => [], issuableTemplates: [],
'projectPath' => @project.path, projectPath: @project.path,
'projectNamespace' => @project.namespace.path, projectNamespace: @project.namespace.path,
'initialTitleHtml' => issue.title, initialTitleHtml: issue.title,
'initialTitleText' => issue.title, initialTitleText: issue.title,
'initialDescriptionHtml' => '<p dir="auto">issue text</p>', initialDescriptionHtml: '<p dir="auto">issue text</p>',
'initialDescriptionText' => 'issue text', initialDescriptionText: 'issue text',
'initialTaskStatus' => '0 of 0 tasks completed' initialTaskStatus: '0 of 0 tasks completed'
} }
expect(JSON.parse(helper.issuable_initial_data(issue))).to eq(expected_data) expect(helper.issuable_initial_data(issue)).to eq(expected_data)
end end
it 'returns the correct json for an epic' do it 'returns the correct data for an epic' do
epic = create(:epic, author: user, description: 'epic text') epic = create(:epic, author: user, description: 'epic text')
@group = epic.group @group = epic.group
expected_data = { expected_data = {
'endpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}", endpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
'updateEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json", updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
'issueLinksEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues", issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
'canUpdate' => true, canUpdate: true,
'canDestroy' => true, canDestroy: true,
'canAdmin' => true, canAdmin: true,
'issuableRef' => "&#{epic.iid}", issuableRef: "&#{epic.iid}",
'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown", markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown', markdownDocsPath: '/help/user/markdown',
'issuableTemplates' => nil, issuableTemplates: nil,
'groupPath' => @group.path, groupPath: @group.path,
'initialTitleHtml' => epic.title, initialTitleHtml: epic.title,
'initialTitleText' => epic.title, initialTitleText: epic.title,
'initialDescriptionHtml' => '<p dir="auto">epic text</p>', initialDescriptionHtml: '<p dir="auto">epic text</p>',
'initialDescriptionText' => 'epic text', initialDescriptionText: 'epic text',
'initialTaskStatus' => '0 of 0 tasks completed' initialTaskStatus: '0 of 0 tasks completed'
} }
expect(JSON.parse(helper.issuable_initial_data(epic))).to eq(expected_data) expect(helper.issuable_initial_data(epic)).to eq(expected_data)
end end
end end
......
import Vue from 'vue'; import Vue from 'vue';
import eventHub from '~/filtered_search/event_hub'; import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content'; import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const createComponent = (propsData) => { const createComponent = (propsData) => {
......
import Vue from 'vue';
import store from '~/ide/stores';
import commitActions from '~/ide/components/commit_sidebar/actions.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('IDE commit sidebar actions', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(commitActions);
vm = createComponentWithStore(Component, store);
vm.$store.state.currentBranchId = 'master';
vm.$mount();
Vue.nextTick(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders 3 groups', () => {
expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3);
});
it('renders current branch text', () => {
expect(vm.$el.textContent).toContain('Commit to master branch');
});
});
import Vue from 'vue';
import store from '~/ide/stores';
import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
import { resetStore } from '../../helpers';
describe('IDE commit sidebar radio group', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(radioGroup);
store.state.commit.commitAction = '2';
vm = createComponentWithStore(Component, store, {
value: '1',
label: 'test',
checked: true,
});
vm.$mount();
Vue.nextTick(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('uses label if present', () => {
expect(vm.$el.textContent).toContain('test');
});
it('uses slot if label is not present', (done) => {
vm.$destroy();
vm = new Vue({
components: {
radioGroup,
},
store,
template: `
<radio-group
value="1"
>
Testing slot
</radio-group>
`,
});
vm.$mount();
Vue.nextTick(() => {
expect(vm.$el.textContent).toContain('Testing slot');
done();
});
});
it('updates store when changing radio button', (done) => {
vm.$el.querySelector('input').dispatchEvent(new Event('change'));
Vue.nextTick(() => {
expect(store.state.commit.commitAction).toBe('1');
done();
});
});
it('renders helpText tooltip', (done) => {
vm.helpText = 'help text';
Vue.nextTick(() => {
const help = vm.$el.querySelector('.help-block');
expect(help).not.toBeNull();
expect(help.getAttribute('data-original-title')).toBe('help text');
done();
});
});
describe('with input', () => {
beforeEach((done) => {
vm.$destroy();
const Component = Vue.extend(radioGroup);
store.state.commit.commitAction = '1';
vm = createComponentWithStore(Component, store, {
value: '1',
label: 'test',
checked: true,
showInput: true,
});
vm.$mount();
Vue.nextTick(done);
});
it('renders input box when commitAction matches value', () => {
expect(vm.$el.querySelector('.form-control')).not.toBeNull();
});
it('hides input when commitAction doesnt match value', (done) => {
store.state.commit.commitAction = '2';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.form-control')).toBeNull();
done();
});
});
it('updates branch name in store on input', (done) => {
const input = vm.$el.querySelector('.form-control');
input.value = 'testing-123';
input.dispatchEvent(new Event('input'));
Vue.nextTick(() => {
expect(store.state.commit.newBranchName).toBe('testing-123');
done();
});
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import * as urlUtils from '~/lib/utils/url_utility';
import store from '~/ide/stores'; import store from '~/ide/stores';
import service from '~/ide/services'; import service from '~/ide/services';
import repoCommitSection from '~/ide/components/repo_commit_section.vue'; import repoCommitSection from '~/ide/components/repo_commit_section.vue';
...@@ -76,8 +75,6 @@ describe('RepoCommitSection', () => { ...@@ -76,8 +75,6 @@ describe('RepoCommitSection', () => {
committedStateSvgPath: 'svg', committedStateSvgPath: 'svg',
}).$mount(); }).$mount();
// Vue.nextTick();
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg');
}); });
...@@ -98,62 +95,57 @@ describe('RepoCommitSection', () => { ...@@ -98,62 +95,57 @@ describe('RepoCommitSection', () => {
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
}); });
describe('when submitting', () => { it('updates commitMessage in store on input', (done) => {
let changedFiles; const textarea = vm.$el.querySelector('textarea');
beforeEach(() => { textarea.value = 'testing commit message';
vm.commitMessage = 'testing';
changedFiles = JSON.parse(JSON.stringify(vm.$store.state.changedFiles));
spyOn(service, 'commit').and.returnValue(Promise.resolve({ textarea.dispatchEvent(new Event('input'));
data: {
short_id: '1', getSetTimeoutPromise()
stats: {}, .then(() => {
}, expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
})); })
}); .then(done)
.catch(done.fail);
});
it('allows you to submit', () => { describe('discard draft button', () => {
expect(vm.$el.querySelector('form .btn').disabled).toBeTruthy(); it('hidden when commitMessage is empty', () => {
expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull();
}); });
it('submits commit', (done) => { it('resets commitMessage when clicking discard button', (done) => {
vm.makeCommit(); vm.$store.state.commit.commitMessage = 'testing commit message';
// Wait for the branch check to finish
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => Vue.nextTick())
.then(() => { .then(() => {
const args = service.commit.calls.allArgs()[0]; vm.$el.querySelector('.multi-file-commit-form .btn-default').click();
const { commit_message, actions, branch: payloadBranch } = args[1]; })
.then(Vue.nextTick)
expect(commit_message).toBe('testing'); .then(() => {
expect(actions.length).toEqual(2); expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
expect(payloadBranch).toEqual('master');
expect(actions[0].action).toEqual('update');
expect(actions[1].action).toEqual('update');
expect(actions[0].content).toEqual(changedFiles[0].content);
expect(actions[1].content).toEqual(changedFiles[1].content);
expect(actions[0].file_path).toEqual(changedFiles[0].path);
expect(actions[1].file_path).toEqual(changedFiles[1].path);
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('All changes are committed');
expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('commitsvg');
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
});
it('redirects to MR creation page if start new MR checkbox checked', (done) => { describe('when submitting', () => {
spyOn(urlUtils, 'visitUrl'); beforeEach(() => {
vm.startNewMR = true; spyOn(vm, 'commitChanges');
});
vm.makeCommit(); it('calls commitChanges', (done) => {
vm.$store.state.commit.commitMessage = 'testing commit message';
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => Vue.nextTick())
.then(() => { .then(() => {
expect(urlUtils.visitUrl).toHaveBeenCalled(); vm.$el.querySelector('.multi-file-commit-form .btn-success').click();
})
.then(Vue.nextTick)
.then(() => {
expect(vm.commitChanges).toHaveBeenCalled();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
......
import { decorateData } from '~/ide/stores/utils'; import { decorateData } from '~/ide/stores/utils';
import state from '~/ide/stores/state'; import state from '~/ide/stores/state';
import commitState from '~/ide/stores/modules/commit/state';
export const resetStore = (store) => { export const resetStore = (store) => {
store.replaceState(state()); const newState = {
...state(),
commit: commitState(),
};
store.replaceState(newState);
}; };
export const file = (name = 'name', id = name, type = '') => decorateData({ export const file = (name = 'name', id = name, type = '') => decorateData({
......
import Vue from 'vue'; import Vue from 'vue';
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
import store from '~/ide/stores'; import store from '~/ide/stores';
import service from '~/ide/services';
import { resetStore, file } from '../helpers'; import { resetStore, file } from '../helpers';
describe('Multi-file store actions', () => { describe('Multi-file store actions', () => {
...@@ -130,190 +129,6 @@ describe('Multi-file store actions', () => { ...@@ -130,190 +129,6 @@ describe('Multi-file store actions', () => {
}); });
}); });
describe('checkCommitStatus', () => {
beforeEach(() => {
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
branches: {
master: {
workingReference: '1',
},
},
};
});
it('calls service', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('checkCommitStatus')
.then(() => {
expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master');
done();
})
.catch(done.fail);
});
it('returns true if current ref does not equal returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('checkCommitStatus')
.then((val) => {
expect(val).toBeTruthy();
done();
})
.catch(done.fail);
});
it('returns false if current ref equals returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '1' },
},
}));
store.dispatch('checkCommitStatus')
.then((val) => {
expect(val).toBeFalsy();
done();
})
.catch(done.fail);
});
});
describe('commitChanges', () => {
let payload;
beforeEach(() => {
spyOn(window, 'scrollTo');
document.body.innerHTML += '<div class="flash-container"></div>';
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: 'webUrl',
branches: {
master: {
workingReference: '1',
},
},
};
payload = {
branch: 'master',
};
});
afterEach(() => {
document.querySelector('.flash-container').remove();
});
describe('success', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
id: '123456',
short_id: '123',
message: 'test message',
committed_date: 'date',
stats: {
additions: '1',
deletions: '2',
},
},
}));
});
it('calls service', (done) => {
store.dispatch('commitChanges', { payload, newMr: false })
.then(() => {
expect(service.commit).toHaveBeenCalledWith('abcproject', payload);
done();
}).catch(done.fail);
});
it('sets last Commit Msg', (done) => {
store.dispatch('commitChanges', { payload, newMr: false })
.then(() => {
expect(store.state.lastCommitMsg).toBe(
'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.',
);
done();
}).catch(done.fail);
});
it('adds commit data to changed files', (done) => {
const changedFile = file();
store.state.changedFiles.push(changedFile);
store.dispatch('commitChanges', { payload, newMr: false })
.then(() => {
expect(changedFile.lastCommit.message).toBe('test message');
done();
}).catch(done.fail);
});
it('scrolls to top of page', (done) => {
store.dispatch('commitChanges', { payload, newMr: false })
.then(() => {
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
done();
}).catch(done.fail);
});
it('redirects to new merge request page', (done) => {
spyOn(urlUtils, 'visitUrl');
store.dispatch('commitChanges', { payload, newMr: true })
.then(() => {
expect(urlUtils.visitUrl).toHaveBeenCalledWith('webUrl/merge_requests/new?merge_request%5Bsource_branch%5D=master');
done();
}).catch(done.fail);
});
});
describe('failed', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
message: 'failed message',
},
}));
});
it('shows failed message', (done) => {
store.dispatch('commitChanges', { payload, newMr: false })
.then(() => {
const alert = document.querySelector('.flash-container');
expect(alert.textContent.trim()).toBe(
'failed message',
);
done();
}).catch(done.fail);
});
});
});
describe('createTempEntry', () => { describe('createTempEntry', () => {
beforeEach(() => { beforeEach(() => {
store.state.trees['abcproject/mybranch'] = { store.state.trees['abcproject/mybranch'] = {
......
import store from '~/ide/stores';
import service from '~/ide/services';
import router from '~/ide/ide_router';
import eventHub from '~/ide/eventhub';
import * as consts from '~/ide/stores/modules/commit/constants';
import * as urlUtils from '~/lib/utils/url_utility';
import { resetStore, file } from '../../../helpers';
describe('IDE commit module actions', () => {
afterEach(() => {
resetStore(store);
});
describe('updateCommitMessage', () => {
it('updates store with new commit message', (done) => {
store.dispatch('commit/updateCommitMessage', 'testing')
.then(() => {
expect(store.state.commit.commitMessage).toBe('testing');
})
.then(done)
.catch(done.fail);
});
});
describe('discardDraft', () => {
it('resets commit message to blank', (done) => {
store.state.commit.commitMessage = 'testing';
store.dispatch('commit/discardDraft')
.then(() => {
expect(store.state.commit.commitMessage).not.toBe('testing');
})
.then(done)
.catch(done.fail);
});
});
describe('updateCommitAction', () => {
it('updates store with new commit action', (done) => {
store.dispatch('commit/updateCommitAction', '1')
.then(() => {
expect(store.state.commit.commitAction).toBe('1');
})
.then(done)
.catch(done.fail);
});
});
describe('updateBranchName', () => {
it('updates store with new branch name', (done) => {
store.dispatch('commit/updateBranchName', 'branch-name')
.then(() => {
expect(store.state.commit.newBranchName).toBe('branch-name');
})
.then(done)
.catch(done.fail);
});
});
describe('setLastCommitMessage', () => {
beforeEach(() => {
Object.assign(store.state, {
currentProjectId: 'abcproject',
projects: {
abcproject: {
web_url: 'http://testing',
},
},
});
});
it('updates commit message with short_id', (done) => {
store.dispatch('commit/setLastCommitMessage', { short_id: '123' })
.then(() => {
expect(store.state.lastCommitMsg).toContain(
'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>',
);
})
.then(done)
.catch(done.fail);
});
it('updates commit message with stats', (done) => {
store.dispatch('commit/setLastCommitMessage', {
short_id: '123',
stats: {
additions: '1',
deletions: '2',
},
})
.then(() => {
expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.');
})
.then(done)
.catch(done.fail);
});
});
describe('checkCommitStatus', () => {
beforeEach(() => {
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
branches: {
master: {
workingReference: '1',
},
},
};
});
it('calls service', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then(() => {
expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master');
done();
})
.catch(done.fail);
});
it('returns true if current ref does not equal returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '123' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then((val) => {
expect(val).toBeTruthy();
done();
})
.catch(done.fail);
});
it('returns false if current ref equals returned ID', (done) => {
spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
data: {
commit: { id: '1' },
},
}));
store.dispatch('commit/checkCommitStatus')
.then((val) => {
expect(val).toBeFalsy();
done();
})
.catch(done.fail);
});
});
describe('updateFilesAfterCommit', () => {
const data = {
id: '123',
message: 'testing commit message',
committed_date: '123',
committer_name: 'root',
};
const branch = 'master';
let f;
beforeEach(() => {
spyOn(eventHub, '$emit');
spyOn(router, 'push');
f = file('changedFile');
Object.assign(f, {
active: true,
content: 'file content',
});
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: 'web_url',
branches: {
master: {
workingReference: '',
},
},
};
store.state.changedFiles.push(f);
store.state.openFiles.push(f);
});
it('updates stores working reference', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(
store.state.projects.abcproject.branches.master.workingReference,
).toBe(data.id);
})
.then(done)
.catch(done.fail);
});
it('removes all changed files', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(store.state.changedFiles.length).toBe(0);
})
.then(done)
.catch(done.fail);
});
it('sets files commit data', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(f.lastCommit.message).toBe(data.message);
})
.then(done)
.catch(done.fail);
});
it('updates raw content for changed file', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(f.raw).toBe(f.content);
})
.then(done)
.catch(done.fail);
});
it('emits changed event for file', (done) => {
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content);
})
.then(done)
.catch(done.fail);
});
it('pushes route to new branch if commitAction is new branch', (done) => {
store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(router.push).toHaveBeenCalledWith(
`/project/abcproject/blob/master/${f.path}`,
);
})
.then(done)
.catch(done.fail);
});
it('resets stores commit actions', (done) => {
store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
store.dispatch('commit/updateFilesAfterCommit', {
data,
branch,
})
.then(() => {
expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH);
})
.then(done)
.catch(done.fail);
});
});
describe('commitChanges', () => {
beforeEach(() => {
spyOn(router, 'push');
document.body.innerHTML += '<div class="flash-container"></div>';
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
web_url: 'webUrl',
branches: {
master: {
workingReference: '1',
},
},
};
store.state.changedFiles.push(file('changed'));
store.state.changedFiles[0].active = true;
store.state.openFiles = store.state.changedFiles;
store.state.commit.commitAction = '2';
store.state.commit.commitMessage = 'testing 123';
});
afterEach(() => {
document.querySelector('.flash-container').remove();
});
describe('success', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
id: '123456',
short_id: '123',
message: 'test message',
committed_date: 'date',
stats: {
additions: '1',
deletions: '2',
},
},
}));
});
it('calls service', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(service.commit).toHaveBeenCalledWith('abcproject', {
branch: jasmine.anything(),
commit_message: 'testing 123',
actions: [{
action: 'update',
file_path: jasmine.anything(),
content: jasmine.anything(),
encoding: jasmine.anything(),
}],
start_branch: 'master',
});
done();
}).catch(done.fail);
});
it('pushes router to new route', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(router.push).toHaveBeenCalledWith(
`/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`,
);
done();
}).catch(done.fail);
});
it('sets last Commit Msg', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(store.state.lastCommitMsg).toBe(
'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
);
done();
}).catch(done.fail);
});
it('adds commit data to changed files', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
expect(store.state.openFiles[0].lastCommit.message).toBe('test message');
done();
}).catch(done.fail);
});
it('redirects to new merge request page', (done) => {
spyOn(urlUtils, 'visitUrl');
store.state.commit.commitAction = '3';
store.dispatch('commit/commitChanges')
.then(() => {
expect(urlUtils.visitUrl).toHaveBeenCalledWith(
`webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`,
);
done();
}).catch(done.fail);
});
});
describe('failed', () => {
beforeEach(() => {
spyOn(service, 'commit').and.returnValue(Promise.resolve({
data: {
message: 'failed message',
},
}));
});
it('shows failed message', (done) => {
store.dispatch('commit/commitChanges')
.then(() => {
const alert = document.querySelector('.flash-container');
expect(alert.textContent.trim()).toBe(
'failed message',
);
done();
}).catch(done.fail);
});
});
});
});
import commitState from '~/ide/stores/modules/commit/state';
import * as consts from '~/ide/stores/modules/commit/constants';
import * as getters from '~/ide/stores/modules/commit/getters';
describe('IDE commit module getters', () => {
let state;
beforeEach(() => {
state = commitState();
});
describe('discardDraftButtonDisabled', () => {
it('returns true when commitMessage is empty', () => {
expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
});
it('returns false when commitMessage is not empty & loading is false', () => {
state.commitMessage = 'test';
state.submitCommitLoading = false;
expect(getters.discardDraftButtonDisabled(state)).toBeFalsy();
});
it('returns true when commitMessage is not empty & loading is true', () => {
state.commitMessage = 'test';
state.submitCommitLoading = true;
expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
});
});
describe('commitButtonDisabled', () => {
const localGetters = {
discardDraftButtonDisabled: false,
};
const rootState = {
changedFiles: ['a'],
};
it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy();
});
it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => {
rootState.changedFiles.length = 0;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is true', () => {
localGetters.discardDraftButtonDisabled = true;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
localGetters.discardDraftButtonDisabled = false;
rootState.changedFiles.length = 0;
expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy();
});
});
describe('newBranchName', () => {
it('includes username, currentBranchId, patch & random number', () => {
gon.current_username = 'username';
const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' });
expect(branch).toMatch(/username-testing-patch-\d{5}$/);
});
});
describe('branchName', () => {
const rootState = {
currentBranchId: 'master',
};
const localGetters = {
newBranchName: 'newBranchName',
};
beforeEach(() => {
Object.assign(state, {
newBranchName: 'state-newBranchName',
});
});
it('defualts to currentBranchId', () => {
expect(getters.branchName(state, null, rootState)).toBe('master');
});
['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => {
describe(type, () => {
beforeEach(() => {
Object.assign(state, {
commitAction: consts[type],
});
});
it('uses newBranchName when not empty', () => {
expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName');
});
it('uses getters newBranchName when state newBranchName is empty', () => {
Object.assign(state, {
newBranchName: '',
});
expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName');
});
});
});
});
});
import commitState from '~/ide/stores/modules/commit/state';
import mutations from '~/ide/stores/modules/commit/mutations';
describe('IDE commit module mutations', () => {
let state;
beforeEach(() => {
state = commitState();
});
describe('UPDATE_COMMIT_MESSAGE', () => {
it('updates commitMessage', () => {
mutations.UPDATE_COMMIT_MESSAGE(state, 'testing');
expect(state.commitMessage).toBe('testing');
});
});
describe('UPDATE_COMMIT_ACTION', () => {
it('updates commitAction', () => {
mutations.UPDATE_COMMIT_ACTION(state, 'testing');
expect(state.commitAction).toBe('testing');
});
});
describe('UPDATE_NEW_BRANCH_NAME', () => {
it('updates newBranchName', () => {
mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing');
expect(state.newBranchName).toBe('testing');
});
});
describe('UPDATE_LOADING', () => {
it('updates submitCommitLoading', () => {
mutations.UPDATE_LOADING(state, true);
expect(state.submitCommitLoading).toBeTruthy();
});
});
});
...@@ -40,7 +40,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -40,7 +40,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
describe "#available?" do describe "#available?" do
let(:opts) { { go: false } } let(:opts) { OpenStruct.new(go: false) }
context "when the command has a condition block" do context "when the command has a condition block" do
before do before do
...@@ -78,7 +78,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -78,7 +78,7 @@ describe Gitlab::QuickActions::CommandDefinition do
it "doesn't execute the command" do it "doesn't execute the command" do
expect(context).not_to receive(:instance_exec) expect(context).not_to receive(:instance_exec)
subject.execute(context, {}, nil) subject.execute(context, nil)
expect(context.run).to be false expect(context.run).to be false
end end
...@@ -95,7 +95,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -95,7 +95,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
it "doesn't execute the command" do it "doesn't execute the command" do
subject.execute(context, {}, nil) subject.execute(context, nil)
expect(context.run).to be false expect(context.run).to be false
end end
...@@ -109,7 +109,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -109,7 +109,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do context "when the command is provided an argument" do
it "executes the command" do it "executes the command" do
subject.execute(context, {}, true) subject.execute(context, true)
expect(context.run).to be true expect(context.run).to be true
end end
...@@ -117,7 +117,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -117,7 +117,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do context "when the command is not provided an argument" do
it "executes the command" do it "executes the command" do
subject.execute(context, {}, nil) subject.execute(context, nil)
expect(context.run).to be true expect(context.run).to be true
end end
...@@ -131,7 +131,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -131,7 +131,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do context "when the command is provided an argument" do
it "executes the command" do it "executes the command" do
subject.execute(context, {}, true) subject.execute(context, true)
expect(context.run).to be true expect(context.run).to be true
end end
...@@ -139,7 +139,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -139,7 +139,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do context "when the command is not provided an argument" do
it "doesn't execute the command" do it "doesn't execute the command" do
subject.execute(context, {}, nil) subject.execute(context, nil)
expect(context.run).to be false expect(context.run).to be false
end end
...@@ -153,7 +153,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -153,7 +153,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is provided an argument" do context "when the command is provided an argument" do
it "executes the command" do it "executes the command" do
subject.execute(context, {}, true) subject.execute(context, true)
expect(context.run).to be true expect(context.run).to be true
end end
...@@ -161,7 +161,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -161,7 +161,7 @@ describe Gitlab::QuickActions::CommandDefinition do
context "when the command is not provided an argument" do context "when the command is not provided an argument" do
it "executes the command" do it "executes the command" do
subject.execute(context, {}, nil) subject.execute(context, nil)
expect(context.run).to be true expect(context.run).to be true
end end
...@@ -175,7 +175,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -175,7 +175,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
it 'executes the command passing the parsed param' do it 'executes the command passing the parsed param' do
subject.execute(context, {}, 'something ') subject.execute(context, 'something ')
expect(context.received_arg).to eq('something') expect(context.received_arg).to eq('something')
end end
...@@ -192,7 +192,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -192,7 +192,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
it 'returns nil' do it 'returns nil' do
result = subject.explain({}, {}, nil) result = subject.explain({}, nil)
expect(result).to be_nil expect(result).to be_nil
end end
...@@ -204,7 +204,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -204,7 +204,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
it 'returns this static string' do it 'returns this static string' do
result = subject.explain({}, {}, nil) result = subject.explain({}, nil)
expect(result).to eq 'Explanation' expect(result).to eq 'Explanation'
end end
...@@ -216,7 +216,7 @@ describe Gitlab::QuickActions::CommandDefinition do ...@@ -216,7 +216,7 @@ describe Gitlab::QuickActions::CommandDefinition do
end end
it 'invokes the proc' do it 'invokes the proc' do
result = subject.explain({}, {}, 'explanation') result = subject.explain({}, 'explanation')
expect(result).to eq 'Dynamic explanation' expect(result).to eq 'Dynamic explanation'
end end
......
...@@ -76,7 +76,7 @@ describe Gitlab::QuickActions::Dsl do ...@@ -76,7 +76,7 @@ describe Gitlab::QuickActions::Dsl do
expect(dynamic_description_def.name).to eq(:dynamic_description) expect(dynamic_description_def.name).to eq(:dynamic_description)
expect(dynamic_description_def.aliases).to eq([]) expect(dynamic_description_def.aliases).to eq([])
expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE') expect(dynamic_description_def.to_h(OpenStruct.new(noteable: 'issue'))[:description]).to eq('A dynamic description for ISSUE')
expect(dynamic_description_def.explanation).to eq('') expect(dynamic_description_def.explanation).to eq('')
expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument']) expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument'])
expect(dynamic_description_def.condition_block).to be_nil expect(dynamic_description_def.condition_block).to be_nil
......
require 'spec_helper'
require 'email_spec'
describe Emails::PagesDomains do
include EmailSpec::Matchers
include_context 'gitlab email notification'
set(:project) { create(:project) }
set(:domain) { create(:pages_domain, project: project) }
set(:user) { project.owner }
shared_examples 'a pages domain email' do
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'has the expected content' do
aggregate_failures do
is_expected.to have_subject(email_subject)
is_expected.to have_body_text(project.human_name)
is_expected.to have_body_text(domain.domain)
is_expected.to have_body_text domain.url
is_expected.to have_body_text project_pages_domain_url(project, domain)
is_expected.to have_body_text help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
end
end
end
describe '#pages_domain_enabled_email' do
let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been enabled" }
subject { Notify.pages_domain_enabled_email(domain, user) }
it_behaves_like 'a pages domain email'
it { is_expected.to have_body_text 'has been enabled' }
end
describe '#pages_domain_disabled_email' do
let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been disabled" }
subject { Notify.pages_domain_disabled_email(domain, user) }
it_behaves_like 'a pages domain email'
it { is_expected.to have_body_text 'has been disabled' }
end
describe '#pages_domain_verification_succeeded_email' do
let(:email_subject) { "#{project.path} | Verification succeeded for GitLab Pages domain '#{domain.domain}'" }
subject { Notify.pages_domain_verification_succeeded_email(domain, user) }
it_behaves_like 'a pages domain email'
it { is_expected.to have_body_text 'successfully verified' }
end
describe '#pages_domain_verification_failed_email' do
let(:email_subject) { "#{project.path} | ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'" }
subject { Notify.pages_domain_verification_failed_email(domain, user) }
it_behaves_like 'a pages domain email'
it 'says verification has failed and when the domain is enabled until' do
is_expected.to have_body_text 'Verification has failed'
is_expected.to have_body_text domain.enabled_until.strftime('%F %T')
end
end
end
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20180216121030_enqueue_verify_pages_domain_workers')
describe EnqueueVerifyPagesDomainWorkers, :sidekiq, :migration do
around do |example|
Sidekiq::Testing.fake! do
example.run
end
end
describe '#up' do
it 'enqueues a verification worker for every domain' do
domains = 1.upto(3).map { |i| PagesDomain.create!(domain: "my#{i}.domain.com") }
expect { migrate! }.to change(PagesDomainVerificationWorker.jobs, :size).by(3)
enqueued_ids = PagesDomainVerificationWorker.jobs.map { |job| job['args'] }
expected_ids = domains.map { |domain| [domain.id] }
expect(enqueued_ids).to match_array(expected_ids)
end
end
end
require 'spec_helper' require 'spec_helper'
describe PagesDomain do describe PagesDomain do
using RSpec::Parameterized::TableSyntax
subject(:pages_domain) { described_class.new }
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
end end
...@@ -64,19 +68,51 @@ describe PagesDomain do ...@@ -64,19 +68,51 @@ describe PagesDomain do
end end
end end
describe 'validations' do
it { is_expected.to validate_presence_of(:verification_code) }
end
describe '#verification_code' do
subject { pages_domain.verification_code }
it 'is set automatically with 128 bits of SecureRandom data' do
expect(SecureRandom).to receive(:hex).with(16) { 'verification code' }
is_expected.to eq('verification code')
end
end
describe '#keyed_verification_code' do
subject { pages_domain.keyed_verification_code }
it { is_expected.to eq("gitlab-pages-verification-code=#{pages_domain.verification_code}") }
end
describe '#verification_domain' do
subject { pages_domain.verification_domain }
it { is_expected.to be_nil }
it 'is a well-known subdomain if the domain is present' do
pages_domain.domain = 'example.com'
is_expected.to eq('_gitlab-pages-verification-code.example.com')
end
end
describe '#url' do describe '#url' do
subject { domain.url } subject { domain.url }
context 'without the certificate' do context 'without the certificate' do
let(:domain) { build(:pages_domain, certificate: '') } let(:domain) { build(:pages_domain, certificate: '') }
it { is_expected.to eq('http://my.domain.com') } it { is_expected.to eq("http://#{domain.domain}") }
end end
context 'with a certificate' do context 'with a certificate' do
let(:domain) { build(:pages_domain, :with_certificate) } let(:domain) { build(:pages_domain, :with_certificate) }
it { is_expected.to eq('https://my.domain.com') } it { is_expected.to eq("https://#{domain.domain}") }
end end
end end
...@@ -154,4 +190,108 @@ describe PagesDomain do ...@@ -154,4 +190,108 @@ describe PagesDomain do
# We test only existence of output, since the output is long # We test only existence of output, since the output is long
it { is_expected.not_to be_empty } it { is_expected.not_to be_empty }
end end
describe '#update_daemon' do
it 'runs when the domain is created' do
domain = build(:pages_domain)
expect(domain).to receive(:update_daemon)
domain.save!
end
it 'runs when the domain is destroyed' do
domain = create(:pages_domain)
expect(domain).to receive(:update_daemon)
domain.destroy!
end
it 'delegates to Projects::UpdatePagesConfigurationService' do
service = instance_double('Projects::UpdatePagesConfigurationService')
expect(Projects::UpdatePagesConfigurationService).to receive(:new) { service }
expect(service).to receive(:execute)
create(:pages_domain)
end
context 'configuration updates when attributes change' do
set(:project1) { create(:project) }
set(:project2) { create(:project) }
set(:domain) { create(:pages_domain) }
where(:attribute, :old_value, :new_value, :update_expected) do
now = Time.now
future = now + 1.day
:project | nil | :project1 | true
:project | :project1 | :project1 | false
:project | :project1 | :project2 | true
:project | :project1 | nil | true
# domain can't be set to nil
:domain | 'a.com' | 'a.com' | false
:domain | 'a.com' | 'b.com' | true
# verification_code can't be set to nil
:verification_code | 'foo' | 'foo' | false
:verification_code | 'foo' | 'bar' | false
:verified_at | nil | now | false
:verified_at | now | now | false
:verified_at | now | future | false
:verified_at | now | nil | false
:enabled_until | nil | now | true
:enabled_until | now | now | false
:enabled_until | now | future | false
:enabled_until | now | nil | true
end
with_them do
it 'runs if a relevant attribute has changed' do
a = old_value.is_a?(Symbol) ? send(old_value) : old_value
b = new_value.is_a?(Symbol) ? send(new_value) : new_value
domain.update!(attribute => a)
if update_expected
expect(domain).to receive(:update_daemon)
else
expect(domain).not_to receive(:update_daemon)
end
domain.update!(attribute => b)
end
end
context 'TLS configuration' do
set(:domain_with_tls) { create(:pages_domain, :with_key, :with_certificate) }
let(:cert1) { domain_with_tls.certificate }
let(:cert2) { cert1 + ' ' }
let(:key1) { domain_with_tls.key }
let(:key2) { key1 + ' ' }
it 'updates when added' do
expect(domain).to receive(:update_daemon)
domain.update!(key: key1, certificate: cert1)
end
it 'updates when changed' do
expect(domain_with_tls).to receive(:update_daemon)
domain_with_tls.update!(key: key2, certificate: cert2)
end
it 'updates when removed' do
expect(domain_with_tls).to receive(:update_daemon)
domain_with_tls.update!(key: nil, certificate: nil)
end
end
end
end
end end
...@@ -4,40 +4,60 @@ describe Ci::CreateTraceArtifactService do ...@@ -4,40 +4,60 @@ describe Ci::CreateTraceArtifactService do
describe '#execute' do describe '#execute' do
subject { described_class.new(nil, nil).execute(job) } subject { described_class.new(nil, nil).execute(job) }
let(:job) { create(:ci_build) }
context 'when the job does not have trace artifact' do context 'when the job does not have trace artifact' do
context 'when the job has a trace file' do context 'when the job has a trace file' do
before do let!(:job) { create(:ci_build, :trace_live) }
allow_any_instance_of(Gitlab::Ci::Trace) let!(:legacy_path) { job.trace.read { |stream| return stream.path } }
.to receive(:default_path) { expand_fixture_path('trace/sample_trace') } let!(:legacy_checksum) { Digest::SHA256.file(legacy_path).hexdigest }
let(:new_path) { job.job_artifacts_trace.file.path }
let(:new_checksum) { Digest::SHA256.file(new_path).hexdigest }
allow_any_instance_of(JobArtifactUploader).to receive(:move_to_cache) { false } it { expect(File.exist?(legacy_path)).to be_truthy }
allow_any_instance_of(JobArtifactUploader).to receive(:move_to_store) { false }
end
it 'creates trace artifact' do it 'creates trace artifact' do
expect { subject }.to change { Ci::JobArtifact.count }.by(1) expect { subject }.to change { Ci::JobArtifact.count }.by(1)
expect(job.job_artifacts_trace.read_attribute(:file)).to eq('sample_trace') expect(File.exist?(legacy_path)).to be_falsy
expect(File.exist?(new_path)).to be_truthy
expect(new_checksum).to eq(legacy_checksum)
expect(job.job_artifacts_trace.file.exists?).to be_truthy
expect(job.job_artifacts_trace.file.filename).to eq('job.log')
end end
context 'when the job has already had trace artifact' do context 'when failed to create trace artifact record' do
before do before do
create(:ci_job_artifact, :trace, job: job) # When ActiveRecord error happens
allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
.and_return("Error")
subject rescue nil
job.reload
end end
it 'does not create trace artifact' do it 'keeps legacy trace and removes trace artifact' do
expect { subject }.not_to change { Ci::JobArtifact.count } expect(File.exist?(legacy_path)).to be_truthy
expect(job.job_artifacts_trace).to be_nil
end end
end end
end end
context 'when the job does not have a trace file' do context 'when the job does not have a trace file' do
let!(:job) { create(:ci_build) }
it 'does not create trace artifact' do it 'does not create trace artifact' do
expect { subject }.not_to change { Ci::JobArtifact.count } expect { subject }.not_to change { Ci::JobArtifact.count }
end end
end end
end end
context 'when the job has already had trace artifact' do
let!(:job) { create(:ci_build, :trace_artifact) }
it 'does not create trace artifact' do
expect { subject }.not_to change { Ci::JobArtifact.count }
end
end
end end
end end
...@@ -1720,6 +1720,78 @@ describe NotificationService, :mailer do ...@@ -1720,6 +1720,78 @@ describe NotificationService, :mailer do
end end
end end
describe 'Pages domains' do
set(:project) { create(:project) }
set(:domain) { create(:pages_domain, project: project) }
set(:u_blocked) { create(:user, :blocked) }
set(:u_silence) { create_user_with_notification(:disabled, 'silent', project) }
set(:u_owner) { project.owner }
set(:u_master1) { create(:user) }
set(:u_master2) { create(:user) }
set(:u_developer) { create(:user) }
before do
project.add_master(u_blocked)
project.add_master(u_silence)
project.add_master(u_master1)
project.add_master(u_master2)
project.add_developer(u_developer)
reset_delivered_emails!
end
%i[
pages_domain_enabled
pages_domain_disabled
pages_domain_verification_succeeded
pages_domain_verification_failed
].each do |sym|
describe "##{sym}" do
subject(:notify!) { notification.send(sym, domain) }
it 'emails current watching masters' do
expect(Notify).to receive(:"#{sym}_email").at_least(:once).and_call_original
notify!
should_only_email(u_master1, u_master2, u_owner)
end
it 'emails nobody if the project is missing' do
domain.project = nil
notify!
should_not_email_anyone
end
end
end
describe '#pages_domain_verification_failed' do
it 'emails current watching masters' do
notification.pages_domain_verification_failed(domain)
should_only_email(u_master1, u_master2, u_owner)
end
end
describe '#pages_domain_enabled' do
it 'emails current watching masters' do
notification.pages_domain_enabled(domain)
should_only_email(u_master1, u_master2, u_owner)
end
end
describe '#pages_domain_disabled' do
it 'emails current watching masters' do
notification.pages_domain_disabled(domain)
should_only_email(u_master1, u_master2, u_owner)
end
end
end
def build_team(project) def build_team(project)
@u_watcher = create_global_setting_for(create(:user), :watch) @u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating) @u_participating = create_global_setting_for(create(:user), :participating)
......
...@@ -564,6 +564,22 @@ describe QuickActions::InterpretService do ...@@ -564,6 +564,22 @@ describe QuickActions::InterpretService do
let(:issuable) { merge_request } let(:issuable) { merge_request }
end end
context 'only group milestones available' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:milestone) { create(:milestone, group: group, title: '10.0') }
it_behaves_like 'milestone command' do
let(:content) { "/milestone %#{milestone.title}" }
let(:issuable) { issue }
end
it_behaves_like 'milestone command' do
let(:content) { "/milestone %#{milestone.title}" }
let(:issuable) { merge_request }
end
end
it_behaves_like 'remove_milestone command' do it_behaves_like 'remove_milestone command' do
let(:content) { '/remove_milestone' } let(:content) { '/remove_milestone' }
let(:issuable) { issue } let(:issuable) { issue }
......
require 'spec_helper'
describe VerifyPagesDomainService do
using RSpec::Parameterized::TableSyntax
include EmailHelpers
let(:error_status) { { status: :error, message: "Couldn't verify #{domain.domain}" } }
subject(:service) { described_class.new(domain) }
describe '#execute' do
context 'verification code recognition (verified domain)' do
where(:domain_sym, :code_sym) do
:domain | :verification_code
:domain | :keyed_verification_code
:verification_domain | :verification_code
:verification_domain | :keyed_verification_code
end
with_them do
set(:domain) { create(:pages_domain) }
let(:domain_name) { domain.send(domain_sym) }
let(:verification_code) { domain.send(code_sym) }
it 'verifies and enables the domain' do
stub_resolver(domain_name => ['something else', verification_code])
expect(service.execute).to eq(status: :success)
expect(domain).to be_verified
expect(domain).to be_enabled
end
it 'verifies and enables when the code is contained partway through a TXT record' do
stub_resolver(domain_name => "something #{verification_code} else")
expect(service.execute).to eq(status: :success)
expect(domain).to be_verified
expect(domain).to be_enabled
end
it 'does not verify when the code is not present' do
stub_resolver(domain_name => 'something else')
expect(service.execute).to eq(error_status)
expect(domain).not_to be_verified
expect(domain).to be_enabled
end
end
context 'verified domain' do
set(:domain) { create(:pages_domain) }
it 'unverifies (but does not disable) when the right code is not present' do
stub_resolver(domain.domain => 'something else')
expect(service.execute).to eq(error_status)
expect(domain).not_to be_verified
expect(domain).to be_enabled
end
it 'unverifies (but does not disable) when no records are present' do
stub_resolver
expect(service.execute).to eq(error_status)
expect(domain).not_to be_verified
expect(domain).to be_enabled
end
end
context 'expired domain' do
set(:domain) { create(:pages_domain, :expired) }
it 'verifies and enables when the right code is present' do
stub_resolver(domain.domain => domain.keyed_verification_code)
expect(service.execute).to eq(status: :success)
expect(domain).to be_verified
expect(domain).to be_enabled
end
it 'disables when the right code is not present' do
error_status[:message] += '. It is now disabled.'
stub_resolver
expect(service.execute).to eq(error_status)
expect(domain).not_to be_verified
expect(domain).not_to be_enabled
end
end
end
context 'timeout behaviour' do
let(:domain) { create(:pages_domain) }
it 'sets a timeout on the DNS query' do
expect(stub_resolver).to receive(:timeouts=).with(described_class::RESOLVER_TIMEOUT_SECONDS)
service.execute
end
end
context 'email notifications' do
let(:notification_service) { instance_double('NotificationService') }
where(:factory, :verification_succeeds, :expected_notification) do
nil | true | nil
nil | false | :verification_failed
:reverify | true | nil
:reverify | false | :verification_failed
:unverified | true | :verification_succeeded
:unverified | false | nil
:expired | true | nil
:expired | false | :disabled
:disabled | true | :enabled
:disabled | false | nil
end
with_them do
let(:domain) { create(:pages_domain, *[factory].compact) }
before do
allow(service).to receive(:notification_service) { notification_service }
if verification_succeeds
stub_resolver(domain.domain => domain.verification_code)
else
stub_resolver
end
end
it 'sends a notification if appropriate' do
if expected_notification
expect(notification_service).to receive(:"pages_domain_#{expected_notification}").with(domain)
end
service.execute
end
end
context 'pages verification disabled' do
let(:domain) { create(:pages_domain, :disabled) }
before do
stub_application_setting(pages_domain_verification_enabled: false)
allow(service).to receive(:notification_service) { notification_service }
end
it 'skips email notifications' do
expect(notification_service).not_to receive(:pages_domain_enabled)
service.execute
end
end
end
context 'pages configuration updates' do
context 'enabling a disabled domain' do
let(:domain) { create(:pages_domain, :disabled) }
it 'schedules an update' do
stub_resolver(domain.domain => domain.verification_code)
expect(domain).to receive(:update_daemon)
service.execute
end
end
context 'verifying an enabled domain' do
let(:domain) { create(:pages_domain) }
it 'schedules an update' do
stub_resolver(domain.domain => domain.verification_code)
expect(domain).not_to receive(:update_daemon)
service.execute
end
end
context 'disabling an expired domain' do
let(:domain) { create(:pages_domain, :expired) }
it 'schedules an update' do
stub_resolver
expect(domain).to receive(:update_daemon)
service.execute
end
end
context 'failing to verify a disabled domain' do
let(:domain) { create(:pages_domain, :disabled) }
it 'does not schedule an update' do
stub_resolver
expect(domain).not_to receive(:update_daemon)
service.execute
end
end
end
context 'no verification code' do
let(:domain) { create(:pages_domain) }
it 'returns an error' do
domain.verification_code = ''
disallow_resolver!
expect(service.execute).to eq(status: :error, message: "No verification code set for #{domain.domain}")
end
end
context 'pages domain verification is disabled' do
let(:domain) { create(:pages_domain, :disabled) }
before do
stub_application_setting(pages_domain_verification_enabled: false)
end
it 'extends domain validity by unconditionally reverifying' do
disallow_resolver!
service.execute
expect(domain).to be_verified
expect(domain).to be_enabled
end
it 'does not shorten any grace period' do
grace = Time.now + 1.year
domain.update!(enabled_until: grace)
disallow_resolver!
service.execute
expect(domain.enabled_until).to be_like_time(grace)
end
end
end
def disallow_resolver!
expect(Resolv::DNS).not_to receive(:open)
end
def stub_resolver(stubbed_lookups = {})
resolver = instance_double('Resolv::DNS')
allow(resolver).to receive(:timeouts=)
expect(Resolv::DNS).to receive(:open).and_yield(resolver)
allow(resolver).to receive(:getresources) { [] }
stubbed_lookups.each do |domain, records|
records = Array(records).map { |txt| Resolv::DNS::Resource::IN::TXT.new(txt) }
allow(resolver).to receive(:getresources).with(domain, Resolv::DNS::Resource::IN::TXT) { records }
end
resolver
end
end
...@@ -30,11 +30,11 @@ RSpec.configure do |config| ...@@ -30,11 +30,11 @@ RSpec.configure do |config|
end end
config.before(:each, :js) do config.before(:each, :js) do
DatabaseCleaner.strategy = :deletion, { except: %w[licenses] } DatabaseCleaner.strategy = :deletion, { except: %w[licenses], cache_tables: false }
end end
config.before(:each, :delete) do config.before(:each, :delete) do
DatabaseCleaner.strategy = :deletion, { except: %w[licenses] } DatabaseCleaner.strategy = :deletion, { except: %w[licenses], cache_tables: false }
end end
config.before(:each, :migration) do config.before(:each, :migration) do
......
require 'spec_helper' require 'spec_helper'
describe AuthorizedProjectsWorker do describe AuthorizedProjectsWorker do
let(:project) { create(:project) }
def build_args_list(*ids, multiply: 1)
args_list = ids.map { |id| [id] }
args_list * multiply
end
describe '.bulk_perform_and_wait' do
it 'schedules the ids and waits for the jobs to complete' do
args_list = build_args_list(project.owner.id)
project.owner.project_authorizations.delete_all
described_class.bulk_perform_and_wait(args_list)
expect(project.owner.project_authorizations.count).to eq(1)
end
it 'inlines workloads <= 3 jobs' do
args_list = build_args_list(project.owner.id, multiply: 3)
expect(described_class).to receive(:bulk_perform_inline).with(args_list)
described_class.bulk_perform_and_wait(args_list)
end
it 'runs > 3 jobs using sidekiq' do
project.owner.project_authorizations.delete_all
expect(described_class).to receive(:bulk_perform_async).and_call_original
args_list = build_args_list(project.owner.id, multiply: 4)
described_class.bulk_perform_and_wait(args_list)
expect(project.owner.project_authorizations.count).to eq(1)
end
end
describe '.bulk_perform_inline' do
it 'refreshes the authorizations inline' do
project.owner.project_authorizations.delete_all
expect_any_instance_of(described_class).to receive(:perform).and_call_original
described_class.bulk_perform_inline(build_args_list(project.owner.id))
expect(project.owner.project_authorizations.count).to eq(1)
end
it 'enqueues jobs if an error is raised' do
invalid_id = -1
args_list = build_args_list(project.owner.id, invalid_id)
allow_any_instance_of(described_class).to receive(:perform).with(project.owner.id)
allow_any_instance_of(described_class).to receive(:perform).with(invalid_id).and_raise(ArgumentError)
expect(described_class).to receive(:bulk_perform_async).with(build_args_list(invalid_id))
described_class.bulk_perform_inline(args_list)
end
end
describe '.bulk_perform_async' do
it "uses it's respective sidekiq queue" do
args_list = build_args_list(project.owner.id)
push_bulk_args = {
'class' => described_class,
'args' => args_list
}
expect(Sidekiq::Client).to receive(:push_bulk).with(push_bulk_args).once
described_class.bulk_perform_async(args_list)
end
end
describe '#perform' do describe '#perform' do
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -85,12 +12,6 @@ describe AuthorizedProjectsWorker do ...@@ -85,12 +12,6 @@ describe AuthorizedProjectsWorker do
job.perform(user.id) job.perform(user.id)
end end
it 'notifies the JobWaiter when done if the key is provided' do
expect(Gitlab::JobWaiter).to receive(:notify).with('notify-key', job.jid)
job.perform(user.id, 'notify-key')
end
context "when the user is not found" do context "when the user is not found" do
it "does nothing" do it "does nothing" do
expect_any_instance_of(User).not_to receive(:refresh_authorized_projects) expect_any_instance_of(User).not_to receive(:refresh_authorized_projects)
......
require 'spec_helper'
describe WaitableWorker do
let(:worker) do
Class.new do
def self.name
'Gitlab::Foo::Bar::DummyWorker'
end
class << self
cattr_accessor(:counter) { 0 }
end
include ApplicationWorker
prepend WaitableWorker
def perform(i = 0)
self.class.counter += i
end
end
end
subject(:job) { worker.new }
describe '.bulk_perform_and_wait' do
it 'schedules the jobs and waits for them to complete' do
worker.bulk_perform_and_wait([[1], [2]])
expect(worker.counter).to eq(3)
end
it 'inlines workloads <= 3 jobs' do
args_list = [[1], [2], [3]]
expect(worker).to receive(:bulk_perform_inline).with(args_list).and_call_original
worker.bulk_perform_and_wait(args_list)
expect(worker.counter).to eq(6)
end
it 'runs > 3 jobs using sidekiq' do
expect(worker).to receive(:bulk_perform_async)
worker.bulk_perform_and_wait([[1], [2], [3], [4]])
end
end
describe '.bulk_perform_inline' do
it 'runs the jobs inline' do
expect(worker).not_to receive(:bulk_perform_async)
worker.bulk_perform_inline([[1], [2]])
expect(worker.counter).to eq(3)
end
it 'enqueues jobs if an error is raised' do
expect(worker).to receive(:bulk_perform_async).with([['foo']])
worker.bulk_perform_inline([[1], ['foo']])
end
end
describe '#perform' do
shared_examples 'perform' do
it 'notifies the JobWaiter when done if the key is provided' do
key = Gitlab::JobWaiter.new.key
expect(Gitlab::JobWaiter).to receive(:notify).with(key, job.jid)
job.perform(*args, key)
end
it 'does not notify the JobWaiter when done if no key is provided' do
expect(Gitlab::JobWaiter).not_to receive(:notify)
job.perform(*args)
end
end
context 'when the worker takes arguments' do
let(:args) { [1] }
it_behaves_like 'perform'
end
context 'when the worker takes no arguments' do
let(:args) { [] }
it_behaves_like 'perform'
end
end
end
require 'spec_helper'
describe PagesDomainVerificationCronWorker do
subject(:worker) { described_class.new }
describe '#perform' do
it 'enqueues a PagesDomainVerificationWorker for domains needing verification' do
verified = create(:pages_domain)
reverify = create(:pages_domain, :reverify)
disabled = create(:pages_domain, :disabled)
[reverify, disabled].each do |domain|
expect(PagesDomainVerificationWorker).to receive(:perform_async).with(domain.id)
end
expect(PagesDomainVerificationWorker).not_to receive(:perform_async).with(verified.id)
worker.perform
end
end
end
require 'spec_helper'
describe PagesDomainVerificationWorker do
subject(:worker) { described_class.new }
let(:domain) { create(:pages_domain) }
describe '#perform' do
it 'does nothing for a non-existent domain' do
domain.destroy
expect(VerifyPagesDomainService).not_to receive(:new)
expect { worker.perform(domain.id) }.not_to raise_error
end
it 'delegates to VerifyPagesDomainService' do
service = double(:service)
expected_domain = satisfy { |obj| obj == domain }
expect(VerifyPagesDomainService).to receive(:new).with(expected_domain) { service }
expect(service).to receive(:execute)
worker.perform(domain.id)
end
end
end
...@@ -2,32 +2,46 @@ require 'spec_helper' ...@@ -2,32 +2,46 @@ require 'spec_helper'
describe StuckImportJobsWorker do describe StuckImportJobsWorker do
let(:worker) { described_class.new } let(:worker) { described_class.new }
let(:exclusive_lease_uuid) { SecureRandom.uuid }
before do
allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid)
end
shared_examples 'project import job detection' do shared_examples 'project import job detection' do
describe 'long running import' do context 'when the job has completed' do
it 'marks the project as failed' do context 'when the import status was already updated' do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(['123']) before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids) do
project.import_start
project.import_finish
[project.import_jid]
end
end
expect { worker.perform }.to change { project.reload.import_status }.to('failed') it 'does not mark the project as failed' do
worker.perform
expect(project.reload.import_status).to eq('finished')
end
end
context 'when the import status was not updated' do
before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([project.import_jid])
end
it 'marks the project as failed' do
worker.perform
expect(project.reload.import_status).to eq('failed')
end
end end
end end
describe 'running import' do context 'when the job is still in Sidekiq' do
it 'does not mark the project as failed' do before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([]) allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([])
expect { worker.perform }.not_to change { project.reload.import_status }
end end
describe 'import without import_jid' do it 'does not mark the project as failed' do
it 'marks the project as failed' do expect { worker.perform }.not_to change { project.reload.import_status }
expect { worker.perform }.to change { project.reload.import_status }.to('failed')
end
end end
end end
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