Commit 4a6ed479 authored by Stan Hu's avatar Stan Hu

Merge branch 'master' into sh-support-bitbucket-server-import

parents 615e1807 4f7dce76
...@@ -418,7 +418,7 @@ group :ed25519 do ...@@ -418,7 +418,7 @@ group :ed25519 do
end end
# Gitaly GRPC client # Gitaly GRPC client
gem 'gitaly-proto', '~> 0.102.0', require: 'gitaly' gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0' gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed # Locked until https://github.com/google/protobuf/issues/4210 is closed
......
...@@ -282,7 +282,7 @@ GEM ...@@ -282,7 +282,7 @@ GEM
gettext_i18n_rails (>= 0.7.1) gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gitaly-proto (0.102.0) gitaly-proto (0.103.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.10) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
...@@ -1041,7 +1041,7 @@ DEPENDENCIES ...@@ -1041,7 +1041,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.102.0) gitaly-proto (~> 0.103.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2) gitlab-gollum-lib (~> 4.2)
......
...@@ -285,7 +285,7 @@ GEM ...@@ -285,7 +285,7 @@ GEM
gettext_i18n_rails (>= 0.7.1) gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gitaly-proto (0.102.0) gitaly-proto (0.103.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.10) grpc (~> 1.10)
github-linguist (5.3.3) github-linguist (5.3.3)
...@@ -1051,7 +1051,7 @@ DEPENDENCIES ...@@ -1051,7 +1051,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3) gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.102.0) gitaly-proto (~> 0.103.0)
github-linguist (~> 5.3.3) github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2) gitlab-gollum-lib (~> 4.2)
......
...@@ -197,24 +197,7 @@ to. For example: ...@@ -197,24 +197,7 @@ to. For example:
If you think a merge request should go into an RC or patch even though it does not meet these requirements, If you think a merge request should go into an RC or patch even though it does not meet these requirements,
you can ask for an exception to be made. you can ask for an exception to be made.
Go to [Release tasks issue tracker](https://gitlab.com/gitlab-org/release/tasks/issues/new) and create an issue Check [this guide](https://gitlab.com/gitlab-org/release/docs/blob/master/general/exception-request/process.md) about how to open an exception request before opening one.
using the `Exception-request` issue template.
**Do not** set the relevant `Pick into X.Y` label (see above) before request an
exception; this should be done after the exception is approved.
You can find who is who on the [team page](https://about.gitlab.com/team/).
Whether an exception is made is determined by weighing the benefit and urgency of the change
(how important it is to the company that this is released _right now_ instead of in a month)
against the potential negative impact
(things breaking without enough time to comfortably find and fix them before the release on the 22nd).
When in doubt, we err on the side of _not_ cherry-picking.
For example, it is likely that an exception will be made for a trivial 1-5 line performance improvement
(e.g. adding a database index or adding `includes` to a query), but not for a new feature, no matter how relatively small or thoroughly tested.
All MRs which have had exceptions granted must be merged by the 15th.
### Regressions ### Regressions
......
<script> <script>
/* eslint-disable vue/require-default-prop */ /* eslint-disable vue/require-default-prop */
import './issue_card_inner'; import IssueCardInner from './issue_card_inner.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
export default { export default {
name: 'BoardsIssueCard', name: 'BoardsIssueCard',
components: { components: {
'issue-card-inner': gl.issueBoards.IssueCardInner, IssueCardInner,
}, },
props: { props: {
list: { list: {
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,7 @@ export default {
} }
}, },
}, },
}; };
</script> </script>
<template> <template>
......
import $ from 'jquery';
import Vue from 'vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssueCardInner = Vue.extend({
components: {
UserAvatarLink,
},
props: {
issue: {
type: Object,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
list: {
type: Object,
required: false,
default: () => ({}),
},
rootPath: {
type: String,
required: true,
},
updateFilters: {
type: Boolean,
required: false,
default: false,
},
groupId: {
type: Number,
required: false,
default: null,
},
},
data() {
return {
limitBeforeCounter: 3,
maxRender: 4,
maxCounter: 99,
};
},
computed: {
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
return `${this.assigneeCounterLabel} more`;
},
assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
},
issueId() {
if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
},
},
methods: {
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
shouldRenderAssignee(index) {
// Eg. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
if (this.issue.assignees.length <= this.maxRender) {
return index < this.maxRender;
}
return index < this.limitBeforeCounter;
},
assigneeUrl(assignee) {
return `${this.rootPath}${assignee.username}`;
},
assigneeUrlTitle(assignee) {
return `Assigned to ${assignee.name}`;
},
avatarUrlTitle(assignee) {
return `Avatar for ${assignee.name}`;
},
showLabel(label) {
if (!label.id) return false;
return true;
},
filterByLabel(label, e) {
if (!this.updateFilters) return;
const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&');
const labelTitle = encodeURIComponent(label.title);
const param = `label_name[]=${labelTitle}`;
const labelIndex = filterPath.indexOf(param);
$(e.currentTarget).tooltip('hide');
if (labelIndex === -1) {
filterPath.push(param);
} else {
filterPath.splice(labelIndex, 1);
}
gl.issueBoards.BoardsStore.filter.path = filterPath.join('&');
Store.updateFiltersUrl();
eventHub.$emit('updateTokens');
},
labelStyle(label) {
return {
backgroundColor: label.color,
color: label.textColor,
};
},
},
template: `
<div>
<div class="board-card-header">
<h4 class="board-card-title">
<i
class="fa fa-eye-slash confidential-icon"
v-if="issue.confidential"
aria-hidden="true"
/>
<a
class="js-no-trigger"
:href="issue.path"
:title="issue.title">{{ issue.title }}</a>
<span
class="board-card-number"
v-if="issueId"
>
{{ issue.referencePath }}
</span>
</h4>
<div class="board-card-assignee">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
:key="assignee.id"
v-if="shouldRenderAssignee(index)"
class="js-no-trigger"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar"
:tooltip-text="assigneeUrlTitle(assignee)"
tooltip-placement="bottom"
/>
<span
class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip"
v-if="shouldRenderCounter"
>
{{ assigneeCounterLabel }}
</span>
</div>
</div>
<div
class="board-card-footer"
v-if="showLabelFooter"
>
<button
class="badge color-label has-tooltip"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
@click="filterByLabel(label, $event)"
:style="labelStyle(label)"
:title="label.description"
data-container="body">
{{ label.title }}
</button>
</div>
</div>
`,
});
<script>
import $ from 'jquery';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
export default {
components: {
UserAvatarLink,
},
props: {
issue: {
type: Object,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
list: {
type: Object,
required: false,
default: () => ({}),
},
rootPath: {
type: String,
required: true,
},
updateFilters: {
type: Boolean,
required: false,
default: false,
},
groupId: {
type: Number,
required: false,
default: null,
},
},
data() {
return {
limitBeforeCounter: 3,
maxRender: 4,
maxCounter: 99,
};
},
computed: {
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
assigneeCounterTooltip() {
return `${this.assigneeCounterLabel} more`;
},
assigneeCounterLabel() {
if (this.numberOverLimit > this.maxCounter) {
return `${this.maxCounter}+`;
}
return `+${this.numberOverLimit}`;
},
shouldRenderCounter() {
if (this.issue.assignees.length <= this.maxRender) {
return false;
}
return this.issue.assignees.length > this.numberOverLimit;
},
issueId() {
if (this.issue.iid) {
return `#${this.issue.iid}`;
}
return false;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
},
},
methods: {
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
shouldRenderAssignee(index) {
// Eg. maxRender is 4,
// Render up to all 4 assignees if there are only 4 assigness
// Otherwise render up to the limitBeforeCounter
if (this.issue.assignees.length <= this.maxRender) {
return index < this.maxRender;
}
return index < this.limitBeforeCounter;
},
assigneeUrl(assignee) {
return `${this.rootPath}${assignee.username}`;
},
assigneeUrlTitle(assignee) {
return `Assigned to ${assignee.name}`;
},
avatarUrlTitle(assignee) {
return `Avatar for ${assignee.name}`;
},
showLabel(label) {
if (!label.id) return false;
return true;
},
filterByLabel(label, e) {
if (!this.updateFilters) return;
const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&');
const labelTitle = encodeURIComponent(label.title);
const param = `label_name[]=${labelTitle}`;
const labelIndex = filterPath.indexOf(param);
$(e.currentTarget).tooltip('hide');
if (labelIndex === -1) {
filterPath.push(param);
} else {
filterPath.splice(labelIndex, 1);
}
gl.issueBoards.BoardsStore.filter.path = filterPath.join('&');
Store.updateFiltersUrl();
eventHub.$emit('updateTokens');
},
labelStyle(label) {
return {
backgroundColor: label.color,
color: label.textColor,
};
},
},
};
</script>
<template>
<div>
<div class="board-card-header">
<h4 class="board-card-title">
<i
v-if="issue.confidential"
class="fa fa-eye-slash confidential-icon"
aria-hidden="true"
></i>
<a
:href="issue.path"
:title="issue.title"
class="js-no-trigger">{{ issue.title }}</a>
<span
v-if="issueId"
class="board-card-number"
>
{{ issue.referencePath }}
</span>
</h4>
<div class="board-card-assignee">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar"
:tooltip-text="assigneeUrlTitle(assignee)"
class="js-no-trigger"
tooltip-placement="bottom"
/>
<span
v-if="shouldRenderCounter"
:title="assigneeCounterTooltip"
class="avatar-counter has-tooltip"
>
{{ assigneeCounterLabel }}
</span>
</div>
</div>
<div
v-if="showLabelFooter"
class="board-card-footer"
>
<button
v-for="label in issue.labels"
v-if="showLabel(label)"
:key="label.id"
:style="labelStyle(label)"
:title="label.description"
class="badge color-label has-tooltip"
type="button"
data-container="body"
@click="filterByLabel(label, $event)"
>
{{ label.title }}
</button>
</div>
</div>
</template>
import Vue from 'vue';
import modalFilters from './filters';
import modalTabs from './tabs.vue';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
gl.issueBoards.ModalHeader = Vue.extend({
components: {
modalTabs,
modalFilters,
},
mixins: [modalMixin],
props: {
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
selectAllText() {
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
return 'Select all';
}
return 'Deselect all';
},
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
},
},
methods: {
toggleAll() {
this.$refs.selectAllBtn.blur();
ModalStore.toggleAll();
},
},
template: `
<div>
<header class="add-issues-header form-actions">
<h2>
Add issues
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click="toggleModal(false)">
<span aria-hidden="true">×</span>
</button>
</h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs>
<div
class="add-issues-search append-bottom-10"
v-if="showSearch">
<modal-filters :store="filter" />
<button
type="button"
class="btn btn-success btn-inverted prepend-left-10"
ref="selectAllBtn"
@click="toggleAll">
{{ selectAllText }}
</button>
</div>
</div>
`,
});
<script>
import ModalFilters from './filters';
import ModalTabs from './tabs.vue';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
export default {
components: {
ModalTabs,
ModalFilters,
},
mixins: [modalMixin],
props: {
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
selectAllText() {
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
return 'Select all';
}
return 'Deselect all';
},
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
},
},
methods: {
toggleAll() {
this.$refs.selectAllBtn.blur();
ModalStore.toggleAll();
},
},
};
</script>
<template>
<div>
<header class="add-issues-header form-actions">
<h2>
Add issues
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click="toggleModal(false)"
>
<span aria-hidden="true">×</span>
</button>
</h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0"/>
<div
v-if="showSearch"
class="add-issues-search append-bottom-10">
<modal-filters :store="filter" />
<button
ref="selectAllBtn"
type="button"
class="btn btn-success btn-inverted prepend-left-10"
@click="toggleAll"
>
{{ selectAllText }}
</button>
</div>
</div>
</template>
/* global ListIssue */
import Vue from 'vue';
import queryData from '~/boards/utils/query_data';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import './header';
import './list';
import ModalFooter from './footer.vue';
import EmptyState from './empty_state.vue';
import ModalStore from '../../stores/modal_store';
gl.issueBoards.IssuesModal = Vue.extend({
components: {
EmptyState,
'modal-header': gl.issueBoards.ModalHeader,
'modal-list': gl.issueBoards.ModalList,
ModalFooter,
loadingIcon,
},
props: {
newIssuePath: {
type: String,
required: true,
},
emptyStateSvg: {
type: String,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
showList() {
if (this.activeTab === 'selected') {
return this.selectedIssues.length > 0;
}
return this.issuesCount > 0;
},
showEmptyState() {
if (!this.loading && this.issuesCount === 0) {
return true;
}
return this.activeTab === 'selected' && this.selectedIssues.length === 0;
},
},
watch: {
page() {
this.loadIssues();
},
showAddIssuesModal() {
if (this.showAddIssuesModal && !this.issues.length) {
this.loading = true;
const loadingDone = () => {
this.loading = false;
};
this.loadIssues()
.then(loadingDone)
.catch(loadingDone);
} else if (!this.showAddIssuesModal) {
this.issues = [];
this.selectedIssues = [];
this.issuesCount = false;
}
},
filter: {
handler() {
if (this.$el.tagName) {
this.page = 1;
this.filterLoading = true;
const loadingDone = () => {
this.filterLoading = false;
};
this.loadIssues(true)
.then(loadingDone)
.catch(loadingDone);
}
},
deep: true,
},
},
created() {
this.page = 1;
},
methods: {
loadIssues(clearIssues = false) {
if (!this.showAddIssuesModal) return false;
return gl.boardService.getBacklog(queryData(this.filter.path, {
page: this.page,
per: this.perPage,
}))
.then(res => res.data)
.then((data) => {
if (clearIssues) {
this.issues = [];
}
data.issues.forEach((issueObj) => {
const issue = new ListIssue(issueObj);
const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
issue.selected = !!foundSelectedIssue;
this.issues.push(issue);
});
this.loadingNewPage = false;
if (!this.issuesCount) {
this.issuesCount = data.size;
}
}).catch(() => {
// TODO: handle request error
});
},
},
template: `
<div
class="add-issues-modal"
v-if="showAddIssuesModal">
<div class="add-issues-container">
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath">
</modal-header>
<modal-list
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:empty-state-svg="emptyStateSvg"
v-if="!loading && showList && !filterLoading"></modal-list>
<empty-state
v-if="showEmptyState"
:new-issue-path="newIssuePath"
:empty-state-svg="emptyStateSvg"></empty-state>
<section
class="add-issues-list text-center"
v-if="loading || filterLoading">
<div class="add-issues-list-loading">
<loading-icon />
</div>
</section>
<modal-footer></modal-footer>
</div>
</div>
`,
});
<script>
/* global ListIssue */
import queryData from '~/boards/utils/query_data';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import ModalHeader from './header.vue';
import ModalList from './list.vue';
import ModalFooter from './footer.vue';
import EmptyState from './empty_state.vue';
import ModalStore from '../../stores/modal_store';
export default {
components: {
EmptyState,
ModalHeader,
ModalList,
ModalFooter,
loadingIcon,
},
props: {
newIssuePath: {
type: String,
required: true,
},
emptyStateSvg: {
type: String,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
showList() {
if (this.activeTab === 'selected') {
return this.selectedIssues.length > 0;
}
return this.issuesCount > 0;
},
showEmptyState() {
if (!this.loading && this.issuesCount === 0) {
return true;
}
return this.activeTab === 'selected' && this.selectedIssues.length === 0;
},
},
watch: {
page() {
this.loadIssues();
},
showAddIssuesModal() {
if (this.showAddIssuesModal && !this.issues.length) {
this.loading = true;
const loadingDone = () => {
this.loading = false;
};
this.loadIssues()
.then(loadingDone)
.catch(loadingDone);
} else if (!this.showAddIssuesModal) {
this.issues = [];
this.selectedIssues = [];
this.issuesCount = false;
}
},
filter: {
handler() {
if (this.$el.tagName) {
this.page = 1;
this.filterLoading = true;
const loadingDone = () => {
this.filterLoading = false;
};
this.loadIssues(true)
.then(loadingDone)
.catch(loadingDone);
}
},
deep: true,
},
},
created() {
this.page = 1;
},
methods: {
loadIssues(clearIssues = false) {
if (!this.showAddIssuesModal) return false;
return gl.boardService
.getBacklog(
queryData(this.filter.path, {
page: this.page,
per: this.perPage,
}),
)
.then(res => res.data)
.then(data => {
if (clearIssues) {
this.issues = [];
}
data.issues.forEach(issueObj => {
const issue = new ListIssue(issueObj);
const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
issue.selected = !!foundSelectedIssue;
this.issues.push(issue);
});
this.loadingNewPage = false;
if (!this.issuesCount) {
this.issuesCount = data.size;
}
})
.catch(() => {
// TODO: handle request error
});
},
},
};
</script>
<template>
<div
v-if="showAddIssuesModal"
class="add-issues-modal">
<div class="add-issues-container">
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath"
/>
<modal-list
v-if="!loading && showList && !filterLoading"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:empty-state-svg="emptyStateSvg"
/>
<empty-state
v-if="showEmptyState"
:new-issue-path="newIssuePath"
:empty-state-svg="emptyStateSvg"
/>
<section
v-if="loading || filterLoading"
class="add-issues-list text-center"
>
<div class="add-issues-list-loading">
<loading-icon />
</div>
</section>
<modal-footer/>
</div>
</div>
</template>
import Vue from 'vue';
import bp from '../../../breakpoints';
import ModalStore from '../../stores/modal_store';
gl.issueBoards.ModalList = Vue.extend({
components: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
props: {
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
emptyStateSvg: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
loopIssues() {
if (this.activeTab === 'all') {
return this.issues;
}
return this.selectedIssues;
},
groupedIssues() {
const groups = [];
this.loopIssues.forEach((issue, i) => {
const index = i % this.columns;
if (!groups[index]) {
groups.push([]);
}
groups[index].push(issue);
});
return groups;
},
},
watch: {
activeTab() {
if (this.activeTab === 'all') {
ModalStore.purgeUnselectedIssues();
}
},
},
mounted() {
this.scrollHandlerWrapper = this.scrollHandler.bind(this);
this.setColumnCountWrapper = this.setColumnCount.bind(this);
this.setColumnCount();
this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper);
window.addEventListener('resize', this.setColumnCountWrapper);
},
beforeDestroy() {
this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper);
window.removeEventListener('resize', this.setColumnCountWrapper);
},
methods: {
scrollHandler() {
const currentPage = Math.floor(this.issues.length / this.perPage);
if (
this.scrollTop() > this.scrollHeight() - 100 &&
!this.loadingNewPage &&
currentPage === this.page
) {
this.loadingNewPage = true;
this.page += 1;
}
},
toggleIssue(e, issue) {
if (e.target.tagName !== 'A') {
ModalStore.toggleIssue(issue);
}
},
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
showIssue(issue) {
if (this.activeTab === 'all') return true;
const index = ModalStore.selectedIssueIndex(issue);
return index !== -1;
},
setColumnCount() {
const breakpoint = bp.getBreakpointSize();
if (breakpoint === 'lg' || breakpoint === 'md') {
this.columns = 3;
} else if (breakpoint === 'sm') {
this.columns = 2;
} else {
this.columns = 1;
}
},
},
template: `
<section
class="add-issues-list add-issues-list-columns"
ref="list">
<div
class="empty-state add-issues-empty-state-filter text-center"
v-if="issuesCount > 0 && issues.length === 0">
<div
class="svg-content">
<img :src="emptyStateSvg"/>
</div>
<div class="text-content">
<h4>
There are no issues to show.
</h4>
</div>
</div>
<div
v-for="group in groupedIssues"
class="add-issues-list-column">
<div
v-for="issue in group"
v-if="showIssue(issue)"
class="board-card-parent">
<div
class="board-card"
:class="{ 'is-active': issue.selected }"
@click="toggleIssue($event, issue)">
<issue-card-inner
:issue="issue"
:issue-link-base="issueLinkBase"
:root-path="rootPath">
</issue-card-inner>
<span
:aria-label="'Issue #' + issue.id + ' selected'"
aria-checked="true"
v-if="issue.selected"
class="issue-card-selected text-center">
<i class="fa fa-check"></i>
</span>
</div>
</div>
</div>
</section>
`,
});
<script>
import bp from '../../../breakpoints';
import ModalStore from '../../stores/modal_store';
import IssueCardInner from '../issue_card_inner.vue';
export default {
components: {
IssueCardInner,
},
props: {
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
emptyStateSvg: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
computed: {
loopIssues() {
if (this.activeTab === 'all') {
return this.issues;
}
return this.selectedIssues;
},
groupedIssues() {
const groups = [];
this.loopIssues.forEach((issue, i) => {
const index = i % this.columns;
if (!groups[index]) {
groups.push([]);
}
groups[index].push(issue);
});
return groups;
},
},
watch: {
activeTab() {
if (this.activeTab === 'all') {
ModalStore.purgeUnselectedIssues();
}
},
},
mounted() {
this.scrollHandlerWrapper = this.scrollHandler.bind(this);
this.setColumnCountWrapper = this.setColumnCount.bind(this);
this.setColumnCount();
this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper);
window.addEventListener('resize', this.setColumnCountWrapper);
},
beforeDestroy() {
this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper);
window.removeEventListener('resize', this.setColumnCountWrapper);
},
methods: {
scrollHandler() {
const currentPage = Math.floor(this.issues.length / this.perPage);
if (
this.scrollTop() > this.scrollHeight() - 100 &&
!this.loadingNewPage &&
currentPage === this.page
) {
this.loadingNewPage = true;
this.page += 1;
}
},
toggleIssue(e, issue) {
if (e.target.tagName !== 'A') {
ModalStore.toggleIssue(issue);
}
},
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
showIssue(issue) {
if (this.activeTab === 'all') return true;
const index = ModalStore.selectedIssueIndex(issue);
return index !== -1;
},
setColumnCount() {
const breakpoint = bp.getBreakpointSize();
if (breakpoint === 'lg' || breakpoint === 'md') {
this.columns = 3;
} else if (breakpoint === 'sm') {
this.columns = 2;
} else {
this.columns = 1;
}
},
},
};
</script>
<template>
<section
ref="list"
class="add-issues-list add-issues-list-columns">
<div
v-if="issuesCount > 0 && issues.length === 0"
class="empty-state add-issues-empty-state-filter text-center">
<div
class="svg-content">
<img :src="emptyStateSvg" />
</div>
<div class="text-content">
<h4>
There are no issues to show.
</h4>
</div>
</div>
<div
v-for="(group, index) in groupedIssues"
:key="index"
class="add-issues-list-column">
<div
v-for="issue in group"
v-if="showIssue(issue)"
:key="issue.id"
class="board-card-parent">
<div
:class="{ 'is-active': issue.selected }"
class="board-card"
@click="toggleIssue($event, issue)">
<issue-card-inner
:issue="issue"
:issue-link-base="issueLinkBase"
:root-path="rootPath"/>
<span
v-if="issue.selected"
:aria-label="'Issue #' + issue.id + ' selected'"
aria-checked="true"
class="issue-card-selected text-center">
<i class="fa fa-check"></i>
</span>
</div>
</div>
</div>
</section>
</template>
...@@ -25,7 +25,7 @@ import './filters/due_date_filters'; ...@@ -25,7 +25,7 @@ import './filters/due_date_filters';
import './components/board'; import './components/board';
import './components/board_sidebar'; import './components/board_sidebar';
import './components/new_list_dropdown'; import './components/new_list_dropdown';
import './components/modal/index'; import BoardAddIssuesModal from './components/modal/index.vue';
import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/first import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/first
export default () => { export default () => {
...@@ -49,7 +49,7 @@ export default () => { ...@@ -49,7 +49,7 @@ export default () => {
components: { components: {
'board': gl.issueBoards.Board, 'board': gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar, 'board-sidebar': gl.issueBoards.BoardSidebar,
'board-add-issues-modal': gl.issueBoards.IssuesModal, BoardAddIssuesModal,
}, },
data: { data: {
state: Store.state, state: Store.state,
......
...@@ -189,12 +189,25 @@ export const getParameterByName = (name, urlToParse) => { ...@@ -189,12 +189,25 @@ export const getParameterByName = (name, urlToParse) => {
return decodeURIComponent(results[2].replace(/\+/g, ' ')); return decodeURIComponent(results[2].replace(/\+/g, ' '));
}; };
const handleSelectedRange = (range) => {
const container = range.commonAncestorContainer;
// add context to fragment if needed
if (container.tagName === 'OL') {
const parentContainer = document.createElement(container.tagName);
parentContainer.appendChild(range.cloneContents());
return parentContainer;
}
return range.cloneContents();
};
export const getSelectedFragment = () => { export const getSelectedFragment = () => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.rangeCount === 0) return null; if (selection.rangeCount === 0) return null;
const documentFragment = document.createDocumentFragment(); const documentFragment = document.createDocumentFragment();
for (let i = 0; i < selection.rangeCount; i += 1) { for (let i = 0; i < selection.rangeCount; i += 1) {
documentFragment.appendChild(selection.getRangeAt(i).cloneContents()); const range = selection.getRangeAt(i);
documentFragment.appendChild(handleSelectedRange(range));
} }
if (documentFragment.textContent.length === 0) return null; if (documentFragment.textContent.length === 0) return null;
......
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown'; import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor'; import AccessorUtilities from '../lib/utils/accessor';
const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
export default class ProtectedBranchCreate { export default class ProtectedBranchCreate {
constructor() { constructor() {
this.$form = $('.js-new-protected-branch'); this.$form = $('.js-new-protected-branch');
...@@ -43,8 +40,6 @@ export default class ProtectedBranchCreate { ...@@ -43,8 +40,6 @@ export default class ProtectedBranchCreate {
onSelect: this.onSelectCallback, onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches, getData: ProtectedBranchCreate.getProtectedBranches,
}); });
this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
} }
// This will run after clicked callback // This will run after clicked callback
...@@ -59,39 +54,10 @@ export default class ProtectedBranchCreate { ...@@ -59,39 +54,10 @@ export default class ProtectedBranchCreate {
$allowedToPushInput.length $allowedToPushInput.length
); );
this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val());
this.$form.find('input[type="submit"]').prop('disabled', completedForm); this.$form.find('input[type="submit"]').prop('disabled', completedForm);
} }
static getProtectedBranches(term, callback) { static getProtectedBranches(term, callback) {
callback(gon.open_branches); callback(gon.open_branches);
} }
loadPreviousSelection(mergeDropdown, pushDropdown) {
let mergeIndex = 0;
let pushIndex = 0;
if (this.isLocalStorageAvailable) {
const savedDefaults = JSON.parse(window.localStorage.getItem(PB_LOCAL_STORAGE_KEY));
if (savedDefaults != null) {
mergeIndex = _.findLastIndex(mergeDropdown.fullData.roles, {
id: parseInt(savedDefaults.mergeSelection, 0),
});
pushIndex = _.findLastIndex(pushDropdown.fullData.roles, {
id: parseInt(savedDefaults.pushSelection, 0),
});
}
}
mergeDropdown.selectRowAtIndex(mergeIndex);
pushDropdown.selectRowAtIndex(pushIndex);
}
savePreviousSelection(mergeSelection, pushSelection) {
if (this.isLocalStorageAvailable) {
const branchDefaults = {
mergeSelection,
pushSelection,
};
window.localStorage.setItem(PB_LOCAL_STORAGE_KEY, JSON.stringify(branchDefaults));
}
}
} }
...@@ -289,7 +289,7 @@ export default class SearchAutocomplete { ...@@ -289,7 +289,7 @@ export default class SearchAutocomplete {
} }
// If the dropdown is closed, we'll open it // If the dropdown is closed, we'll open it
if (!this.dropdown.hasClass('open')) { if (!this.dropdown.hasClass('show')) {
this.loadingSuggestions = false; this.loadingSuggestions = false;
this.dropdownToggle.dropdown('toggle'); this.dropdownToggle.dropdown('toggle');
return this.searchInput.removeClass('disabled'); return this.searchInput.removeClass('disabled');
...@@ -424,9 +424,9 @@ export default class SearchAutocomplete { ...@@ -424,9 +424,9 @@ export default class SearchAutocomplete {
} }
disableAutocomplete() { disableAutocomplete() {
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) { if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('disabled'); this.searchInput.addClass('disabled');
this.dropdown.removeClass('open').trigger('hidden.bs.dropdown'); this.dropdown.removeClass('show').trigger('hidden.bs.dropdown');
this.restoreMenu(); this.restoreMenu();
} }
} }
......
...@@ -8,7 +8,6 @@ class HealthController < ActionController::Base ...@@ -8,7 +8,6 @@ class HealthController < ActionController::Base
Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck, Gitlab::HealthChecks::Redis::SharedStateCheck,
Gitlab::HealthChecks::FsShardsCheck,
Gitlab::HealthChecks::GitalyCheck Gitlab::HealthChecks::GitalyCheck
].freeze ].freeze
......
...@@ -270,7 +270,7 @@ module ApplicationHelper ...@@ -270,7 +270,7 @@ module ApplicationHelper
{ {
members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
issues: issues_project_autocomplete_sources_path(object), issues: issues_project_autocomplete_sources_path(object),
merge_requests: merge_requests_project_autocomplete_sources_path(object), mergeRequests: merge_requests_project_autocomplete_sources_path(object),
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object), milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]) commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
......
...@@ -6,7 +6,8 @@ class MetricsService ...@@ -6,7 +6,8 @@ class MetricsService
Gitlab::HealthChecks::Redis::RedisCheck, Gitlab::HealthChecks::Redis::RedisCheck,
Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck Gitlab::HealthChecks::Redis::SharedStateCheck,
Gitlab::HealthChecks::GitalyCheck
].freeze ].freeze
def prometheus_metrics_text def prometheus_metrics_text
......
# frozen_string_literal: true
class AdminEmailWorker class AdminEmailWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class ArchiveTraceWorker class ArchiveTraceWorker
include ApplicationWorker include ApplicationWorker
include PipelineBackgroundQueue include PipelineBackgroundQueue
......
# frozen_string_literal: true
class AuthorizedProjectsWorker class AuthorizedProjectsWorker
include ApplicationWorker include ApplicationWorker
prepend WaitableWorker prepend WaitableWorker
......
# frozen_string_literal: true
class BackgroundMigrationWorker class BackgroundMigrationWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class BuildCoverageWorker class BuildCoverageWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class BuildFinishedWorker class BuildFinishedWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class BuildHooksWorker class BuildHooksWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class BuildQueueWorker class BuildQueueWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class BuildSuccessWorker class BuildSuccessWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class BuildTraceSectionsWorker class BuildTraceSectionsWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
module Ci module Ci
class ArchiveTracesCronWorker class ArchiveTracesCronWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
module Ci module Ci
class BuildTraceChunkFlushWorker class BuildTraceChunkFlushWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class ClusterInstallAppWorker class ClusterInstallAppWorker
include ApplicationWorker include ApplicationWorker
include ClusterQueue include ClusterQueue
......
# frozen_string_literal: true
class ClusterProvisionWorker class ClusterProvisionWorker
include ApplicationWorker include ApplicationWorker
include ClusterQueue include ClusterQueue
......
# frozen_string_literal: true
class ClusterWaitForAppInstallationWorker class ClusterWaitForAppInstallationWorker
include ApplicationWorker include ApplicationWorker
include ClusterQueue include ClusterQueue
......
# frozen_string_literal: true
class ClusterWaitForIngressIpAddressWorker class ClusterWaitForIngressIpAddressWorker
include ApplicationWorker include ApplicationWorker
include ClusterQueue include ClusterQueue
......
# frozen_string_literal: true
Sidekiq::Worker.extend ActiveSupport::Concern Sidekiq::Worker.extend ActiveSupport::Concern
module ApplicationWorker module ApplicationWorker
......
# frozen_string_literal: true
module ClusterApplications module ClusterApplications
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
## ##
# Concern for setting Sidekiq settings for the various Gcp clusters workers. # Concern for setting Sidekiq settings for the various Gcp clusters workers.
# #
......
# frozen_string_literal: true
# Concern that sets various Sidekiq settings for workers executed using a # Concern that sets various Sidekiq settings for workers executed using a
# cronjob. # cronjob.
module CronjobQueue module CronjobQueue
......
# frozen_string_literal: true
# Concern for enabling a few lines of exception backtraces in Sidekiq # Concern for enabling a few lines of exception backtraces in Sidekiq
module ExceptionBacktrace module ExceptionBacktrace
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
module Gitlab module Gitlab
module GithubImport module GithubImport
module Queue module Queue
......
# frozen_string_literal: true
module MailSchedulerQueue module MailSchedulerQueue
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
module NewIssuable module NewIssuable
attr_reader :issuable, :user attr_reader :issuable, :user
......
# frozen_string_literal: true
# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers. # Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers.
module ObjectStorageQueue module ObjectStorageQueue
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
## ##
# Concern for setting Sidekiq settings for the low priority CI pipeline workers. # Concern for setting Sidekiq settings for the low priority CI pipeline workers.
# #
......
# frozen_string_literal: true
## ##
# Concern for setting Sidekiq settings for the various CI pipeline workers. # Concern for setting Sidekiq settings for the various CI pipeline workers.
# #
......
# frozen_string_literal: true
module ProjectImportOptions module ProjectImportOptions
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
# Used in EE by mirroring # Used in EE by mirroring
module ProjectStartImport module ProjectStartImport
def start(project) def start(project)
......
# frozen_string_literal: true
# Concern for setting Sidekiq settings for the various repository check workers. # Concern for setting Sidekiq settings for the various repository check workers.
module RepositoryCheckQueue module RepositoryCheckQueue
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
module WaitableWorker module WaitableWorker
extend ActiveSupport::Concern extend ActiveSupport::Concern
......
# frozen_string_literal: true
class CreateGpgSignatureWorker class CreateGpgSignatureWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class CreateNoteDiffFileWorker class CreateNoteDiffFileWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class CreatePipelineWorker class CreatePipelineWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class DeleteMergedBranchesWorker class DeleteMergedBranchesWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class DeleteUserWorker class DeleteUserWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class EmailReceiverWorker class EmailReceiverWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class EmailsOnPushWorker class EmailsOnPushWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class ExpireBuildArtifactsWorker class ExpireBuildArtifactsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class ExpireBuildInstanceArtifactsWorker class ExpireBuildInstanceArtifactsWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class ExpireJobCacheWorker class ExpireJobCacheWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class ExpirePipelineCacheWorker class ExpirePipelineCacheWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class GitGarbageCollectWorker class GitGarbageCollectWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class GitlabShellWorker class GitlabShellWorker
include ApplicationWorker include ApplicationWorker
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
......
# frozen_string_literal: true
class GitlabUsagePingWorker class GitlabUsagePingWorker
LEASE_TIMEOUT = 86400 LEASE_TIMEOUT = 86400
......
# frozen_string_literal: true
class GroupDestroyWorker class GroupDestroyWorker
include ApplicationWorker include ApplicationWorker
include ExceptionBacktrace include ExceptionBacktrace
......
# frozen_string_literal: true
class ImportExportProjectCleanupWorker class ImportExportProjectCleanupWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class InvalidGpgSignatureUpdateWorker class InvalidGpgSignatureUpdateWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
require 'json' require 'json'
require 'socket' require 'socket'
...@@ -69,8 +71,8 @@ class IrkerWorker ...@@ -69,8 +71,8 @@ class IrkerWorker
newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches" newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches"
newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors
privmsg = "[#{repo_name}] #{committer} has created a new branch " privmsg = "[#{repo_name}] #{committer} has created a new branch " \
privmsg += "#{branch}: #{newbranch}" "#{branch}: #{newbranch}"
sendtoirker privmsg sendtoirker privmsg
end end
...@@ -112,9 +114,7 @@ class IrkerWorker ...@@ -112,9 +114,7 @@ class IrkerWorker
url = compare_url data, project.full_path url = compare_url data, project.full_path
commits = colorize_commits data['total_commits_count'] commits = colorize_commits data['total_commits_count']
new_commits = 'new commit' new_commits = 'new commit'.pluralize(data['total_commits_count'])
new_commits += 's' if data['total_commits_count'] > 1
sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \ sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \
"to #{branch}: #{url}" "to #{branch}: #{url}"
end end
...@@ -122,8 +122,8 @@ class IrkerWorker ...@@ -122,8 +122,8 @@ class IrkerWorker
def compare_url(data, repo_path) def compare_url(data, repo_path)
sha1 = Commit.truncate_sha(data['before']) sha1 = Commit.truncate_sha(data['before'])
sha2 = Commit.truncate_sha(data['after']) sha2 = Commit.truncate_sha(data['after'])
compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" \
compare_url += "/#{sha1}...#{sha2}" "/#{sha1}...#{sha2}"
colorize_url compare_url colorize_url compare_url
end end
...@@ -144,8 +144,7 @@ class IrkerWorker ...@@ -144,8 +144,7 @@ class IrkerWorker
def files_count(commit) def files_count(commit)
diff_size = commit.raw_deltas.size diff_size = commit.raw_deltas.size
files = "#{diff_size} file" files = "#{diff_size} file".pluralize(diff_size)
files += 's' if diff_size > 1
files files
end end
......
# frozen_string_literal: true
class IssueDueSchedulerWorker class IssueDueSchedulerWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
module MailScheduler module MailScheduler
class IssueDueWorker class IssueDueWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
require 'active_job/arguments' require 'active_job/arguments'
module MailScheduler module MailScheduler
......
# frozen_string_literal: true
class MergeWorker class MergeWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
# Worker to destroy projects that do not have a namespace # Worker to destroy projects that do not have a namespace
# #
# It destroys everything it can without having the info about the namespace it # It destroys everything it can without having the info about the namespace it
......
# frozen_string_literal: true
class NewIssueWorker class NewIssueWorker
include ApplicationWorker include ApplicationWorker
include NewIssuable include NewIssuable
......
# frozen_string_literal: true
class NewMergeRequestWorker class NewMergeRequestWorker
include ApplicationWorker include ApplicationWorker
include NewIssuable include NewIssuable
......
# frozen_string_literal: true
class NewNoteWorker class NewNoteWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
module ObjectStorage module ObjectStorage
class BackgroundMoveWorker class BackgroundMoveWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
# @Deprecated - remove once the `object_storage_upload` queue is empty # @Deprecated - remove once the `object_storage_upload` queue is empty
# The queue has been renamed `object_storage:object_storage_background_upload` # The queue has been renamed `object_storage:object_storage_background_upload`
# #
......
# frozen_string_literal: true
class PagesDomainVerificationCronWorker class PagesDomainVerificationCronWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class PagesDomainVerificationWorker class PagesDomainVerificationWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class PagesWorker class PagesWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class PipelineHooksWorker class PipelineHooksWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PipelineMetricsWorker class PipelineMetricsWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PipelineNotificationWorker class PipelineNotificationWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PipelineProcessWorker class PipelineProcessWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PipelineScheduleWorker class PipelineScheduleWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class PipelineSuccessWorker class PipelineSuccessWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PipelineUpdateWorker class PipelineUpdateWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class PluginWorker class PluginWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class PostReceive class PostReceive
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
# Worker for processing individiual commit messages pushed to a repository. # Worker for processing individiual commit messages pushed to a repository.
# #
# Jobs for this worker are scheduled for every commit that is being pushed. As a # Jobs for this worker are scheduled for every commit that is being pushed. As a
......
# frozen_string_literal: true
# Worker for updating any project specific caches. # Worker for updating any project specific caches.
class ProjectCacheWorker class ProjectCacheWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class ProjectDestroyWorker class ProjectDestroyWorker
include ApplicationWorker include ApplicationWorker
include ExceptionBacktrace include ExceptionBacktrace
......
# frozen_string_literal: true
class ProjectExportWorker class ProjectExportWorker
include ApplicationWorker include ApplicationWorker
include ExceptionBacktrace include ExceptionBacktrace
......
# frozen_string_literal: true
class ProjectMigrateHashedStorageWorker class ProjectMigrateHashedStorageWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class ProjectServiceWorker class ProjectServiceWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
# Worker for updating any project specific caches. # Worker for updating any project specific caches.
class PropagateServiceTemplateWorker class PropagateServiceTemplateWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class PruneOldEventsWorker class PruneOldEventsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class ReactiveCachingWorker class ReactiveCachingWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class RebaseWorker class RebaseWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class RemoveExpiredGroupLinksWorker class RemoveExpiredGroupLinksWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class RemoveExpiredMembersWorker class RemoveExpiredMembersWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class RemoveOldWebHookLogsWorker class RemoveOldWebHookLogsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class RemoveUnreferencedLfsObjectsWorker class RemoveUnreferencedLfsObjectsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class RepositoryArchiveCacheWorker class RepositoryArchiveCacheWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
module RepositoryCheck module RepositoryCheck
class BatchWorker class BatchWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
module RepositoryCheck module RepositoryCheck
class ClearWorker class ClearWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
module RepositoryCheck module RepositoryCheck
class SingleRepositoryWorker class SingleRepositoryWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class RepositoryForkWorker class RepositoryForkWorker
include ApplicationWorker include ApplicationWorker
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
......
# frozen_string_literal: true
class RepositoryImportWorker class RepositoryImportWorker
include ApplicationWorker include ApplicationWorker
include ExceptionBacktrace include ExceptionBacktrace
......
# frozen_string_literal: true
class RepositoryRemoveRemoteWorker class RepositoryRemoveRemoteWorker
include ApplicationWorker include ApplicationWorker
include ExclusiveLeaseGuard include ExclusiveLeaseGuard
......
# frozen_string_literal: true
class RepositoryUpdateRemoteMirrorWorker class RepositoryUpdateRemoteMirrorWorker
UpdateAlreadyInProgressError = Class.new(StandardError) UpdateAlreadyInProgressError = Class.new(StandardError)
UpdateError = Class.new(StandardError) UpdateError = Class.new(StandardError)
......
# frozen_string_literal: true
class RequestsProfilesWorker class RequestsProfilesWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class RunPipelineScheduleWorker class RunPipelineScheduleWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class ScheduleUpdateUserActivityWorker class ScheduleUpdateUserActivityWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class StageUpdateWorker class StageUpdateWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class StorageMigratorWorker class StorageMigratorWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class StuckCiJobsWorker class StuckCiJobsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class StuckImportJobsWorker class StuckImportJobsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class StuckMergeJobsWorker class StuckMergeJobsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class SystemHookPushWorker class SystemHookPushWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class TrendingProjectsWorker class TrendingProjectsWorker
include ApplicationWorker include ApplicationWorker
include CronjobQueue include CronjobQueue
......
# frozen_string_literal: true
class UpdateHeadPipelineForMergeRequestWorker class UpdateHeadPipelineForMergeRequestWorker
include ApplicationWorker include ApplicationWorker
include PipelineQueue include PipelineQueue
......
# frozen_string_literal: true
class UpdateMergeRequestsWorker class UpdateMergeRequestsWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class UpdateUserActivityWorker class UpdateUserActivityWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class UploadChecksumWorker class UploadChecksumWorker
include ApplicationWorker include ApplicationWorker
......
# frozen_string_literal: true
class WaitForClusterCreationWorker class WaitForClusterCreationWorker
include ApplicationWorker include ApplicationWorker
include ClusterQueue include ClusterQueue
......
# frozen_string_literal: true
class WebHookWorker class WebHookWorker
include ApplicationWorker include ApplicationWorker
......
---
title: Keep lists ordered when copying only list items
merge_request: 18522
author: Jan Beckmann
type: fixed
---
title: CE port gitlab-ee!6112
merge_request: 19714
author:
type: other
---
title: Fix loading screen for search autocomplete dropdown
merge_request:
author:
type: fixed
---
title: Fix broken '!' support to autocomplete MRs in GFM fields
merge_request: 20204
author:
type: fixed
---
title: Enable frozen string in app/workers/*.rb
merge_request: 19944
author: gfyoung
type: other
---
title: Finish enabling frozen string for app/workers/*.rb
merge_request: 20197
author: gfyoung
type: other
---
title: Gitaly metrics check for read/writeability
merge_request: 20022
author:
type: other
...@@ -82,6 +82,46 @@ To migrate your existing projects to the new storage type, check the specific ...@@ -82,6 +82,46 @@ To migrate your existing projects to the new storage type, check the specific
[rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage [rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage
[storage-paths]: repository_storage_types.md [storage-paths]: repository_storage_types.md
#### Rollback
There is no automated rollback implemented. Below are the steps required to rollback
from each storage migration.
The rollback has to be performed in the reverse order. To get into "Legacy" state,
you need to rollback Attachments first, then Project.
Also note that if Geo is enabled, after the migration was triggered, an event is generated
to replicate the operation on any Secondary node. That means the on disk changes will also
need to be performed on these nodes as well. Database changes will propagate without issues.
You must make sure the migration event was already processed or otherwise it may migrate
the files back to Hashed state again.
##### Attachments
To rollback single Attachment migration, rename `aa/bb/abcdef1234567890...` folder back to `namespace/project`.
Both folder names can be generated by the `FileUploader.absolute_base_dir(project)`, you
just need to switch the version from the `project` back to the previous one.
```ruby
project.storage_version
# => 2
FileUploader.absolute_base_dir(project)
# => "/opt/gitlab/embedded/service/gitlab-rails/public/uploads/@hashed/d4/73/d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"
project.storage_version = 1
FileUploader.absolute_base_dir(project)
# => "/opt/gitlab/embedded/service/gitlab-rails/public/uploads/gitlab/gitlab-shell-renamed"
```
##### Project
To rollback single Project migration, move `@hashed/aa/bb/aabbcdef1234567890abcdef.git` and `@hashed/aa/bb/aabbcdef1234567890abcdef.wiki.git`
back to `namespace/project.git` and `namespace/project.wiki.git` respectively and switch the version from the `project` back to `null`.
### Hashed Storage coverage ### Hashed Storage coverage
We are incrementally moving every storable object in GitLab to the Hashed We are incrementally moving every storable object in GitLab to the Hashed
...@@ -100,6 +140,30 @@ which is true for CI Cache and LFS Objects. ...@@ -100,6 +140,30 @@ which is true for CI Cache and LFS Objects.
| Pages | Yes | No | - | - | | Pages | Yes | No | - | - |
| Docker Registry | Yes | No | - | - | | Docker Registry | Yes | No | - | - |
| CI Build Logs | No | No | - | - | | CI Build Logs | No | No | - | - |
| CI Artifacts | No | No | Yes (Premium) | - | | CI Artifacts | No | No | Yes | 9.4 / 10.6 |
| CI Cache | No | No | Yes | - | | CI Cache | No | No | Yes | - |
| LFS Objects | Yes | No | Yes (Premium) | - | | LFS Objects | Yes | Similar | Yes | 10.0 / 10.7 |
#### Implementation Details
##### Avatars
Each file is stored in a folder with its `id` from the database. The filename is always `avatar.png` for user avatars.
When avatar is replaced, `Upload` model is destroyed and a new one takes place with different `id`.
##### CI Artifacts
CI Artifacts are S3 compatible since **9.4** (GitLab Premium), and available in GitLab Core since **10.6**.
##### LFS Objects
LFS Objects implements a similar storage pattern using 2 chars, 2 level folders, following git own implementation:
```ruby
"shared/lfs-objects/#{oid[0..1}/#{oid[2..3]}/#{oid[4..-1]}"
# Based on object `oid`: `8909029eb962194cfb326259411b22ae3f4a814b5be4f80651735aeef9f3229c`, path will be:
"shared/lfs-objects/89/09/029eb962194cfb326259411b22ae3f4a814b5be4f80651735aeef9f3229c"
```
They are also S3 compatible since **10.0** (GitLab Premium), and available in GitLab Core since **10.7**.
...@@ -22,6 +22,18 @@ module Gitaly ...@@ -22,6 +22,18 @@ module Gitaly
server_version == Gitlab::GitalyClient.expected_server_version server_version == Gitlab::GitalyClient.expected_server_version
end end
def read_writeable?
readable? && writeable?
end
def readable?
storage_status&.readable
end
def writeable?
storage_status&.writeable
end
def address def address
Gitlab::GitalyClient.address(@storage) Gitlab::GitalyClient.address(@storage)
rescue RuntimeError => e rescue RuntimeError => e
...@@ -30,13 +42,17 @@ module Gitaly ...@@ -30,13 +42,17 @@ module Gitaly
private private
def storage_status
@storage_status ||= info.storage_statuses.find { |s| s.storage_name == storage }
end
def info def info
@info ||= @info ||=
begin begin
Gitlab::GitalyClient::ServerService.new(@storage).info Gitlab::GitalyClient::ServerService.new(@storage).info
rescue GRPC::Unavailable, GRPC::GRPC::DeadlineExceeded rescue GRPC::Unavailable, GRPC::GRPC::DeadlineExceeded
# This will show the server as being out of date # This will show the server as being out of date
Gitaly::ServerInfoResponse.new(git_version: '', server_version: '') Gitaly::ServerInfoResponse.new(git_version: '', server_version: '', storage_statuses: [])
end end
end end
end end
......
...@@ -493,13 +493,18 @@ module Gitlab ...@@ -493,13 +493,18 @@ module Gitlab
def tree_entry(path) def tree_entry(path)
return unless path.present? return unless path.present?
@repository.gitaly_migrate(:commit_tree_entry) do |is_migrated| # We're only interested in metadata, so limit actual data to 1 byte
if is_migrated # since Gitaly doesn't support "send no data" option.
gitaly_tree_entry(path) entry = @repository.gitaly_commit_client.tree_entry(id, path, 1)
else return unless entry
rugged_tree_entry(path)
end # To be compatible with the rugged format
end entry = entry.to_h
entry.delete(:data)
entry[:name] = File.basename(path)
entry[:type] = entry[:type].downcase
entry
end end
def to_gitaly_commit def to_gitaly_commit
...@@ -562,28 +567,6 @@ module Gitlab ...@@ -562,28 +567,6 @@ module Gitlab
SERIALIZE_KEYS SERIALIZE_KEYS
end end
def gitaly_tree_entry(path)
# We're only interested in metadata, so limit actual data to 1 byte
# since Gitaly doesn't support "send no data" option.
entry = @repository.gitaly_commit_client.tree_entry(id, path, 1)
return unless entry
# To be compatible with the rugged format
entry = entry.to_h
entry.delete(:data)
entry[:name] = File.basename(path)
entry[:type] = entry[:type].downcase
entry
end
# Is this the same as Blob.find_entry_by_path ?
def rugged_tree_entry(path)
rugged_commit.tree.path(path)
rescue Rugged::TreeError
nil
end
def gitaly_commit_author_from_rugged(author_or_committer) def gitaly_commit_author_from_rugged(author_or_committer)
Gitaly::CommitAuthor.new( Gitaly::CommitAuthor.new(
name: author_or_committer[:name].b, name: author_or_committer[:name].b,
......
...@@ -76,6 +76,13 @@ module Gitlab ...@@ -76,6 +76,13 @@ module Gitlab
end end
def tree_entry(ref, path, limit = nil) def tree_entry(ref, path, limit = nil)
if Pathname.new(path).cleanpath.to_s.start_with?('../')
# The TreeEntry RPC should return an empty reponse in this case but in
# Gitaly 0.107.0 and earlier we get an exception instead. This early return
# saves us a Gitaly roundtrip while also avoiding the exception.
return
end
request = Gitaly::TreeEntryRequest.new( request = Gitaly::TreeEntryRequest.new(
repository: @gitaly_repo, repository: @gitaly_repo,
revision: encode_binary(ref), revision: encode_binary(ref),
......
module Gitlab
module HealthChecks
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/1218
class FsShardsCheck
extend BaseAbstractCheck
RANDOM_STRING = SecureRandom.hex(1000).freeze
COMMAND_TIMEOUT = '1'.freeze
TIMEOUT_EXECUTABLE = 'timeout'.freeze
class << self
def readiness
repository_storages.map do |storage_name|
begin
if !storage_circuitbreaker_test(storage_name)
HealthChecks::Result.new(false, 'circuitbreaker tripped', shard: storage_name)
elsif !storage_stat_test(storage_name)
HealthChecks::Result.new(false, 'cannot stat storage', shard: storage_name)
else
with_temp_file(storage_name) do |tmp_file_path|
if !storage_write_test(tmp_file_path)
HealthChecks::Result.new(false, 'cannot write to storage', shard: storage_name)
elsif !storage_read_test(tmp_file_path)
HealthChecks::Result.new(false, 'cannot read from storage', shard: storage_name)
else
HealthChecks::Result.new(true, nil, shard: storage_name)
end
end
end
rescue RuntimeError => ex
message = "unexpected error #{ex} when checking storage #{storage_name}"
Rails.logger.error(message)
HealthChecks::Result.new(false, message, shard: storage_name)
end
end
end
def metrics
repository_storages.flat_map do |storage_name|
[
storage_stat_metrics(storage_name),
storage_write_metrics(storage_name),
storage_read_metrics(storage_name),
storage_circuitbreaker_metrics(storage_name)
].flatten
end
end
private
def operation_metrics(ok_metric, latency_metric, **labels)
result, elapsed = yield
[
metric(latency_metric, elapsed, **labels),
metric(ok_metric, result ? 1 : 0, **labels)
]
rescue RuntimeError => ex
Rails.logger.error("unexpected error #{ex} when checking #{ok_metric}")
[metric(ok_metric, 0, **labels)]
end
def repository_storages
storages_paths.keys
end
def storages_paths
Gitlab.config.repositories.storages
end
def exec_with_timeout(cmd_args, *args, &block)
Gitlab::Popen.popen([TIMEOUT_EXECUTABLE, COMMAND_TIMEOUT].concat(cmd_args), *args, &block)
end
def with_temp_file(storage_name)
temp_file_path = Dir::Tmpname.create(%w(fs_shards_check +deleted), storage_path(storage_name)) { |path| path }
yield temp_file_path
ensure
delete_test_file(temp_file_path)
end
def storage_path(storage_name)
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
storages_paths[storage_name]&.legacy_disk_path
end
end
# All below test methods use shell commands to perform actions on storage volumes.
# In case a storage volume have connectivity problems causing pure Ruby IO operation to wait indefinitely,
# we can rely on shell commands to be terminated once `timeout` kills them.
#
# However we also fallback to pure Ruby file operations in case a specific shell command is missing
# so we are still able to perform healthchecks and gather metrics from such system.
def delete_test_file(tmp_path)
_, status = exec_with_timeout(%W{ rm -f #{tmp_path} })
status.zero?
rescue Errno::ENOENT
File.delete(tmp_path) rescue Errno::ENOENT
end
def storage_stat_test(storage_name)
stat_path = File.join(storage_path(storage_name), '.')
begin
_, status = exec_with_timeout(%W{ stat #{stat_path} })
status.zero?
rescue Errno::ENOENT
File.exist?(stat_path) && File::Stat.new(stat_path).readable?
end
end
def storage_write_test(tmp_path)
_, status = exec_with_timeout(%W{ tee #{tmp_path} }) do |stdin|
stdin.write(RANDOM_STRING)
end
status.zero?
rescue Errno::ENOENT
written_bytes = File.write(tmp_path, RANDOM_STRING) rescue Errno::ENOENT
written_bytes == RANDOM_STRING.length
end
def storage_read_test(tmp_path)
_, status = exec_with_timeout(%W{ diff #{tmp_path} - }) do |stdin|
stdin.write(RANDOM_STRING)
end
status.zero?
rescue Errno::ENOENT
file_contents = File.read(tmp_path) rescue Errno::ENOENT
file_contents == RANDOM_STRING
end
def storage_circuitbreaker_test(storage_name)
Gitlab::Git::Storage::CircuitBreaker.build(storage_name).perform { "OK" }
rescue Gitlab::Git::Storage::Inaccessible
nil
end
def storage_stat_metrics(storage_name)
operation_metrics(:filesystem_accessible, :filesystem_access_latency_seconds, shard: storage_name) do
with_timing { storage_stat_test(storage_name) }
end
end
def storage_write_metrics(storage_name)
operation_metrics(:filesystem_writable, :filesystem_write_latency_seconds, shard: storage_name) do
with_temp_file(storage_name) do |tmp_file_path|
with_timing { storage_write_test(tmp_file_path) }
end
end
end
def storage_read_metrics(storage_name)
operation_metrics(:filesystem_readable, :filesystem_read_latency_seconds, shard: storage_name) do
with_temp_file(storage_name) do |tmp_file_path|
storage_write_test(tmp_file_path) # writes data used by read test
with_timing { storage_read_test(tmp_file_path) }
end
end
end
def storage_circuitbreaker_metrics(storage_name)
operation_metrics(:filesystem_circuitbreaker,
:filesystem_circuitbreaker_latency_seconds,
shard: storage_name) do
with_timing { storage_circuitbreaker_test(storage_name) }
end
end
end
end
end
end
...@@ -13,14 +13,14 @@ module Gitlab ...@@ -13,14 +13,14 @@ module Gitlab
end end
def metrics def metrics
repository_storages.flat_map do |storage_name| Gitaly::Server.all.flat_map do |server|
result, elapsed = with_timing { check(storage_name) } result, elapsed = with_timing { server.read_writeable? }
labels = { shard: storage_name } labels = { shard: server.storage }
[ [
metric("#{metric_prefix}_success", successful?(result) ? 1 : 0, **labels), metric("#{metric_prefix}_success", result ? 1 : 0, **labels),
metric("#{metric_prefix}_latency_seconds", elapsed, **labels) metric("#{metric_prefix}_latency_seconds", elapsed, **labels)
].flatten ]
end end
end end
...@@ -36,10 +36,6 @@ module Gitlab ...@@ -36,10 +36,6 @@ module Gitlab
METRIC_PREFIX METRIC_PREFIX
end end
def successful?(result)
result[:success]
end
def repository_storages def repository_storages
storages.keys storages.keys
end end
......
...@@ -69,8 +69,7 @@ describe HealthController do ...@@ -69,8 +69,7 @@ describe HealthController do
expect(json_response['cache_check']['status']).to eq('ok') expect(json_response['cache_check']['status']).to eq('ok')
expect(json_response['queues_check']['status']).to eq('ok') expect(json_response['queues_check']['status']).to eq('ok')
expect(json_response['shared_state_check']['status']).to eq('ok') expect(json_response['shared_state_check']['status']).to eq('ok')
expect(json_response['fs_shards_check']['status']).to eq('ok') expect(json_response['gitaly_check']['status']).to eq('ok')
expect(json_response['fs_shards_check']['labels']['shard']).to eq('default')
end end
end end
...@@ -122,7 +121,6 @@ describe HealthController do ...@@ -122,7 +121,6 @@ describe HealthController do
expect(json_response['cache_check']['status']).to eq('ok') expect(json_response['cache_check']['status']).to eq('ok')
expect(json_response['queues_check']['status']).to eq('ok') expect(json_response['queues_check']['status']).to eq('ok')
expect(json_response['shared_state_check']['status']).to eq('ok') expect(json_response['shared_state_check']['status']).to eq('ok')
expect(json_response['fs_shards_check']['status']).to eq('ok')
end end
end end
......
...@@ -59,6 +59,13 @@ describe MetricsController do ...@@ -59,6 +59,13 @@ describe MetricsController do
expect(response.body).to match(/^redis_shared_state_ping_latency_seconds [0-9\.]+$/) expect(response.body).to match(/^redis_shared_state_ping_latency_seconds [0-9\.]+$/)
end end
it 'returns Gitaly metrics' do
get :index
expect(response.body).to match(/^gitaly_health_check_success{shard="default"} 1$/)
expect(response.body).to match(/^gitaly_health_check_latency_seconds{shard="default"} [0-9\.]+$/)
end
context 'prometheus metrics are disabled' do context 'prometheus metrics are disabled' do
before do before do
allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false) allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false)
......
...@@ -60,33 +60,6 @@ feature 'Protected Branches', :js do ...@@ -60,33 +60,6 @@ feature 'Protected Branches', :js do
expect(page).to have_content('No branches to show') expect(page).to have_content('No branches to show')
end end
end end
describe "Saved defaults" do
it "keeps the allowed to merge and push dropdowns defaults based on the previous selection" do
visit project_protected_branches_path(project)
form = '.js-new-protected-branch'
within form do
find(".js-allowed-to-merge").click
wait_for_requests
click_link 'No one'
find(".js-allowed-to-push").click
wait_for_requests
click_link 'Developers + Maintainers'
end
visit project_protected_branches_path(project)
within form do
page.within(".js-allowed-to-merge") do
expect(page.find(".dropdown-toggle-text")).to have_content("No one")
end
page.within(".js-allowed-to-push") do
expect(page.find(".dropdown-toggle-text")).to have_content("Developers + Maintainers")
end
end
end
end
end end
context 'logged in as admin' do context 'logged in as admin' do
...@@ -97,6 +70,7 @@ feature 'Protected Branches', :js do ...@@ -97,6 +70,7 @@ feature 'Protected Branches', :js do
describe "explicit protected branches" do describe "explicit protected branches" do
it "allows creating explicit protected branches" do it "allows creating explicit protected branches" do
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch') set_protected_branch_name('some-branch')
click_on "Protect" click_on "Protect"
...@@ -110,6 +84,7 @@ feature 'Protected Branches', :js do ...@@ -110,6 +84,7 @@ feature 'Protected Branches', :js do
project.repository.add_branch(admin, 'some-branch', commit.id) project.repository.add_branch(admin, 'some-branch', commit.id)
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch') set_protected_branch_name('some-branch')
click_on "Protect" click_on "Protect"
...@@ -118,6 +93,7 @@ feature 'Protected Branches', :js do ...@@ -118,6 +93,7 @@ feature 'Protected Branches', :js do
it "displays an error message if the named branch does not exist" do it "displays an error message if the named branch does not exist" do
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch') set_protected_branch_name('some-branch')
click_on "Protect" click_on "Protect"
...@@ -128,6 +104,7 @@ feature 'Protected Branches', :js do ...@@ -128,6 +104,7 @@ feature 'Protected Branches', :js do
describe "wildcard protected branches" do describe "wildcard protected branches" do
it "allows creating protected branches with a wildcard" do it "allows creating protected branches with a wildcard" do
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('*-stable') set_protected_branch_name('*-stable')
click_on "Protect" click_on "Protect"
...@@ -141,6 +118,7 @@ feature 'Protected Branches', :js do ...@@ -141,6 +118,7 @@ feature 'Protected Branches', :js do
project.repository.add_branch(admin, 'staging-stable', 'master') project.repository.add_branch(admin, 'staging-stable', 'master')
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('*-stable') set_protected_branch_name('*-stable')
click_on "Protect" click_on "Protect"
...@@ -157,6 +135,7 @@ feature 'Protected Branches', :js do ...@@ -157,6 +135,7 @@ feature 'Protected Branches', :js do
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
set_protected_branch_name('*-stable') set_protected_branch_name('*-stable')
set_defaults
click_on "Protect" click_on "Protect"
visit project_protected_branches_path(project) visit project_protected_branches_path(project)
...@@ -180,4 +159,18 @@ feature 'Protected Branches', :js do ...@@ -180,4 +159,18 @@ feature 'Protected Branches', :js do
find(".dropdown-input-field").set(branch_name) find(".dropdown-input-field").set(branch_name)
click_on("Create wildcard #{branch_name}") click_on("Create wildcard #{branch_name}")
end end
def set_defaults
find(".js-allowed-to-merge").click
within('.qa-allowed-to-merge-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
find(".js-allowed-to-push").click
within('.qa-allowed-to-push-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
end
end end
...@@ -157,7 +157,7 @@ describe ApplicationHelper do ...@@ -157,7 +157,7 @@ describe ApplicationHelper do
let(:noteable_type) { Issue } let(:noteable_type) { Issue }
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(project, noteable_type) sources = helper.autocomplete_data_sources(project, noteable_type)
expect(sources.keys).to match_array([:members, :issues, :merge_requests, :labels, :milestones, :commands]) expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands])
sources.keys.each do |key| sources.keys.each do |key|
expect(sources[key]).not_to be_nil expect(sources[key]).not_to be_nil
end end
......
...@@ -44,4 +44,59 @@ describe('CopyAsGFM', () => { ...@@ -44,4 +44,59 @@ describe('CopyAsGFM', () => {
callPasteGFM(); callPasteGFM();
}); });
}); });
describe('CopyAsGFM.copyGFM', () => {
// Stub getSelection to return a purpose-built object.
const stubSelection = (html, parentNode) => ({
getRangeAt: () => ({
commonAncestorContainer: { tagName: parentNode },
cloneContents: () => {
const fragment = document.createDocumentFragment();
const node = document.createElement('div');
node.innerHTML = html;
Array.from(node.childNodes).forEach((item) => fragment.appendChild(item));
return fragment;
},
}),
rangeCount: 1,
});
const clipboardData = {
setData() {},
};
const simulateCopy = () => {
const e = {
originalEvent: {
clipboardData,
},
preventDefault() {},
stopPropagation() {},
};
CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection);
return clipboardData;
};
beforeEach(() => spyOn(clipboardData, 'setData'));
describe('list handling', () => {
it('uses correct gfm for unordered lists', () => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'UL');
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
const expectedGFM = '- List Item1\n- List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
});
it('uses correct gfm for ordered lists', () => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'OL');
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
const expectedGFM = '1. List Item1\n1. List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
});
});
});
}); });
...@@ -9,7 +9,7 @@ import '~/vue_shared/models/assignee'; ...@@ -9,7 +9,7 @@ import '~/vue_shared/models/assignee';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
import '~/boards/stores/boards_store'; import '~/boards/stores/boards_store';
import '~/boards/components/issue_card_inner'; import IssueCardInner from '~/boards/components/issue_card_inner.vue';
import { listObj } from './mock_data'; import { listObj } from './mock_data';
describe('Issue card component', () => { describe('Issue card component', () => {
...@@ -48,7 +48,7 @@ describe('Issue card component', () => { ...@@ -48,7 +48,7 @@ describe('Issue card component', () => {
component = new Vue({ component = new Vue({
el: document.querySelector('.test-container'), el: document.querySelector('.test-container'),
components: { components: {
'issue-card': gl.issueBoards.IssueCardInner, 'issue-card': IssueCardInner,
}, },
data() { data() {
return { return {
......
require 'spec_helper' require 'spec_helper'
describe Gitaly::Server do describe Gitaly::Server do
let(:server) { described_class.new('default') }
describe '.all' do describe '.all' do
let(:storages) { Gitlab.config.repositories.storages } let(:storages) { Gitlab.config.repositories.storages }
...@@ -17,6 +19,38 @@ describe Gitaly::Server do ...@@ -17,6 +19,38 @@ describe Gitaly::Server do
it { is_expected.to respond_to(:up_to_date?) } it { is_expected.to respond_to(:up_to_date?) }
it { is_expected.to respond_to(:address) } it { is_expected.to respond_to(:address) }
describe 'readable?' do
context 'when the storage is readable' do
it 'returns true' do
expect(server).to be_readable
end
end
context 'when the storage is not readable' do
let(:server) { described_class.new('broken') }
it 'returns false' do
expect(server).not_to be_readable
end
end
end
describe 'writeable?' do
context 'when the storage is writeable' do
it 'returns true' do
expect(server).to be_writeable
end
end
context 'when the storage is not writeable' do
let(:server) { described_class.new('broken') }
it 'returns false' do
expect(server).not_to be_writeable
end
end
end
describe 'request memoization' do describe 'request memoization' do
context 'when requesting multiple properties', :request_store do context 'when requesting multiple properties', :request_store do
it 'uses memoization for the info request' do it 'uses memoization for the info request' do
......
require 'spec_helper'
describe Gitlab::HealthChecks::FsShardsCheck do
def command_exists?(command)
_, status = Gitlab::Popen.popen(%W{ #{command} 1 echo })
status.zero?
rescue Errno::ENOENT
false
end
def timeout_command
@timeout_command ||=
if command_exists?('timeout')
'timeout'
elsif command_exists?('gtimeout')
'gtimeout'
else
''
end
end
let(:metric_class) { Gitlab::HealthChecks::Metric }
let(:result_class) { Gitlab::HealthChecks::Result }
let(:repository_storages) { ['default'] }
let(:tmp_dir) { Dir.mktmpdir }
let(:storages_paths) do
{
default: Gitlab::GitalyClient::StorageSettings.new('path' => tmp_dir)
}.with_indifferent_access
end
before do
allow(described_class).to receive(:repository_storages) { repository_storages }
allow(described_class).to receive(:storages_paths) { storages_paths }
stub_const('Gitlab::HealthChecks::FsShardsCheck::TIMEOUT_EXECUTABLE', timeout_command)
end
after do
FileUtils.remove_entry_secure(tmp_dir) if Dir.exist?(tmp_dir)
end
shared_examples 'filesystem checks' do
describe '#readiness' do
subject { described_class.readiness }
context 'storage has a tripped circuitbreaker', :broken_storage do
let(:repository_storages) { ['broken'] }
let(:storages_paths) do
Gitlab.config.repositories.storages
end
it { is_expected.to include(result_class.new(false, 'circuitbreaker tripped', shard: 'broken')) }
end
context 'storage points to not existing folder' do
let(:storages_paths) do
{
default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist')
}.with_indifferent_access
end
before do
allow(described_class).to receive(:storage_circuitbreaker_test) { true }
end
it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: 'default')) }
end
context 'storage points to directory that has both read and write rights' do
before do
FileUtils.chmod_R(0755, tmp_dir)
end
it { is_expected.to include(result_class.new(true, nil, shard: 'default')) }
it 'cleans up files used for testing' do
expect(described_class).to receive(:storage_write_test).with(any_args).and_call_original
expect { subject }.not_to change(Dir.entries(tmp_dir), :count)
end
context 'read test fails' do
before do
allow(described_class).to receive(:storage_read_test).with(any_args).and_return(false)
end
it { is_expected.to include(result_class.new(false, 'cannot read from storage', shard: 'default')) }
end
context 'write test fails' do
before do
allow(described_class).to receive(:storage_write_test).with(any_args).and_return(false)
end
it { is_expected.to include(result_class.new(false, 'cannot write to storage', shard: 'default')) }
end
end
end
describe '#metrics' do
context 'storage points to not existing folder' do
let(:storages_paths) do
{
default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist')
}.with_indifferent_access
end
it 'provides metrics' do
metrics = described_class.metrics
expect(metrics).to all(have_attributes(labels: { shard: 'default' }))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_circuitbreaker_latency_seconds, value: be >= 0))
end
end
context 'storage points to directory that has both read and write rights' do
before do
FileUtils.chmod_R(0755, tmp_dir)
end
it 'provides metrics' do
metrics = described_class.metrics
expect(metrics).to all(have_attributes(labels: { shard: 'default' }))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 1))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 1))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 1))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_circuitbreaker_latency_seconds, value: be >= 0))
end
it 'cleans up files used for metrics' do
expect { described_class.metrics }.not_to change(Dir.entries(tmp_dir), :count)
end
end
end
end
context 'when timeout kills fs checks' do
before do
stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '1')
allow(described_class).to receive(:exec_with_timeout).and_wrap_original { |m| m.call(%w(sleep 60)) }
FileUtils.chmod_R(0755, tmp_dir)
end
describe '#readiness' do
subject { described_class.readiness }
it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: 'default')) }
end
describe '#metrics' do
it 'provides metrics' do
metrics = described_class.metrics
expect(metrics).to all(have_attributes(labels: { shard: 'default' }))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_readable, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_writable, value: 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_access_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_read_latency_seconds, value: be >= 0))
expect(metrics).to include(an_object_having_attributes(name: :filesystem_write_latency_seconds, value: be >= 0))
end
end
end
context 'when popen always finds required binaries' do
before do
allow(described_class).to receive(:exec_with_timeout).and_wrap_original do |method, *args, &block|
begin
method.call(*args, &block)
rescue RuntimeError, Errno::ENOENT
raise 'expected not to happen'
end
end
stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '10')
end
it_behaves_like 'filesystem checks'
end
context 'when popen never finds required binaries' do
before do
allow(Gitlab::Popen).to receive(:popen).and_raise(Errno::ENOENT)
end
it_behaves_like 'filesystem checks'
end
end
...@@ -30,13 +30,14 @@ describe Gitlab::HealthChecks::GitalyCheck do ...@@ -30,13 +30,14 @@ describe Gitlab::HealthChecks::GitalyCheck do
describe '#metrics' do describe '#metrics' do
subject { described_class.metrics } subject { described_class.metrics }
let(:server) { double(storage: 'default', read_writeable?: up) }
before do before do
expect(Gitlab::GitalyClient::HealthCheckService).to receive(:new).and_return(gitaly_check) allow(Gitaly::Server).to receive(:new).and_return(server)
end end
context 'Gitaly server is up' do context 'Gitaly server is up' do
let(:gitaly_check) { double(check: { success: true }) } let(:up) { true }
it 'provides metrics' do it 'provides metrics' do
expect(subject).to all(have_attributes(labels: { shard: 'default' })) expect(subject).to all(have_attributes(labels: { shard: 'default' }))
...@@ -46,7 +47,7 @@ describe Gitlab::HealthChecks::GitalyCheck do ...@@ -46,7 +47,7 @@ describe Gitlab::HealthChecks::GitalyCheck do
end end
context 'Gitaly server is down' do context 'Gitaly server is down' do
let(:gitaly_check) { double(check: { success: false, message: 'Connection refused' }) } let(:up) { false }
it 'provides metrics' do it 'provides metrics' do
expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_success', value: 0)) expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_success', value: 0))
......
...@@ -514,7 +514,6 @@ eos ...@@ -514,7 +514,6 @@ eos
end end
describe '#uri_type' do describe '#uri_type' do
shared_examples 'URI type' do
it 'returns the URI type at the given path' do it 'returns the URI type at the given path' do
expect(commit.uri_type('files/html')).to be(:tree) expect(commit.uri_type('files/html')).to be(:tree)
expect(commit.uri_type('files/images/logo-black.png')).to be(:raw) expect(commit.uri_type('files/images/logo-black.png')).to be(:raw)
...@@ -524,6 +523,7 @@ eos ...@@ -524,6 +523,7 @@ eos
it "returns nil if the path doesn't exists" do it "returns nil if the path doesn't exists" do
expect(commit.uri_type('this/path/doesnt/exist')).to be_nil expect(commit.uri_type('this/path/doesnt/exist')).to be_nil
expect(commit.uri_type('../path/doesnt/exist')).to be_nil
end end
it 'is nil if the path is nil or empty' do it 'is nil if the path is nil or empty' do
...@@ -532,15 +532,6 @@ eos ...@@ -532,15 +532,6 @@ eos
end end
end end
context 'when Gitaly commit_tree_entry feature is enabled' do
it_behaves_like 'URI type'
end
context 'when Gitaly commit_tree_entry feature is disabled', :disable_gitaly do
it_behaves_like 'URI type'
end
end
describe '.from_hash' do describe '.from_hash' do
let(:new_commit) { described_class.from_hash(commit.to_hash, project) } let(:new_commit) { described_class.from_hash(commit.to_hash, project) }
......
...@@ -2294,6 +2294,28 @@ describe Repository do ...@@ -2294,6 +2294,28 @@ describe Repository do
end end
end end
describe '#local_branches' do
it 'returns the local branches' do
masterrev = repository.find_branch('master').dereferenced_target
create_remote_branch('joe', 'remote_branch', masterrev)
repository.add_branch(user, 'local_branch', masterrev.id)
expect(repository.local_branches.any? { |branch| branch.name == 'remote_branch' }).to eq(false)
expect(repository.local_branches.any? { |branch| branch.name == 'local_branch' }).to eq(true)
end
end
describe '#remote_branches' do
it 'returns the remote branches' do
masterrev = repository.find_branch('master').dereferenced_target
create_remote_branch('joe', 'remote_branch', masterrev)
repository.add_branch(user, 'local_branch', masterrev.id)
expect(repository.remote_branches('joe').any? { |branch| branch.name == 'local_branch' }).to eq(false)
expect(repository.remote_branches('joe').any? { |branch| branch.name == 'remote_branch' }).to eq(true)
end
end
describe '#commit_count' do describe '#commit_count' do
context 'with a non-existing repository' do context 'with a non-existing repository' do
it 'returns 0' do it 'returns 0' do
......
...@@ -5,6 +5,12 @@ shared_examples "protected branches > access control > CE" do ...@@ -5,6 +5,12 @@ shared_examples "protected branches > access control > CE" do
set_protected_branch_name('master') set_protected_branch_name('master')
find(".js-allowed-to-merge").click
within('.qa-allowed-to-merge-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
within('.js-new-protected-branch') do within('.js-new-protected-branch') do
allowed_to_push_button = find(".js-allowed-to-push") allowed_to_push_button = find(".js-allowed-to-push")
...@@ -25,6 +31,18 @@ shared_examples "protected branches > access control > CE" do ...@@ -25,6 +31,18 @@ shared_examples "protected branches > access control > CE" do
set_protected_branch_name('master') set_protected_branch_name('master')
find(".js-allowed-to-merge").click
within('.qa-allowed-to-merge-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
find(".js-allowed-to-push").click
within('.qa-allowed-to-push-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
click_on "Protect" click_on "Protect"
expect(ProtectedBranch.count).to eq(1) expect(ProtectedBranch.count).to eq(1)
...@@ -59,6 +77,12 @@ shared_examples "protected branches > access control > CE" do ...@@ -59,6 +77,12 @@ shared_examples "protected branches > access control > CE" do
end end
end end
find(".js-allowed-to-push").click
within('.qa-allowed-to-push-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
click_on "Protect" click_on "Protect"
expect(ProtectedBranch.count).to eq(1) expect(ProtectedBranch.count).to eq(1)
...@@ -70,6 +94,18 @@ shared_examples "protected branches > access control > CE" do ...@@ -70,6 +94,18 @@ shared_examples "protected branches > access control > CE" do
set_protected_branch_name('master') set_protected_branch_name('master')
find(".js-allowed-to-merge").click
within('.qa-allowed-to-merge-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
find(".js-allowed-to-push").click
within('.qa-allowed-to-push-dropdown') do
expect(first("li")).to have_content("Roles")
find(:link, 'No one').click
end
click_on "Protect" click_on "Protect"
expect(ProtectedBranch.count).to eq(1) expect(ProtectedBranch.count).to eq(1)
......
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