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
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.102.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.103.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
......
......@@ -282,7 +282,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.102.0)
gitaly-proto (0.103.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
......@@ -1041,7 +1041,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.102.0)
gitaly-proto (~> 0.103.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
......
......@@ -285,7 +285,7 @@ GEM
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gitaly-proto (0.102.0)
gitaly-proto (0.103.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
......@@ -1051,7 +1051,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.102.0)
gitaly-proto (~> 0.103.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
......
......@@ -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,
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
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.
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.
### Regressions
......
<script>
/* eslint-disable vue/require-default-prop */
import './issue_card_inner';
import eventHub from '../eventhub';
/* eslint-disable vue/require-default-prop */
import IssueCardInner from './issue_card_inner.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
export default {
export default {
name: 'BoardsIssueCard',
components: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
IssueCardInner,
},
props: {
list: {
......@@ -72,7 +72,7 @@ export default {
}
},
},
};
};
</script>
<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';
import './components/board';
import './components/board_sidebar';
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
export default () => {
......@@ -49,7 +49,7 @@ export default () => {
components: {
'board': gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar,
'board-add-issues-modal': gl.issueBoards.IssuesModal,
BoardAddIssuesModal,
},
data: {
state: Store.state,
......
......@@ -189,12 +189,25 @@ export const getParameterByName = (name, urlToParse) => {
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 = () => {
const selection = window.getSelection();
if (selection.rangeCount === 0) return null;
const documentFragment = document.createDocumentFragment();
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;
......
import $ from 'jquery';
import _ from 'underscore';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults';
export default class ProtectedBranchCreate {
constructor() {
this.$form = $('.js-new-protected-branch');
......@@ -43,8 +40,6 @@ export default class ProtectedBranchCreate {
onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches,
});
this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown'));
}
// This will run after clicked callback
......@@ -59,39 +54,10 @@ export default class ProtectedBranchCreate {
$allowedToPushInput.length
);
this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val());
this.$form.find('input[type="submit"]').prop('disabled', completedForm);
}
static getProtectedBranches(term, callback) {
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 {
}
// If the dropdown is closed, we'll open it
if (!this.dropdown.hasClass('open')) {
if (!this.dropdown.hasClass('show')) {
this.loadingSuggestions = false;
this.dropdownToggle.dropdown('toggle');
return this.searchInput.removeClass('disabled');
......@@ -424,9 +424,9 @@ export default class SearchAutocomplete {
}
disableAutocomplete() {
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('disabled');
this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
this.dropdown.removeClass('show').trigger('hidden.bs.dropdown');
this.restoreMenu();
}
}
......
......@@ -8,7 +8,6 @@ class HealthController < ActionController::Base
Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck,
Gitlab::HealthChecks::FsShardsCheck,
Gitlab::HealthChecks::GitalyCheck
].freeze
......
......@@ -270,7 +270,7 @@ module ApplicationHelper
{
members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
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]),
milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
......
......@@ -6,7 +6,8 @@ class MetricsService
Gitlab::HealthChecks::Redis::RedisCheck,
Gitlab::HealthChecks::Redis::CacheCheck,
Gitlab::HealthChecks::Redis::QueuesCheck,
Gitlab::HealthChecks::Redis::SharedStateCheck
Gitlab::HealthChecks::Redis::SharedStateCheck,
Gitlab::HealthChecks::GitalyCheck
].freeze
def prometheus_metrics_text
......
# frozen_string_literal: true
class AdminEmailWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class ArchiveTraceWorker
include ApplicationWorker
include PipelineBackgroundQueue
......
# frozen_string_literal: true
class AuthorizedProjectsWorker
include ApplicationWorker
prepend WaitableWorker
......
# frozen_string_literal: true
class BackgroundMigrationWorker
include ApplicationWorker
......
# frozen_string_literal: true
class BuildCoverageWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class BuildFinishedWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class BuildHooksWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class BuildQueueWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class BuildSuccessWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class BuildTraceSectionsWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
module Ci
class ArchiveTracesCronWorker
include ApplicationWorker
......
# frozen_string_literal: true
module Ci
class BuildTraceChunkFlushWorker
include ApplicationWorker
......
# frozen_string_literal: true
class ClusterInstallAppWorker
include ApplicationWorker
include ClusterQueue
......
# frozen_string_literal: true
class ClusterProvisionWorker
include ApplicationWorker
include ClusterQueue
......
# frozen_string_literal: true
class ClusterWaitForAppInstallationWorker
include ApplicationWorker
include ClusterQueue
......
# frozen_string_literal: true
class ClusterWaitForIngressIpAddressWorker
include ApplicationWorker
include ClusterQueue
......
# frozen_string_literal: true
Sidekiq::Worker.extend ActiveSupport::Concern
module ApplicationWorker
......
# frozen_string_literal: true
module ClusterApplications
extend ActiveSupport::Concern
......
# frozen_string_literal: true
##
# 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
# cronjob.
module CronjobQueue
......
# frozen_string_literal: true
# Concern for enabling a few lines of exception backtraces in Sidekiq
module ExceptionBacktrace
extend ActiveSupport::Concern
......
# frozen_string_literal: true
module Gitlab
module GithubImport
module Queue
......
# frozen_string_literal: true
module MailSchedulerQueue
extend ActiveSupport::Concern
......
# frozen_string_literal: true
module NewIssuable
attr_reader :issuable, :user
......
# frozen_string_literal: true
# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers.
module ObjectStorageQueue
extend ActiveSupport::Concern
......
# frozen_string_literal: true
##
# 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.
#
......
# frozen_string_literal: true
module ProjectImportOptions
extend ActiveSupport::Concern
......
# frozen_string_literal: true
# Used in EE by mirroring
module ProjectStartImport
def start(project)
......
# frozen_string_literal: true
# Concern for setting Sidekiq settings for the various repository check workers.
module RepositoryCheckQueue
extend ActiveSupport::Concern
......
# frozen_string_literal: true
module WaitableWorker
extend ActiveSupport::Concern
......
# frozen_string_literal: true
class CreateGpgSignatureWorker
include ApplicationWorker
......
# frozen_string_literal: true
class CreateNoteDiffFileWorker
include ApplicationWorker
......
# frozen_string_literal: true
class CreatePipelineWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class DeleteMergedBranchesWorker
include ApplicationWorker
......
# frozen_string_literal: true
class DeleteUserWorker
include ApplicationWorker
......
# frozen_string_literal: true
class EmailReceiverWorker
include ApplicationWorker
......
# frozen_string_literal: true
class EmailsOnPushWorker
include ApplicationWorker
......
# frozen_string_literal: true
class ExpireBuildArtifactsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class ExpireBuildInstanceArtifactsWorker
include ApplicationWorker
......
# frozen_string_literal: true
class ExpireJobCacheWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class ExpirePipelineCacheWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class GitGarbageCollectWorker
include ApplicationWorker
......
# frozen_string_literal: true
class GitlabShellWorker
include ApplicationWorker
include Gitlab::ShellAdapter
......
# frozen_string_literal: true
class GitlabUsagePingWorker
LEASE_TIMEOUT = 86400
......
# frozen_string_literal: true
class GroupDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
......
# frozen_string_literal: true
class ImportExportProjectCleanupWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class InvalidGpgSignatureUpdateWorker
include ApplicationWorker
......
# frozen_string_literal: true
require 'json'
require 'socket'
......@@ -69,8 +71,8 @@ class IrkerWorker
newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches"
newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors
privmsg = "[#{repo_name}] #{committer} has created a new branch "
privmsg += "#{branch}: #{newbranch}"
privmsg = "[#{repo_name}] #{committer} has created a new branch " \
"#{branch}: #{newbranch}"
sendtoirker privmsg
end
......@@ -112,9 +114,7 @@ class IrkerWorker
url = compare_url data, project.full_path
commits = colorize_commits data['total_commits_count']
new_commits = 'new commit'
new_commits += 's' if data['total_commits_count'] > 1
new_commits = 'new commit'.pluralize(data['total_commits_count'])
sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \
"to #{branch}: #{url}"
end
......@@ -122,8 +122,8 @@ class IrkerWorker
def compare_url(data, repo_path)
sha1 = Commit.truncate_sha(data['before'])
sha2 = Commit.truncate_sha(data['after'])
compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare"
compare_url += "/#{sha1}...#{sha2}"
compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" \
"/#{sha1}...#{sha2}"
colorize_url compare_url
end
......@@ -144,8 +144,7 @@ class IrkerWorker
def files_count(commit)
diff_size = commit.raw_deltas.size
files = "#{diff_size} file"
files += 's' if diff_size > 1
files = "#{diff_size} file".pluralize(diff_size)
files
end
......
# frozen_string_literal: true
class IssueDueSchedulerWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
module MailScheduler
class IssueDueWorker
include ApplicationWorker
......
# frozen_string_literal: true
require 'active_job/arguments'
module MailScheduler
......
# frozen_string_literal: true
class MergeWorker
include ApplicationWorker
......
# frozen_string_literal: true
# Worker to destroy projects that do not have a namespace
#
# It destroys everything it can without having the info about the namespace it
......
# frozen_string_literal: true
class NewIssueWorker
include ApplicationWorker
include NewIssuable
......
# frozen_string_literal: true
class NewMergeRequestWorker
include ApplicationWorker
include NewIssuable
......
# frozen_string_literal: true
class NewNoteWorker
include ApplicationWorker
......
# frozen_string_literal: true
module ObjectStorage
class BackgroundMoveWorker
include ApplicationWorker
......
# frozen_string_literal: true
# @Deprecated - remove once the `object_storage_upload` queue is empty
# The queue has been renamed `object_storage:object_storage_background_upload`
#
......
# frozen_string_literal: true
class PagesDomainVerificationCronWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class PagesDomainVerificationWorker
include ApplicationWorker
......
# frozen_string_literal: true
class PagesWorker
include ApplicationWorker
......
# frozen_string_literal: true
class PipelineHooksWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PipelineMetricsWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PipelineNotificationWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PipelineProcessWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PipelineScheduleWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class PipelineSuccessWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PipelineUpdateWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class PluginWorker
include ApplicationWorker
......
# frozen_string_literal: true
class PostReceive
include ApplicationWorker
......
# frozen_string_literal: true
# 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
......
# frozen_string_literal: true
# Worker for updating any project specific caches.
class ProjectCacheWorker
include ApplicationWorker
......
# frozen_string_literal: true
class ProjectDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
......
# frozen_string_literal: true
class ProjectExportWorker
include ApplicationWorker
include ExceptionBacktrace
......
# frozen_string_literal: true
class ProjectMigrateHashedStorageWorker
include ApplicationWorker
......
# frozen_string_literal: true
class ProjectServiceWorker
include ApplicationWorker
......
# frozen_string_literal: true
# Worker for updating any project specific caches.
class PropagateServiceTemplateWorker
include ApplicationWorker
......
# frozen_string_literal: true
class PruneOldEventsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class ReactiveCachingWorker
include ApplicationWorker
......
# frozen_string_literal: true
class RebaseWorker
include ApplicationWorker
......
# frozen_string_literal: true
class RemoveExpiredGroupLinksWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class RemoveExpiredMembersWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class RemoveOldWebHookLogsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class RemoveUnreferencedLfsObjectsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class RepositoryArchiveCacheWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
module RepositoryCheck
class BatchWorker
include ApplicationWorker
......
# frozen_string_literal: true
module RepositoryCheck
class ClearWorker
include ApplicationWorker
......
# frozen_string_literal: true
module RepositoryCheck
class SingleRepositoryWorker
include ApplicationWorker
......
# frozen_string_literal: true
class RepositoryForkWorker
include ApplicationWorker
include Gitlab::ShellAdapter
......
# frozen_string_literal: true
class RepositoryImportWorker
include ApplicationWorker
include ExceptionBacktrace
......
# frozen_string_literal: true
class RepositoryRemoveRemoteWorker
include ApplicationWorker
include ExclusiveLeaseGuard
......
# frozen_string_literal: true
class RepositoryUpdateRemoteMirrorWorker
UpdateAlreadyInProgressError = Class.new(StandardError)
UpdateError = Class.new(StandardError)
......
# frozen_string_literal: true
class RequestsProfilesWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class RunPipelineScheduleWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class ScheduleUpdateUserActivityWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class StageUpdateWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class StorageMigratorWorker
include ApplicationWorker
......
# frozen_string_literal: true
class StuckCiJobsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class StuckImportJobsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class StuckMergeJobsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class SystemHookPushWorker
include ApplicationWorker
......
# frozen_string_literal: true
class TrendingProjectsWorker
include ApplicationWorker
include CronjobQueue
......
# frozen_string_literal: true
class UpdateHeadPipelineForMergeRequestWorker
include ApplicationWorker
include PipelineQueue
......
# frozen_string_literal: true
class UpdateMergeRequestsWorker
include ApplicationWorker
......
# frozen_string_literal: true
class UpdateUserActivityWorker
include ApplicationWorker
......
# frozen_string_literal: true
class UploadChecksumWorker
include ApplicationWorker
......
# frozen_string_literal: true
class WaitForClusterCreationWorker
include ApplicationWorker
include ClusterQueue
......
# frozen_string_literal: true
class WebHookWorker
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
[rake tasks]: raketasks/storage.md#migrate-existing-projects-to-hashed-storage
[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
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.
| Pages | Yes | No | - | - |
| Docker Registry | Yes | 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 | - |
| 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
server_version == Gitlab::GitalyClient.expected_server_version
end
def read_writeable?
readable? && writeable?
end
def readable?
storage_status&.readable
end
def writeable?
storage_status&.writeable
end
def address
Gitlab::GitalyClient.address(@storage)
rescue RuntimeError => e
......@@ -30,13 +42,17 @@ module Gitaly
private
def storage_status
@storage_status ||= info.storage_statuses.find { |s| s.storage_name == storage }
end
def info
@info ||=
begin
Gitlab::GitalyClient::ServerService.new(@storage).info
rescue GRPC::Unavailable, GRPC::GRPC::DeadlineExceeded
# 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
......
......@@ -493,13 +493,18 @@ module Gitlab
def tree_entry(path)
return unless path.present?
@repository.gitaly_migrate(:commit_tree_entry) do |is_migrated|
if is_migrated
gitaly_tree_entry(path)
else
rugged_tree_entry(path)
end
end
# 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
def to_gitaly_commit
......@@ -562,28 +567,6 @@ module Gitlab
SERIALIZE_KEYS
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)
Gitaly::CommitAuthor.new(
name: author_or_committer[:name].b,
......
......@@ -76,6 +76,13 @@ module Gitlab
end
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(
repository: @gitaly_repo,
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
end
def metrics
repository_storages.flat_map do |storage_name|
result, elapsed = with_timing { check(storage_name) }
labels = { shard: storage_name }
Gitaly::Server.all.flat_map do |server|
result, elapsed = with_timing { server.read_writeable? }
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)
].flatten
]
end
end
......@@ -36,10 +36,6 @@ module Gitlab
METRIC_PREFIX
end
def successful?(result)
result[:success]
end
def repository_storages
storages.keys
end
......
......@@ -69,8 +69,7 @@ describe HealthController do
expect(json_response['cache_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['fs_shards_check']['status']).to eq('ok')
expect(json_response['fs_shards_check']['labels']['shard']).to eq('default')
expect(json_response['gitaly_check']['status']).to eq('ok')
end
end
......@@ -122,7 +121,6 @@ describe HealthController do
expect(json_response['cache_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['fs_shards_check']['status']).to eq('ok')
end
end
......
......@@ -59,6 +59,13 @@ describe MetricsController do
expect(response.body).to match(/^redis_shared_state_ping_latency_seconds [0-9\.]+$/)
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
before do
allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false)
......
......@@ -60,33 +60,6 @@ feature 'Protected Branches', :js do
expect(page).to have_content('No branches to show')
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
context 'logged in as admin' do
......@@ -97,6 +70,7 @@ feature 'Protected Branches', :js do
describe "explicit protected branches" do
it "allows creating explicit protected branches" do
visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch')
click_on "Protect"
......@@ -110,6 +84,7 @@ feature 'Protected Branches', :js do
project.repository.add_branch(admin, 'some-branch', commit.id)
visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch')
click_on "Protect"
......@@ -118,6 +93,7 @@ feature 'Protected Branches', :js do
it "displays an error message if the named branch does not exist" do
visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('some-branch')
click_on "Protect"
......@@ -128,6 +104,7 @@ feature 'Protected Branches', :js do
describe "wildcard protected branches" do
it "allows creating protected branches with a wildcard" do
visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('*-stable')
click_on "Protect"
......@@ -141,6 +118,7 @@ feature 'Protected Branches', :js do
project.repository.add_branch(admin, 'staging-stable', 'master')
visit project_protected_branches_path(project)
set_defaults
set_protected_branch_name('*-stable')
click_on "Protect"
......@@ -157,6 +135,7 @@ feature 'Protected Branches', :js do
visit project_protected_branches_path(project)
set_protected_branch_name('*-stable')
set_defaults
click_on "Protect"
visit project_protected_branches_path(project)
......@@ -180,4 +159,18 @@ feature 'Protected Branches', :js do
find(".dropdown-input-field").set(branch_name)
click_on("Create wildcard #{branch_name}")
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
......@@ -157,7 +157,7 @@ describe ApplicationHelper do
let(:noteable_type) { Issue }
it 'returns paths for autocomplete_sources_controller' do
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|
expect(sources[key]).not_to be_nil
end
......
......@@ -44,4 +44,59 @@ describe('CopyAsGFM', () => {
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';
import '~/boards/models/issue';
import '~/boards/models/list';
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';
describe('Issue card component', () => {
......@@ -48,7 +48,7 @@ describe('Issue card component', () => {
component = new Vue({
el: document.querySelector('.test-container'),
components: {
'issue-card': gl.issueBoards.IssueCardInner,
'issue-card': IssueCardInner,
},
data() {
return {
......
require 'spec_helper'
describe Gitaly::Server do
let(:server) { described_class.new('default') }
describe '.all' do
let(:storages) { Gitlab.config.repositories.storages }
......@@ -17,6 +19,38 @@ describe Gitaly::Server do
it { is_expected.to respond_to(:up_to_date?) }
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
context 'when requesting multiple properties', :request_store 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
describe '#metrics' do
subject { described_class.metrics }
let(:server) { double(storage: 'default', read_writeable?: up) }
before do
expect(Gitlab::GitalyClient::HealthCheckService).to receive(:new).and_return(gitaly_check)
allow(Gitaly::Server).to receive(:new).and_return(server)
end
context 'Gitaly server is up' do
let(:gitaly_check) { double(check: { success: true }) }
let(:up) { true }
it 'provides metrics' do
expect(subject).to all(have_attributes(labels: { shard: 'default' }))
......@@ -46,7 +47,7 @@ describe Gitlab::HealthChecks::GitalyCheck do
end
context 'Gitaly server is down' do
let(:gitaly_check) { double(check: { success: false, message: 'Connection refused' }) }
let(:up) { false }
it 'provides metrics' do
expect(subject).to include(an_object_having_attributes(name: 'gitaly_health_check_success', value: 0))
......
......@@ -514,7 +514,6 @@ eos
end
describe '#uri_type' do
shared_examples 'URI type' 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/images/logo-black.png')).to be(:raw)
......@@ -524,6 +523,7 @@ eos
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('../path/doesnt/exist')).to be_nil
end
it 'is nil if the path is nil or empty' do
......@@ -532,15 +532,6 @@ eos
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
let(:new_commit) { described_class.from_hash(commit.to_hash, project) }
......
......@@ -2294,6 +2294,28 @@ describe Repository do
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
context 'with a non-existing repository' do
it 'returns 0' do
......
......@@ -5,6 +5,12 @@ shared_examples "protected branches > access control > CE" do
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
allowed_to_push_button = find(".js-allowed-to-push")
......@@ -25,6 +31,18 @@ shared_examples "protected branches > access control > CE" do
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"
expect(ProtectedBranch.count).to eq(1)
......@@ -59,6 +77,12 @@ shared_examples "protected branches > access control > CE" do
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"
expect(ProtectedBranch.count).to eq(1)
......@@ -70,6 +94,18 @@ shared_examples "protected branches > access control > CE" do
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"
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