Commit cf5801ba authored by Luke Bennett's avatar Luke Bennett

Merge branch 'ce-to-ee-2018-01-11' into 'master'

CE upstream - Thursday

Closes #4511, #4552, gitlab-qa#58, gitaly#793, gitlab-ce#41137, and gitlab-ce#41491

See merge request gitlab-org/gitlab-ee!4023
parents 8c0eb08e 4513deb4
......@@ -8,7 +8,8 @@
"plugins": [
["istanbul", {
"exclude": [
"spec/javascripts/**/*"
"spec/javascripts/**/*",
"app/assets/javascripts/locale/**/app.js"
]
}],
["transform-define", {
......
......@@ -629,6 +629,7 @@ codequality:
paths: [codeclimate.json]
sast:
<<: *except-docs
image: registry.gitlab.com/gitlab-org/gl-sast:latest
before_script: []
script:
......
app/assets/images/multi-editor-on.png

5.34 KB | W: | H:

app/assets/images/multi-editor-on.png

3.88 KB | W: | H:

app/assets/images/multi-editor-on.png
app/assets/images/multi-editor-on.png
app/assets/images/multi-editor-on.png
app/assets/images/multi-editor-on.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -43,8 +43,7 @@
</h5>
<a
:href="mergeRequest.url"
class="issue-link"
>
class="issue-link">
!{{ mergeRequest.iid }}
</a>
&middot;
......@@ -52,8 +51,7 @@
{{ s__('OpenedNDaysAgo|Opened') }}
<a
:href="mergeRequest.url"
class="issue-date"
>
class="issue-date">
{{ mergeRequest.createdAt }}
</a>
</span>
......@@ -61,8 +59,7 @@
{{ s__('ByAuthor|by') }}
<a
:href="mergeRequest.author.webUrl"
class="issue-author-link"
>
class="issue-author-link">
{{ mergeRequest.author.name }}
</a>
</span>
......
......@@ -47,18 +47,14 @@
<a
:href="issue.url"
class="issue-link"
>
#{{ issue.iid }}
</a>
>#{{ issue.iid }}</a>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
<a
:href="issue.url"
class="issue-date"
>
{{ issue.createdAt }}
</a>
>{{ issue.createdAt }}</a>
</span>
<span>
{{ s__('ByAuthor|by') }}
......
......@@ -57,9 +57,7 @@
<a
:href="commit.commitUrl"
class="commit-hash-link commit-sha"
>
{{ commit.shortSha }}
</a>
>{{ commit.shortSha }}</a>
{{ s__('FirstPushedBy|pushed by') }}
<a
:href="commit.author.webUrl"
......
......@@ -46,9 +46,7 @@
<a
:href="mergeRequest.url"
class="issue-link"
>
!{{ mergeRequest.iid }}
</a>
>!{{ mergeRequest.iid }}</a>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
......
......@@ -63,7 +63,8 @@
</a>
<span
class="icon-branch"
v-html="iconBranch">
v-html="iconBranch"
>
</span>
<a
:href="build.commitUrl"
......
......@@ -80,16 +80,14 @@
</span>
<a
:href="build.commitUrl"
class="commit-sha"
>
class="commit-sha">
{{ build.shortSha }}
</a>
</h5>
<span>
<a
:href="build.url"
class="issue-date"
>
class="issue-date">
{{ build.date }}
</a>
</span>
......
......@@ -12,7 +12,6 @@
components: {
loadingIcon,
},
props: {
actions: {
type: Array,
......@@ -69,7 +68,8 @@
<span v-html="playIconSvg"></span>
<i
class="fa fa-caret-down"
aria-hidden="true">
aria-hidden="true"
>
</i>
<loading-icon v-if="isLoading" />
</span>
......@@ -78,8 +78,7 @@
<ul class="dropdown-menu dropdown-menu-align-right">
<li
v-for="(action, i) in actions"
:key="i"
>
:key="i">
<button
type="button"
class="js-manual-action-link no-btn btn"
......
......@@ -3,13 +3,12 @@
import { s__ } from '../../locale';
/**
* Renders the external url link in environments table.
*/
* Renders the external url link in environments table.
*/
export default {
directives: {
tooltip,
},
props: {
externalUrl: {
type: String,
......
<script>
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
* Renders the Monitoring (Metrics) link in environments table.
*/
import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
monitoringUrl: {
type: String,
......
<script>
/**
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
*
* Makes a post request when the button is clicked.
*/
* Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`.
*
* Makes a post request when the button is clicked.
*/
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
......@@ -12,6 +12,7 @@
components: {
loadingIcon,
},
props: {
retryUrl: {
type: String,
......
<script>
/**
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
* Renders the stop "button" that allows stop an environment.
* Used in environments table.
*/
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
......@@ -11,6 +11,7 @@
components: {
loadingIcon,
},
directives: {
tooltip,
},
......
<script>
/**
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
import terminalIconSvg from 'icons/_icon_terminal.svg';
import tooltip from '../../vue_shared/directives/tooltip';
......@@ -10,6 +10,7 @@
directives: {
tooltip,
},
props: {
terminalPath: {
type: String,
......@@ -17,6 +18,7 @@
default: '',
},
},
data() {
return {
terminalIconSvg,
......
......@@ -32,7 +32,6 @@ export default {
default: false,
},
},
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
......@@ -88,8 +87,7 @@ export default {
</div>
<template
v-for="(model, i) in environments"
:model="model"
>
:model="model">
<div
is="environment-item"
:model="model"
......@@ -117,8 +115,7 @@ export default {
>
<div
v-if="model.isLoadingFolderContent"
:key="i"
>
:key="i">
<loading-icon size="2" />
</div>
......
......@@ -29,7 +29,6 @@
required: true,
},
},
methods: {
successCallback(resp) {
this.saveData(resp);
......
<script>
/* global Flash */
import { s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import modal from '~/vue_shared/components/modal.vue';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import { COMMON_STR } from '../constants';
import { mergeUrlParams } from '../../lib/utils/url_utility';
import groupsComponent from './groups.vue';
export default {
components: {
loadingIcon,
modal,
groupsComponent,
},
props: {
......@@ -32,6 +36,10 @@ export default {
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
showModal: false,
groupLeaveConfirmationMessage: '',
targetGroup: null,
targetParentGroup: null,
};
},
computed: {
......@@ -48,7 +56,7 @@ export default {
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleChildren', this.toggleChildren);
eventHub.$on('leaveGroup', this.leaveGroup);
eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal);
eventHub.$on('updatePagination', this.updatePagination);
eventHub.$on('updateGroups', this.updateGroups);
},
......@@ -58,7 +66,7 @@ export default {
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleChildren', this.toggleChildren);
eventHub.$off('leaveGroup', this.leaveGroup);
eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal);
eventHub.$off('updatePagination', this.updatePagination);
eventHub.$off('updateGroups', this.updateGroups);
},
......@@ -141,14 +149,23 @@ export default {
parentGroup.isOpen = false;
}
},
leaveGroup(group, parentGroup) {
const targetGroup = group;
targetGroup.isBeingRemoved = true;
this.service.leaveGroup(targetGroup.leavePath)
showLeaveGroupModal(group, parentGroup) {
this.targetGroup = group;
this.targetParentGroup = parentGroup;
this.showModal = true;
this.groupLeaveConfirmationMessage = s__(`GroupsTree|Are you sure you want to leave the "${group.fullName}" group?`);
},
hideLeaveGroupModal() {
this.showModal = false;
},
leaveGroup() {
this.showModal = false;
this.targetGroup.isBeingRemoved = true;
this.service.leaveGroup(this.targetGroup.leavePath)
.then(res => res.json())
.then((res) => {
$.scrollTo(0);
this.store.removeGroup(targetGroup, parentGroup);
this.store.removeGroup(this.targetGroup, this.targetParentGroup);
Flash(res.notice, 'notice');
})
.catch((err) => {
......@@ -157,7 +174,7 @@ export default {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
Flash(message);
targetGroup.isBeingRemoved = false;
this.targetGroup.isBeingRemoved = false;
});
},
updatePagination(headers) {
......@@ -190,5 +207,14 @@ export default {
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
/>
<modal
v-show="showModal"
:primary-button-label="__('Leave')"
kind="warning"
:title="__('Are you sure?')"
:text="groupLeaveConfirmationMessage"
@cancel="hideLeaveGroupModal"
@submit="leaveGroup"
/>
</div>
</template>
<script>
import { s__ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import modal from '~/vue_shared/components/modal.vue';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
export default {
components: {
icon,
modal,
export default {
components: {
icon,
},
directives: {
tooltip,
},
props: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
directives: {
tooltip,
group: {
type: Object,
required: true,
},
props: {
parentGroup: {
type: Object,
required: false,
default: () => ({}),
},
group: {
type: Object,
required: true,
},
},
computed: {
leaveBtnTitle() {
return COMMON_STR.LEAVE_BTN_TITLE;
},
data() {
return {
modalStatus: false,
};
editBtnTitle() {
return COMMON_STR.EDIT_BTN_TITLE;
},
computed: {
leaveBtnTitle() {
return COMMON_STR.LEAVE_BTN_TITLE;
},
editBtnTitle() {
return COMMON_STR.EDIT_BTN_TITLE;
},
leaveConfirmationMessage() {
return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
},
},
methods: {
onLeaveGroup() {
eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup);
},
methods: {
onLeaveGroup() {
this.modalStatus = true;
},
leaveGroup() {
this.modalStatus = false;
eventHub.$emit('leaveGroup', this.group, this.parentGroup);
},
},
};
},
};
</script>
<template>
......@@ -78,14 +63,5 @@
class="leave-group btn no-expand">
<icon name="leave"/>
</a>
<modal
v-show="modalStatus"
:primary-button-label="__('Leave')"
kind="warning"
:title="__('Are you sure?')"
:text="__('Are you sure you want to leave this group?')"
:body="leaveConfirmationMessage"
@submit="leaveGroup"
/>
</div>
</template>
......@@ -41,7 +41,7 @@ export default {
</div>
</div>
<div>
<repo-tree :tree-id="branch.treeId"/>
<repo-tree :tree-id="branch.treeId" />
</div>
</div>
</template>
......@@ -73,7 +73,8 @@
<div
class="multi-file-loading-container"
v-for="n in 3"
:key="n">
:key="n"
>
<skeleton-loading-container />
</div>
</template>
......
......@@ -61,11 +61,10 @@
v-else
class="vertical-center render-error">
<p class="text-center">
The source could not be displayed because a rendering error occurred. You can
<a
The source could not be displayed because a rendering error occurred.
You can <a
:href="activeFile.rawPath"
download
>download</a> it instead.
download>download</a> it instead.
</p>
</div>
</div>
......
......@@ -267,6 +267,7 @@
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
......
......@@ -66,7 +66,6 @@
window.addEventListener('resize', this.resizeThrottled, false);
}
},
methods: {
getGraphsData() {
this.state = 'loading';
......
......@@ -259,7 +259,8 @@
<svg
class="graph-data"
:viewBox="innerViewBox"
ref="graphData">
ref="graphData"
>
<graph-path
v-for="(path, index) in timeSeries"
:key="index"
......
<script>
import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
import Icon from '../../../vue_shared/components/icon.vue';
import icon from '../../../vue_shared/components/icon.vue';
export default {
components: {
Icon,
icon,
},
props: {
currentXCoordinate: {
......
......@@ -51,6 +51,7 @@
return `note_${this.note.id}`;
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
......@@ -59,6 +60,7 @@
}
});
},
methods: {
...mapActions([
'deleteNote',
......
......@@ -14,6 +14,7 @@
directives: {
tooltip,
},
props: {
tooltipText: {
type: String,
......
......@@ -63,7 +63,8 @@
* target the click event of this component.
*/
stopDropdownClickPropagation() {
$(this.$el.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
$(this.$el
.querySelectorAll('.js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item'))
.on('click', (e) => {
e.stopPropagation();
});
......
......@@ -250,7 +250,8 @@
<div
class="blank-state-row"
v-if="shouldRenderNoPipelinesMessage">
v-if="shouldRenderNoPipelinesMessage"
>
<div class="blank-state-center">
<h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2>
</div>
......
......@@ -162,7 +162,8 @@
<ul
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
aria-labelledby="stageDropdown">
aria-labelledby="stageDropdown"
>
<li
:class="dropdownClass"
......
......@@ -96,7 +96,8 @@
<span
v-tooltip
:title="item.revision"
data-placement="bottom">
data-placement="bottom"
>
{{ item.shortRevision }}
</span>
</td>
......@@ -121,10 +122,12 @@
:aria-label="s__('ContainerRegistry|Remove tag')"
data-container="body"
v-tooltip
@click="handleDeleteRegistry(item)">
@click="handleDeleteRegistry(item)"
>
<i
class="fa fa-trash"
aria-hidden="true">
aria-hidden="true"
>
</i>
</button>
</td>
......
......@@ -101,7 +101,8 @@
<div
v-for="participant in visibleParticipants"
:key="participant.id"
class="participants-author js-participants-author">
class="participants-author js-participants-author"
>
<a
class="author_link"
:href="participant.web_url"
......
......@@ -28,7 +28,6 @@ export default {
beforeDestroy() {
eventHub.$off('toggleSubscription', this.onToggleSubscription);
},
methods: {
onToggleSubscription() {
this.mediator.toggleSubscription()
......
......@@ -126,7 +126,6 @@
<template v-if="pipeline.coverage">
Coverage {{ pipeline.coverage }}%
</template>
</div>
</template>
</div>
......
......@@ -127,7 +127,8 @@
v-if="action.type === 'link'"
:href="action.path"
:class="action.cssClass"
:key="i">
:key="i"
>
{{ action.label }}
</a>
......
......@@ -121,7 +121,8 @@
/>
<div
class="md-write-holder"
v-show="!previewMarkdown">
v-show="!previewMarkdown"
>
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
......
......@@ -71,13 +71,15 @@
class="js-preview-link"
href="#md-preview-holder"
tabindex="-1"
@click.prevent="previewMarkdownTab($event)">
@click.prevent="previewMarkdownTab($event)"
>
Preview
</a>
</li>
<li
class="md-header-toolbar"
:class="{ active: !previewMarkdown }">
:class="{ active: !previewMarkdown }"
>
<toolbar-button
tag="**"
button-title="Add bold text"
......@@ -125,7 +127,8 @@
data-container="body"
tabindex="-1"
title="Go full screen"
type="button">
type="button"
>
<icon
name="screen-full"
/>
......
......@@ -16,7 +16,6 @@
return this.quickActionsDocsPath !== '';
},
},
};
</script>
......@@ -27,7 +26,8 @@
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
tabindex="-1"
>
Markdown is supported
</a>
</template>
......
<script>
/* eslint-disable vue/require-default-prop */
/* eslint-disable vue/require-default-prop */
export default {
name: 'Modal',
......
<script>
import modal from './modal.vue';
import modal from './modal.vue';
export default {
name: 'RecaptchaModal',
export default {
name: 'RecaptchaModal',
components: {
modal,
},
components: {
modal,
},
props: {
html: {
type: String,
required: false,
default: '',
props: {
html: {
type: String,
required: false,
default: '',
},
},
},
data() {
return {
script: {},
scriptSrc: 'https://www.google.com/recaptcha/api.js',
};
},
data() {
return {
script: {},
scriptSrc: 'https://www.google.com/recaptcha/api.js',
};
},
watch: {
html() {
this.appendRecaptchaScript();
watch: {
html() {
this.appendRecaptchaScript();
},
},
},
mounted() {
window.recaptchaDialogCallback = this.submit.bind(this);
},
mounted() {
window.recaptchaDialogCallback = this.submit.bind(this);
},
methods: {
appendRecaptchaScript() {
this.removeRecaptchaScript();
methods: {
appendRecaptchaScript() {
this.removeRecaptchaScript();
const script = document.createElement('script');
script.src = this.scriptSrc;
script.classList.add('js-recaptcha-script');
script.async = true;
script.defer = true;
const script = document.createElement('script');
script.src = this.scriptSrc;
script.classList.add('js-recaptcha-script');
script.async = true;
script.defer = true;
this.script = script;
this.script = script;
document.body.appendChild(script);
},
document.body.appendChild(script);
},
removeRecaptchaScript() {
if (this.script instanceof Element) this.script.remove();
},
removeRecaptchaScript() {
if (this.script instanceof Element) this.script.remove();
},
close() {
this.removeRecaptchaScript();
this.$emit('close');
},
close() {
this.removeRecaptchaScript();
this.$emit('close');
},
submit() {
this.$el.querySelector('form').submit();
submit() {
this.$el.querySelector('form').submit();
},
},
},
};
};
</script>
<template>
......
......@@ -18,14 +18,9 @@
margin: $gl-padding 0;
&.limited-width-container .file-content {
max-width: $limited-layout-width-sm;
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
@media (min-width: $screen-md-min) {
padding-top: 64px;
padding-bottom: 64px;
}
}
}
......@@ -128,7 +123,7 @@
}
&.wiki {
padding: 30px $gl-padding;
padding: $gl-padding;
}
&.blob-no-preview {
......
......@@ -16,12 +16,6 @@
display: inline-block;
}
@media (min-width: $screen-md-min) {
.blob-viewer[data-type="rich"] {
margin: 20px;
}
}
.ide-view {
display: flex;
height: calc(100vh - #{$header-height});
......
class Projects::Clusters::GcpController < Projects::ApplicationController
before_action :authorize_read_cluster!
before_action :authorize_google_api, except: [:login]
before_action :authorize_google_project_billing, only: [:new]
before_action :authorize_google_project_billing, only: [:new, :create]
before_action :authorize_create_cluster!, only: [:new, :create]
before_action :verify_billing, only: [:create]
def login
begin
......@@ -23,24 +24,34 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
end
def create
@cluster = ::Clusters::CreateService
.new(project, current_user, create_params)
.execute(token_in_session)
if @cluster.persisted?
redirect_to project_cluster_path(project, @cluster)
else
render :new
end
end
private
def verify_billing
case google_project_billing_status
when 'true'
@cluster = ::Clusters::CreateService
.new(project, current_user, create_params)
.execute(token_in_session)
return redirect_to project_cluster_path(project, @cluster) if @cluster.persisted?
return
when 'false'
flash[:error] = _('Please enable billing for one of your projects to be able to create a cluster.')
flash[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" }
else
flash[:error] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
flash[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
end
@cluster = ::Clusters::Cluster.new(create_params)
render :new
end
private
def create_params
params.require(:cluster).permit(
:enabled,
......
......@@ -46,7 +46,7 @@ module BlobHelper
end
def ide_edit_text
"#{_('Multi Edit')} <span class='label label-primary'>#{_('Beta')}</span>".html_safe
"#{_('Web IDE')}"
end
def ide_blob_link(project = @project, ref = @ref, path = @path, options = {})
......
......@@ -49,6 +49,7 @@ class MergeRequestDiff < ActiveRecord::Base
ensure_commit_shas
save_commits
save_diffs
save
keep_around_commits
end
......@@ -56,7 +57,6 @@ class MergeRequestDiff < ActiveRecord::Base
self.start_commit_sha ||= merge_request.target_branch_sha
self.head_commit_sha ||= merge_request.source_branch_sha
self.base_commit_sha ||= find_base_sha
save
end
# Override head_commit_sha to keep compatibility with merge request diff
......@@ -195,7 +195,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits_count
merge_request_diff_commits.size
super || merge_request_diff_commits.size
end
private
......@@ -264,13 +264,16 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :overflow if diff_collection.overflow?
end
update(new_attributes)
assign_attributes(new_attributes)
end
def save_commits
MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
merge_request_diff_commits.reload
# merge_request_diff_commits.reload is preferred way to reload associated
# objects but it returns cached result for some reason in this case
commits = merge_request_diff_commits(true)
self.commits_count = commits.size
end
def repository
......
......@@ -1161,7 +1161,7 @@ class Project < ActiveRecord::Base
def change_head(branch)
if repository.branch_exists?(branch)
repository.before_change_head
repository.write_ref('HEAD', "refs/heads/#{branch}")
repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}", shell: false)
repository.copy_gitattributes(branch)
repository.after_change_head
reload_default_branch
......
......@@ -267,7 +267,7 @@ class Repository
# This will still fail if the file is corrupted (e.g. 0 bytes)
begin
write_ref(keep_around_ref_name(sha), sha)
raw_repository.write_ref(keep_around_ref_name(sha), sha, shell: false)
rescue Rugged::ReferenceError => ex
Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}"
rescue Rugged::OSError => ex
......@@ -281,10 +281,6 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
def write_ref(ref_path, sha)
rugged.references.create(ref_path, sha, force: true)
end
def diverging_commit_counts(branch)
root_ref_hash = raw_repository.commit(root_ref).id
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
......
......@@ -2,7 +2,10 @@ class CheckGcpProjectBillingService
def execute(token)
client = GoogleApi::CloudPlatform::Client.new(token, nil)
client.projects_list.select do |project|
client.projects_get_billing_info(project.name).billingEnabled
begin
client.projects_get_billing_info(project.project_id).billing_enabled
rescue
end
end
end
end
......@@ -24,7 +24,7 @@ module Issues
@new_issue = create_new_issue
rewrite_notes
rewrite_award_emoji
rewrite_issue_award_emoji
add_note_moved_from
# Old issue tasks
......@@ -76,7 +76,7 @@ module Issues
end
def rewrite_notes
@old_issue.notes.find_each do |note|
@old_issue.notes_with_associations.find_each do |note|
new_note = note.dup
new_params = { project: @new_project, noteable: @new_issue,
note: rewrite_content(new_note.note),
......@@ -84,13 +84,19 @@ module Issues
updated_at: note.updated_at }
new_note.update(new_params)
rewrite_award_emoji(note, new_note)
end
end
def rewrite_award_emoji
@old_issue.award_emoji.each do |award|
def rewrite_issue_award_emoji
rewrite_award_emoji(@old_issue, @new_issue)
end
def rewrite_award_emoji(old_awardable, new_awardable)
old_awardable.award_emoji.each do |award|
new_award = award.dup
new_award.awardable = @new_issue
new_award.awardable = new_awardable
new_award.save
end
end
......
......@@ -156,13 +156,9 @@ module MergeRequests
end
def assign_title_from_issue
return unless issue
return unless issue && issue.is_a?(Issue)
merge_request.title =
case issue
when Issue then "Resolve \"#{issue.title}\""
when ExternalIssue then "Resolve #{issue.title}"
end
merge_request.title = "Resolve \"#{issue.title}\""
end
def issue_iid
......
module MergeRequests
class RebaseService < MergeRequests::WorkingCopyBaseService
REBASE_ERROR = 'Rebase failed. Please rebase locally'.freeze
def execute(merge_request)
@merge_request = merge_request
if rebase
success
else
error('Failed to rebase. Should be done manually')
error(REBASE_ERROR)
end
end
......@@ -22,8 +24,8 @@ module MergeRequests
true
rescue => e
log_error('Failed to rebase branch:')
log_error(e.message, save_message_on_model: true)
log_error(REBASE_ERROR, save_message_on_model: true)
log_error(e.message)
false
end
end
......
......@@ -56,8 +56,6 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
%li
= link_to "Turn on multi edit", profile_preferences_path
- if current_user
%li
= link_to "Help", help_path
......
......@@ -5,8 +5,8 @@
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4
%h4.prepend-top-0
GitLab multi file editor
%p Unlock an additional editing experience which makes it possible to edit and commit multiple files
Web IDE (Beta)
%p Enable the new web IDE on this device to make it possible to open and edit multiple files with a single commit
.col-lg-8.multi-file-editor-options
= label_tag do
.preview.append-bottom-10= image_tag "multi-editor-off.png"
......
......@@ -30,12 +30,13 @@
%li
= link_to project_new_blob_path(@project, @project.default_branch || 'master') do
#{ _('New file') }
%li
= link_to new_project_branch_path(@project) do
#{ _('New branch') }
%li
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
- unless @project.empty_repo?
%li
= link_to new_project_branch_path(@project) do
#{ _('New branch') }
%li
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
- elsif current_user && current_user.already_forked?(@project)
%li
= link_to project_new_blob_path(@project, @project.default_branch || 'master') do
......
......@@ -4,11 +4,11 @@
= s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:')
%ul
%li
- link_to_kubernetes_engine = link_to(s_('ClusterIntegration|access to Google Kubernetes Engine'), 'https://console.cloud.google.com', target: '_blank', rel: 'noopener noreferrer')
- link_to_kubernetes_engine = link_to(s_('ClusterIntegration|access to Google Kubernetes Engine'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Your account must have %{link_to_kubernetes_engine}').html_safe % { link_to_kubernetes_engine: link_to_kubernetes_engine }
%li
- link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/kubernetes-engine/docs/quickstart', target: '_blank', rel: 'noopener noreferrer')
- link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/kubernetes-engine/docs/quickstart?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements }
%li
- link_to_container_project = link_to(s_('ClusterIntegration|Google Kubernetes Engine project'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer')
- link_to_container_project = link_to(s_('ClusterIntegration|Google Kubernetes Engine project'), 'https://console.cloud.google.com/home/dashboard?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below').html_safe % { link_to_container_project: link_to_container_project }
- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs "Clusters", project_clusters_path(@project)
- breadcrumb_title @cluster.id
- breadcrumb_title @cluster.name
- page_title _("Cluster")
- expanded = Rails.env.test?
......
......@@ -35,3 +35,6 @@
- if diff_file.mode_changed?
%small
#{diff_file.a_mode}#{diff_file.b_mode}
- if diff_file.stored_externally? && diff_file.external_storage == :lfs
%span.label.label-lfs.append-right-5 LFS
......@@ -4,7 +4,7 @@ class CheckGcpProjectBillingWorker
include ApplicationWorker
include ClusterQueue
LEASE_TIMEOUT = 15.seconds.to_i
LEASE_TIMEOUT = 3.seconds.to_i
SESSION_KEY_TIMEOUT = 5.minutes
BILLING_TIMEOUT = 1.hour
......@@ -23,13 +23,13 @@ class CheckGcpProjectBillingWorker
end
def self.redis_shared_state_key_for(token)
"gitlab:gcp:#{token.hash}:billing_enabled"
"gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled"
end
def perform(token_key)
return unless token_key
token = self.get_session_token(token_key)
token = self.class.get_session_token(token_key)
return unless token
return unless try_obtain_lease_for(token)
......
---
title: Default merge request title is set correctly again when external issue tracker is activated
merge_request: 16356
author: Ben305
type: fixed
---
title: Store number of commits in merge_request_diffs table.
merge_request:
author:
type: performance
---
title: Add `pipelines` endpoint to merge requests API
merge_request: 15454
author: Tony Rom <thetonyrom@gmail.com>
type: added
---
title: Hide new branch and tag links for projects with an empty repo
merge_request:
author:
type: fixed
---
title: Display user friendly error message if rebase fails.
merge_request:
author:
type: fixed
---
title: Make project README containers wider on fixed layout
merge_request: 16181
author: Takuya Noguchi
type: fixed
---
title: Make modal dialog common for Groups tree app
merge_request: 16311
author:
type: fixed
---
title: Make rich blob viewer wider for PC
merge_request: 16262
author: Takuya Noguchi
type: fixed
---
title: Fix web ide user preferences copy and buttons
merge_request: 41789
author:
type: other
---
title: Add rake task to check integrity of uploaded files
merge_request:
author:
type: added
---
title: Fix bug where award emojis would be lost when moving issues between projects
merge_request:
author:
type: fixed
class AddCommitsCountToMergeRequestDiff < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
MIGRATION = 'AddMergeRequestDiffCommitsCount'.freeze
BATCH_SIZE = 5000
DELAY_INTERVAL = 5.minutes.to_i
class MergeRequestDiff < ActiveRecord::Base
self.table_name = 'merge_request_diffs'
include ::EachBatch
end
disable_ddl_transaction!
def up
add_column :merge_request_diffs, :commits_count, :integer
say 'Populating the MergeRequestDiff `commits_count`'
queue_background_migration_jobs_by_range_at_intervals(MergeRequestDiff, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
end
def down
remove_column :merge_request_diffs, :commits_count
end
end
......@@ -1397,6 +1397,7 @@ ActiveRecord::Schema.define(version: 20180105233807) do
t.string "real_size"
t.string "head_commit_sha"
t.string "start_commit_sha"
t.integer "commits_count"
end
add_index "merge_request_diffs", ["merge_request_id", "id"], name: "index_merge_request_diffs_on_merge_request_id_and_id", using: :btree
......
......@@ -66,3 +66,15 @@ On the sign in page there should now be a Crowd tab in the sign in form.
[reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure
[restart]: ../restart_gitlab.md#installations-from-source
## Troubleshooting
If you see an error message like the one below when you sign in after Crowd authentication is configured, you may want to consult the Crowd administrator for the Crowd log file to know the exact cause:
```
could not authorize you from Crowd because invalid credentials
```
Please make sure the Crowd users who need to login to GitLab are authorized to [the application](#configure-a-new-crowd-application) in the step of **Authorisation**. This could be verified by try "Authentication test" for Crowd as of 2.11.
![Example Crowd application authorisation configuration](img/crowd_application_authorisation.png)
\ No newline at end of file
......@@ -76,6 +76,39 @@ Example output:
![gitlab:user:check_repos output](../img/raketasks/check_repos_output.png)
## Uploaded Files Integrity
The uploads check Rake task will loop through all uploads in the database
and run two checks to determine the integrity of each file:
1. Check if the file exist on the file system.
1. Check if the checksum of the file on the file system matches the checksum in the database.
**Omnibus Installation**
```
sudo gitlab-rake gitlab:uploads:check
```
**Source Installation**
```bash
sudo -u git -H bundle exec rake gitlab:uploads:check RAILS_ENV=production
```
This task also accepts some environment variables which you can use to override
certain values:
Variable | Type | Description
-------- | ---- | -----------
`BATCH` | integer | Specifies the size of the batch. Defaults to 200.
`ID_FROM` | integer | Specifies the ID to start from, inclusive of the value.
`ID_TO` | integer | Specifies the ID value to end at, inclusive of the value.
```bash
sudo gitlab-rake gitlab:uploads:check BATCH=100 ID_FROM=50 ID_TO=250
```
## LDAP Check
The LDAP check Rake task will test the bind_dn and password credentials
......
......@@ -474,6 +474,30 @@ Parameters:
}
```
## List MR pipelines
Get a list of merge request pipelines.
```
GET /projects/:id/merge_requests/:merge_request_iid/pipelines
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `merge_request_iid` (required) - The internal ID of the merge request
```json
[
{
"id": 77,
"sha": "959e04d7c7a30600c894bd3c0cd0e1ce7f42c11d",
"ref": "master",
"status": "success"
}
]
```
## Create MR
Creates a new merge request.
......
......@@ -368,7 +368,6 @@ Please check this [rules][eslint-plugin-vue-rules] for more documentation.
// bad
<component
bar="bar" />
```
#### Quotes
......@@ -520,7 +519,6 @@ On those a default key should not be provided.
1. Properties in a Vue Component:
Check [order of properties in components rule][vue-order].
#### Vue and Bootstrap
1. Tooltips: Do not rely on `has-tooltip` class name for Vue components
......
......@@ -25,7 +25,7 @@ It is possible to run end-to-end tests (eventually being run within a
the `package-qa` manual action, that should be present in a merge request
widget.
Mmanual action that starts end-to-end tests is also available in merge requests
Manual action that starts end-to-end tests is also available in merge requests
in Omnibus GitLab project.
Below you can read more about how to use it and how does it work.
......
......@@ -228,7 +228,7 @@ instance and project. In addition, all admins can use the admin interface under
|---------------------------------------|-----------------|-------------|----------|--------|
| See commits and jobs | ✓ | ✓ | ✓ | ✓ |
| Retry or cancel job | | ✓ | ✓ | ✓ |
| Erase job artifacts and trace | | ✓ [^7] | ✓ | ✓ |
| Erase job artifacts and trace | | ✓ [^5] | ✓ | ✓ |
| Remove project | | | ✓ | ✓ |
| Create project | | | ✓ | ✓ |
| Change project configuration | | | ✓ | ✓ |
......@@ -251,13 +251,13 @@ users:
| Run CI job | | ✓ | ✓ | ✓ |
| Clone source and LFS from current project | | ✓ | ✓ | ✓ |
| Clone source and LFS from public projects | | ✓ | ✓ | ✓ |
| Clone source and LFS from internal projects | | ✓ [^5] | ✓ [^5] | ✓ |
| Clone source and LFS from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] |
| Clone source and LFS from internal projects | | ✓ [^6] | ✓ [^6] | ✓ |
| Clone source and LFS from private projects | | ✓ [^7] | ✓ [^7] | ✓ [^7] |
| Push source and LFS | | | | |
| Pull container images from current project | | ✓ | ✓ | ✓ |
| Pull container images from public projects | | ✓ | ✓ | ✓ |
| Pull container images from internal projects| | ✓ [^5] | ✓ [^5] | ✓ |
| Pull container images from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] |
| Pull container images from internal projects| | ✓ [^6] | ✓ [^6] | ✓ |
| Pull container images from private projects | | ✓ [^7] | ✓ [^7] | ✓ [^7] |
| Push container images to current project | | ✓ | ✓ | ✓ |
| Push container images to other projects | | | | |
......@@ -287,13 +287,14 @@ with the permissions described on the documentation on [auditor users permission
Auditor users are available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/)
only.
[^1]: On public and internal projects, all users are able to perform this action.
[^1]: On public and internal projects, all users are able to perform this action
[^2]: Guest users can only view the confidential issues they created themselves
[^3]: If **Public pipelines** is enabled in **Project Settings > CI/CD**
[^4]: Not allowed for Guest, Reporter, Developer, Master, or Owner
[^5]: Only if user is not external one.
[^6]: Only if user is a member of the project.
[^7]: Only if the build was triggered by the user
[^5]: Only if the job was triggered by the user
[^6]: Only if user is not external one
[^7]: Only if user is a member of the project
[ce-18994]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18994
[new-mod]: project/new_ci_build_permissions_model.md
[ee-998]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/998
......
......@@ -23,9 +23,14 @@ following prerequisites must be met.
be enabled in GitLab at the instance level. If that's not the case, ask your
GitLab administrator to enable it.
- Your associated Google account must have the right privileges to manage
clusters on GKE. That would mean that a
[billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account)
must be set up.
clusters on GKE. That would mean that a [billing
account](https://cloud.google.com/billing/docs/how-to/manage-billing-account)
must be set up and that you have to have permissions to access it.
- You must have Master [permissions] in order to be able to access the
**Cluster** page.
- You must have [Cloud Billing API](https://cloud.google.com/billing/) enabled
- You must have [Resource Manager
API](https://cloud.google.com/resource-manager/)
**For an existing Kubernetes cluster:**
......
......@@ -10,12 +10,7 @@ in the table below.
| `description` | A name for the issue tracker (to differentiate between instances, for example) |
| `project_url` | The URL to the project in Redmine which is being linked to this GitLab project |
| `issues_url` | The URL to the issue in Redmine project that is linked to this GitLab project. Note that the `issues_url` requires `:id` in the URL. This ID is used by GitLab as a placeholder to replace the issue number. |
| `new_issue_url` | This is the URL to create a new issue in Redmine for the project linked to this GitLab project |
Once you have configured and enabled Redmine:
- the **Issues** link on the GitLab project pages takes you to the appropriate
Redmine issue index
- clicking **New issue** on the project dashboard creates a new Redmine issue
| `new_issue_url` | This is the URL to create a new issue in Redmine for the project linked to this GitLab project. **This is currently not being used and will be removed in a future release.** |
As an example, below is a configuration for a project named gitlab-ci.
......
......@@ -24,6 +24,13 @@ module API
.preload(:notes, :author, :assignee, :milestone, :latest_merge_request_diff, :labels, :timelogs)
end
def merge_request_pipelines_with_access
authorize! :read_pipeline, user_project
mr = find_merge_request_with_access(params[:merge_request_iid])
mr.all_pipelines
end
params :merge_requests_params do
optional :state, type: String, values: %w[opened closed merged all], default: 'all',
desc: 'Return opened, closed, merged, or all merge requests'
......@@ -226,6 +233,15 @@ module API
present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
end
desc 'Get the merge request pipelines' do
success Entities::PipelineBasic
end
get ':id/merge_requests/:merge_request_iid/pipelines' do
pipelines = merge_request_pipelines_with_access
present paginate(pipelines), with: Entities::PipelineBasic
end
desc 'Update a merge request' do
success Entities::MergeRequest
end
......
# frozen_string_literal: true
# rubocop:disable Style/Documentation
# rubocop:disable Metrics/LineLength
module Gitlab
module BackgroundMigration
class AddMergeRequestDiffCommitsCount
class MergeRequestDiff < ActiveRecord::Base
self.table_name = 'merge_request_diffs'
end
def perform(start_id, stop_id)
Rails.logger.info("Setting commits_count for merge request diffs: #{start_id} - #{stop_id}")
update = '
commits_count = (
SELECT count(*)
FROM merge_request_diff_commits
WHERE merge_request_diffs.id = merge_request_diff_commits.merge_request_diff_id
)'.squish
MergeRequestDiff.where(id: start_id..stop_id).update_all(update)
end
end
end
end
......@@ -1104,14 +1104,27 @@ module Gitlab
end
end
def write_ref(ref_path, ref)
def write_ref(ref_path, ref, old_ref: nil, shell: true)
if shell
shell_write_ref(ref_path, ref, old_ref)
else
rugged_write_ref(ref_path, ref)
end
end
def shell_write_ref(ref_path, ref, old_ref)
raise ArgumentError, "invalid ref_path #{ref_path.inspect}" if ref_path.include?(' ')
raise ArgumentError, "invalid ref #{ref.inspect}" if ref.include?("\x00")
raise ArgumentError, "invalid old_ref #{old_ref.inspect}" if !old_ref.nil? && old_ref.include?("\x00")
input = "update #{ref_path}\x00#{ref}\x00\x00"
input = "update #{ref_path}\x00#{ref}\x00#{old_ref}\x00"
run_git!(%w[update-ref --stdin -z]) { |stdin| stdin.write(input) }
end
def rugged_write_ref(ref_path, ref)
rugged.references.create(ref_path, ref, force: true)
end
def fetch_ref(source_repository, source_ref:, target_ref:)
Gitlab::Git.check_namespace!(source_repository)
source_repository = RemoteRepository.new(source_repository) unless source_repository.is_a?(RemoteRepository)
......
......@@ -47,15 +47,15 @@ module GoogleApi
service.authorization = access_token
service.fetch_all(items: :projects) do |token|
service.list_projects(page_token: token)
service.list_projects(page_token: token, options: user_agent_header)
end
end
def projects_get_billing_info(project_name)
def projects_get_billing_info(project_id)
service = Google::Apis::CloudbillingV1::CloudbillingService.new
service.authorization = access_token
service.get_project_billing_info("projects/#{project_name}")
service.get_project_billing_info("projects/#{project_id}", options: user_agent_header)
end
def projects_zones_clusters_get(project_id, zone, cluster_id)
......
namespace :gitlab do
namespace :uploads do
desc 'GitLab | Uploads | Check integrity of uploaded files'
task check: :environment do
puts 'Checking integrity of uploaded files'
uploads_batches do |batch|
batch.each do |upload|
puts "- Checking file (#{upload.id}): #{upload.absolute_path}".color(:green)
if upload.exist?
check_checksum(upload)
else
puts " * File does not exist on the file system".color(:red)
end
end
end
puts 'Done!'
end
def batch_size
ENV.fetch('BATCH', 200).to_i
end
def calculate_checksum(absolute_path)
Digest::SHA256.file(absolute_path).hexdigest
end
def check_checksum(upload)
checksum = calculate_checksum(upload.absolute_path)
if checksum != upload.checksum
puts " * File checksum (#{checksum}) does not match the one in the database (#{upload.checksum})".color(:red)
end
end
def uploads_batches(&block)
Upload.all.in_batches(of: batch_size, start: ENV['ID_FROM'], finish: ENV['ID_TO']) do |relation| # rubocop: disable Cop/InBatches
yield relation
end
end
end
end
......@@ -27,13 +27,17 @@ following call would login to a local [GDK] instance and run all specs in
bin/qa Test::Instance http://localhost:3000
```
### Writing tests
1. [Using page objects](qa/page/README.md)
### Running specific tests
You can also supply specific tests to run as another parameter. For example, to
test the EE license specs, you can run:
run the repository-related specs, you can execute:
```
EE_LICENSE="<YOUR LICENSE KEY>" bin/qa Test::Instance http://localhost qa/specs/features/ee
bin/qa Test::Instance http://localhost qa/specs/features/repository/
```
Since the arguments would be passed to `rspec`, you could use all `rspec`
......
require 'forwardable'
module QA
module Factory
class Base
extend SingleForwardable
def_delegators :evaluator, :dependency, :dependencies
def_delegators :evaluator, :product, :attributes
def fabricate!(*_args)
raise NotImplementedError
end
def self.fabricate!(*args)
Factory::Product.populate!(new) do |factory|
new.tap do |factory|
yield factory if block_given?
dependencies.each do |name, signature|
......@@ -14,19 +21,37 @@ module QA
end
factory.fabricate!(*args)
return Factory::Product.populate!(self)
end
end
def self.dependencies
@dependencies ||= {}
def self.evaluator
@evaluator ||= Factory::Base::DSL.new(self)
end
def self.dependency(factory, as:, &block)
as.tap do |name|
class_eval { attr_accessor name }
class DSL
attr_reader :dependencies, :attributes
def initialize(base)
@base = base
@dependencies = {}
@attributes = {}
end
def dependency(factory, as:, &block)
as.tap do |name|
@base.class_eval { attr_accessor name }
Dependency::Signature.new(factory, block).tap do |signature|
@dependencies.store(name, signature)
end
end
end
Dependency::Signature.new(factory, block).tap do |signature|
dependencies.store(name, signature)
def product(attribute, &block)
Product::Attribute.new(attribute, block).tap do |signature|
@attributes.store(attribute, signature)
end
end
end
......
......@@ -5,8 +5,9 @@ module QA
class Product
include Capybara::DSL
def initialize(factory)
@factory = factory
Attribute = Struct.new(:name, :block)
def initialize
@location = current_url
end
......@@ -15,11 +16,13 @@ module QA
end
def self.populate!(factory)
raise ArgumentError unless block_given?
yield factory
new(factory)
new.tap do |product|
factory.attributes.each_value do |attribute|
product.instance_exec(&attribute.block).tap do |value|
product.define_singleton_method(attribute.name) { value }
end
end
end
end
end
end
......
......@@ -13,6 +13,10 @@ module QA
@description = 'My awesome project'
end
product :name do
Page::Project::Show.act { project_name }
end
def fabricate!
group.visit!
......
# Page objects in GitLab QA
In GitLab QA we are using a known pattern, called _Page Objects_.
This means that we have built an abstraction for all GitLab pages that we use
to drive GitLab QA scenarios. Whenever we do something on a page, like filling
in a form, or clicking a button, we do that only through a page object
associated with this area of GitLab.
For example, when GitLab QA test harness signs in into GitLab, it needs to fill
in a user login and user password. In order to do that, we have a class, called
`Page::Main::Login` and `sign_in_using_credentials` methods, that is the only
piece of the code, that has knowledge about `user_login` and `user_password`
fields.
## Why do we need that?
We need page objects, because we need to reduce duplication and avoid problems
whenever someone changes some selectors in GitLab's source code.
Imagine that we have a hundred specs in GitLab QA, and we need to sign into
GitLab each time, before we make assertions. Without a page object one would
need to rely on volatile helpers or invoke Capybara methods directly. Imagine
invoking `fill_in :user_login` in every `*_spec.rb` file / test example.
When someone later changes `t.text_field :login` in the view associated with
this page to `t.text_field :username` it will generate a different field
identifier, what would effectively break all tests.
Because we are using `Page::Main::Login.act { sign_in_using_credentials }`
everywhere, when we want to sign into GitLab, the page object is the single
source of truth, and we will need to update `fill_in :user_login`
to `fill_in :user_username` only in a one place.
## What problems did we have in the past?
We do not run QA tests for every commit, because of performance reasons, and
the time it would take to build packages and test everything.
That is why when someone changes `t.text_field :login` to
`t.text_field :username` in the _new session_ view we won't know about this
change until our GitLab QA nightly pipeline fails, or until someone triggers
`package-qa` action in their merge request.
Obviously such a change would break all tests. We call this problem a _fragile
tests problem_.
In order to make GitLab QA more reliable and robust, we had to solve this
problem by introducing coupling between GitLab CE / EE views and GitLab QA.
## How did we solve fragile tests problem?
Currently, when you add a new `Page::Base` derived class, you will also need to
define all selectors that your page objects depends on.
Whenever you push your code to CE / EE repository, `qa:selectors` sanity test
job is going to be run as a part of a CI pipeline.
This test is going to validate all page objects that we have implemented in
`qa/page` directory. When it fails, you will be notified about missing
or invalid views / selectors definition.
## How to properly implement a page object?
We have built a DSL to define coupling between a page object and GitLab views
it is actually implemented by. See an example below.
```ruby
module Page
module Main
class Login < Page::Base
view 'app/views/devise/passwords/edit.html.haml' do
element :password_field, 'password_field :password'
element :password_confirmation, 'password_field :password_confirmation'
element :change_password_button, 'submit "Change your password"'
end
view 'app/views/devise/sessions/_new_base.html.haml' do
element :login_field, 'text_field :login'
element :passowrd_field, 'password_field :password'
element :sign_in_button, 'submit "Sign in"'
end
# ...
end
end
```
It is possible to use `element` DSL method without value, with a String value
or with a Regexp.
```ruby
view 'app/views/my/view.html.haml' do
# Require `f.submit "Sign in"` to be present in `my/view.html.haml
element :my_button, 'f.submit "Sign in"'
# Match every line in `my/view.html.haml` against
# `/link_to .* "My Profile"/` regexp.
element :profile_link, /link_to .* "My Profile"/
# Implicitly require `.qa-logout-button` CSS class to be present in the view
element :logout_button
end
```
## Where to ask for help?
If you need more information, ask for help on `#qa` channel on Slack (GitLab
Team only).
If you are not a Team Member, and you still need help to contribute, please
open an issue in GitLab QA issue tracker.
......@@ -4,11 +4,13 @@ module QA
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
Factory::Resource::Project.fabricate! do |project|
created_project = Factory::Resource::Project.fabricate! do |project|
project.name = 'awesome-project'
project.description = 'create awesome project test'
end
expect(created_project.name).to match /^awesome-project-\h{16}$/
expect(page).to have_content(
/Project \S?awesome-project\S+ was successfully created/
)
......
describe QA::Factory::Base do
let(:factory) { spy('factory') }
let(:product) { spy('product') }
describe '.fabricate!' do
subject { Class.new(described_class) }
let(:factory) { spy('factory') }
let(:product) { spy('product') }
before do
allow(QA::Factory::Product).to receive(:new).and_return(product)
......@@ -59,30 +60,63 @@ describe QA::Factory::Base do
it 'defines dependency accessors' do
expect(subject.new).to respond_to :mydep, :mydep=
end
end
describe 'building dependencies' do
let(:dependency) { double('dependency') }
let(:instance) { spy('instance') }
describe 'dependencies fabrication' do
let(:dependency) { double('dependency') }
let(:instance) { spy('instance') }
subject do
Class.new(described_class) do
dependency Some::MyDependency, as: :mydep
end
end
before do
stub_const('Some::MyDependency', dependency)
allow(subject).to receive(:new).and_return(instance)
allow(instance).to receive(:mydep).and_return(nil)
allow(QA::Factory::Product).to receive(:new)
end
it 'builds all dependencies first' do
expect(dependency).to receive(:fabricate!).once
subject.fabricate!
end
end
end
describe '.product' do
subject do
Class.new(described_class) do
dependency Some::MyDependency, as: :mydep
product :token do
page.do_something_on_page!
'resulting value'
end
end
end
before do
stub_const('Some::MyDependency', dependency)
allow(subject).to receive(:new).and_return(instance)
allow(instance).to receive(:mydep).and_return(nil)
allow(QA::Factory::Product).to receive(:new)
it 'appends new product attribute' do
expect(subject.attributes).to be_one
expect(subject.attributes).to have_key(:token)
end
it 'builds all dependencies first' do
expect(dependency).to receive(:fabricate!).once
describe 'populating fabrication product with data' do
let(:page) { spy('page') }
before do
allow(subject).to receive(:new).and_return(factory)
allow(QA::Factory::Product).to receive(:new).and_return(product)
allow(product).to receive(:page).and_return(page)
end
subject.fabricate!
it 'populates product after fabrication' do
subject.fabricate!
expect(page).to have_received(:do_something_on_page!)
expect(product.token).to eq 'resulting value'
end
end
end
end
......@@ -3,19 +3,8 @@ describe QA::Factory::Product do
let(:product) { spy('product') }
describe '.populate!' do
it 'instantiates and yields factory' do
expect(described_class).to receive(:new).with(factory)
described_class.populate!(factory) do |instance|
instance.something = 'string'
end
expect(factory).to have_received(:something=).with('string')
end
it 'returns a fabrication product' do
expect(described_class).to receive(:new)
.with(factory).and_return(product)
expect(described_class).to receive(:new).and_return(product)
result = described_class.populate!(factory) do |instance|
instance.something = 'string'
......@@ -23,11 +12,6 @@ describe QA::Factory::Product do
expect(result).to be product
end
it 'raises unless block given' do
expect { described_class.populate!(factory) }
.to raise_error ArgumentError
end
end
describe '.visit!' do
......@@ -37,8 +21,7 @@ describe QA::Factory::Product do
allow_any_instance_of(described_class)
.to receive(:visit).and_return('visited some url')
expect(described_class.new(factory).visit!)
.to eq 'visited some url'
expect(subject.visit!).to eq 'visited some url'
end
end
end
......@@ -137,11 +137,14 @@ describe Projects::Clusters::GcpController do
context 'when access token is valid' do
before do
stub_google_api_validate_token
allow_any_instance_of(described_class).to receive(:authorize_google_project_billing)
end
context 'when google project billing is enabled' do
before do
stub_google_project_billing_status
redis_double = double
allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double)
allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true')
end
it 'creates a new cluster' do
......@@ -158,7 +161,7 @@ describe Projects::Clusters::GcpController do
it 'renders the cluster form with an error' do
go
expect(response).to set_flash[:error]
expect(response).to set_flash[:alert]
expect(response).to render_template('new')
end
end
......
......@@ -80,8 +80,8 @@ feature 'EE Clusters' do
allow_any_instance_of(Projects::Clusters::GcpController)
.to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
stub_google_project_billing_status
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return('true')
allow_any_instance_of(GoogleApi::CloudPlatform::Client)
.to receive(:projects_zones_clusters_create) do
......
......@@ -8,6 +8,10 @@ describe Geo::RenameRepositoryService do
subject(:service) { described_class.new(project.id, old_path, new_path) }
describe '#execute' do
before do
TestEnv.clean_test_path
end
context 'project backed by legacy storage' do
it 'moves the project repositories' do
expect_any_instance_of(Geo::MoveRepositoryService).to receive(:execute)
......
......@@ -13,6 +13,8 @@ feature 'Gcp Cluster', :js do
end
context 'when user has signed with Google' do
let(:project_id) { 'test-project-1234' }
before do
allow_any_instance_of(Projects::Clusters::GcpController)
.to receive(:token_in_session).and_return('token')
......@@ -23,7 +25,7 @@ feature 'Gcp Cluster', :js do
context 'when user has a GCP project with billing enabled' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
stub_google_project_billing_status
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return('true')
end
context 'when user does not have a cluster and visits cluster index page' do
......@@ -131,15 +133,41 @@ feature 'Gcp Cluster', :js do
context 'when user does not have a GCP project with billing enabled' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return('false')
visit project_clusters_path(project)
click_link 'Add cluster'
click_link 'Create on GKE'
fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
fill_in 'cluster_name', with: 'dev-cluster'
click_button 'Create cluster'
end
it 'user sees form with error' do
expect(page).to have_content('Please enable billing for one of your projects to be able to create a cluster, then try again.')
end
end
context 'when gcp billing status is not in redis' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(nil)
visit project_clusters_path(project)
click_link 'Add cluster'
click_link 'Create on GKE'
fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
fill_in 'cluster_name', with: 'dev-cluster'
click_button 'Create cluster'
end
it 'user sees a check page' do
pending 'the frontend still has not been implemented'
expect(page).to have_link('Continue')
it 'user sees form with error' do
expect(page).to have_content('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
end
end
end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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