-
+
+ :href="commitRef.ref_url"
+ v-tooltip
+ data-container="body"
+ :title="commitRef.name">
{{commitRef.name}}
@@ -153,7 +162,8 @@
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
/>
-
{{title}}
diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
new file mode 100644
index 00000000000..397d16331d5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ This is a confidential issue. Your comment will not be visible to the public.
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 4e10bbc7408..759d30c9c7c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -5,19 +5,30 @@
export default {
props: {
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ addSpacingClasses: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ },
},
data() {
return {
markdownPreview: '',
+ referencedCommands: '',
+ referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -26,36 +37,49 @@
markdownHeader,
markdownToolbar,
},
+ computed: {
+ shouldShowReferencedUsers() {
+ const referencedUsersThreshold = 10;
+ return this.referencedUsers.length >= referencedUsersThreshold;
+ },
+ },
methods: {
toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown;
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ const text = this.$slots.textarea[0].elm.value;
+
if (!this.previewMarkdown) {
this.markdownPreview = '';
- } else {
+ } else if (text) {
this.markdownPreviewLoading = true;
- this.$http.post(
- this.markdownPreviewUrl,
- {
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- text: this.$slots.textarea[0].elm.value,
- },
- )
- .then(resp => resp.json())
- .then((data) => {
- this.markdownPreviewLoading = false;
- this.markdownPreview = data.body;
-
- this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
- });
- })
- .catch(() => new Flash('Error loading markdown preview'));
+ this.$http.post(this.markdownPreviewPath, { text })
+ .then(resp => resp.json())
+ .then((data) => {
+ this.renderMarkdown(data);
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ } else {
+ this.renderMarkdown();
}
},
+ renderMarkdown(data = {}) {
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body || 'Nothing to preview.';
+
+ if (data.references) {
+ this.referencedCommands = data.references.commands;
+ this.referencedUsers = data.references.users;
+ }
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ },
},
mounted() {
/*
@@ -74,7 +98,8 @@
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ />
+
+
+
+
+
+
+ You are about to add
+
+
+ {{referencedUsers.length}}
+
+ people to the discussion. Proceed with caution.
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 93252293ba6..65fe7bbd94e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,10 +1,14 @@
@@ -12,22 +16,77 @@
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 4ce767e4cc4..c165ec0b94b 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -95,8 +95,8 @@
.is-selected .pika-day,
.pika-day:hover,
.is-today .pika-day {
- background: $gl-primary;
- color: $white-light;
+ background: $gray-darker;
+ color: $gl-text-color;
box-shadow: none;
}
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index e16fbbf43b5..68a51c5a461 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -16,6 +16,7 @@
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
+.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 5871383a57b..fad991f2c49 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -193,7 +193,7 @@
min-width: 240px;
max-width: 500px;
margin-top: 2px;
- margin-bottom: 0;
+ margin-bottom: 2px;
font-size: 14px;
font-weight: $gl-font-weight-normal;
padding: 8px 0;
@@ -368,6 +368,10 @@
transform: translateY(0);
}
+.comment-type-dropdown.open .dropdown-menu {
+ display: block;
+}
+
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
@@ -618,6 +622,11 @@
border-top: 1px solid $dropdown-divider-color;
}
+.dropdown-footer-content {
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
.dropdown-due-date-footer {
padding-top: 0;
margin-left: 10px;
@@ -729,6 +738,7 @@
#{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav {
li {
+ display: block;
padding: 0 1px;
&:hover {
@@ -748,9 +758,13 @@
}
a,
- button {
+ button,
+ .menu-item {
border-radius: 0;
+ box-shadow: none;
padding: 8px 16px;
+ text-align: left;
+ width: 100%;
// make sure the text color is not overriden
&.text-danger {
@@ -796,6 +810,15 @@
#{$selector}.dropdown-menu-align-right {
margin-top: 2px;
}
+
+ .open {
+ #{$selector}.dropdown-menu,
+ #{$selector}.dropdown-menu-nav {
+ @media (max-width: $screen-xs-max) {
+ max-width: 100%;
+ }
+ }
+ }
}
@include new-style-dropdown('.js-namespace-select + ');
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 40e8a928e6e..ef58382ba41 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -132,3 +132,7 @@
width: calc(100% + 35px);
}
}
+
+.issuable-sidebar {
+ @include new-style-dropdown;
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 26920869bec..01fffa717e9 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
-$stage-hover-bg: #eaf3fc;
-$stage-hover-border: #d1e7fc;
-$action-icon-color: #d6d6d6;
+$stage-hover-bg: $gray-darker;
+$ci-action-icon-size: 22px;
+$pipeline-dropdown-line-height: 20px;
+$pipeline-dropdown-status-icon-size: 18px;
/*
Pipeline Schedules
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
index 54fa4109f8b..b711bd12c73 100644
--- a/app/assets/stylesheets/new_nav.scss
+++ b/app/assets/stylesheets/new_nav.scss
@@ -8,15 +8,23 @@ header.navbar-gitlab-new {
border-bottom: 0;
.header-content {
+ display: -webkit-flex;
+ display: flex;
padding-left: 0;
.title-container {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-align-items: stretch;
align-items: stretch;
+ -webkit-flex: 1 1 auto;
+ flex: 1 1 auto;
padding-top: 0;
overflow: visible;
}
.title {
+ display: -webkit-flex;
display: flex;
padding-right: 0;
color: currentColor;
@@ -27,6 +35,7 @@ header.navbar-gitlab-new {
}
> a {
+ display: -webkit-flex;
display: flex;
align-items: center;
padding-right: $gl-padding;
@@ -177,6 +186,7 @@ header.navbar-gitlab-new {
}
.navbar-sub-nav {
+ display: -webkit-flex;
display: flex;
margin-bottom: 0;
color: $indigo-200;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 3d04df8d820..50ec5110bf1 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -322,14 +322,13 @@
}
.build-dropdown {
- padding: $gl-padding 0;
+ @include new-style-dropdown;
+
+ margin: $gl-padding 0;
+ padding: 0;
.dropdown-menu-toggle {
- margin-top: 8px;
- }
-
- .dropdown-menu {
- margin-top: -$gl-padding;
+ margin-top: #{$gl-padding / 2};
}
svg {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index a8d2ae0af28..e7c830cbc69 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -12,6 +12,8 @@
.environments-container {
.ci-table {
+ @include new-style-dropdown;
+
.deployment-column {
> span {
word-break: break-all;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index ab5a901da71..6523376ccc3 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -473,7 +473,7 @@
padding-top: 6px;
}
- .open .dropdown-menu {
+ .dropdown-menu {
width: 100%;
}
}
@@ -486,6 +486,24 @@
}
}
+.sidebar-move-issue-dropdown {
+ @include new-style-dropdown;
+}
+
+.sidebar-move-issue-confirmation-button {
+ width: 100%;
+
+ &.is-loading {
+ .sidebar-move-issue-confirmation-loading-icon {
+ display: inline-block;
+ }
+ }
+}
+
+.sidebar-move-issue-confirmation-loading-icon {
+ display: none;
+}
+
.detail-page-description {
padding: 16px 0;
@@ -498,6 +516,7 @@
color: $gray-darkest;
display: block;
margin: 16px 0 0;
+ font-size: 85%;
.author_link {
color: $gray-darkest;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index e2177f96aee..0213e7aa9d9 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -186,6 +186,8 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
+ @include new-style-dropdown;
+
.btn-group:not(.hide) {
display: flex;
}
@@ -212,15 +214,6 @@ ul.related-merge-requests > li {
}
li:not(.divider) {
- padding: 6px;
- cursor: pointer;
-
- &:hover,
- &:focus {
- background-color: $dropdown-hover-color;
- color: $white-light;
- }
-
&.droplab-item-selected {
.icon-container {
i {
@@ -250,6 +243,10 @@ ul.related-merge-requests > li {
}
}
+.discussion-reply-holder .note-edit-form {
+ display: block;
+}
+
@media (min-width: $screen-sm-min) {
.emoji-block .row {
display: flex;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index ee48f7a3626..443f5500684 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -116,6 +116,8 @@
}
.manage-labels-list {
+ @include new-style-dropdown;
+
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 3fb02e9964f..b3bab082a35 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -55,6 +55,10 @@
display: -webkit-flex;
display: flex;
}
+
+ .dropdown-menu.dropdown-menu-align-right {
+ margin-top: -2px;
+ }
}
.form-horizontal {
@@ -306,3 +310,7 @@
}
}
}
+
+.member-form-control {
+ @include new-style-dropdown;
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 9d51c0b7a8a..8609f72bdab 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -174,17 +174,6 @@
vertical-align: top;
}
- .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item {
- display: flex;
- align-items: center;
-
- .ci-status-text,
- .ci-status-icon {
- top: 0;
- margin-right: 10px;
- }
- }
-
.normal {
line-height: 28px;
}
@@ -291,6 +280,7 @@
.dropdown-toggle {
.fa {
+ margin-left: 0;
color: inherit;
}
}
@@ -731,3 +721,7 @@
font-size: 16px;
}
}
+
+.merge-request-form {
+ @include new-style-dropdown;
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9558924bbcb..8932cff22a8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -20,10 +20,6 @@
}
}
-.new-note {
- display: none;
-}
-
.new-note,
.note-edit-form {
.note-form-actions {
@@ -202,6 +198,10 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
+
+ &.is-replying {
+ padding-bottom: $gl-padding;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index fbfe5d3c682..45f2aed1531 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -100,6 +100,20 @@ ul.notes {
}
}
+ .editing-spinner {
+ display: none;
+ }
+
+ &.is-requesting {
+ .note-timestamp {
+ display: none;
+ }
+
+ .editing-spinner {
+ display: inline-block;
+ }
+ }
+
&.is-editing {
.note-header,
.note-text,
@@ -365,9 +379,7 @@ ul.notes {
}
.discussion-header,
-.note-header {
- position: relative;
-
+.note-header-info {
a {
color: inherit;
@@ -402,6 +414,10 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
+
+ &.discussion {
+ padding-bottom: 0;
+ }
}
.system-note .note-header-info {
@@ -453,6 +469,8 @@ ul.notes {
}
.note-actions {
+ @include new-style-dropdown;
+
align-self: flex-start;
flex-shrink: 0;
display: inline-flex;
@@ -488,22 +506,6 @@ ul.notes {
.more-actions-dropdown {
width: 180px;
min-width: 180px;
- margin-top: $gl-btn-padding;
-
- li > a,
- li > .btn {
- color: $gl-text-color;
- padding: $gl-btn-padding;
- width: 100%;
- text-align: left;
-
- &:hover,
- &:focus {
- color: $gl-text-color;
- background-color: $blue-25;
- border-radius: $border-radius-default;
- }
- }
}
.discussion-actions {
@@ -814,10 +816,6 @@ ul.notes {
}
}
-.discussion-notes .flash-container {
- margin-bottom: 0;
-}
-
// Merge request notes in diffs
.diff-file {
// Diff is inline
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 51656669c98..cb8815e4775 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -40,7 +40,7 @@
.btn.btn-retry:hover,
.btn.btn-retry:focus {
- border-color: $gray-darkest;
+ border-color: $dropdown-toggle-active-border-color;
background-color: $white-normal;
}
@@ -206,8 +206,8 @@
.stage-cell {
.mini-pipeline-graph-dropdown-toggle svg {
- height: 22px;
- width: 22px;
+ height: $ci-action-icon-size;
+ width: $ci-action-icon-size;
position: absolute;
top: -1px;
left: -1px;
@@ -219,7 +219,7 @@
display: inline-block;
position: relative;
vertical-align: middle;
- height: 22px;
+ height: $ci-action-icon-size;
margin: 3px 0;
+ .stage-container {
@@ -257,6 +257,8 @@
// Pipeline visualization
.pipeline-actions {
+ @include new-style-dropdown;
+
border-bottom: none;
}
@@ -308,7 +310,7 @@
a {
text-decoration: none;
- color: $gl-text-color-secondary;
+ color: $gl-text-color;
}
svg {
@@ -432,7 +434,11 @@
width: 186px;
margin-bottom: 10px;
white-space: normal;
- color: $gl-text-color-secondary;
+
+ // ensure .build-content has hover style when action-icon is hovered
+ .ci-job-dropdown-container:hover .build-content {
+ @extend .build-content:hover;
+ }
// Action Icons in big pipeline-graph nodes
.ci-action-icon-container .ci-action-icon-wrapper {
@@ -445,11 +451,11 @@
&:hover {
background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
svg {
- fill: $border-color;
+ fill: $gl-text-color-secondary;
position: relative;
left: -1px;
top: -1px;
@@ -475,19 +481,10 @@
background-color: transparent;
border: none;
padding: 0;
- color: $gl-text-color-secondary;
&:focus {
outline: none;
}
-
- &:hover {
- color: $gl-text-color;
-
- .dropdown-counter-badge {
- color: $gl-text-color;
- }
- }
}
.build-content {
@@ -502,8 +499,7 @@
a.build-content:hover,
button.build-content:hover {
background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-border;
- color: $gl-text-color;
+ border: 1px solid $dropdown-toggle-active-border-color;
}
@@ -564,7 +560,6 @@
// Triggers the dropdown in the big pipeline graph
.dropdown-counter-badge {
- color: $border-color;
font-weight: 100;
font-size: 15px;
position: absolute;
@@ -606,8 +601,8 @@ button.mini-pipeline-graph-dropdown-toggle {
background-color: $white-light;
border-width: 1px;
border-style: solid;
- width: 22px;
- height: 22px;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
margin: 0;
padding: 0;
transition: all 0.2s linear;
@@ -669,105 +664,119 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
+@include new-style-dropdown('.big-pipeline-graph-dropdown-menu');
+@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu');
+
// dropdown content for big and mini pipeline
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
width: 195px;
max-width: 195px;
- li {
- padding: 2px 3px;
- }
-
.scrollable-menu {
padding: 0;
max-height: 245px;
overflow: auto;
}
- // Action icon on the right
- a.ci-action-icon-wrapper {
- color: $action-icon-color;
- border: 1px solid $action-icon-color;
- border-radius: 20px;
- width: 22px;
- height: 22px;
- padding: 2px 0 0 5px;
- cursor: pointer;
- float: right;
- margin: -26px 9px 0 0;
- font-size: 12px;
- background-color: $white-light;
+ li {
+ position: relative;
- &:hover,
- &:focus {
- background-color: $stage-hover-bg;
- border: 1px solid transparent;
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
+ @extend .mini-pipeline-graph-dropdown-item:hover;
}
- svg {
- width: 22px;
- height: 22px;
- left: -6px;
- position: relative;
- top: -3px;
- fill: $action-icon-color;
+ // Action icon on the right
+ a.ci-action-icon-wrapper {
+ border-radius: 50%;
+ border: 1px solid $border-color;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ padding: 2px 0 0 5px;
+ font-size: 12px;
+ background-color: $white-light;
+ position: absolute;
+ top: 50%;
+ right: $gl-padding;
+ margin-top: -#{$ci-action-icon-size / 2};
+
+ &:hover,
+ &:focus {
+ background-color: $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
+ }
+
+ svg {
+ fill: $gl-text-color-secondary;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ left: -6px;
+ position: relative;
+ top: -3px;
+ }
+
+ &:hover svg,
+ &:focus svg {
+ fill: $gl-text-color;
+ }
}
- &:hover svg,
- &:focus svg {
- fill: $gl-text-color;
- }
- }
-
- // link to the build
- .mini-pipeline-graph-dropdown-item {
- padding: 3px 7px 4px;
- clear: both;
- font-weight: $gl-font-weight-normal;
- line-height: 1.428571429;
- white-space: nowrap;
- margin: 0 5px;
- border-radius: 3px;
-
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
+ // link to the build
+ .mini-pipeline-graph-dropdown-item {
+ padding: 3px 7px 4px;
+ align-items: center;
+ clear: both;
+ display: flex;
+ font-weight: normal;
+ line-height: $line-height-base;
white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- color: $gl-text-color-secondary;
- margin-left: 2px;
- display: inline-block;
- top: 1px;
- vertical-align: text-bottom;
- position: relative;
+ border-radius: 3px;
- @media (max-width: $screen-xs-max) {
- max-width: 60%;
+ .ci-job-name-component {
+ align-items: center;
+ display: flex;
+ flex: 1;
}
- }
- // status icon on the left
- .ci-status-icon {
- top: 3px;
- position: relative;
+ // build name
+ .ci-build-text,
+ .ci-status-text {
+ font-weight: 200;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 70%;
+ margin-left: 2px;
+ display: inline-block;
- > svg {
- overflow: visible;
- width: 18px;
- height: 18px;
+ @media (max-width: $screen-xs-max) {
+ max-width: 60%;
+ }
}
- }
- &:hover,
- &:focus {
- outline: none;
- text-decoration: none;
- color: $gl-text-color;
- background-color: $stage-hover-bg;
+ .ci-status-icon {
+ @extend .append-right-8;
+
+ position: relative;
+
+ > svg {
+ width: $pipeline-dropdown-status-icon-size;
+ height: $pipeline-dropdown-status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ background-color: $stage-hover-bg;
+ }
}
}
}
@@ -776,16 +785,9 @@ button.mini-pipeline-graph-dropdown-toggle {
.big-pipeline-graph-dropdown-menu {
width: 195px;
min-width: 195px;
- left: auto;
- right: -195px;
- top: -4px;
+ left: 100%;
+ top: -10px;
box-shadow: 0 1px 5px $black-transparent;
-
- .mini-pipeline-graph-dropdown-item {
- .ci-status-icon {
- top: -1px;
- }
- }
}
/**
@@ -806,15 +808,14 @@ button.mini-pipeline-graph-dropdown-toggle {
}
&::before {
- left: -5px;
- margin-top: -6px;
+ left: -6px;
+ margin-top: 3px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
- left: -4px;
- margin-top: -9px;
+ left: -5px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 39c4264e496..19caefa1961 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -299,28 +299,6 @@
}
}
-.project-visibility-level-holder {
- .radio {
- margin-bottom: 10px;
-
- i {
- margin: 2px 0;
- font-size: 20px;
- }
-
- .option-title {
- font-weight: $gl-font-weight-normal;
- display: inline-block;
- color: $gl-text-color;
- }
-
- .option-descr {
- margin-left: 29px;
- color: $project-option-descr-color;
- }
- }
-}
-
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 15df51e9c69..41a6ba2023a 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -143,6 +143,47 @@
}
}
+.visibility-level-setting {
+ .radio {
+ margin-bottom: 10px;
+
+ i.fa {
+ margin: 2px 0;
+ font-size: 20px;
+ }
+
+ .option-title {
+ font-weight: $gl-font-weight-normal;
+ display: inline-block;
+ color: $gl-text-color;
+ }
+
+ .option-description,
+ .option-disabled-reason {
+ margin-left: 29px;
+ color: $project-option-descr-color;
+ }
+
+ .option-disabled-reason {
+ display: none;
+ }
+
+ &.disabled {
+ i.fa {
+ opacity: 0.5;
+ }
+
+ .option-description {
+ display: none;
+ }
+
+ .option-disabled-reason {
+ display: block;
+ }
+ }
+ }
+}
+
.prometheus-metrics-monitoring {
.panel {
.panel-toggle {
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index fa1bc72560e..a99563b7100 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -117,11 +117,14 @@ class Admin::UsersController < Admin::ApplicationController
user_params_with_pass = user_params.dup
if params[:user][:password].present?
- user_params_with_pass.merge!(
+ password_params = {
password: params[:user][:password],
- password_confirmation: params[:user][:password_confirmation],
- password_expires_at: Time.now
- )
+ password_confirmation: params[:user][:password_confirmation]
+ }
+
+ password_params[:password_expires_at] = Time.now unless changing_own_password?
+
+ user_params_with_pass.merge!(password_params)
end
respond_to do |format|
@@ -167,6 +170,10 @@ class Admin::UsersController < Admin::ApplicationController
protected
+ def changing_own_password?
+ user == current_user
+ end
+
def user
@user ||= User.find_by!(username: params[:id])
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1d92ea11bda..97922e39ba8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base
end
def check_password_expiration
- if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication?
+ if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user?
return redirect_to new_profile_password_path
end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 59be955599d..dfc8bd0ba81 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -41,12 +41,6 @@ class AutocompleteController < ApplicationController
project = Project.find_by_id(params[:project_id])
projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
- no_project = {
- id: 0,
- name_with_namespace: 'No project'
- }
- projects.unshift(no_project) unless params[:offset_id].present?
-
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index af5f683bab5..18fd8eb114d 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern
included do
+ before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
end
@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at }
- @notes = notes_finder.execute.inc_relations_for_view
- @notes = prepare_notes_for_rendering(@notes)
+ notes = notes_finder.execute
+ .inc_relations_for_view
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
- @notes.each do |note|
- next if note.cross_reference_not_visible_for?(current_user)
+ notes = prepare_notes_for_rendering(notes)
- notes_json[:notes] << note_json(note)
- end
+ notes_json[:notes] =
+ if noteable.discussions_rendered_on_frontend?
+ note_serializer.represent(notes)
+ else
+ notes.map { |note| note_json(note) }
+ end
render json: notes_json
end
@@ -82,22 +87,27 @@ module NotesActions
}
if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
+ attrs[:valid] = true
- discussion = note.to_discussion(noteable)
- unless discussion.individual_note?
+ if noteable.nil? || noteable.discussions_rendered_on_frontend?
+ attrs.merge!(note_serializer.represent(note))
+ else
attrs.merge!(
- discussion_resolvable: discussion.resolvable?,
-
- diff_discussion_html: diff_discussion_html(discussion),
- discussion_html: discussion_html(discussion)
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ note: note.note
)
+
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
+ attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
+ diff_discussion_html: diff_discussion_html(discussion),
+ discussion_html: discussion_html(discussion)
+ )
+ end
end
else
attrs.merge!(
@@ -168,6 +178,10 @@ module NotesActions
)
end
+ def set_polling_interval_header
+ Gitlab::PollingInterval.set_header(response, interval: 6_000)
+ end
+
def noteable
@noteable ||= notes_finder.target
end
@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end
+ def note_serializer
+ NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
+ end
+
def note_project
return @note_project if defined?(@note_project)
return nil unless project
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
index ad2f4bbc486..0218ac83441 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -1,5 +1,8 @@
module RequiresWhitelistedMonitoringClient
extend ActiveSupport::Concern
+
+ include Gitlab::CurrentSettings
+
included do
before_action :validate_ip_whitelisted_or_valid_token!
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index aa8cf630032..fda944adecd 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -1,8 +1,6 @@
class PasswordsController < Devise::PasswordsController
- include Gitlab::CurrentSettings
-
before_action :resource_from_email, only: [:create]
- before_action :check_password_authentication_available, only: [:create]
+ before_action :prevent_ldap_reset, only: [:create]
before_action :throttle_reset, only: [:create]
def edit
@@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email)
end
- def check_password_authentication_available
- return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?)
+ def prevent_ldap_reset
+ return unless resource&.ldap_user?
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Password authentication is unavailable."
+ alert: "Cannot reset password for LDAP user."
end
def throttle_reset
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index c423761ab24..7beb52dd8e8 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def authorize_change_password!
- render_404 unless @user.allow_password_authentication?
+ render_404 if @user.ldap_user?
end
def user_params
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 221e01b415a..d7dd8ddcb7d 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -94,6 +94,6 @@ class Projects::ApplicationController < ApplicationController
end
def require_pages_enabled!
- not_found unless Gitlab.config.pages.enabled
+ not_found unless @project.pages_available?
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 1afaceac567..0d4266f0899 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_create_issue!, only: [:new, :create]
# Allow modify issue
- before_action :authorize_update_issue!, only: [:edit, :update]
+ before_action :authorize_update_issue!, only: [:edit, :update, :move]
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request!, only: [:create_merge_request]
@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: IssueSerializer.new.represent(@issue)
+ render json: serializer.represent(@issue)
end
end
end
+ def discussions
+ notes = @issue.notes
+ .inc_relations_for_view
+ .includes(:noteable)
+ .fresh
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
+
+ prepare_notes_for_rendering(notes)
+
+ discussions = Discussion.build_collection(notes, @issue)
+
+ render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
+ end
+
def create
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -128,25 +142,33 @@ class Projects::IssuesController < Projects::ApplicationController
@issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue)
- if params[:move_to_project_id].to_i > 0
- new_project = Project.find(params[:move_to_project_id])
- return render_404 unless issue.can_move?(current_user, new_project)
-
- move_service = Issues::MoveService.new(project, current_user)
- @issue = move_service.execute(@issue, new_project)
- end
-
respond_to do |format|
format.html do
recaptcha_check_with_fallback { render :edit }
end
format.json do
- if @issue.valid?
- render json: IssueSerializer.new.represent(@issue)
- else
- render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
- end
+ render_issue_json
+ end
+ end
+
+ rescue ActiveRecord::StaleObjectError
+ render_conflict_response
+ end
+
+ def move
+ params.require(:move_to_project_id)
+
+ if params[:move_to_project_id].to_i > 0
+ new_project = Project.find(params[:move_to_project_id])
+ return render_404 unless issue.can_move?(current_user, new_project)
+
+ @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue)
+ end
+
+ respond_to do |format|
+ format.json do
+ render_issue_json
end
end
@@ -257,6 +279,14 @@ class Projects::IssuesController < Projects::ApplicationController
return render_404 unless @project.feature_available?(:issues, current_user)
end
+ def render_issue_json
+ if @issue.valid?
+ render json: serializer.represent(@issue)
+ else
+ render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
def issue_params
params.require(:issue).permit(*issue_params_attributes)
end
@@ -287,4 +317,8 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice
end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: issue.project)
+ end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 1d24563a6a6..ed17b3b4689 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -20,7 +20,10 @@ class ProjectsController < Projects::ApplicationController
end
def new
- @project = Project.new
+ namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
+ return access_denied! if namespace && !can?(current_user, :create_projects, namespace)
+
+ @project = Project.new(namespace_id: namespace&.id)
end
def edit
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7e0d3b5c979..c8dd2275730 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -24,7 +24,6 @@ class IssuableFinder
include CreatedAtFilter
NONE = '0'.freeze
- IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze
attr_accessor :current_user, :params
@@ -68,7 +67,7 @@ class IssuableFinder
# grouping and counting within that query.
#
def count_by_state
- count_params = params.merge(state: nil, sort: nil, for_counting: true)
+ count_params = params.merge(state: nil, sort: nil)
labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params)
counts = Hash.new(0)
@@ -91,16 +90,6 @@ class IssuableFinder
execute.find_by!(*params)
end
- def state_counter_cache_key
- cache_key(state_counter_cache_key_components)
- end
-
- def clear_caches!
- state_counter_cache_key_components_permutations.each do |components|
- Rails.cache.delete(cache_key(components))
- end
- end
-
def group
return @group if defined?(@group)
@@ -432,20 +421,4 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
-
- def state_counter_cache_key_components
- opts = params.with_indifferent_access
- opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
- opts.delete_if { |_, value| value.blank? }
-
- ['issuables_count', klass.to_ability_name, opts.sort]
- end
-
- def state_counter_cache_key_components_permutations
- [state_counter_cache_key_components]
- end
-
- def cache_key(components)
- Digest::SHA1.hexdigest(components.flatten.join('-'))
- end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 0ec42a4e6eb..aa9cef6b08c 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder
project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
end
- # Anonymous users can't see any confidential issues.
- #
- # Users without access to see _all_ confidential issues (as in
- # `user_can_see_all_confidential_issues?`) are more complicated, because they
- # can see confidential issues where:
- # 1. They are an assignee.
- # 2. They are an author.
- #
- # That's fine for most cases, but if we're just counting, we need to cache
- # effectively. If we cached this accurately, we'd have a cache key for every
- # authenticated user without sufficient access to the project. Instead, when
- # we are counting, we treat them as if they can't see any confidential issues.
- #
- # This does mean the counts may be wrong for those users, but avoids an
- # explosion in cache keys.
- def user_cannot_see_confidential_issues?(for_counting: false)
+ def user_cannot_see_confidential_issues?
return false if user_can_see_all_confidential_issues?
- current_user.blank? || for_counting || params[:for_counting]
- end
-
- def state_counter_cache_key_components
- extra_components = [
- user_can_see_all_confidential_issues?,
- user_cannot_see_confidential_issues?(for_counting: true)
- ]
-
- super + extra_components
- end
-
- def state_counter_cache_key_components_permutations
- # Ignore the last two, as we'll provide both options for them.
- components = super.first[0..-3]
-
- [
- components + [false, true],
- components + [true, false]
- ]
+ current_user.blank?
end
def by_assignee(items)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 07775a8b159..017df8f6794 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -202,7 +202,7 @@ module ApplicationHelper
end
def support_url
- current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
+ Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
def page_filter_path(options = {})
@@ -303,7 +303,7 @@ module ApplicationHelper
end
def show_new_nav?
- cookies["new_nav"] == "true"
+ true
end
def collapsed_sidebar?
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 3b76da238e0..b93f5f0af1c 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -1,5 +1,8 @@
module ApplicationSettingsHelper
extend self
+
+ include Gitlab::CurrentSettings
+
delegate :gravatar_enabled?,
:signup_enabled?,
:password_authentication_enabled?,
@@ -81,6 +84,18 @@ module ApplicationSettingsHelper
end
end
+ def key_restriction_options_for_select(type)
+ bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits|
+ ["Must be at least #{bits} bits", bits]
+ end
+
+ [
+ ['Are allowed', 0],
+ *bit_size_options,
+ ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE]
+ ]
+ end
+
def repository_storages_options_for_select
options = Gitlab.config.repositories.storages.map do |name, storage|
["#{name} - #{storage['path']}", name]
@@ -113,6 +128,9 @@ module ApplicationSettingsHelper
:domain_blacklist_enabled,
:domain_blacklist_raw,
:domain_whitelist_raw,
+ :dsa_key_restriction,
+ :ecdsa_key_restriction,
+ :ed25519_key_restriction,
:email_author_in_body,
:enabled_git_access_protocol,
:gravatar_enabled,
@@ -156,6 +174,7 @@ module ApplicationSettingsHelper
:repository_storages,
:require_two_factor_authentication,
:restricted_visibility_levels,
+ :rsa_key_restriction,
:send_user_confirmation_email,
:sentry_dsn,
:sentry_enabled,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 9c71d6c7f4c..66dc0b1e6f7 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,4 +1,6 @@
module AuthHelper
+ include Gitlab::CurrentSettings
+
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index ff305fa39b4..5089da519df 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -97,9 +97,11 @@ module DropdownsHelper
end
end
- def dropdown_footer(&block)
+ def dropdown_footer(add_content_class: false, &block)
content_tag(:div, class: "dropdown-footer") do
- if block
+ if add_content_class
+ content_tag(:div, capture(&block), class: "dropdown-footer-content")
+ else
capture(&block)
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 9247b1f72de..b5dece38de1 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -1,9 +1,9 @@
module FormHelper
- def form_errors(model)
+ def form_errors(model, type: 'form')
return unless model.errors.any?
pluralized = 'error'.pluralize(model.errors.count)
- headline = "The form contains the following #{pluralized}:"
+ headline = "The #{type} contains the following #{pluralized}:"
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 4123a96911f..dd159d12aa0 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -68,7 +68,7 @@ module GroupsHelper
def group_title_link(group, hidable: false)
link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do
output =
- if show_new_nav?
+ if show_new_nav? && !Rails.env.test?
image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16)
else
""
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 2a748ce0a75..d81ba2c06eb 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -35,7 +35,7 @@ module IssuablesHelper
def serialize_issuable(issuable)
case issuable
when Issue
- IssueSerializer.new.represent(issuable).to_json
+ IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest
MergeRequestSerializer
.new(current_user: current_user, project: issuable.project)
@@ -207,12 +207,10 @@ module IssuablesHelper
endpoint: project_issue_path(@project, issuable),
canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable),
- canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference,
isConfidential: issuable.confidential,
- markdownPreviewUrl: preview_markdown_path(@project),
- markdownDocs: help_page_path('user/markdown'),
- projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
+ markdownPreviewPath: preview_markdown_path(@project),
+ markdownDocsPath: help_page_path('user/markdown'),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
@@ -240,16 +238,9 @@ module IssuablesHelper
}
end
- def issuables_count_for_state(issuable_type, state, finder: nil)
- finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
- cache_key = finder.state_counter_cache_key
-
- @counts ||= {}
- @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
- finder.count_by_state
- end
-
- @counts[cache_key][state]
+ def issuables_count_for_state(issuable_type, state)
+ finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
+ finder.count_by_state[state]
end
def close_issuable_url(issuable)
@@ -361,6 +352,8 @@ module IssuablesHelper
def issuable_sidebar_options(issuable, can_edit_issuable)
{
endpoint: "#{issuable_json_path(issuable)}?basic=true",
+ moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
+ projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
editable: can_edit_issuable,
currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
rootPath: root_path,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 7e1ccb23e9e..853ce827061 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -137,7 +137,7 @@ module IssuesHelper
end
def awards_sort(awards)
- awards.sort_by do |award, notes|
+ awards.sort_by do |award, award_emojis|
if award == "thumbsup"
0
elsif award == "thumbsdown"
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 7f656b8caae..d7df9bb06d2 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -4,7 +4,8 @@ module NamespacesHelper
end
def namespaces_options(selected = :current_user, display_path: false, extra_group: nil)
- groups = current_user.owned_groups + current_user.masters_groups
+ groups = current_user.owned_groups + current_user.masters_groups
+ users = [current_user.namespace]
unless extra_group.nil? || extra_group.is_a?(Group)
extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group'
@@ -14,22 +15,9 @@ module NamespacesHelper
groups |= [extra_group]
end
- users = [current_user.namespace]
-
- data_attr_group = { 'data-options-parent' => 'groups' }
- data_attr_users = { 'data-options-parent' => 'users' }
-
- group_opts = [
- "Groups", groups.sort_by(&:human_name).map { |g| [display_path ? g.full_path : g.human_name, g.id, data_attr_group] }
- ]
-
- users_opts = [
- "Users", users.sort_by(&:human_name).map { |u| [display_path ? u.path : u.human_name, u.id, data_attr_users] }
- ]
-
options = []
- options << group_opts
- options << users_opts
+ options << options_for_group(groups, display_path: display_path, type: 'group')
+ options << options_for_group(users, display_path: display_path, type: 'user')
if selected == :current_user && current_user.namespace
selected = current_user.namespace.id
@@ -45,4 +33,23 @@ module NamespacesHelper
avatar_icon(namespace.owner.email, size)
end
end
+
+ private
+
+ def options_for_group(namespaces, display_path:, type:)
+ group_label = type.pluralize
+ elements = namespaces.sort_by(&:human_name).map! do |n|
+ [display_path ? n.full_path : n.human_name, n.id,
+ data: {
+ options_parent: group_label,
+ visibility_level: n.visibility_level_value,
+ visibility: n.visibility,
+ name: n.name,
+ show_path: (type == 'group') ? group_path(n) : user_path(n),
+ edit_path: (type == 'group') ? edit_group_path(n) : nil
+ }]
+ end
+
+ [group_label.camelize, elements]
+ end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index e857e837c16..8c5e258f519 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -93,11 +93,13 @@ module NotesHelper
end
end
- def notes_url
+ def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet)
+ snippet_notes_path(@snippet, params)
else
- project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+ params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
+
+ project_noteable_notes_path(@project, params)
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index bee4950e414..0bf94fd30db 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -1,4 +1,6 @@
module ProjectsHelper
+ include Gitlab::CurrentSettings
+
def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
@@ -60,7 +62,7 @@ module ProjectsHelper
project_link = link_to project_path(project), { class: "project-item-select-holder" } do
output =
- if show_new_nav?
+ if show_new_nav? && !Rails.env.test?
project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16)
else
""
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 08fd97cd048..c98f65c7644 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -22,8 +22,14 @@ module SystemNoteHelper
'duplicate' => 'icon_clone'
}.freeze
+ def system_note_icon_name(note)
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
+
def icon_for_system_note(note)
- icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ icon_name = system_note_icon_name(note)
custom_icon(icon_name) if icon_name
end
+
+ extend self
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 35755bc149b..46867d2d974 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -63,6 +63,68 @@ module VisibilityLevelHelper
end
end
+ def restricted_visibility_level_description(level)
+ level_name = Gitlab::VisibilityLevel.level_name(level)
+ "#{level_name.capitalize} visibility has been restricted by the administrator."
+ end
+
+ def disallowed_visibility_level_description(level, form_model)
+ case form_model
+ when Project
+ disallowed_project_visibility_level_description(level, form_model)
+ when Group
+ disallowed_group_visibility_level_description(level, form_model)
+ end
+ end
+
+ # Note: these messages closely mirror the form validation strings found in the project
+ # model and any changes or additons to these may also need to be made there.
+ def disallowed_project_visibility_level_description(level, project)
+ level_name = Gitlab::VisibilityLevel.level_name(level).downcase
+ reasons = []
+ instructions = ''
+
+ unless project.visibility_level_allowed_as_fork?(level)
+ reasons << "the fork source project has lower visibility"
+ end
+
+ unless project.visibility_level_allowed_by_group?(level)
+ errors = visibility_level_errors_for_group(project.group, level_name)
+
+ reasons << errors[:reason]
+ instructions << errors[:instruction]
+ end
+
+ reasons = reasons.any? ? ' because ' + reasons.to_sentence : ''
+ "This project cannot be #{level_name}#{reasons}.#{instructions}".html_safe
+ end
+
+ # Note: these messages closely mirror the form validation strings found in the group
+ # model and any changes or additons to these may also need to be made there.
+ def disallowed_group_visibility_level_description(level, group)
+ level_name = Gitlab::VisibilityLevel.level_name(level).downcase
+ reasons = []
+ instructions = ''
+
+ unless group.visibility_level_allowed_by_projects?(level)
+ reasons << "it contains projects with higher visibility"
+ end
+
+ unless group.visibility_level_allowed_by_sub_groups?(level)
+ reasons << "it contains sub-groups with higher visibility"
+ end
+
+ unless group.visibility_level_allowed_by_parent?(level)
+ errors = visibility_level_errors_for_group(group.parent, level_name)
+
+ reasons << errors[:reason]
+ instructions << errors[:instruction]
+ end
+
+ reasons = reasons.any? ? ' because ' + reasons.to_sentence : ''
+ "This group cannot be #{level_name}#{reasons}.#{instructions}".html_safe
+ end
+
def visibility_icon_description(form_model)
case form_model
when Project
@@ -95,7 +157,18 @@ module VisibilityLevelHelper
:default_group_visibility,
to: :current_application_settings
- def skip_level?(form_model, level)
- form_model.is_a?(Project) && !form_model.visibility_level_allowed?(level)
+ def disallowed_visibility_level?(form_model, level)
+ return false unless form_model.respond_to?(:visibility_level_allowed?)
+ !form_model.visibility_level_allowed?(level)
+ end
+
+ private
+
+ def visibility_level_errors_for_group(group, level_name)
+ group_name = link_to group.name, group_path(group)
+ change_visiblity = link_to 'change the visibility', edit_group_path(group)
+
+ { reason: "the visibility of #{group_name} is #{group.visibility}",
+ instruction: " To make this group #{level_name}, you must first #{change_visiblity} of the parent group." }
end
end
diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb
index 654468bc7fe..8e99db444d6 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/base_mailer.rb
@@ -1,11 +1,13 @@
class BaseMailer < ActionMailer::Base
+ include Gitlab::CurrentSettings
+
around_action :render_with_default_locale
helper ApplicationHelper
helper MarkupHelper
attr_accessor :current_user
- helper_method :current_user, :can?
+ helper_method :current_user, :can?, :current_application_settings
default from: proc { default_sender_address.format }
default reply_to: proc { default_reply_to_address.format }
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 8e446ff6dd8..3568e72e463 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters
}x
+ # Setting a key restriction to `-1` means that all keys of this type are
+ # forbidden.
+ FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
+ SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize
@@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
numericality: { greater_than_or_equal_to: 0 }
+ SUPPORTED_KEY_TYPES.each do |type|
+ validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
+ end
+
+ validates :allowed_key_types, presence: true
+
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
@@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base
end
before_validation :ensure_uuid!
+
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base
default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
disabled_oauth_sign_in_sources: [],
domain_whitelist: Settings.gitlab['domain_whitelist'],
+ dsa_key_restriction: 0,
+ ecdsa_key_restriction: 0,
+ ed25519_key_restriction: 0,
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
help_page_hide_commercial_content: false,
@@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base
max_attachment_size: Settings.gitlab['max_attachment_size'],
password_authentication_enabled: Settings.gitlab['password_authentication_enabled'],
performance_bar_allowed_group_id: nil,
+ rsa_key_restriction: 0,
plantuml_enabled: false,
plantuml_url: nil,
project_export_enabled: true,
@@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base
usage_ping_can_be_configured? && super
end
+ def allowed_key_types
+ SUPPORTED_KEY_TYPES.select do |type|
+ key_restriction_for(type) != FORBIDDEN_KEY_VALUE
+ end
+ end
+
+ def key_restriction_for(type)
+ attr_name = "#{type}_key_restriction"
+
+ has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
+ end
+
private
def ensure_uuid!
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 91b62dabbcd..4d1a15c53aa 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) }
+ after_save :expire_etag_cache
+ after_destroy :expire_etag_cache
+
class << self
def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count')
@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote?
self.name == UPVOTE_NAME
end
+
+ def expire_etag_cache
+ awardable.try(:expire_etag_cache)
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 8adaafe6439..ba3156154ac 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -3,6 +3,7 @@ module Ci
include TokenAuthenticatable
include AfterCommitQueue
include Presentable
+ include Importable
belongs_to :runner
belongs_to :trigger_request
@@ -26,6 +27,7 @@ module Ci
validates :coverage, numericality: true, allow_blank: true
validates :ref, presence: true
+ validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
@@ -34,6 +36,7 @@ module Ci
scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) }
+ scope :ref_protected, -> { where(protected: true) }
mount_uploader :artifacts_file, ArtifactUploader
mount_uploader :artifacts_metadata, ArtifactUploader
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 46e5c344fdc..35d14b6e297 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -36,6 +36,7 @@ module Ci
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
validates :status, presence: { unless: :importing? }
+ validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create
validate :valid_commit_sha, unless: :importing?
after_create :keep_around_commits, unless: :importing?
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 906a76ec560..b1798084787 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -5,7 +5,7 @@ module Ci
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour
AVAILABLE_SCOPES = %w[specific shared active paused online].freeze
- FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze
+ FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze
has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -35,11 +35,17 @@ module Ci
end
validate :tag_constraints
+ validates :access_level, presence: true
acts_as_taggable
after_destroy :cleanup_runner_queue
+ enum access_level: {
+ not_protected: 0,
+ ref_protected: 1
+ }
+
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
@@ -106,6 +112,8 @@ module Ci
end
def can_pick?(build)
+ return false if self.ref_protected? && !build.protected?
+
assignable_for?(build.project) && accepting_tags?(build)
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index d41c88b4e30..c943365016f 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -251,6 +251,28 @@ class Commit
project.repository.next_branch("cherry-pick-#{short_id}", mild: true)
end
+ def cherry_pick_description(user)
+ message_body = "(cherry picked from commit #{sha})"
+
+ if merged_merge_request?(user)
+ commits_in_merge_request = merged_merge_request(user).commits
+
+ if commits_in_merge_request.present?
+ message_body << "\n"
+
+ commits_in_merge_request.reverse.each do |commit_in_merge|
+ message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}"
+ end
+ end
+ end
+
+ message_body
+ end
+
+ def cherry_pick_message(user)
+ %Q{#{message}\n\n#{cherry_pick_description(user)}}
+ end
+
def revert_description(user)
if merged_merge_request?(user)
"This reverts merge request #{merged_merge_request(user).to_reference}"
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index c7bdc997eca..1c4ddabcad5 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end
+ def discussions_rendered_on_frontend?
+ false
+ end
+
def discussion_notes
notes
end
@@ -38,7 +42,7 @@ module Noteable
def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes
- # besides MR diff notes, that we do no want to display on the MR Changes tab.
+ # besides MR diff notes, that we do not want to display on the MR Changes tab.
notes.inc_relations_for_view.grouped_diff_discussions(*args)
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index f2707022a4b..731d9b9a745 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -28,7 +28,7 @@ module Spammable
def submittable_as_spam?
if user_agent_detail
- user_agent_detail.submittable? && current_application_settings.akismet_enabled
+ user_agent_detail.submittable? && Gitlab::CurrentSettings.current_application_settings.akismet_enabled
else
false
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index d1cec7613af..b80da7b246a 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -81,6 +81,10 @@ class Discussion
last_note.author
end
+ def updated?
+ last_updated_at != created_at
+ end
+
def id
first_note.discussion_id(context_noteable)
end
diff --git a/app/models/group.rb b/app/models/group.rb
index cb3ee032f69..190b27cf66b 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -26,6 +26,8 @@ class Group < Namespace
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :visibility_level_allowed_by_projects
+ validate :visibility_level_allowed_by_sub_groups
+ validate :visibility_level_allowed_by_parent
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
@@ -102,15 +104,24 @@ class Group < Namespace
full_name
end
- def visibility_level_allowed_by_projects
- allowed_by_projects = self.projects.where('visibility_level > ?', self.visibility_level).none?
+ def visibility_level_allowed_by_parent?(level = self.visibility_level)
+ return true unless parent_id && parent_id.nonzero?
- unless allowed_by_projects
- level_name = Gitlab::VisibilityLevel.level_name(visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed since there are projects with higher visibility.")
- end
+ level <= parent.visibility_level
+ end
- allowed_by_projects
+ def visibility_level_allowed_by_projects?(level = self.visibility_level)
+ !projects.where('visibility_level > ?', level).exists?
+ end
+
+ def visibility_level_allowed_by_sub_groups?(level = self.visibility_level)
+ !children.where('visibility_level > ?', level).exists?
+ end
+
+ def visibility_level_allowed?(level = self.visibility_level)
+ visibility_level_allowed_by_parent?(level) &&
+ visibility_level_allowed_by_projects?(level) &&
+ visibility_level_allowed_by_sub_groups?(level)
end
def avatar_url(**args)
@@ -275,11 +286,29 @@ class Group < Namespace
list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten
end
- protected
+ private
def update_two_factor_requirement
return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
users.find_each(&:update_two_factor_requirement)
end
+
+ def visibility_level_allowed_by_parent
+ return if visibility_level_allowed_by_parent?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since the parent group has a #{parent.visibility} visibility.")
+ end
+
+ def visibility_level_allowed_by_projects
+ return if visibility_level_allowed_by_projects?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since this group contains projects with higher visibility.")
+ end
+
+ def visibility_level_allowed_by_sub_groups
+ return if visibility_level_allowed_by_sub_groups?
+
+ errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
+ end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index b9aa937d2f9..8c7d492e605 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -269,7 +269,17 @@ class Issue < ActiveRecord::Base
end
end
+ def discussions_rendered_on_frontend?
+ true
+ end
+
+ def update_project_counter_caches?
+ state_changed? || confidential_changed?
+ end
+
def update_project_counter_caches
+ return unless update_project_counter_caches?
+
Projects::OpenIssuesCountService.new(project).refresh_cache
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 49bc26122fa..a6b4dcfec0d 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -1,6 +1,7 @@
require 'digest/md5'
class Key < ActiveRecord::Base
+ include Gitlab::CurrentSettings
include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i
@@ -12,14 +13,18 @@ class Key < ActiveRecord::Base
validates :title,
presence: true,
length: { maximum: 255 }
+
validates :key,
presence: true,
length: { maximum: 5000 },
format: { with: /\A(ssh|ecdsa)-.*\Z/ }
+
validates :fingerprint,
uniqueness: true,
presence: { message: 'cannot be generated' }
+ validate :key_meets_restrictions
+
delegate :name, :email, to: :user, prefix: true
after_commit :add_to_shell, on: :create
@@ -80,6 +85,10 @@ class Key < ActiveRecord::Base
SystemHooksService.new.execute_hooks_for(self, :destroy)
end
+ def public_key
+ @public_key ||= Gitlab::SSHPublicKey.new(key)
+ end
+
private
def generate_fingerprint
@@ -87,7 +96,27 @@ class Key < ActiveRecord::Base
return unless self.key.present?
- self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint
+ self.fingerprint = public_key.fingerprint
+ end
+
+ def key_meets_restrictions
+ restriction = current_application_settings.key_restriction_for(public_key.type)
+
+ if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
+ errors.add(:key, forbidden_key_type_message)
+ elsif public_key.bits < restriction
+ errors.add(:key, "must be at least #{restriction} bits")
+ end
+ end
+
+ def forbidden_key_type_message
+ allowed_types =
+ current_application_settings
+ .allowed_key_types
+ .map(&:upcase)
+ .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+
+ "type is forbidden. Must be #{allowed_types}"
end
def notify_user
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 5be2f6d4e82..7a817eedec2 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base
self.merge_requests_closing_issues.delete_all
closes_issues(current_user).each do |issue|
+ next if issue.is_a?(ExternalIssue)
+
self.merge_requests_closing_issues.create!(issue: issue)
end
end
@@ -942,7 +944,13 @@ class MergeRequest < ActiveRecord::Base
true
end
+ def update_project_counter_caches?
+ state_changed?
+ end
+
def update_project_counter_caches
+ return unless update_project_counter_caches?
+
Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index e7bc1d1b080..e7cbc5170e8 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -195,6 +195,10 @@ class Namespace < ActiveRecord::Base
parent.present?
end
+ def subgroup?
+ has_parent?
+ end
+
def soft_delete_without_removing_associations
# We can't use paranoia's `#destroy` since this will hard-delete projects.
# Project uses `pending_delete` instead of the acts_as_paranoia gem.
diff --git a/app/models/note.rb b/app/models/note.rb
index a752c897d63..1073c115630 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base
end
end
+ def expire_etag_cache
+ return unless noteable&.discussions_rendered_on_frontend?
+
+ key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
+ project,
+ target_type: noteable_type.underscore,
+ target_id: noteable_id
+ )
+ Gitlab::EtagCaching::Store.new.touch(key)
+ end
+
private
def keep_around_commit
@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base
def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self)
end
-
- def expire_etag_cache
- return unless for_issue?
-
- key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
- noteable.project,
- target_type: noteable_type.underscore,
- target_id: noteable.id
- )
- Gitlab::EtagCaching::Store.new.touch(key)
- end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 9d7bea4eb66..b9247fb535a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -19,6 +19,7 @@ class Project < ActiveRecord::Base
include Routable
extend Gitlab::ConfigHelper
+ extend Gitlab::CurrentSettings
BoardLimitExceeded = Class.new(StandardError)
@@ -222,6 +223,7 @@ class Project < ActiveRecord::Base
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_limit, on: :create
+ validate :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? }
validate :avatar_type,
if: ->(project) { project.avatar.present? && project.avatar_changed? }
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
@@ -464,7 +466,7 @@ class Project < ActiveRecord::Base
end
def repository_storage_path
- Gitlab.config.repositories.storages[repository_storage]['path']
+ Gitlab.config.repositories.storages[repository_storage].try(:[], 'path')
end
def team
@@ -579,7 +581,7 @@ class Project < ActiveRecord::Base
end
def valid_import_url?
- valid? || errors.messages[:import_url].nil?
+ valid?(:import_url) || errors.messages[:import_url].nil?
end
def create_or_update_import_data(data: nil, credentials: nil)
@@ -996,6 +998,20 @@ class Project < ActiveRecord::Base
end
end
+ # Check if repository already exists on disk
+ def can_create_repository?
+ return false unless repository_storage_path
+
+ expires_full_path_cache # we need to clear cache to validate renames correctly
+
+ if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git")
+ errors.add(:base, 'There is already a repository with that name on disk')
+ return false
+ end
+
+ true
+ end
+
def create_repository(force: false)
# Forked import is handled asynchronously
return if forked? && !force
@@ -1231,6 +1247,10 @@ class Project < ActiveRecord::Base
File.join(pages_path, 'public')
end
+ def pages_available?
+ Gitlab.config.pages.enabled && !namespace.subgroup?
+ end
+
def remove_private_deploy_keys
exclude_keys_linked_to_other_projects = <<-SQL
NOT EXISTS (
@@ -1482,6 +1502,10 @@ class Project < ActiveRecord::Base
self.storage_version.nil?
end
+ def renamed?
+ persisted? && path_changed?
+ end
+
private
def storage
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 5f0d0802ac9..89bfc5f9a9c 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,6 +2,8 @@ class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
+ extend Gitlab::CurrentSettings
+
protected_ref_access_levels :merge, :push
# Check if branch name is marked as protected in the system
diff --git a/app/models/repository.rb b/app/models/repository.rb
index d29d2a83708..5474c8eeb68 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -908,7 +908,7 @@ class Repository
committer = user_to_committer(user)
- create_commit(message: commit.message,
+ create_commit(message: commit.cherry_pick_message(user),
author: {
email: commit.author_email,
name: commit.author_name,
@@ -1044,7 +1044,7 @@ class Repository
end
def fetch_remote(remote, forced: false, no_tags: false)
- gitlab_shell.fetch_remote(repository_storage_path, disk_path, remote, forced: forced, no_tags: no_tags)
+ gitlab_shell.fetch_remote(raw_repository, remote, forced: forced, no_tags: no_tags)
end
def fetch_ref(source_path, source_ref, target_ref)
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 09d5ff46618..9533aa7f555 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -10,6 +10,8 @@ class Snippet < ActiveRecord::Base
include Spammable
include Editable
+ extend Gitlab::CurrentSettings
+
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content
diff --git a/app/models/user.rb b/app/models/user.rb
index 70787de4b40..68ec93a3ec5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord'
class User < ActiveRecord::Base
extend Gitlab::ConfigHelper
+ extend Gitlab::CurrentSettings
include Gitlab::ConfigHelper
include Gitlab::CurrentSettings
@@ -602,7 +603,7 @@ class User < ActiveRecord::Base
end
def require_personal_access_token_creation_for_git_auth?
- return false if allow_password_authentication? || ldap_user?
+ return false if current_application_settings.password_authentication_enabled? || ldap_user?
PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 5c7c2204374..f2315bb3dbb 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -84,7 +84,7 @@ class WikiPage
# The formatted title of this page.
def title
if @attributes[:title]
- self.class.unhyphenize(@attributes[:title])
+ CGI.unescape_html(self.class.unhyphenize(@attributes[:title]))
else
""
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index a605a3457c8..8fa7b2753c7 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -1,8 +1,6 @@
require_dependency 'declarative_policy'
class BasePolicy < DeclarativePolicy::Base
- include Gitlab::CurrentSettings
-
desc "User is an instance admin"
with_options scope: :user, score: 0
condition(:admin) { @user&.admin? }
@@ -15,6 +13,6 @@ class BasePolicy < DeclarativePolicy::Base
desc "The application is restricted from public visibility"
condition(:restricted_public_level, scope: :global) do
- current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
+ Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
end
diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb
new file mode 100644
index 00000000000..6e03cd02392
--- /dev/null
+++ b/app/serializers/award_emoji_entity.rb
@@ -0,0 +1,4 @@
+class AwardEmojiEntity < Grape::Entity
+ expose :name
+ expose :user, using: API::Entities::UserSafe
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
new file mode 100644
index 00000000000..0a92e3f8167
--- /dev/null
+++ b/app/serializers/discussion_entity.rb
@@ -0,0 +1,10 @@
+class DiscussionEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id, :reply_id
+ expose :expanded?, as: :expanded
+
+ expose :notes, using: NoteEntity
+
+ expose :individual_note?, as: :individual_note
+end
diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb
new file mode 100644
index 00000000000..ed5e1224bb2
--- /dev/null
+++ b/app/serializers/discussion_serializer.rb
@@ -0,0 +1,3 @@
+class DiscussionSerializer < BaseSerializer
+ entity DiscussionEntity
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index bd5211b8e58..61c7a428745 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
+ expose :milestone, using: API::Entities::Milestone
+ expose :labels, using: LabelEntity
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c189a4992da..0d6feb78173 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity
expose :due_date
expose :moved_to_id
expose :project_id
- expose :milestone, using: API::Entities::Milestone
- expose :labels, using: LabelEntity
expose :web_url do |issue|
project_issue_path(issue.project, issue)
end
+
+ expose :current_user do
+ expose :can_create_note do |issue|
+ can?(request.current_user, :create_note, issue.project)
+ end
+
+ expose :can_update do |issue|
+ can?(request.current_user, :update_issue, issue)
+ end
+ end
+
+ expose :create_note_path do |issue|
+ project_notes_path(issue.project, target_type: 'issue', target_id: issue.id)
+ end
+
+ expose :preview_note_path do |issue|
+ preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id)
+ end
end
diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb
new file mode 100644
index 00000000000..1ad50568ab9
--- /dev/null
+++ b/app/serializers/note_attachment_entity.rb
@@ -0,0 +1,5 @@
+class NoteAttachmentEntity < Grape::Entity
+ expose :url
+ expose :filename
+ expose :image?, as: :image
+end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
new file mode 100644
index 00000000000..7d50e0ff10d
--- /dev/null
+++ b/app/serializers/note_entity.rb
@@ -0,0 +1,60 @@
+class NoteEntity < API::Entities::Note
+ include RequestAwareEntity
+
+ expose :type
+
+ expose :author, using: NoteUserEntity
+
+ expose :human_access do |note|
+ note.project.team.human_max_access(note.author_id)
+ end
+
+ unexpose :note, as: :body
+ expose :note
+
+ expose :redacted_note_html, as: :note_html
+
+ expose :last_edited_at, if: -> (note, _) { note.edited? }
+ expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? }
+
+ expose :current_user do
+ expose :can_edit do |note|
+ Ability.can_edit_note?(request.current_user, note)
+ end
+ end
+
+ expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
+ SystemNoteHelper.system_note_icon_name(note)
+ end
+
+ expose :discussion_id do |note|
+ note.discussion_id(request.noteable)
+ end
+
+ expose :emoji_awardable?, as: :emoji_awardable
+ expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
+ expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
+ if note.for_personal_snippet?
+ toggle_award_emoji_snippet_note_path(note.noteable, note)
+ else
+ toggle_award_emoji_project_note_path(note.project, note.id)
+ end
+ end
+
+ expose :report_abuse_path do |note|
+ new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
+ end
+
+ expose :path do |note|
+ if note.for_personal_snippet?
+ snippet_note_path(note.noteable, note)
+ else
+ project_note_path(note.project, note)
+ end
+ end
+
+ expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
+ expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
+ delete_attachment_project_note_path(note.project, note)
+ end
+end
diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb
new file mode 100644
index 00000000000..2afe40d7a34
--- /dev/null
+++ b/app/serializers/note_serializer.rb
@@ -0,0 +1,3 @@
+class NoteSerializer < BaseSerializer
+ entity NoteEntity
+end
diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb
new file mode 100644
index 00000000000..7289f3a0222
--- /dev/null
+++ b/app/serializers/note_user_entity.rb
@@ -0,0 +1,3 @@
+class NoteUserEntity < UserEntity
+ unexpose :web_url
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
new file mode 100644
index 00000000000..49a71ebac61
--- /dev/null
+++ b/app/serializers/user_serializer.rb
@@ -0,0 +1,3 @@
+class UserSerializer < BaseSerializer
+ entity UserEntity
+end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index 7b5482b3cd1..aa6f0e841c9 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -1,4 +1,6 @@
class AkismetService
+ include Gitlab::CurrentSettings
+
attr_accessor :owner, :text, :options
def initialize(owner, text, options = {})
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 7dae5880931..9a636346899 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -1,6 +1,6 @@
module Auth
class ContainerRegistryAuthenticationService < BaseService
- include Gitlab::CurrentSettings
+ extend Gitlab::CurrentSettings
AUDIENCE = 'container_registry'.freeze
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index de2cd7e87be..414c01b2546 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -12,7 +12,8 @@ module Ci
tag: tag?,
trigger_requests: Array(trigger_request),
user: current_user,
- pipeline_schedule: schedule
+ pipeline_schedule: schedule,
+ protected: project.protected_for?(ref)
)
result = validate(current_user,
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 414f672cc6a..b8db709211a 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -77,7 +77,9 @@ module Ci
end
def new_builds
- Ci::Build.pending.unstarted
+ builds = Ci::Build.pending.unstarted
+ builds = builds.ref_protected if runner.ref_protected?
+ builds
end
def shared_runner_build_limits_feature_enabled?
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index ea3b8d66ed9..d67b9f5cc56 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -3,7 +3,7 @@ module Ci
CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
allow_failure stage_id stage stage_idx trigger_request
yaml_variables when environment coverage_regex
- description tag_list].freeze
+ description tag_list protected].freeze
def execute(build)
reprocess!(build).tap do |new_build|
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 1486db046b5..8b967b78052 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -56,6 +56,7 @@ class IssuableBaseService < BaseService
params.delete(:assignee_id)
params.delete(:due_date)
params.delete(:canonical_issue_id)
+ params.delete(:project)
end
filter_assignee(issuable)
@@ -244,9 +245,7 @@ class IssuableBaseService < BaseService
new_assignees = issuable.assignees.to_a
affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
- # Don't clear the project cache, because it will be handled by the
- # appropriate service (close / reopen / merge / etc.).
- invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true)
+ invalidate_cache_counts(issuable, users: affected_assignees.compact)
after_update(issuable)
issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update')
@@ -340,18 +339,9 @@ class IssuableBaseService < BaseService
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
- def invalidate_cache_counts(issuable, users: [], skip_project_cache: false)
+ def invalidate_cache_counts(issuable, users: [])
users.each do |user|
user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end
-
- unless skip_project_cache
- case issuable
- when Issue
- IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches!
- when MergeRequest
- MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches!
- end
- end
end
end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 8d918ccc635..deb4990eb4f 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -6,7 +6,7 @@ module Issues
handle_move_between_iids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- update(issue)
+ move_issue_to_new_project(issue) || update(issue)
end
def before_update(issue)
@@ -74,6 +74,17 @@ module Issues
end
end
+ def move_issue_to_new_project(issue)
+ target_project = params.delete(:target_project)
+
+ return unless target_project &&
+ issue.can_move?(current_user, target_project) &&
+ target_project != issue.project
+
+ update(issue)
+ Issues::MoveService.new(project, current_user).execute(issue, target_project)
+ end
+
private
def get_issue_if_allowed(project, iid)
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index e6a68d983ef..3047268b2d1 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -1,7 +1,6 @@
module Projects
class AfterImportService
- RESERVED_REFS_REGEXP =
- %r{\Arefs/(?:#{Regexp.union(*Repository::RESERVED_REFS_NAMES)})/}
+ RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') }
def initialize(project)
@project = project
@@ -9,7 +8,7 @@ module Projects
def execute
Projects::HousekeepingService.new(@project).execute do
- repository.delete_refs(*garbage_refs)
+ repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
end
rescue Projects::HousekeepingService::LeaseTaken => e
Rails.logger.info(
@@ -18,10 +17,6 @@ module Projects
private
- def garbage_refs
- @garbage_refs ||= repository.all_ref_names_except(RESERVED_REFS_REGEXP)
- end
-
def repository
@repository ||= @project.repository
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 394b336a638..f6b83a2f621 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -1,5 +1,7 @@
module Projects
class UpdatePagesService < BaseService
+ include Gitlab::CurrentSettings
+
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
SITE_PATH = 'public/'.freeze
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index c7832c47e1a..9cdb9935bea 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -505,6 +505,24 @@ module QuickActions
end
end
+ desc 'Move this issue to another project.'
+ explanation do |path_to_project|
+ "Moves this issue to #{path_to_project}."
+ end
+ params 'path/to/project'
+ condition do
+ issuable.is_a?(Issue) &&
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :move do |target_project_path|
+ target_project = Project.find_by_full_path(target_project_path)
+
+ if target_project.present?
+ @updates[:target_project] = target_project
+ end
+ end
+
def extract_users(params)
return [] if params.nil?
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 6c5b2baff41..76700dfcdee 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -1,4 +1,6 @@
class UploadService
+ include Gitlab::CurrentSettings
+
def initialize(model, file, uploader_class = FileUploader)
@model, @file, @uploader_class = model, file, uploader_class
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index ff234a3440f..6f05500adea 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -1,5 +1,7 @@
module Users
class BuildService < BaseService
+ include Gitlab::CurrentSettings
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
new file mode 100644
index 00000000000..204be827941
--- /dev/null
+++ b/app/validators/key_restriction_validator.rb
@@ -0,0 +1,29 @@
+class KeyRestrictionValidator < ActiveModel::EachValidator
+ FORBIDDEN = -1
+
+ def self.supported_sizes(type)
+ Gitlab::SSHPublicKey.supported_sizes(type)
+ end
+
+ def self.supported_key_restrictions(type)
+ [0, *supported_sizes(type), FORBIDDEN]
+ end
+
+ def validate_each(record, attribute, value)
+ unless valid_restriction?(value)
+ record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}")
+ end
+ end
+
+ private
+
+ def supported_sizes_message
+ sizes = self.class.supported_sizes(options[:type])
+ sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+ end
+
+ def valid_restriction?(value)
+ choices = self.class.supported_key_restrictions(options[:type])
+ choices.include?(value)
+ end
+end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 959af5c0d13..a010b4691bf 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -7,15 +7,15 @@
= f.label :default_branch_protection, class: 'control-label col-sm-2'
.col-sm-10
= f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_project_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_snippet_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new)
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :default_group_visibility, class: 'control-label col-sm-2'
.col-sm-10
= render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new)
@@ -42,12 +42,7 @@
= link_to "(?)", help_page_path("integration/bitbucket")
and GitLab.com
= link_to "(?)", help_page_path("integration/gitlab")
- .form-group
- %label.control-label.col-sm-2 Enabled Git access protocols
- .col-sm-10
- = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
- %span.help-block#clone-protocol-help
- Allow only the selected protocols to be used for Git access.
+
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
@@ -55,6 +50,20 @@
= f.check_box :project_export_enabled
Project export enabled
+ .form-group
+ %label.control-label.col-sm-2 Enabled Git access protocols
+ .col-sm-10
+ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
+ %span.help-block#clone-protocol-help
+ Allow only the selected protocols to be used for Git access.
+
+ - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ - field_name = :"#{type}_key_restriction"
+ .form-group
+ = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
+
%fieldset
%legend Account and Limit Settings
.form-group
@@ -153,7 +162,7 @@
.checkbox
= f.label :password_authentication_enabled do
= f.check_box :password_authentication_enabled
- Password authentication enabled
+ Sign-in enabled
- if omniauth_enabled? && button_based_providers.any?
.form-group
= f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2'
@@ -530,24 +539,27 @@
.help-block
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
- %fieldset
- %legend Koding
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :koding_enabled do
- = f.check_box :koding_enabled
- Enable Koding
- .form-group
- = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
- .col-sm-10
- = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
- .help-block
- Koding has integration enabled out of the box for the
- %strong gitlab
- team, and you need to provide that team's URL here. Learn more in the
- = succeed "." do
- = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
+ - if koding_enabled?
+ %fieldset
+ %legend Koding
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :koding_enabled do
+ = f.check_box :koding_enabled
+ Enable Koding
+ .help-block
+ Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again.
+ .form-group
+ = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
+ .help-block
+ Koding has integration enabled out of the box for the
+ %strong gitlab
+ team, and you need to provide that team's URL here. Learn more in the
+ = succeed "." do
+ = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
%fieldset
%legend PlantUML
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 8ed23ac4919..dcfb7f0c32d 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -6,14 +6,14 @@
- tooltip = "#{subject.name} - #{status.label}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= custom_icon(status.icon)
%span.ci-build-text= subject.name
- else
- .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } }
+ .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= custom_icon(status.icon)
%span.ci-build-text= subject.name
- if status.has_action?
- = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do
+ = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
= custom_icon(status.action_icon)
diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml
index c1dabeed387..25e90924413 100644
--- a/app/views/discussions/_headline.html.haml
+++ b/app/views/discussions/_headline.html.haml
@@ -5,7 +5,7 @@
by
= link_to_member(@project, discussion.resolved_by, avatar: false)
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
-- elsif discussion.last_updated_at != discussion.created_at
+- elsif discussion.updated?
.discussion-headline-light.js-discussion-headline
Last updated
- if discussion.last_updated_by
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 12bc092d216..837ef385dd5 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -12,6 +12,8 @@
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do
= icon('rss')
+ %span.icon-label
+ Subscribe
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues
- if group_issues_exists
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b32cfe158bb..1d875f81041 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -74,8 +74,6 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- %li
- = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation")
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml
index 2c1c23d6ea9..c84d7053cd6 100644
--- a/app/views/layouts/header/_new.html.haml
+++ b/app/views/layouts/header/_new.html.haml
@@ -4,7 +4,7 @@
.header-content
.title-container
%h1.title
- = link_to root_path, title: 'Dashboard' do
+ = link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
%span.logo-text.hidden-xs
= render 'shared/logo_type.svg'
@@ -37,13 +37,13 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
%li
- = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('issues')
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
%li
- = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
@@ -68,8 +68,6 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- %li
- = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation")
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml
index 53dbf9e2f2b..f5361c7af0c 100644
--- a/app/views/layouts/nav/_new_project_sidebar.html.haml
+++ b/app/views/layouts/nav/_new_project_sidebar.html.haml
@@ -208,7 +208,7 @@
= link_to project_settings_ci_cd_path(@project), title: 'CI / CD' do
%span
CI / CD
- - if Gitlab.config.pages.enabled
+ - if @project.pages_available?
= nav_link(controller: :pages) do
= link_to project_pages_path(@project), title: 'Pages' do
%span
diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml
index 26d9640e98a..448f6abedf2 100644
--- a/app/views/layouts/nav/_profile.html.haml
+++ b/app/views/layouts/nav/_profile.html.haml
@@ -29,7 +29,7 @@
= link_to profile_emails_path, title: 'Emails' do
%span
Emails
- - if current_user.allow_password_authentication?
+ - unless current_user.ldap_user?
= nav_link(controller: :passwords) do
= link_to edit_profile_password_path, title: 'Password' do
%span
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index d2a60ac2867..103446243e5 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,6 +1,12 @@
%li.key-list-item
.pull-left.append-right-10
- = icon 'key', class: "settings-list-icon hidden-xs"
+ - if key.valid?
+ = icon 'key', class: 'settings-list-icon hidden-xs'
+ - else
+ = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip',
+ title: key.errors.full_messages.join(', ')
+
+
.key-list-item-info
= link_to path_to_key(key, is_admin), class: "title" do
= key.title
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index d44603c638c..77521417f47 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -16,6 +16,7 @@
%strong= @key.last_used_at.try(:to_s, :medium) || 'N/A'
.col-md-8
+ = form_errors(@key, type: 'key') unless @key.valid?
%p
%span.light Fingerprint:
%code.key-fingerprint= @key.fingerprint
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index f08dcc0c242..9e7fe556d88 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -18,26 +18,6 @@
= scheme.name
.col-sm-12
%hr
- .col-lg-4.profile-settings-sidebar#new-navigation
- %h4.prepend-top-0
- New Navigation
- %p
- This setting allows you to turn on or off the new upcoming navigation concept.
- .col-lg-8.syntax-theme
- .nav-wip
- %p
- The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation.
- %p
- %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more
- about the improvements that are coming soon!
- = label_tag do
- .preview= image_tag "old_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? }
- Old
- = label_tag do
- .preview= image_tag "new_nav.png"
- %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? }
- New
.col-sm-12
%hr
.col-lg-4.profile-settings-sidebar
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 97041b87c48..71424593f2e 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,10 +1,5 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
-- if defined?(@issue) && @issue.confidential?
- .confidential-issue-warning
- = confidential_icon(@issue)
- %span This is a confidential issue. Your comment will not be visible to the public.
-
.md-area
.md-header
%ul.nav-links.clearfix
diff --git a/app/views/projects/boards/components/sidebar/_due_date.html.haml b/app/views/projects/boards/components/sidebar/_due_date.html.haml
index f44a9d49a54..e8394eab213 100644
--- a/app/views/projects/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/projects/boards/components/sidebar/_due_date.html.haml
@@ -3,7 +3,7 @@
Due date
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
.value-content
%span.no-value{ "v-if" => "!issue.dueDate" }
diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml
index 7d0c35fe183..6b389736e8b 100644
--- a/app/views/projects/boards/components/sidebar/_labels.html.haml
+++ b/app/views/projects/boards/components/sidebar/_labels.html.haml
@@ -3,7 +3,7 @@
Labels
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None
diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml
index 002e9994ee0..a1ddb261ea3 100644
--- a/app/views/projects/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml
@@ -3,7 +3,7 @@
Milestone
- if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading")
- = link_to "Edit", "#", class: "edit-link pull-right"
+ = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value
%span.no-value{ "v-if" => "!issue.milestone" }
None
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 8b095f4ca10..483f28c74f2 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,17 @@
+- @gfm_form = true
+
- content_for :note_actions do
- if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
-#notes
- = render 'shared/notes/notes_with_form', :autocomplete => true
+%section.js-vue-notes-event
+ #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
+ register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
+ new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
+ markdown_docs_path: help_page_path('user/markdown'),
+ quick_actions_docs_path: help_page_path('user/project/quick_actions'),
+ notes_path: notes_url,
+ last_fetched_at: Time.now.to_i,
+ issue_data: serialize_issuable(@issue),
+ current_user_data: UserSerializer.new.represent(current_user).to_json } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index de0f1de057d..fd7ff176c5e 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,11 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'common_vue'
+ = webpack_bundle_tag 'notes'
+
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
@@ -23,7 +28,7 @@
= icon('eye-slash', class: 'is-confidential')
= issuable_meta(@issue, @project, "Issue")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
@@ -36,8 +41,8 @@
- if @issue.author && current_user != @issue.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- if can_update_issue
- %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam
%li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- if can_update_issue || can_report_spam
@@ -74,7 +79,7 @@
.content-block.emoji-block
.row
- .col-sm-8
+ .col-sm-8.js-issue-note-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index a2e819fb3a7..f3c44c94a5c 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -17,7 +17,7 @@
.issuable-meta
= issuable_meta(@merge_request, @project, "Merge request")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index 5698bb281b4..adffd67029a 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -112,7 +112,7 @@
%span.light (optional)
= f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250
- .form-group.project-visibility-level-holder
+ .form-group.visibility-level-setting
= f.label :visibility_level, class: 'label-light' do
Visibility Level
= link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }
diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml
index 2ef1f98ba48..ac8e15a48b2 100644
--- a/app/views/projects/runners/_form.html.haml
+++ b/app/views/projects/runners/_form.html.haml
@@ -6,6 +6,12 @@
.checkbox
= f.check_box :active
%span.light Paused Runners don't accept new jobs
+ .form-group
+ = label :protected, "Protected", class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.check_box :access_level, {}, 'ref_protected', 'not_protected'
+ %span.light This runner will only run on pipelines trigged on protected branches
.form-group
= label :run_untagged, 'Run untagged jobs', class: 'control-label'
.col-sm-10
diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml
index 49415ba557b..dfab04aa1fb 100644
--- a/app/views/projects/runners/show.html.haml
+++ b/app/views/projects/runners/show.html.haml
@@ -19,6 +19,9 @@
%tr
%td Active
%td= @runner.active? ? 'Yes' : 'No'
+ %tr
+ %td Protected
+ %td= @runner.ref_protected? ? 'Yes' : 'No'
%tr
%td Can run untagged jobs
%td= @runner.run_untagged? ? 'Yes' : 'No'
diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml
index 15ba09b10ba..7d24c6a9122 100644
--- a/app/views/projects/settings/_head.html.haml
+++ b/app/views/projects/settings/_head.html.haml
@@ -23,7 +23,7 @@
= link_to project_settings_ci_cd_path(@project), title: 'Pipelines' do
%span
Pipelines
- - if Gitlab.config.pages.enabled
+ - if @project.pages_available?
= nav_link(controller: :pages) do
= link_to project_pages_path(@project), title: 'Pages' do
%span
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 8ded7440de3..23a418ad640 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -14,10 +14,10 @@
%ul
%li
= link_to_label(label, subject: subject, type: :merge_request) do
- view merge requests
+ View merge requests
%li
= link_to_label(label, subject: subject) do
- view open issues
+ View open issues
- if current_user
%li.label-subscription
- if can_subscribe_to_label_in_different_levels?(label)
diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml
index 73efec88bb1..192d2502aaf 100644
--- a/app/views/shared/_visibility_level.html.haml
+++ b/app/views/shared/_visibility_level.html.haml
@@ -1,6 +1,6 @@
- with_label = local_assigns.fetch(:with_label, true)
-.form-group.project-visibility-level-holder
+.form-group.visibility-level-setting
- if with_label
= f.label :visibility_level, class: 'control-label' do
Visibility Level
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 182c4eebd50..0ec7677a566 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -1,15 +1,17 @@
- Gitlab::VisibilityLevel.values.each do |level|
- - next if skip_level?(form_model, level)
- .radio
- - restricted = restricted_visibility_levels.include?(level)
+ - disallowed = disallowed_visibility_level?(form_model, level)
+ - restricted = restricted_visibility_levels.include?(level)
+ - disabled = disallowed || restricted
+ .radio{ class: [('disabled' if disabled), ('restricted' if restricted)] }
= form.label "#{model_method}_#{level}" do
- = form.radio_button model_method, level, checked: (selected_level == level), disabled: restricted
+ = form.radio_button model_method, level, checked: (selected_level == level), disabled: disabled
= visibility_level_icon(level)
.option-title
= visibility_level_label(level)
- .option-descr
+ .option-description
= visibility_level_description(level, form_model)
-- unless restricted_visibility_levels.empty?
- %div
- %span.info
- Some visibility level settings have been restricted by the administrator.
+ .option-disabled-reason
+ - if restricted
+ = restricted_visibility_level_description(level)
+ - elsif disallowed
+ = disallowed_visibility_level_description(level, form_model)
diff --git a/app/views/shared/icons/_icon_arrow_right.svg.erb b/app/views/shared/icons/_icon_arrow_right.svg.erb
new file mode 100644
index 00000000000..24d64eb73bd
--- /dev/null
+++ b/app/views/shared/icons/_icon_arrow_right.svg.erb
@@ -0,0 +1 @@
+
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index f22b6c9a6c2..cb706d80f23 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -4,9 +4,9 @@
- if can_update && is_current_user
= link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
- class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
+ class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
= link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method,
- class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
+ class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
- elsif can_update && !is_current_user
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
- elsif issuable.author
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index daa05990ae9..d8144a39b23 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -2,7 +2,7 @@
- button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize
- button_responsive_class = 'hidden-xs hidden-sm'
-- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button"
+- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable)
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c016aa2abcd..bb02dfa0d3a 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -29,18 +29,6 @@
= render 'shared/issuable/form/metadata', issuable: issuable, form: form
-- if issuable.can_move?(current_user)
- %hr
- .form-group
- = label_tag :move_to_project_id, 'Move', class: 'control-label'
- .col-sm-10
- .issuable-form-select-holder
- = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE }
-
- %span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default',
- title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' }
- = icon('question-circle')
-
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
= render 'shared/issuable/form/merge_params', issuable: issuable
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index c3f25c9d255..b07bc45512f 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -34,7 +34,7 @@
Milestone
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if issuable.milestone
= link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
@@ -60,7 +60,7 @@
Due date
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if issuable.due_date
@@ -95,7 +95,7 @@
Labels
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- selected_labels.each do |label|
@@ -141,5 +141,22 @@
%cite{ title: project_ref }
= project_ref
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ - if current_user && issuable.can_move?(current_user)
+ .block.js-sidebar-move-issue-block
+ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' }
+ = custom_icon('icon_arrow_right')
+ .dropdown.sidebar-move-issue-dropdown.hide-collapsed
+ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
+ data: { toggle: 'dropdown' } }
+ Move issue
+ .dropdown-menu.dropdown-menu-selectable
+ = dropdown_title('Move issue')
+ = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search')
+ = dropdown_content
+ = dropdown_loading
+ = dropdown_footer add_content_class: true do
+ %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
+ Move
+ = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
%script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 57392cd7fbb..58782fa5f58 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -13,7 +13,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
- if !signed_in
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
= sidebar_gutter_toggle_icon
diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml
index 66091d95a91..9b2b6e572e7 100644
--- a/app/views/shared/issuable/form/_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_issue_assignee.html.haml
@@ -11,7 +11,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if assignees.any?
- assignees.each do |assignee|
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
index 18011d528a0..bf8613b0f0d 100644
--- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
@@ -9,7 +9,7 @@
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to 'Edit', '#', class: 'edit-link pull-right'
+ = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
- if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 40379f48393..f03e0ab154c 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -21,7 +21,7 @@
.title
Start date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value
%span.value-content
- if milestone.start_date
@@ -51,7 +51,7 @@
.title.hide-collapsed
Due date
- if @project && can?(current_user, :admin_milestone, @project)
- = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right'
+ = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed
%span.value-content
- if milestone.due_date
@@ -88,7 +88,7 @@
.block.merge-requests
.sidebar-collapsed-icon
%strong
- = icon('exclamation', 'aria-hidden': 'true')
+ = custom_icon('mr_bold')
%span= milestone.merge_requests.count
.title.hide-collapsed
Merge requests
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index eae04c9bbb8..e3e86709b8f 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -17,9 +17,9 @@
- elsif !current_user
.disabled-comment.text-center.prepend-top-default
Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link'
or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml
new file mode 100644
index 00000000000..8ec78bbd41f
--- /dev/null
+++ b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml
@@ -0,0 +1,5 @@
+---
+title: Add settings for minimum SSH key strength and allowed key type
+merge_request: 13712
+author: Cory Hinshaw
+type: added
diff --git a/changelogs/unreleased/28202_decrease_abc_threshold_step3.yml b/changelogs/unreleased/28202_decrease_abc_threshold_step3.yml
new file mode 100644
index 00000000000..ed38fd37103
--- /dev/null
+++ b/changelogs/unreleased/28202_decrease_abc_threshold_step3.yml
@@ -0,0 +1,5 @@
+---
+title: Decrease ABC threshold to 55.25
+merge_request: 13904
+author: Maxim Rydkin
+type: other
diff --git a/changelogs/unreleased/28938-password-change-workflow-for-admins.yml b/changelogs/unreleased/28938-password-change-workflow-for-admins.yml
new file mode 100644
index 00000000000..0781e1a2fce
--- /dev/null
+++ b/changelogs/unreleased/28938-password-change-workflow-for-admins.yml
@@ -0,0 +1,5 @@
+---
+title: Changes the password change workflow for admins.
+merge_request: 13901
+author:
+type: fixed
diff --git a/changelogs/unreleased/30162-retire-koding-integration.yml b/changelogs/unreleased/30162-retire-koding-integration.yml
new file mode 100644
index 00000000000..63c2b9eb161
--- /dev/null
+++ b/changelogs/unreleased/30162-retire-koding-integration.yml
@@ -0,0 +1,4 @@
+---
+title: Deprecation of Koding integration, removal of setting in Admin Panel
+merge_request: 13992
+author: @mydigitalself
diff --git a/changelogs/unreleased/31273-creating-an-project-within-an-internal-sub-group-gives-the-option-to-set-it-a-public.yml b/changelogs/unreleased/31273-creating-an-project-within-an-internal-sub-group-gives-the-option-to-set-it-a-public.yml
new file mode 100644
index 00000000000..4d21717e161
--- /dev/null
+++ b/changelogs/unreleased/31273-creating-an-project-within-an-internal-sub-group-gives-the-option-to-set-it-a-public.yml
@@ -0,0 +1,6 @@
+---
+title: Ensure correct visibility level options shown on all Project, Group, and Snippets
+ forms
+merge_request: 13442
+author:
+type: fixed
diff --git a/changelogs/unreleased/31470-fix-api-files-raw.yml b/changelogs/unreleased/31470-fix-api-files-raw.yml
new file mode 100644
index 00000000000..271a945a998
--- /dev/null
+++ b/changelogs/unreleased/31470-fix-api-files-raw.yml
@@ -0,0 +1,5 @@
+---
+title: Fix the /projects/:id/repository/files/:file_path/raw endpoint to handle dots in the file_path
+merge_request: 13512
+author: mahcsig
+type: fixed
diff --git a/changelogs/unreleased/34261-move-move-to-sidebar.yml b/changelogs/unreleased/34261-move-move-to-sidebar.yml
new file mode 100644
index 00000000000..59fa1d4c221
--- /dev/null
+++ b/changelogs/unreleased/34261-move-move-to-sidebar.yml
@@ -0,0 +1,5 @@
+---
+title: Move "Move issue" controls to right-sidebar
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/35686-unescape-wiki-title.yml b/changelogs/unreleased/35686-unescape-wiki-title.yml
new file mode 100644
index 00000000000..4b2b7078163
--- /dev/null
+++ b/changelogs/unreleased/35686-unescape-wiki-title.yml
@@ -0,0 +1,5 @@
+---
+title: Unescape HTML characters in Wiki title
+merge_request: 13942
+author: Jacopo Beschi @jacopo-beschi
+type: fixed
diff --git a/changelogs/unreleased/36061-mr-ref.yml b/changelogs/unreleased/36061-mr-ref.yml
deleted file mode 100644
index 039666070a7..00000000000
--- a/changelogs/unreleased/36061-mr-ref.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Instrument MergeRequest#ensure_ref_fetched
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/36582-fix-note-resolved-icon.yml b/changelogs/unreleased/36582-fix-note-resolved-icon.yml
deleted file mode 100644
index 758c0ecd212..00000000000
--- a/changelogs/unreleased/36582-fix-note-resolved-icon.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Update and fix resolvable note icons for easier recognition
-merge_request:
-author:
-type: changed
diff --git a/changelogs/unreleased/36860-deleted-user-fix.yml b/changelogs/unreleased/36860-deleted-user-fix.yml
deleted file mode 100644
index 79e75441134..00000000000
--- a/changelogs/unreleased/36860-deleted-user-fix.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix failure when issue is authored by a deleted user
-merge_request: 13807
-author:
-type: fixed
diff --git a/changelogs/unreleased/36917-branch-tooltip.yml b/changelogs/unreleased/36917-branch-tooltip.yml
new file mode 100644
index 00000000000..2d37de50cec
--- /dev/null
+++ b/changelogs/unreleased/36917-branch-tooltip.yml
@@ -0,0 +1,5 @@
+---
+title: Adds tooltip to the branch name and improves performance
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/37179-dashboard-project-dropdown.yml b/changelogs/unreleased/37179-dashboard-project-dropdown.yml
new file mode 100644
index 00000000000..3ef080b8eae
--- /dev/null
+++ b/changelogs/unreleased/37179-dashboard-project-dropdown.yml
@@ -0,0 +1,5 @@
+---
+title: Removes disabled state from dashboard project button
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/add_message_to_the_404_page.yml b/changelogs/unreleased/add_message_to_the_404_page.yml
new file mode 100644
index 00000000000..f567796fe9f
--- /dev/null
+++ b/changelogs/unreleased/add_message_to_the_404_page.yml
@@ -0,0 +1,5 @@
+---
+title: Changed message and title on the 404 page
+merge_request:
+author: Branka Martinovic
+type: added
diff --git a/changelogs/unreleased/bugfix-notify-custom-participants.yml b/changelogs/unreleased/bugfix-notify-custom-participants.yml
deleted file mode 100644
index 04fcb95e18a..00000000000
--- a/changelogs/unreleased/bugfix-notify-custom-participants.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed: Notifications weren't sending to participating users with a `Custom` notification setting.
-merge_request: 13680
-author: jneen
-type: fixed
diff --git a/changelogs/unreleased/bvl-validate-po-files.yml b/changelogs/unreleased/bvl-validate-po-files.yml
new file mode 100644
index 00000000000..f840b2c3973
--- /dev/null
+++ b/changelogs/unreleased/bvl-validate-po-files.yml
@@ -0,0 +1,4 @@
+---
+title: Validate PO-files in static analysis
+merge_request: 13000
+author:
diff --git a/changelogs/unreleased/changes-bar-sticky-fix.yml b/changelogs/unreleased/changes-bar-sticky-fix.yml
deleted file mode 100644
index 7d62773ef7a..00000000000
--- a/changelogs/unreleased/changes-bar-sticky-fix.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fixed diff changes bar buttons from showing/hiding whilst scrolling
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/docs-fix-15669-issue-move-api.yml b/changelogs/unreleased/docs-fix-15669-issue-move-api.yml
new file mode 100644
index 00000000000..db68428fda3
--- /dev/null
+++ b/changelogs/unreleased/docs-fix-15669-issue-move-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add to_project_id parameter to Move Issue via API example
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml b/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml
new file mode 100644
index 00000000000..b57b9a3dfbe
--- /dev/null
+++ b/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml
@@ -0,0 +1,5 @@
+---
+title: Protected runners
+merge_request: 13194
+author:
+type: added
diff --git a/changelogs/unreleased/fix-import-events.yml b/changelogs/unreleased/fix-import-events.yml
deleted file mode 100644
index 84b4410a019..00000000000
--- a/changelogs/unreleased/fix-import-events.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix events error importing GitLab projects
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-npm-security-updates.yml b/changelogs/unreleased/fix-npm-security-updates.yml
new file mode 100644
index 00000000000..faa0c3149b8
--- /dev/null
+++ b/changelogs/unreleased/fix-npm-security-updates.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade brace-expansion NPM package due to security issue
+merge_request: 13665
+author: Markus Koller
+type: security
diff --git a/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml b/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml
deleted file mode 100644
index fb97bdb6b30..00000000000
--- a/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml b/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml
new file mode 100644
index 00000000000..fa50e36e28a
--- /dev/null
+++ b/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml
@@ -0,0 +1,5 @@
+---
+title: Fix typo in the API Deploy Keys documentation page
+merge_request: 14014
+author: Vitaliy @blackst0ne Klachkov
+type: fixed
diff --git a/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml
new file mode 100644
index 00000000000..edf11484d1f
--- /dev/null
+++ b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml
@@ -0,0 +1,5 @@
+---
+title: Make Gitaly PostUploadPack mandatory
+merge_request: 13953
+author:
+type: changed
diff --git a/changelogs/unreleased/mk-fix-user-namespace-rename.yml b/changelogs/unreleased/mk-fix-user-namespace-rename.yml
deleted file mode 100644
index bb43b21f708..00000000000
--- a/changelogs/unreleased/mk-fix-user-namespace-rename.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Make username update fail if the namespace update fails
-merge_request: 13642
-author:
-type: fixed
diff --git a/changelogs/unreleased/move-action.yml b/changelogs/unreleased/move-action.yml
new file mode 100644
index 00000000000..65eceae3ef9
--- /dev/null
+++ b/changelogs/unreleased/move-action.yml
@@ -0,0 +1,4 @@
+---
+title: Allow users to move issues to other projects using a / command
+merge_request: 13436
+author: Manolis Mavrofidis
diff --git a/changelogs/unreleased/mr-index-eager-load.yml b/changelogs/unreleased/mr-index-eager-load.yml
deleted file mode 100644
index 11c33055b17..00000000000
--- a/changelogs/unreleased/mr-index-eager-load.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Eager load head pipeline projects for MRs index
-merge_request:
-author:
-type: other
diff --git a/changelogs/unreleased/revert-appearances-description-html-not-null.yml b/changelogs/unreleased/revert-appearances-description-html-not-null.yml
deleted file mode 100644
index 4e3c39cb5fd..00000000000
--- a/changelogs/unreleased/revert-appearances-description-html-not-null.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Re-allow appearances.description_html to be NULL
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/rouge-2-2-1.yml b/changelogs/unreleased/rouge-2-2-1.yml
new file mode 100644
index 00000000000..2d8879e5574
--- /dev/null
+++ b/changelogs/unreleased/rouge-2-2-1.yml
@@ -0,0 +1,5 @@
+---
+title: Bump rouge to v2.2.1
+merge_request: 13887
+author:
+type: other
diff --git a/changelogs/unreleased/sidebar-cache-updates.yml b/changelogs/unreleased/sidebar-cache-updates.yml
new file mode 100644
index 00000000000..aebe53ba5b2
--- /dev/null
+++ b/changelogs/unreleased/sidebar-cache-updates.yml
@@ -0,0 +1,5 @@
+---
+title: Only update the sidebar count caches when needed
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml
new file mode 100644
index 00000000000..602ca358b8b
--- /dev/null
+++ b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml
@@ -0,0 +1,5 @@
+---
+title: Add 'from commit' information to cherry-picked commits
+merge_request: 13475
+author: Saverio Miroddi
+type: added
diff --git a/changelogs/unreleased/zj-disable-pages-in-subgroups.yml b/changelogs/unreleased/zj-disable-pages-in-subgroups.yml
new file mode 100644
index 00000000000..22c36214e1f
--- /dev/null
+++ b/changelogs/unreleased/zj-disable-pages-in-subgroups.yml
@@ -0,0 +1,5 @@
+---
+title: Remove pages settings when not available
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/zj-sort-templates.yml b/changelogs/unreleased/zj-sort-templates.yml
new file mode 100644
index 00000000000..443c4355890
--- /dev/null
+++ b/changelogs/unreleased/zj-sort-templates.yml
@@ -0,0 +1,5 @@
+---
+title: Sort templates in the dropdown
+merge_request:
+author:
+type: fixed
diff --git a/config/application.rb b/config/application.rb
index f69dab4de39..32a290f2002 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -51,31 +51,24 @@ module Gitlab
# Configure sensitive parameters which will be filtered from the log file.
#
# Parameters filtered:
- # - Password (:password, :password_confirmation)
- # - Private tokens
+ # - Any parameter ending with `_token`
+ # - Any parameter containing `password`
+ # - Any parameter containing `secret`
# - Two-factor tokens (:otp_attempt)
# - Repo/Project Import URLs (:import_url)
# - Build variables (:variables)
# - GitLab Pages SSL cert/key info (:certificate, :encrypted_key)
# - Webhook URLs (:hook)
- # - GitLab-shell secret token (:secret_token)
# - Sentry DSN (:sentry_dsn)
# - Deploy keys (:key)
+ config.filter_parameters += [/_token$/, /password/, /secret/]
config.filter_parameters += %i(
- authentication_token
certificate
encrypted_key
hook
import_url
- incoming_email_token
- rss_token
key
otp_attempt
- password
- password_confirmation
- private_token
- runners_token
- secret_token
sentry_dsn
variables
)
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index ca5b941aebf..c9018f3bf0e 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -404,3 +404,9 @@
:why: https://github.com/mafintosh/thunky/blob/master/README.md#license
:versions: []
:when: 2017-08-07 05:56:09.907045000 Z
+- - :whitelist
+ - Unlicense
+ - :who: Nick Thomas
+ :why: https://gitlab.com/gitlab-com/organization/issues/116
+ :versions: []
+ :when: 2017-09-01 17:17:51.996511844 Z
diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb
index 370a976b64a..5b455a8065a 100644
--- a/config/initializers/8_metrics.rb
+++ b/config/initializers/8_metrics.rb
@@ -122,6 +122,7 @@ def instrument_classes(instrumentation)
# Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/36061
instrumentation.instrument_instance_method(MergeRequest, :ensure_ref_fetched)
+ instrumentation.instrument_instance_method(MergeRequest, :fetch_ref)
end
# rubocop:enable Metrics/AbcSize
diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb
index eb589ecdb52..fd0167aa476 100644
--- a/config/initializers/fast_gettext.rb
+++ b/config/initializers/fast_gettext.rb
@@ -1,4 +1,7 @@
-FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po
+FastGettext.add_text_domain 'gitlab',
+ path: File.join(Rails.root, 'locale'),
+ type: :po,
+ ignore_fuzzy: true
FastGettext.default_text_domain = 'gitlab'
FastGettext.default_available_locales = Gitlab::I18n.available_locales
FastGettext.default_locale = :en
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
index 6b0cff75653..62d0967009a 100644
--- a/config/initializers/sentry.rb
+++ b/config/initializers/sentry.rb
@@ -1,19 +1,18 @@
# Be sure to restart your server when you modify this file.
require 'gitlab/current_settings'
-include Gitlab::CurrentSettings
if Rails.env.production?
# allow it to fail: it may do so when create_from_defaults is executed before migrations are actually done
begin
- sentry_enabled = current_application_settings.sentry_enabled
+ sentry_enabled = Gitlab::CurrentSettings.current_application_settings.sentry_enabled
rescue
sentry_enabled = false
end
if sentry_enabled
Raven.configure do |config|
- config.dsn = current_application_settings.sentry_dsn
+ config.dsn = Gitlab::CurrentSettings.current_application_settings.sentry_dsn
config.release = Gitlab::REVISION
# Sanitize fields based on those sanitized from Rails.
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
index e8213ac8ba4..f2fde1e0048 100644
--- a/config/initializers/session_store.rb
+++ b/config/initializers/session_store.rb
@@ -1,11 +1,10 @@
# Be sure to restart your server when you modify this file.
require 'gitlab/current_settings'
-include Gitlab::CurrentSettings
# allow it to fail: it may do so when create_from_defaults is executed before migrations are actually done
begin
- Settings.gitlab['session_expire_delay'] = current_application_settings.session_expire_delay || 10080
+ Settings.gitlab['session_expire_delay'] = Gitlab::CurrentSettings.current_application_settings.session_expire_delay || 10080
rescue
Settings.gitlab['session_expire_delay'] ||= 10080
end
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 06928c7b9ce..a15e7f8a344 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -303,11 +303,13 @@ constraints(ProjectUrlConstrainer.new) do
member do
post :toggle_subscription
post :mark_as_spam
+ post :move
get :referenced_merge_requests
get :related_branches
get :can_create_branch
get :realtime_changes
post :create_merge_request
+ get :discussions, format: :json
end
collection do
post :bulk_update
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 7d63a42d7d8..ad88e48550d 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -55,6 +55,7 @@ var config = {
monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js',
+ notes: './notes/index.js',
pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/pipelines_bundle.js',
pipelines_charts: './pipelines/pipelines_charts.js',
@@ -194,6 +195,7 @@ var config = {
'merge_conflicts',
'monitoring',
'notebook_viewer',
+ 'notes',
'pdf_viewer',
'pipelines',
'pipelines_details',
diff --git a/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb
new file mode 100644
index 00000000000..5b6079002c0
--- /dev/null
+++ b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb
@@ -0,0 +1,29 @@
+class AddMinimumKeyLengthToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ # A key restriction has these possible states:
+ #
+ # * -1 means "this key type is completely disabled"
+ # * 0 means "all keys of this type are valid"
+ # * > 0 means "keys must have at least this many bits to be valid"
+ #
+ # The default is 0, for backward compatibility
+ add_column_with_default :application_settings, :rsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :dsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :ecdsa_key_restriction, :integer, default: 0
+ add_column_with_default :application_settings, :ed25519_key_restriction, :integer, default: 0
+ end
+
+ def down
+ remove_column :application_settings, :rsa_key_restriction
+ remove_column :application_settings, :dsa_key_restriction
+ remove_column :application_settings, :ecdsa_key_restriction
+ remove_column :application_settings, :ed25519_key_restriction
+ end
+end
diff --git a/db/migrate/20170816133938_add_access_level_to_ci_runners.rb b/db/migrate/20170816133938_add_access_level_to_ci_runners.rb
new file mode 100644
index 00000000000..fc484730f42
--- /dev/null
+++ b/db/migrate/20170816133938_add_access_level_to_ci_runners.rb
@@ -0,0 +1,16 @@
+class AddAccessLevelToCiRunners < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:ci_runners, :access_level, :integer,
+ default: Ci::Runner.access_levels['not_protected'])
+ end
+
+ def down
+ remove_column(:ci_runners, :access_level)
+ end
+end
diff --git a/db/migrate/20170816133940_add_protected_to_ci_builds.rb b/db/migrate/20170816133940_add_protected_to_ci_builds.rb
new file mode 100644
index 00000000000..c73a4387d29
--- /dev/null
+++ b/db/migrate/20170816133940_add_protected_to_ci_builds.rb
@@ -0,0 +1,7 @@
+class AddProtectedToCiBuilds < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :ci_builds, :protected, :boolean
+ end
+end
diff --git a/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb b/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb
new file mode 100644
index 00000000000..ce8f1e03686
--- /dev/null
+++ b/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb
@@ -0,0 +1,7 @@
+class AddProtectedToCiPipelines < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ add_column :ci_pipelines, :protected, :boolean
+ end
+end
diff --git a/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb b/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb
new file mode 100644
index 00000000000..caf7c705a6e
--- /dev/null
+++ b/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb
@@ -0,0 +1,15 @@
+class AddIndexOnCiBuildsProtected < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :ci_builds, :protected
+ end
+
+ def down
+ remove_concurrent_index :ci_builds, :protected if index_exists?(:ci_builds, :protected)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0f4b0c0c3b3..a5f867df9ae 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -129,6 +129,10 @@ ActiveRecord::Schema.define(version: 20170824162758) do
t.boolean "password_authentication_enabled"
t.boolean "project_export_enabled", default: true, null: false
t.boolean "hashed_storage_enabled", default: false, null: false
+ t.integer "rsa_key_restriction", default: 0, null: false
+ t.integer "dsa_key_restriction", default: 0, null: false
+ t.integer "ecdsa_key_restriction", default: 0, null: false
+ t.integer "ed25519_key_restriction", default: 0, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -242,6 +246,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do
t.integer "auto_canceled_by_id"
t.boolean "retried"
t.integer "stage_id"
+ t.boolean "protected"
end
add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree
@@ -250,6 +255,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do
add_index "ci_builds", ["commit_id", "type", "name", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_name_and_ref", using: :btree
add_index "ci_builds", ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree
add_index "ci_builds", ["project_id"], name: "index_ci_builds_on_project_id", using: :btree
+ add_index "ci_builds", ["protected"], name: "index_ci_builds_on_protected", using: :btree
add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree
add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree
add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree
@@ -332,6 +338,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do
t.integer "auto_canceled_by_id"
t.integer "pipeline_schedule_id"
t.integer "source"
+ t.boolean "protected"
end
add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree
@@ -367,6 +374,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do
t.string "architecture"
t.boolean "run_untagged", default: true, null: false
t.boolean "locked", default: false, null: false
+ t.integer "access_level", default: 0, null: false
end
add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree
diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md
index 4fa800ecb9c..273d5a56b6f 100644
--- a/doc/api/deploy_keys.md
+++ b/doc/api/deploy_keys.md
@@ -106,7 +106,7 @@ Example response:
Creates a new deploy key for a project.
If the deploy key already exists in another project, it will be joined to current
-project only if original one was is accessible by the same user.
+project only if original one is accessible by the same user.
```
POST /projects/:id/deploy_keys
diff --git a/doc/api/issues.md b/doc/api/issues.md
index 14635114a31..765246142c1 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -594,7 +594,7 @@ POST /projects/:id/issues/:issue_iid/move
| `to_project_id` | integer | yes | The ID of the new project |
```bash
-curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/85/move
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data '{"to_project_id": 5}' https://gitlab.example.com/api/v4/projects/4/issues/85/move
```
Example response:
diff --git a/doc/api/runners.md b/doc/api/runners.md
index 16d362a3530..6304a496f94 100644
--- a/doc/api/runners.md
+++ b/doc/api/runners.md
@@ -138,7 +138,8 @@ Example response:
"ruby",
"mysql"
],
- "version": null
+ "version": null,
+ "access_level": "ref_protected"
}
```
@@ -156,6 +157,9 @@ PUT /runners/:id
| `description` | string | no | The description of a runner |
| `active` | boolean | no | The state of a runner; can be set to `true` or `false` |
| `tag_list` | array | no | The list of tags for a runner; put array of tags, that should be finally assigned to a runner |
+| `run_untagged` | boolean | no | Flag indicating the runner can execute untagged jobs |
+| `locked` | boolean | no | Flag indicating the runner is locked |
+| `access_level` | string | no | The access_level of the runner; `not_protected` or `ref_protected` |
```
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/6" --form "description=test-1-20150125-test" --form "tag_list=ruby,mysql,tag1,tag2"
@@ -190,7 +194,8 @@ Example response:
"tag1",
"tag2"
],
- "version": null
+ "version": null,
+ "access_level": "ref_protected"
}
```
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 94a9f8265fb..b78f1252108 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -48,7 +48,11 @@ Example response:
"plantuml_enabled": false,
"plantuml_url": null,
"terminal_max_session_time": 0,
- "polling_interval_multiplier": 1.0
+ "polling_interval_multiplier": 1.0,
+ "rsa_key_restriction": 0,
+ "dsa_key_restriction": 0,
+ "ecdsa_key_restriction": 0,
+ "ed25519_key_restriction": 0,
}
```
@@ -88,6 +92,10 @@ PUT /application/settings
| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. |
| `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. |
| `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. |
+| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys.
+| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys.
+| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys.
+| `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys.
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/application/settings?signup_enabled=false&default_project_visibility=internal
@@ -125,6 +133,10 @@ Example response:
"plantuml_enabled": false,
"plantuml_url": null,
"terminal_max_session_time": 0,
- "polling_interval_multiplier": 1.0
+ "polling_interval_multiplier": 1.0,
+ "rsa_key_restriction": 0,
+ "dsa_key_restriction": 0,
+ "ecdsa_key_restriction": 0,
+ "ed25519_key_restriction": 0,
}
```
diff --git a/doc/articles/index.md b/doc/articles/index.md
index 4b0c85b9272..798d4cbf4ff 100644
--- a/doc/articles/index.md
+++ b/doc/articles/index.md
@@ -26,6 +26,7 @@ Build, test, and deploy the software you develop with [GitLab CI/CD](../ci/READM
| Article title | Category | Publishing date |
| :------------ | :------: | --------------: |
+| [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](laravel_with_gitlab_and_envoy/index.md) | Tutorial | 2017-08-31 |
| [How to deploy Maven projects to Artifactory with GitLab CI/CD](artifactory_and_gitlab/index.md) | Tutorial | 2017-08-15 |
| [Making CI Easier with GitLab](https://about.gitlab.com/2017/07/13/making-ci-easier-with-gitlab/) | Concepts | 2017-07-13 |
| [Dockerizing GitLab Review Apps](https://about.gitlab.com/2017/07/11/dockerizing-review-apps/) | Concepts | 2017-07-11 |
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png
new file mode 100644
index 00000000000..a56c07a0da7
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_checkbox.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png
new file mode 100644
index 00000000000..b1406fed6b8
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_empty_image.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg
new file mode 100644
index 00000000000..d1f0cbc08ab
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/container_registry_page_with_image.jpg differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png
new file mode 100644
index 00000000000..9aae11b8679
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/deploy_keys_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png
new file mode 100644
index 00000000000..a06b6d417cd
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/environment_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png
new file mode 100644
index 00000000000..d357ecda7d2
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/environments_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png
new file mode 100644
index 00000000000..3bb21fd12b4
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_welcome_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png b/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png
new file mode 100644
index 00000000000..bc188f83fb1
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/laravel_with_gitlab_and_envoy.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png
new file mode 100644
index 00000000000..baf8dec499c
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/pipeline_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png
new file mode 100644
index 00000000000..d96c43bcf16
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png b/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png
new file mode 100644
index 00000000000..997db10189f
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/pipelines_page_deploy_button.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png b/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png
new file mode 100644
index 00000000000..6dbc29fc25c
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_app_directory.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png b/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png
new file mode 100644
index 00000000000..8a6dcccfa38
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/production_server_current_directory.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png b/doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png
new file mode 100644
index 00000000000..658c0b5bcac
Binary files /dev/null and b/doc/articles/laravel_with_gitlab_and_envoy/img/secret_variables_page.png differ
diff --git a/doc/articles/laravel_with_gitlab_and_envoy/index.md b/doc/articles/laravel_with_gitlab_and_envoy/index.md
new file mode 100644
index 00000000000..e0d8fb8d081
--- /dev/null
+++ b/doc/articles/laravel_with_gitlab_and_envoy/index.md
@@ -0,0 +1,680 @@
+# Test and deploy Laravel applications with GitLab CI/CD and Envoy
+
+> **[Article Type](../../development/writing_documentation.md#types-of-technical-articles):** tutorial ||
+> **Level:** intermediary ||
+> **Author:** [Mehran Rasulian](https://gitlab.com/mehranrasulian) ||
+> **Publication date:** 2017-08-31
+
+## Introduction
+
+GitLab features our applications with Continuous Integration, and it is possible to easily deploy the new code changes to the production server whenever we want.
+
+In this tutorial, we'll show you how to initialize a [Laravel](http://laravel.com/) application and setup our [Envoy](https://laravel.com/docs/envoy) tasks, then we'll jump into see how to test and deploy it with [GitLab CI/CD](../../ci/README.md) via [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/).
+
+We assume you have a basic experience with Laravel, Linux servers,
+and you know how to use GitLab.
+
+Laravel is a high quality web framework written in PHP.
+It has a great community with a [fantastic documentation](https://laravel.com/docs).
+Aside from the usual routing, controllers, requests, responses, views, and (blade) templates, out of the box Laravel provides plenty of additional services such as cache, events, localization, authentication and many others.
+
+We will use [Envoy](https://laravel.com/docs/master/envoy) as an SSH task runner based on PHP.
+It uses a clean, minimal [Blade syntax](https://laravel.com/docs/blade) to setup tasks that can run on remote servers, such as, cloning your project from the repository, installing the Composer dependencies, and running [Artisan commands](https://laravel.com/docs/artisan).
+
+## Initialize our Laravel app on GitLab
+
+We assume [you have installed a new laravel project](https://laravel.com/docs/installation#installation), so let's start with a unit test, and initialize Git for the project.
+
+### Unit Test
+
+Every new installation of Laravel (currently 5.4) comes with two type of tests, 'Feature' and 'Unit', placed in the tests directory.
+Here's a unit test from `test/Unit/ExampleTest.php`:
+
+```php
+assertTrue(true);
+ }
+}
+```
+
+This test is as simple as asserting that the given value is true.
+
+Laravel uses `PHPUnit` for tests by default.
+If we run `vendor/bin/phpunit` we should see the green output:
+
+```bash
+vendor/bin/phpunit
+OK (1 test, 1 assertions)
+```
+
+This test will be used later for continuously testing our app with GitLab CI/CD.
+
+### Push to GitLab
+
+Since we have our app up and running locally, it's time to push the codebase to our remote repository.
+Let's create [a new project](../../gitlab-basics/create-project.md) in GitLab named `laravel-sample`.
+After that, follow the command line instructions displayed on the project's homepage to initiate the repository on our machine and push the first commit.
+
+
+```bash
+cd laravel-sample
+git init
+git remote add origin git@gitlab.example.com:/laravel-sample.git
+git add .
+git commit -m 'Initial Commit'
+git push -u origin master
+```
+
+## Configure the production server
+
+Before we begin setting up Envoy and GitLab CI/CD, let's quickly make sure the production server is ready for deployment.
+We have installed LEMP stack which stands for Linux, Nginx, MySQL and PHP on our Ubuntu 16.04.
+
+### Create a new user
+
+Let's now create a new user that will be used to deploy our website and give it
+the needed permissions using [Linux ACL](https://serversforhackers.com/video/linux-acls):
+
+```bash
+# Create user deployer
+sudo adduser deployer
+# Give the read-write-execute permissions to deployer user for directory /var/www
+sudo setfacl -R -m u:deployer:rwx /var/www
+```
+
+If you don't have ACL installed on your Ubuntu server, use this command to install it:
+
+```bash
+sudo apt install acl
+```
+
+### Add SSH key
+
+Let's suppose we want to deploy our app to the production server from a private repository on GitLab. First, we need to [generate a new SSH key pair **with no passphrase**](../../ssh/README.md) for the deployer user.
+
+After that, we need to copy the private key, which will be used to connect to our server as the deployer user with SSH, to be able to automate our deployment process:
+
+```bash
+# As the deployer user on server
+#
+# Copy the content of public key to authorized_keys
+cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
+# Copy the private key text block
+cat ~/.ssh/id_rsa
+```
+
+Now, let's add it to your GitLab project as a [secret variable](../../ci/variables/README.md#secret-variables).
+Secret variables are user-defined variables and are stored out of `.gitlab-ci.yml`, for security purposes.
+They can be added per project by navigating to the project's **Settings** > **CI/CD**.
+
+![secret variables page](img/secret_variables_page.png)
+
+To the field **KEY**, add the name `SSH_PRIVATE_KEY`, and to the **VALUE** field, paste the private key you've copied earlier.
+We'll use this variable in the `.gitlab-ci.yml` later, to easily connect to our remote server as the deployer user without entering its password.
+
+We also need to add the public key to **Project** > **Settings** > **Repository** as [Deploy Keys](../../ssh/README.md/#deploy-keys), which gives us the ability to access our repository from the server through [SSH protocol](../../gitlab-basics/command-line-commands.md/#start-working-on-your-project).
+
+
+```bash
+# As the deployer user on the server
+#
+# Copy the public key
+cat ~/.ssh/id_rsa.pub
+```
+
+![deploy keys page](img/deploy_keys_page.png)
+
+To the field **Title**, add any name you want, and paste the public key into the **Key** field.
+
+Now, let's clone our repository on the server just to make sure the `deployer` user has access to the repository.
+
+```bash
+# As the deployer user on server
+#
+git clone git@gitlab.example.com:/laravel-sample.git
+```
+
+>**Note:**
+Answer **yes** if asked `Are you sure you want to continue connecting (yes/no)?`.
+It adds GitLab.com to the known hosts.
+
+### Configuring Nginx
+
+Now, let's make sure our web server configuration points to the `current/public` rather than `public`.
+
+Open the default Nginx server block configuration file by typing:
+
+```bash
+sudo nano /etc/nginx/sites-available/default
+```
+
+The configuration should be like this.
+
+```
+server {
+ root /var/www/app/current/public;
+ server_name example.com;
+ # Rest of the configuration
+}
+```
+
+>**Note:**
+You may replace the app's name in `/var/www/app/current/public` with the folder name of your application.
+
+## Setting up Envoy
+
+So we have our Laravel app ready for production.
+The next thing is to use Envoy to perform the deploy.
+
+To use Envoy, we should first install it on our local machine [using the given instructions by Laravel](https://laravel.com/docs/envoy/#introduction).
+
+### How Envoy works
+
+The pros of Envoy is that it doesn't require Blade engine, it just uses Blade syntax to define tasks.
+To start, we create an `Envoy.blade.php` in the root of our app with a simple task to test Envoy.
+
+
+```php
+@servers(['web' => 'remote_username@remote_host'])
+
+@task('list', [on => 'web'])
+ ls -l
+@endtask
+```
+
+As you may expect, we have an array within `@servers` directive at the top of the file, which contains a key named `web` with a value of the server's address (e.g. `deployer@192.168.1.1`).
+Then within our `@task` directive we define the bash commands that should be run on the server when the task is executed.
+
+On the local machine use the `run` command to run Envoy tasks.
+
+```bash
+envoy run list
+```
+
+It should execute the `list` task we defined earlier, which connects to the server and lists directory contents.
+
+Envoy is not a dependency of Laravel, therefore you can use it for any PHP application.
+
+### Zero downtime deployment
+
+Every time we deploy to the production server, Envoy downloads the latest release of our app from GitLab repository and replace it with preview's release.
+Envoy does this without any [downtime](https://en.wikipedia.org/wiki/Downtime),
+so we don't have to worry during the deployment while someone might be reviewing the site.
+Our deployment plan is to clone the latest release from GitLab repository, install the Composer dependencies and finally, activate the new release.
+
+#### @setup directive
+
+The first step of our deployment process is to define a set of variables within [@setup](https://laravel.com/docs/envoy/#setup) directive.
+You may change the `app` to your application's name:
+
+
+```php
+...
+
+@setup
+ $repository = 'git@gitlab.example.com:/laravel-sample.git';
+ $releases_dir = '/var/www/app/releases';
+ $app_dir = '/var/www/app';
+ $release = date('YmdHis');
+ $new_release_dir = $releases_dir .'/'. $release;
+@endsetup
+
+...
+```
+
+- `$repository` is the address of our repository
+- `$releases_dir` directory is where we deploy the app
+- `$app_dir` is the actual location of the app that is live on the server
+- `$release` contains a date, so every time that we deploy a new release of our app, we get a new folder with the current date as name
+- `$new_release_dir` is the full path of the new release which is used just to make the tasks cleaner
+
+#### @story directive
+
+The [@story](https://laravel.com/docs/envoy/#stories) directive allows us define a list of tasks that can be run as a single task.
+Here we have three tasks called `clone_repository`, `run_composer`, `update_symlinks`. These variables are usable to making our task's codes more cleaner:
+
+
+```php
+...
+
+@story('deploy')
+ clone_repository
+ run_composer
+ update_symlinks
+@endstory
+
+...
+```
+
+Let's create these three tasks one by one.
+
+#### Clone the repository
+
+The first task will create the `releases` directory (if it doesn't exist), and then clone the `master` branch of the repository (by default) into the new release directory, given by the `$new_release_dir` variable.
+The `releases` directory will hold all our deployments:
+
+```php
+...
+
+@task('clone_repository')
+ echo 'Cloning repository'
+ [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
+ git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
+@endtask
+
+...
+```
+
+While our project grows, its Git history will be very very long over time.
+Since we are creating a directory per release, it might not be necessary to have the history of the project downloaded for each release.
+The `--depth 1` option is a great solution which saves systems time and disk space as well.
+
+#### Installing dependencies with Composer
+
+As you may know, this task just navigates to the new release directory and runs Composer to install the application dependencies:
+
+```php
+...
+
+@task('run_composer')
+ echo "Starting deployment ({{ $release }})"
+ cd {{ $new_release_dir }}
+ composer install --prefer-dist --no-scripts -q -o
+@endtask
+
+...
+```
+
+#### Activate new release
+
+Next thing to do after preparing the requirements of our new release, is to remove the storage directory from it and to create two symbolic links to point the application's `storage` directory and `.env` file to the new release.
+Then, we need to create another symbolic link to the new release with the name of `current` placed in the app directory.
+The `current` symbolic link always points to the latest release of our app:
+
+```php
+...
+
+@task('update_symlinks')
+ echo "Linking storage directory"
+ rm -rf {{ $new_release_dir }}/storage
+ ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
+
+ echo 'Linking .env file'
+ ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
+
+ echo 'Linking current release'
+ ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
+@endtask
+```
+
+As you see, we use `-nfs` as an option for `ln` command, which says that the `storage`, `.env` and `current` no longer points to the preview's release and will point them to the new release by force (`f` from `-nfs` means force), which is the case when we are doing multiple deployments.
+
+### Full script
+
+The script is ready, but make sure to change the `deployer@192.168.1.1` to your server and also change `/var/www/app` with the directory you want to deploy your app.
+
+At the end, our `Envoy.blade.php` file will look like this:
+
+```php
+@servers(['web' => 'deployer@192.168.1.1'])
+
+@setup
+ $repository = 'git@gitlab.example.com:/laravel-sample.git';
+ $releases_dir = '/var/www/app/releases';
+ $app_dir = '/var/www/app';
+ $release = date('YmdHis');
+ $new_release_dir = $releases_dir .'/'. $release;
+@endsetup
+
+@story('deploy')
+ clone_repository
+ run_composer
+ update_symlinks
+@endstory
+
+@task('clone_repository')
+ echo 'Cloning repository'
+ [ -d {{ $releases_dir }} ] || mkdir {{ $releases_dir }}
+ git clone --depth 1 {{ $repository }} {{ $new_release_dir }}
+@endtask
+
+@task('run_composer')
+ echo "Starting deployment ({{ $release }})"
+ cd {{ $new_release_dir }}
+ composer install --prefer-dist --no-scripts -q -o
+@endtask
+
+@task('update_symlinks')
+ echo "Linking storage directory"
+ rm -rf {{ $new_release_dir }}/storage
+ ln -nfs {{ $app_dir }}/storage {{ $new_release_dir }}/storage
+
+ echo 'Linking .env file'
+ ln -nfs {{ $app_dir }}/.env {{ $new_release_dir }}/.env
+
+ echo 'Linking current release'
+ ln -nfs {{ $new_release_dir }} {{ $app_dir }}/current
+@endtask
+```
+
+One more thing we should do before any deployment is to manually copy our application `storage` folder to the `/var/www/app` directory on the server for the first time.
+You might want to create another Envoy task to do that for you.
+We also create the `.env` file in the same path to setup our production environment variables for Laravel.
+These are persistent data and will be shared to every new release.
+
+Now, we would need to deploy our app by running `envoy run deploy`, but it won't be necessary since GitLab can handle that for us with CI's [environments](../../ci/environments.md), which will be described [later](#setting-up-gitlab-ci-cd) in this tutorial.
+
+Now it's time to commit [Envoy.blade.php](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Envoy.blade.php) and push it to the `master` branch.
+To keep things simple, we commit directly to `master`, without using [feature-branches](../../workflow/gitlab_flow.md/#github-flow-as-a-simpler-alternative) since collaboration is beyond the scope of this tutorial.
+In a real world project, teams may use [Issue Tracker](../../user/project/issues/index.md) and [Merge Requests](../../user/project/merge_requests/index.md) to move their code across branches:
+
+```bash
+git add Envoy.blade.php
+git commit -m 'Add Envoy'
+git push origin master
+```
+
+## Continuous Integration with GitLab
+
+We have our app ready on GitLab, and we also can deploy it manually.
+But let's take a step forward to do it automatically with [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) method.
+We need to check every commit with a set of automated tests to become aware of issues at the earliest, and then, we can deploy to the target environment if we are happy with the result of the tests.
+
+[GitLab CI/CD](../../ci/README.md) allows us to use [Docker](https://docker.com/) engine to handle the process of testing and deploying our app.
+In the case you're not familiar with Docker, refer to [How to Automate Docker Deployments](http://paislee.io/how-to-automate-docker-deployments/).
+
+To be able to build, test, and deploy our app with GitLab CI/CD, we need to prepare our work environment.
+To do that, we'll use a Docker image which has the minimum requirements that a Laravel app needs to run.
+[There are other ways](../../ci/examples/php.md/#test-php-projects-using-the-docker-executor) to do that as well, but they may lead our builds run slowly, which is not what we want when there are faster options to use.
+
+With Docker images our builds run incredibly faster!
+
+### Create a Container Image
+
+Let's create a [Dockerfile](https://gitlab.com/mehranrasulian/laravel-sample/blob/master/Dockerfile) in the root directory of our app with the following content:
+
+```bash
+# Set the base image for subsequent instructions
+FROM php:7.1
+
+# Update packages
+RUN apt-get update
+
+# Install PHP and composer dependencies
+RUN apt-get install -qq git curl libmcrypt-dev libjpeg-dev libpng-dev libfreetype6-dev libbz2-dev
+
+# Clear out the local repository of retrieved package files
+RUN apt-get clean
+
+# Install needed extensions
+# Here you can install any other extension that you need during the test and deployment process
+RUN docker-php-ext-install mcrypt pdo_mysql zip
+
+# Install Composer
+RUN curl --silent --show-error https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+
+# Install Laravel Envoy
+RUN composer global require "laravel/envoy=~1.0"
+```
+
+We added the [official PHP 7.1 Docker image](https://hub.docker.com/r/_/php/), which consist of a minimum installation of Debian Jessie with PHP pre-installed, and works perfectly for our use case.
+
+We used `docker-php-ext-install` (provided by the official PHP Docker image) to install the PHP extensions we need.
+
+#### Setting Up GitLab Container Registry
+
+Now that we have our `Dockerfile` let's build and push it to our [GitLab Container Registry](../../user/project/container_registry.md).
+
+> The registry is the place to store and tag images for later use. Developers may want to maintain their own registry for private, company images, or for throw-away images used only in testing. Using GitLab Container Registry means you don't need to set up and administer yet another service or use a public registry.
+
+On your GitLab project repository navigate to the **Registry** tab.
+
+![container registry page empty image](img/container_registry_page_empty_image.png)
+
+You may need to [enable Container Registry](../../user/project/container_registry.md#enable-the-container-registry-for-your-project) to your project to see this tab. You'll find it under your project's **Settings > General > Sharing and permissions**.
+
+![container registry checkbox](img/container_registry_checkbox.png)
+
+To start using Container Registry on our machine, we first need to login to the GitLab registry using our GitLab username and password:
+
+```bash
+docker login registry.gitlab.com
+```
+Then we can build and push our image to GitLab:
+
+```bash
+docker build -t registry.gitlab.com//laravel-sample .
+
+docker push registry.gitlab.com//laravel-sample
+```
+
+>**Note:**
+To run the above commands, we first need to have [Docker](https://docs.docker.com/engine/installation/) installed on our machine.
+
+Congratulations! You just pushed the first Docker image to the GitLab Registry, and if you refresh the page you should be able to see it:
+
+![container registry page with image](img/container_registry_page_with_image.jpg)
+
+>**Note:**
+You can also [use GitLab CI/CD](https://about.gitlab.com/2016/05/23/gitlab-container-registry/#use-with-gitlab-ci) to build and push your Docker images, rather than doing that on your machine.
+
+We'll use this image further down in the `.gitlab-ci.yml` configuration file to handle the process of testing and deploying our app.
+
+Let's commit the `Dockerfile` file.
+
+```bash
+git add Dockerfile
+git commit -m 'Add Dockerfile'
+git push origin master
+```
+
+### Setting up GitLab CI/CD
+
+In order to build and test our app with GitLab CI/CD, we need a file called `.gitlab-ci.yml` in our repository's root. It is similar to Circle CI and Travis CI, but built-in GitLab.
+
+Our `.gitlab-ci.yml` file will look like this:
+
+```yaml
+image: registry.gitlab.com//laravel-sample:latest
+
+services:
+ - mysql:5.7
+
+variables:
+ MYSQL_DATABASE: homestead
+ MYSQL_ROOT_PASSWORD: secret
+ DB_HOST: mysql
+ DB_USERNAME: root
+
+stages:
+ - test
+ - deploy
+
+unit_test:
+ stage: test
+ script:
+ - composer install
+ - cp .env.example .env
+ - php artisan key:generate
+ - php artisan migrate
+ - vendor/bin/phpunit
+
+deploy_production:
+ stage: deploy
+ script:
+ - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
+ - eval $(ssh-agent -s)
+ - ssh-add <(echo "$SSH_PRIVATE_KEY")
+ - mkdir -p ~/.ssh
+ - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
+
+ - ~/.composer/vendor/bin/envoy run deploy
+ environment:
+ name: production
+ url: http://192.168.1.1
+ when: manual
+ only:
+ - master
+```
+
+That's a lot to take in, isn't it? Let's run through it step by step.
+
+#### Image and Services
+
+[GitLab Runners](../../ci/runners/README.md) run the script defined by `.gitlab-ci.yml`.
+The `image` keyword tells the Runners which image to use.
+The `services` keyword defines additional images [that are linked to the main image](../../ci/docker/using_docker_images.md/#what-is-a-service).
+Here we use the container image we created before as our main image and also use MySQL 5.7 as a service.
+
+```yaml
+image: registry.gitlab.com//laravel-sample:latest
+
+services:
+ - mysql:5.7
+
+...
+```
+
+>**Note:**
+If you wish to test your app with different PHP versions and [database management systems](../../ci/services/README.md), you can define different `image` and `services` keywords for each test job.
+
+#### Variables
+
+GitLab CI/CD allows us to use [environment variables](../../ci/yaml/README.md#variables) in our jobs.
+We defined MySQL as our database management system, which comes with a superuser root created by default.
+
+So we should adjust the configuration of MySQL instance by defining `MYSQL_DATABASE` variable as our database name and `MYSQL_ROOT_PASSWORD` variable as the password of `root`.
+Find out more about MySQL variables at the [official MySQL Docker Image](https://hub.docker.com/r/_/mysql/).
+
+Also set the variables `DB_HOST` to `mysql` and `DB_USERNAME` to `root`, which are Laravel specific variables.
+We define `DB_HOST` as `mysql` instead of `127.0.0.1`, as we use MySQL Docker image as a service which [is linked to the main Docker image](../../ci/docker/using_docker_images.md/#how-services-are-linked-to-the-build).
+
+```yaml
+...
+
+variables:
+ MYSQL_DATABASE: homestead
+ MYSQL_ROOT_PASSWORD: secret
+ DB_HOST: mysql
+ DB_USERNAME: root
+
+...
+```
+
+#### Unit Test as the first job
+
+We defined the required shell scripts as an array of the [script](../../ci/yaml/README.md#script) variable to be executed when running `unit_test` job.
+
+These scripts are some Artisan commands to prepare the Laravel, and, at the end of the script, we'll run the tests by `PHPUnit`.
+
+```yaml
+...
+
+unit_test:
+ script:
+ # Install app dependencies
+ - composer install
+ # Setup .env
+ - cp .env.example .env
+ # Generate an environment key
+ - php artisan key:generate
+ # Run migrations
+ - php artisan migrate
+ # Run tests
+ - vendor/bin/phpunit
+
+...
+```
+
+#### Deploy to production
+
+The job `deploy_production` will deploy the app to the production server.
+To deploy our app with Envoy, we had to set up the `$SSH_PRIVATE_KEY` variable as an [SSH private key](../../ci/ssh_keys/README.md/#ssh-keys-when-using-the-docker-executor).
+If the SSH keys have added successfully, we can run Envoy.
+
+As mentioned before, GitLab supports [Continuous Delivery](https://about.gitlab.com/2016/08/05/continuous-integration-delivery-and-deployment-with-gitlab/#continuous-delivery) methods as well.
+The [environment](../../ci/yaml/README.md#environment) keyword tells GitLab that this job deploys to the `production` environment.
+The `url` keyword is used to generate a link to our application on the GitLab Environments page.
+The `only` keyword tells GitLab CI that the job should be executed only when the pipeline is building the `master` branch.
+Lastly, `when: manual` is used to turn the job from running automatically to a manual action.
+
+```yaml
+...
+
+deploy_production:
+ script:
+ # Add the private SSH key to the build environment
+ - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
+ - eval $(ssh-agent -s)
+ - ssh-add <(echo "$SSH_PRIVATE_KEY")
+ - mkdir -p ~/.ssh
+ - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
+
+ # Run Envoy
+ - ~/.composer/vendor/bin/envoy run deploy
+
+ environment:
+ name: production
+ url: http://192.168.1.1
+ when: manual
+ only:
+ - master
+```
+
+You may also want to add another job for [staging environment](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments), to final test your application before deploying to production.
+
+### Turn on GitLab CI/CD
+
+We have prepared everything we need to test and deploy our app with GitLab CI/CD.
+To do that, commit and push `.gitlab-ci.yml` to the `master` branch. It will trigger a pipeline, which you can watch live under your project's **Pipelines**.
+
+![pipelines page](img/pipelines_page.png)
+
+Here we see our **Test** and **Deploy** stages.
+The **Test** stage has the `unit_test` build running.
+click on it to see the Runner's output.
+
+![pipeline page](img/pipeline_page.png)
+
+After our code passed through the pipeline successfully, we can deploy to our production server by clicking the **play** button on the right side.
+
+![pipelines page deploy button](img/pipelines_page_deploy_button.png)
+
+Once the deploy pipeline passed successfully, navigate to **Pipelines > Environments**.
+
+![environments page](img/environments_page.png)
+
+If something doesn't work as expected, you can roll back to the latest working version of your app.
+
+![environment page](img/environment_page.png)
+
+By clicking on the external link icon specified on the right side, GitLab opens the production website.
+Our deployment successfully was done and we can see the application is live.
+
+![laravel welcome page](img/laravel_welcome_page.png)
+
+In the case that you're interested to know how is the application directory structure on the production server after deployment, here are three directories named `current`, `releases` and `storage`.
+As you know, the `current` directory is a symbolic link that points to the latest release.
+The `.env` file consists of our Laravel environment variables.
+
+![production server app directory](img/production_server_app_directory.png)
+
+If you navigate to the `current` directory, you should see the application's content.
+As you see, the `.env` is pointing to the `/var/www/app/.env` file and also `storage` is pointing to the `/var/www/app/storage/` directory.
+
+![production server current directory](img/production_server_current_directory.png)
+
+## Conclusion
+
+We configured GitLab CI to perform automated tests and used the method of [Continuous Delivery](https://continuousdelivery.com/) to deploy to production a Laravel application with Envoy, directly from the codebase.
+
+Envoy also was a great match to help us deploy the application without writing our custom bash script and doing Linux magics.
diff --git a/doc/articles/numerous_undo_possibilities_in_git/index.md b/doc/articles/numerous_undo_possibilities_in_git/index.md
index 9f1239b8f88..895bbccec08 100644
--- a/doc/articles/numerous_undo_possibilities_in_git/index.md
+++ b/doc/articles/numerous_undo_possibilities_in_git/index.md
@@ -3,7 +3,7 @@
> **Article [Type](../../development/writing_documentation.md#types-of-technical-articles):** tutorial ||
> **Level:** intermediary ||
> **Author:** [Crt Mori](https://gitlab.com/Letme) ||
-> **Publication date:** 2017/08/17
+> **Publication date:** 2017-08-17
## Introduction
diff --git a/doc/ci/README.md b/doc/ci/README.md
index c722d895f42..1bf10e34ae7 100644
--- a/doc/ci/README.md
+++ b/doc/ci/README.md
@@ -112,6 +112,7 @@ Here is an collection of tutorials and guides on setting up your CI pipeline.
- [Run PHP Composer & NPM scripts then deploy them to a staging server](examples/deployment/composer-npm-deploy.md)
- [Analyze code quality with the Code Climate CLI](examples/code_climate.md)
- **Articles**
+ - [How to test and deploy Laravel/PHP applications with GitLab CI/CD and Envoy](../articles/laravel_with_gitlab_and_envoy/index.md)
- [How to deploy Maven projects to Artifactory with GitLab CI/CD](../articles/artifactory_and_gitlab/index.md)
- [Automated Debian packaging](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
- [Spring boot application with GitLab CI and Kubernetes](https://about.gitlab.com/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/)
diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md
index 2458cb959ab..f094546c3bd 100644
--- a/doc/ci/examples/README.md
+++ b/doc/ci/examples/README.md
@@ -50,12 +50,15 @@ Apart from those, here is an collection of tutorials and guides on setting up yo
- **Articles:**
- [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/)
+### Code quality analysis
+
+- [Analyze code quality with the Code Climate CLI](code_climate.md)
+
### Other
- [Using `dpl` as deployment tool](deployment/README.md)
- [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples)
- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml)
-- [Analyze code quality with the Code Climate CLI](code_climate.md)
- **Articles:**
- [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/)
diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md
index 5659a8c2a2a..4d0ba8bfef3 100644
--- a/doc/ci/examples/code_climate.md
+++ b/doc/ci/examples/code_climate.md
@@ -5,10 +5,10 @@ GitLab CI and Docker.
First, you need GitLab Runner with [docker-in-docker executor][dind].
-Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`:
+Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codequality`:
```yaml
-codeclimate:
+codequality:
image: docker:latest
variables:
DOCKER_DRIVER: overlay
@@ -22,7 +22,7 @@ codeclimate:
paths: [codeclimate.json]
```
-This will create a `codeclimate` job in your CI pipeline and will allow you to
+This will create a `codequality` job in your CI pipeline and will allow you to
download and analyze the report artifact in JSON format.
For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 76d746155eb..4ccf1b56771 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -107,6 +107,26 @@ To lock/unlock a Runner:
1. Check the **Lock to current projects** option
1. Click **Save changes** for the changes to take effect
+## Protected Runners
+
+>**Notes:**
+[Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13194)
+in GitLab 10.0.
+
+You can protect Runners from revealing sensitive information.
+Whenever a Runner is protected, the Runner picks only jobs created on
+[protected branches] or [protected tags], and ignores other jobs.
+
+To protect/unprotect Runners:
+
+1. Visit your project's **Settings ➔ Pipelines**
+1. Find a Runner you want to protect/unprotect and make sure it's enabled
+1. Click the pencil button besides the Runner name
+1. Check the **Protected** option
+1. Click **Save changes** for the changes to take effect
+
+![specific Runners edit icon](img/protected_runners_check_box.png)
+
## How shared Runners pick jobs
Shared Runners abide to a process queue we call fair usage. The fair usage
@@ -218,3 +238,5 @@ We're always looking for contributions that can mitigate these
[install]: http://docs.gitlab.com/runner/install/
[fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)
[register]: http://docs.gitlab.com/runner/register/
+[protected branches]: ../../user/project/protected_branches.md
+[protected tags]: ../../user/project/protected_tags.md
diff --git a/doc/ci/runners/img/protected_runners_check_box.png b/doc/ci/runners/img/protected_runners_check_box.png
new file mode 100644
index 00000000000..fb58498c7ce
Binary files /dev/null and b/doc/ci/runners/img/protected_runners_check_box.png differ
diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md
index cf25a8b618f..cdb9858e179 100644
--- a/doc/ci/ssh_keys/README.md
+++ b/doc/ci/ssh_keys/README.md
@@ -42,7 +42,7 @@ It is also good practice to check the server's own public key to make sure you
are not being targeted by a man-in-the-middle attack. To do this, add another
variable named `SSH_SERVER_HOSTKEYS`. To find out the hostkeys of your server, run
the `ssh-keyscan YOUR_SERVER` command from a trusted network (ideally, from the
-server itself), and paste its output into the `SSH_SERVER_HOSTKEY` variable. If
+server itself), and paste its output into the `SSH_SERVER_HOSTKEYS` variable. If
you need to connect to multiple servers, concatenate all the server public keys
that you collected into the **Value** of the variable. There must be one key per
line.
diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md
index 756535e28bc..bd0ef39ca62 100644
--- a/doc/development/i18n_guide.md
+++ b/doc/development/i18n_guide.md
@@ -138,6 +138,47 @@ translations. There's no need to generate `.po` files.
Translations that aren't used in the source code anymore will be marked with
`~#`; these can be removed to keep our translation files clutter-free.
+### Validating PO files
+
+To make sure we keep our translation files up to date, there's a linter that is
+running on CI as part of the `static-analysis` job.
+
+To lint the adjustments in PO files locally you can run `rake gettext:lint`.
+
+The linter will take the following into account:
+
+- Valid PO-file syntax
+- Variable usage
+ - Only one unnamed (`%d`) variable, since the order of variables might change
+ in different languages
+ - All variables used in the message-id are used in the translation
+ - There should be no variables used in a translation that aren't in the
+ message-id
+- Errors during translation.
+
+The errors are grouped per file, and per message ID:
+
+```
+Errors in `locale/zh_HK/gitlab.po`:
+ PO-syntax errors
+ SimplePoParser::ParserErrorSyntax error in lines
+ Syntax error in msgctxt
+ Syntax error in msgid
+ Syntax error in msgstr
+ Syntax error in message_line
+ There should be only whitespace until the end of line after the double quote character of a message text.
+ Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
+ SimplePoParser filtered backtrace: SimplePoParser::ParserError
+Errors in `locale/zh_TW/gitlab.po`:
+ 1 pipeline
+ <%d 條流水線> is using unknown variables: [%d]
+ Failure translating to zh_TW with []: too few arguments
+```
+
+In this output the `locale/zh_HK/gitlab.po` has syntax errors.
+The `locale/zh_TW/gitlab.po` has variables that are used in the translation that
+aren't in the message with id `1 pipeline`.
+
## Working with special content
### Interpolation
diff --git a/doc/development/licensing.md b/doc/development/licensing.md
index 2b16dfe0e7c..60da7b9166d 100644
--- a/doc/development/licensing.md
+++ b/doc/development/licensing.md
@@ -55,6 +55,7 @@ Libraries with the following licenses are acceptable for use:
- [BSD 3-Clause License][BSD-3-Clause] (also known as New BSD or Modified BSD): A permissive (non-copyleft) license as defined by the Open Source Initiative
- [ISC License][ISC] (also known as the OpenBSD License): A permissive (non-copyleft) license as defined by the Open Source Initiative.
- [Creative Commons Zero (CC0)][CC0]: A public domain dedication, recommended as a way to disclaim copyright on your work to the maximum extent possible.
+- [Unlicense][UNLICENSE]: Another public domain dedication.
## Unacceptable Licenses
@@ -101,5 +102,6 @@ Gems which are included only in the "development" or "test" groups by Bundler ar
[OSL]: https://opensource.org/licenses/OSL-3.0
[OSL-GNU]: https://www.gnu.org/licenses/license-list.en.html#OSL
[Org-Repo]: https://gitlab.com/gitlab-com/organization
+[UNLICENSE]: https://unlicense.org
[Acceptable-Licenses]: #acceptable-licenses
[Unacceptable-Licenses]: #unacceptable-licenses
diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md
index 81057736e3a..a339bc23809 100644
--- a/doc/install/kubernetes/gitlab_chart.md
+++ b/doc/install/kubernetes/gitlab_chart.md
@@ -1,9 +1,9 @@
# GitLab Helm Chart
-> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
+> **Note:**
+* > **Note**: This chart will be replaced by the [gitlab-omnibus](gitlab_omnibus.md) chart, once it supports [additional configuration options](https://gitlab.com/charts/charts.gitlab.io/issues/68).
+* Officially supported cloud providers are Google Container Service and Azure Container Service.
-> Officially supported cloud providers are Google Container Service and Azure Container Service.
-
-The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster.
+The `gitlab` Helm chart deploys just GitLab into your Kubernetes cluster, and offers extensive configuration options. For most deployments we recommended the [gitlab-omnibus](gitlab_omnibus.md) chart,
This chart includes the following:
@@ -22,9 +22,7 @@ This chart includes the following:
- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure
- The ability to point a DNS entry or URL at your GitLab install
- The `kubectl` CLI installed locally and authenticated for the cluster
-- The Helm Client installed locally
-- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
-- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository)
+- The [Helm client](https://github.com/kubernetes/helm/blob/master/docs/quickstart.md) installed locally on your machine
## Configuring GitLab
@@ -428,7 +426,7 @@ ingress:
## Installing GitLab using the Helm Chart
> You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically restart. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage.
-Ensure the GitLab repo has been added and re-initialize Helm:
+Add the GitLab Helm repository and initialize Helm:
```bash
helm repo add gitlab https://charts.gitlab.io
diff --git a/doc/install/kubernetes/gitlab_omnibus.md b/doc/install/kubernetes/gitlab_omnibus.md
index 05e0a59ffeb..d7fd8613633 100644
--- a/doc/install/kubernetes/gitlab_omnibus.md
+++ b/doc/install/kubernetes/gitlab_omnibus.md
@@ -1,7 +1,8 @@
# GitLab-Omnibus Helm Chart
-> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
-
-> Officially supported cloud providers are Google Container Service and Azure Container Service.
+> **Note:**
+* This Helm chart is in beta, while [additional features](https://gitlab.com/charts/charts.gitlab.io/issues/68) are being worked on.
+* GitLab is working on a [cloud native set of Charts](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) which will eventually replace these.
+* Officially supported cloud providers are Google Container Service and Azure Container Service.
This work is based partially on: https://github.com/lwolf/kubernetes-gitlab/. GitLab would like to thank Sergey Nuzhdin for his work.
@@ -20,62 +21,53 @@ The deployment includes:
A video demonstration of GitLab utilizing this chart [is available](https://about.gitlab.com/handbook/sales/demo/).
-Terms:
-
-- Google Cloud Platform (**GCP**)
-- Google Container Engine (**GKE**)
-- Azure Container Service (**ACS**)
-- Kubernetes (**k8s**)
-
## Prerequisites
-- _At least_ 4 GB of RAM available on your cluster, in chunks of 1 GB. 41GB of storage and 2 CPU are also required.
+- _At least_ 4 GB of RAM available on your cluster. 41GB of storage and 2 CPU are also required.
- Kubernetes 1.4+ with Beta APIs enabled
- [Persistent Volume](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) provisioner support in the underlying infrastructure
-- An [external IP address](#networking-prerequisites)
- A [wildcard DNS entry](#networking-prerequisites), which resolves to the external IP address
- The `kubectl` CLI installed locally and authenticated for the cluster
-- The Helm Client installed locally
-- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
-- The GitLab Helm Repo [added to your Helm Client](index.md#add-the-gitlab-helm-repository)
+- The [Helm client](https://github.com/kubernetes/helm/blob/master/docs/quickstart.md) installed locally on your machine
### Networking Prerequisites
This chart configures a GitLab server and Kubernetes cluster which can support dynamic [Review Apps](https://docs.gitlab.com/ee/ci/review_apps/index.html), as well as services like the integrated [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html) and [Mattermost](https://docs.gitlab.com/omnibus/gitlab-mattermost/).
-To support the GitLab services and dynamic environments, a wildcard DNS entry is required which resolves to the external Load Balancer IP.
+To support the GitLab services and dynamic environments, a wildcard DNS entry is required which resolves to the [Load Balancer](#load-balancer-ip) or [External IP](#external-ip). Configuration of the DNS entry will depend upon the DNS service being used.
+
+#### External IP (Recommended)
To provision an external IP on GCP and Azure, simply request a new address from the Networking section. Ensure that the region matches the region your container cluster is created in. Note, it is important that the IP is not assigned at this point in time. It will be automatically assigned once the Helm chart is installed, and assigned to the Load Balancer.
Now that an external IP address has been allocated, ensure that the wildcard DNS entry you would like to use resolves to this IP. Please consult the documentation for your DNS service for more information on creating DNS records.
+Finally, set the `baseIP` setting to this IP address when [deploying GitLab](#configuring-and-installing-gitlab).
+
+#### Load Balancer IP
+
+If you do not specify a `baseIP`, an ephemeral IP will be assigned to the Load Balancer or Ingress. You can retrieve this IP by running the following command *after* deploying GitLab:
+
+`kubectl get svc -w --namespace nginx-ingress nginx`
+
+The IP address will be displayed in the `EXTERNAL-IP` field, and should be used to configure the Wildcard DNS entry. For more information on creating a wildcard DNS entry, consult the documentation for the DNS server you are using.
+
+For production deployments of GitLab, we strongly recommend using an [External IP](#external-ip).
+
## Configuring and Installing GitLab
For most installations, only two parameters are required:
-- `baseIP`: the desired [external IP address](#networking-prerequisites)
-- `baseDomain`: the [base domain](#networking-prerequisites) with the wildcard host entry resolving to the `baseIP`. For example, `mycompany.io`.
+- `baseDomain`: the [base domain](#networking-prerequisites) of the wildcard host entry. For example, `mycompany.io` if the wild card entry is `*.mycompany.io`.
+- `legoEmail`: Email address to use when requesting new SSL certificates from Let's Encrypt.
Other common configuration options:
+- `baseIP`: the desired [external IP address](#external-ip-recommended)
- `gitlab`: Choose the [desired edition](https://about.gitlab.com/products), either `ee` or `ce`. `ce` is the default.
- `gitlabEELicense`: For Enterprise Edition, the [license](https://docs.gitlab.com/ee/user/admin_area/license.html) can be installed directly via the Chart
-- `provider`: Optimizes the deployment for a cloud provider. The default is `gke` for GCP, with `acs` also supported for Azure.
-- `legoEmail`: Email address to use when requesting new SSL certificates from Let's Encrypt
+- `provider`: Optimizes the deployment for a cloud provider. The default is `gke` for [Google Container Engine](https://cloud.google.com/container-engine/), with `acs` also supported for the [Azure Container Service](https://azure.microsoft.com/en-us/services/container-service/).
For additional configuration options, consult the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-omnibus/values.yaml).
-These settings can either be passed directly on the command line:
-```bash
-helm install --name gitlab --set baseDomain=gitlab.io,baseIP=1.1.1.1,gitlab=ee,gitlabEELicense=$LICENSE,legoEmail=email@gitlab.com gitlab/gitlab-omnibus
-```
-
-or within a YAML file:
-```bash
-helm install --name gitlab -f values.yaml gitlab/gitlab-omnibus
-```
-
-> **Note:**
-If you are using a machine type with support for less than 4 attached disks, like an Azure trial, you should disable dedicated storage for [Postgres and Redis](#persistent-storage).
-
### Choosing a different GitLab release version
The version of GitLab installed is based on the `gitlab` setting (see [section](#choosing-gitlab-edition) above), and
@@ -83,18 +75,16 @@ the value of the corresponding helm setting: `gitlabCEImage` or `gitabEEImage`.
```yaml
gitlab: CE
-gitlabCEImage: gitlab/gitlab-ce:9.1.2-ce.0
-gitlabEEImage: gitlab/gitlab-ee:9.1.2-ee.0
+gitlabCEImage: gitlab/gitlab-ce:9.5.2-ce.0
+gitlabEEImage: gitlab/gitlab-ee:9.5.2-ee.0
```
The different images can be found in the [gitlab-ce](https://hub.docker.com/r/gitlab/gitlab-ce/tags/) and [gitlab-ee](https://hub.docker.com/r/gitlab/gitlab-ee/tags/)
repositories on Docker Hub.
-> **Note:**
-There is no guarantee that other release versions of GitLab, other than what are
-used by default in the chart, will be supported by a chart install.
-
### Persistent storage
+> **Note:**
+If you are using a machine type with support for less than 4 attached disks, like an Azure trial, you should disable dedicated storage for Postgres and Redis.
By default, persistent storage is enabled for GitLab and the charts it depends
on (Redis and PostgreSQL).
@@ -124,9 +114,10 @@ Ingress routing and SSL are automatically configured within this Chart. An NGINX
Let's Encrypt limits a single TLD to five certificate requests within a single week. This means that common DNS wildcard services like [xip.io](http://xip.io) and [nip.io](http://nip.io) are unlikely to work.
## Installing GitLab using the Helm Chart
-> You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically restart. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage.
+> **Note:**
+You may see a temporary error message `SchedulerPredicates failed due to PersistentVolumeClaim is not bound` while storage provisions. Once the storage provisions, the pods will automatically start. This may take a couple minutes depending on your cloud provider. If the error persists, please review the [prerequisites](#prerequisites) to ensure you have enough RAM, CPU, and storage.
-Ensure the GitLab repo has been added and re-initialize Helm:
+Add the GitLab Helm repository and initialize Helm:
```bash
helm repo add gitlab https://charts.gitlab.io
diff --git a/doc/install/kubernetes/gitlab_runner_chart.md b/doc/install/kubernetes/gitlab_runner_chart.md
index 51f94a33109..d31c763ed64 100644
--- a/doc/install/kubernetes/gitlab_runner_chart.md
+++ b/doc/install/kubernetes/gitlab_runner_chart.md
@@ -1,7 +1,6 @@
# GitLab Runner Helm Chart
-> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
-
-> Officially supported cloud providers are Google Container Service and Azure Container Service.
+> **Note:**
+Officially supported cloud providers are Google Container Service and Azure Container Service.
The `gitlab-runner` Helm chart deploys a GitLab Runner instance into your
Kubernetes cluster.
@@ -17,9 +16,7 @@ This chart configures the Runner to:
- Your GitLab Server's API is reachable from the cluster
- Kubernetes 1.4+ with Beta APIs enabled
- The `kubectl` CLI installed locally and authenticated for the cluster
-- The Helm Client installed locally
-- The Helm Server (Tiller) already installed and running in the cluster, by running `helm init`
-- The GitLab Helm Repo added to your Helm Client. See [Adding GitLab Helm Repo](index.md#add-the-gitlab-helm-repository)
+- The [Helm client](https://github.com/kubernetes/helm/blob/master/docs/quickstart.md) installed locally on your machine
## Configuring GitLab Runner using the Helm Chart
@@ -36,6 +33,8 @@ In order for GitLab Runner to function, your config file **must** specify the fo
- `runnerRegistrationToken` - The Registration Token for adding new Runners to the GitLab Server. This must be
retrieved from your GitLab Instance. See the [GitLab Runner Documentation](../../ci/runners/README.md#creating-and-registering-a-runner) for more information.
+Unless you need to specify additional configuration, you are [ready to install](#installing-gitlab-runner-using-the-helm-chart).
+
### Other configuration
The rest of the configuration is [documented in the `values.yaml`](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-runner/values.yaml) in the chart repository.
@@ -115,6 +114,17 @@ runners:
```
+### Controlling maximum Runner concurrency
+
+A single GitLab Runner deployed on Kubernetes is able to execute multiple jobs in parallel by automatically starting additional Runner pods. The [`concurrent` setting](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section) controls the maximum number of pods allowed at a single time, and defaults to `10`.
+
+```yaml
+## Configure the maximum number of concurrent jobs
+## ref: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section
+##
+concurrent: 10
+```
+
### Running Docker-in-Docker containers with GitLab Runners
See [Running Privileged Containers for the Runners](#running-privileged-containers-for-the-runners) for how to enable it,
@@ -190,7 +200,7 @@ certsSecretName:
## Installing GitLab Runner using the Helm Chart
-Ensure the GitLab repo has been added and re-initialize Helm:
+Add the GitLab Helm repository and initialize Helm:
```bash
helm repo add gitlab https://charts.gitlab.io
diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md
index eb98dc06a18..fb6c0c2d263 100644
--- a/doc/install/kubernetes/index.md
+++ b/doc/install/kubernetes/index.md
@@ -1,48 +1,58 @@
# Installing GitLab on Kubernetes
-> These Helm charts are in beta. GitLab is working on a [cloud-native](http://docs.gitlab.com/omnibus/package-information/cloud_native.html) set of [Charts](https://gitlab.com/charts/helm.gitlab.io) which will replace these.
-
> Officially supported cloud providers are Google Container Service and Azure Container Service.
The easiest method to deploy GitLab in [Kubernetes](https://kubernetes.io/) is
-to take advantage of the official GitLab Helm charts. [Helm] is a package
+to take advantage of GitLab's Helm charts. [Helm] is a package
management tool for Kubernetes, allowing apps to be easily managed via their
Charts. A [Chart] is a detailed description of the application including how it
should be deployed, upgraded, and configured.
-The GitLab Helm repository is located at https://charts.gitlab.io.
-You can report any issues related to GitLab's Helm Charts at
+GitLab provides [official Helm Charts](#official-gitlab-helm-charts-recommended) which are the recommended way to run GitLab within Kubernetes.
+
+There are also two other sets of charts:
+* Our [upcoming cloud native Charts](#upcoming-cloud-native-helm-charts), which are in development but will eventually replace the current official charts.
+* [Community contributed charts](#community-contributed-helm-charts). These charts should be considered deprecated, in favor of the official charts.
+
+## Official GitLab Helm Charts
+
+These charts utilize our [GitLab Omnibus Docker images](https://docs.gitlab.com/omnibus/docker/README.html). You can report any issues and feedback related to these charts at
https://gitlab.com/charts/charts.gitlab.io/issues.
-Contributions and improvements are also very welcome.
-## Prerequisites
+### Deploying GitLab on Kubernetes
+> **Note**: This chart will eventually be replaced by the [cloud native charts](#upcoming-cloud-native-helm-charts), which are presently in development.
-To use the charts, the Helm tool must be installed and initialized. The best
-place to start is by reviewing the [Helm Quick Start Guide][helm-quick].
+The best way to deploy GitLab on Kubernetes is to use the [gitlab-omnibus](gitlab_omnibus.md) chart.
-## Add the GitLab Helm repository
+It includes everything needed to run GitLab, including: a [Runner](https://docs.gitlab.com/runner/), [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html#gitlab-container-registry), [automatic SSL](https://github.com/kubernetes/charts/tree/master/stable/kube-lego), and an [Ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx). This chart is in beta while [additional features](https://gitlab.com/charts/charts.gitlab.io/issues/68) are being completed.
-Once Helm has been installed, the GitLab chart repository must be added:
+### Deploying just the GitLab Runner
-```bash
-helm repo add gitlab https://charts.gitlab.io
-```
+To deploy just the [GitLab Runner](https://docs.gitlab.com/runner/), utilize the [gitlab-runner](gitlab_runner_chart.md) chart.
-After adding the repository, Helm must be re-initialized:
+It offers a quick way to configure and deploy the Runner on Kubernetes, regardless of where your GitLab server may be running.
-```bash
-helm init
-```
+### Advanced deployment of GitLab
+> **Note**: This chart will be replaced by the [gitlab-omnibus](gitlab_omnibus.md) chart, once it supports [additional configuration options](https://gitlab.com/charts/charts.gitlab.io/issues/68).
-## Using the GitLab Helm Charts
+If advanced configuration of GitLab is required, the beta [gitlab](gitlab_chart.md) chart can be used which deploys the GitLab service along with optional Postgres and Redis. It offers extensive configuration, but requires deep knowledge of Kubernetes and Helm to use.
-GitLab makes available three Helm Charts.
+For most deployments we recommend using our [gitlab-omnibus](gitlab_omnibus.md) chart.
-- [gitlab-omnibus](gitlab_omnibus.md): **Recommended** and the easiest way to get started. Includes everything needed to run GitLab, including: a [Runner](https://docs.gitlab.com/runner/), [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html#gitlab-container-registry), [automatic SSL](https://github.com/kubernetes/charts/tree/master/stable/kube-lego), and an [Ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx).
-- [gitlab](gitlab_chart.md): Just the GitLab service, with optional Postgres and Redis.
-- [gitlab-runner](gitlab_runner_chart.md): GitLab Runner, to process CI jobs.
+## Upcoming Cloud Native Helm Charts
-We are also working on a new set of [cloud native Charts](https://gitlab.com/charts/helm.gitlab.io) which will eventually replace these.
+GitLab is working towards a building a [cloud native deployment method](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). A key part of this effort is to isolate each service into it's [own Docker container and Helm chart](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420), rather than utilizing the all-in-one container image of the [current charts](#official-gitlab-helm-charts-recommended).
+
+By offering individual containers and charts, we will be able to provide a number of benefits:
+* Easier horizontal scaling of each service
+* Smaller more efficient images
+* Potential for rolling updates and canaries within a service
+* and plenty more.
+
+This is a large project and will be worked on over the span of multiple releases. For the most up to date status and release information, please see our [tracking issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420).
+
+## Community Contributed Helm Charts
+
+The community has also [contributed GitLab charts](https://github.com/kubernetes/charts/tree/master/stable/gitlab-ce) to the [Helm Stable Repository](https://github.com/kubernetes/charts#repository-structure). These charts should be considered [deprecated](https://github.com/kubernetes/charts/issues/1138) in favor of the [official Charts](#official-gitlab-helm-charts-recommended).
[chart]: https://github.com/kubernetes/charts
-[helm-quick]: https://github.com/kubernetes/helm/blob/master/docs/quickstart.md
[helm]: https://github.com/kubernetes/helm/blob/master/README.md
diff --git a/doc/security/README.md b/doc/security/README.md
index 38706e48ec5..0fea6be8b55 100644
--- a/doc/security/README.md
+++ b/doc/security/README.md
@@ -1,6 +1,7 @@
# Security
- [Password length limits](password_length_limits.md)
+- [Restrict SSH key technologies and minimum length](ssh_keys_restrictions.md)
- [Rack attack](rack_attack.md)
- [Webhooks and insecure internal web services](webhooks.md)
- [Information exclusivity](information_exclusivity.md)
diff --git a/doc/security/img/ssh_keys_restrictions_settings.png b/doc/security/img/ssh_keys_restrictions_settings.png
new file mode 100644
index 00000000000..2e918fd4b3f
Binary files /dev/null and b/doc/security/img/ssh_keys_restrictions_settings.png differ
diff --git a/doc/security/ssh_keys_restrictions.md b/doc/security/ssh_keys_restrictions.md
new file mode 100644
index 00000000000..213fa5bfef5
--- /dev/null
+++ b/doc/security/ssh_keys_restrictions.md
@@ -0,0 +1,19 @@
+# Restrict allowed SSH key technologies and minimum length
+
+`ssh-keygen` allows users to create RSA keys with as few as 768 bits, which
+falls well below recommendations from certain standards groups (such as the US
+NIST). Some organizations deploying GitLab will need to enforce minimum key
+strength, either to satisfy internal security policy or for regulatory
+compliance.
+
+Similarly, certain standards groups recommend using RSA, ECDSA, or ED25519 over
+the older DSA, and administrators may need to limit the allowed SSH key
+algorithms.
+
+GitLab allows you to restrict the allowed SSH key technology as well as specify
+the minimum key length for each technology.
+
+In the Admin area under **Settings** (`/admin/application_settings`), look for
+the "Visibility and Access Controls" area:
+
+![SSH keys restriction admin settings](img/ssh_keys_restrictions_settings.png)
diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md
index 2a8728ed96e..67e856a97cd 100644
--- a/doc/user/project/import/index.md
+++ b/doc/user/project/import/index.md
@@ -7,6 +7,7 @@
1. [From Gitea](gitea.md)
1. [From SVN](svn.md)
1. [From ClearCase](clearcase.md)
+1. [From Perforce](perforce.md)
In addition to the specific migration documentation above, you can import any
Git repository via HTTP from the New Project page. Be aware that if the
diff --git a/doc/user/project/import/perforce.md b/doc/user/project/import/perforce.md
new file mode 100644
index 00000000000..aa7508e1e8e
--- /dev/null
+++ b/doc/user/project/import/perforce.md
@@ -0,0 +1,50 @@
+# Migrating from Perforce Helix
+
+[Perforce Helix](https://www.perforce.com/) provides a set of tools which also
+include a centralized, proprietary version control system similar to Git.
+
+## Perforce vs Git
+
+The following list illustrates the main differences between Perforce Helix and
+Git:
+
+1. In general the biggest difference is that Perforce branching is heavyweight
+ compared to Git's lightweight branching. When you create a branch in Perforce,
+ it creates an integration record in their proprietary database for every file
+ in the branch, regardless how many were actually changed. Whereas Git was
+ implemented with a different architecture so that a single SHA acts as a pointer
+ to the state of the whole repo after the changes, making it very easy to branch.
+ This is what made feature branching workflows so easy to adopt with Git.
+1. Also, context switching between branches is much easier in Git. If your manager
+ said 'You need to stop work on that new feature and fix this security
+ vulnerability' you can do so very easily in Git.
+1. Having a complete copy of the project and its history on your local machine
+ means every transaction is superfast and Git provides that. You can branch/merge
+ and experiment in isolation, then clean up your mess before sharing your new
+ cool stuff with everyone.
+1. Git also made code review simple because you could share your changes without
+ merging them to master, whereas Perforce had to implement a Shelving feature on
+ the server so others could review changes before merging.
+
+## Why migrate
+
+Perforce Helix can be difficult to manage both from a user and an admin
+perspective. Migrating to Git/GitLab there is:
+
+- **No licensing costs**, Git is GPL while Perforce Helix is proprietary.
+- **Shorter learning curve**, Git has a big community and a vast number of
+ tutorials to get you started.
+- **Integration with modern tools**, migrating to Git and GitLab you can have
+ an open source end-to-end software development platform with built-in version
+ control, issue tracking, code review, CI/CD, and more.
+
+## How to migrate
+
+Git includes a built-in mechanism (`git p4`) to pull code from Perforce and to
+submit back from Git to Perforce.
+
+Here's a few links to get you started:
+
+- [git-p4 manual page](https://www.kernel.org/pub/software/scm/git/docs/git-p4.html)
+- [git-p4 example usage](https://git.wiki.kernel.org/index.php/Git-p4_Usage)
+- [Git book migration guide](https://git-scm.com/book/en/v2/Git-and-Other-Systems-Migrating-to-Git#_perforce_import)
diff --git a/doc/user/project/issues/img/sidebar_move_issue.png b/doc/user/project/issues/img/sidebar_move_issue.png
new file mode 100644
index 00000000000..111f7861364
Binary files /dev/null and b/doc/user/project/issues/img/sidebar_move_issue.png differ
diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md
index 20901e01f6e..0f187946a4a 100644
--- a/doc/user/project/issues/index.md
+++ b/doc/user/project/issues/index.md
@@ -86,6 +86,10 @@ Read through the [documentation on creating issues](create_new_issue.md).
Learn distinct ways to [close issues](closing_issues.md) in GitLab.
+## Moving issues
+
+Read through the [documentation on moving issues](moving_issues.md).
+
## Create a merge request from an issue
Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request).
diff --git a/doc/user/project/issues/moving_issues.md b/doc/user/project/issues/moving_issues.md
new file mode 100644
index 00000000000..211a651b89e
--- /dev/null
+++ b/doc/user/project/issues/moving_issues.md
@@ -0,0 +1,10 @@
+# Moving Issues
+
+Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
+
+Moving an issue will close it and duplicate it on the specified project.
+There will also be a system note added to both issues indicating where it came from or went to.
+
+You can move an issue with the "Move issue" button at the bottom of the right-sidebar when viewing the issue.
+
+![move issue - button](img/sidebar_move_issue.png)
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index ce4dd4e99d5..6a5d2d40927 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -38,3 +38,4 @@ do.
| `/award :emoji:` | Toggle award for :emoji: |
| `/board_move ~column` | Move issue to column on the board |
| `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue |
+| `/move path/to/project` | Moves issue to another project |
diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb
index f1288c15084..8fb2ac34c32 100644
--- a/features/steps/explore/projects.rb
+++ b/features/steps/explore/projects.rb
@@ -36,13 +36,13 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps
end
step 'I should see project "Community" home page' do
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Community'
end
end
step 'I should see project "Internal" home page' do
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Internal'
end
end
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
index f6559b6be2f..20edcf75ff1 100644
--- a/features/steps/group/milestones.rb
+++ b/features/steps/group/milestones.rb
@@ -47,7 +47,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
end
step 'I click new milestone button' do
- click_link "New milestone"
+ page.within('.breadcrumbs') do
+ click_link "New milestone"
+ end
end
step 'I press create mileston button' do
diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb
index 5cd9bd38c9d..1a18f1d7065 100644
--- a/features/steps/project/active_tab.rb
+++ b/features/steps/project/active_tab.rb
@@ -22,25 +22,25 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Edit Project"' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Edit Project')
end
end
step 'I click the "Integrations" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Integrations')
end
end
step 'I click the "Repository" tab' do
- page.within '.sub-nav' do
+ page.within '.sidebar-top-level-items > .active' do
click_link('Repository')
end
end
step 'I click the "Activity" tab' do
- page.within '.sub-nav' do
+ page.within '.sidebar-top-level-items > .active' do
click_link('Activity')
end
end
@@ -72,7 +72,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Branches" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
click_link('Branches')
end
end
@@ -82,7 +82,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
end
step 'I click the "Charts" tab' do
- page.within '.sub-nav' do
+ page.within('.sidebar-top-level-items > .active') do
click_link('Charts')
end
end
@@ -102,13 +102,13 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps
# Sub Tabs: Issues
step 'I click the "Milestones" sub tab' do
- page.within('.sub-nav') do
+ page.within('.nav-sidebar') do
click_link('Milestones')
end
end
step 'I click the "Labels" sub tab' do
- page.within('.sub-nav') do
+ page.within('.nav-sidebar') do
click_link('Labels')
end
end
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
index dd4dff7f7a9..3b8d9af96c1 100644
--- a/features/steps/project/fork.rb
+++ b/features/steps/project/fork.rb
@@ -36,7 +36,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I goto the Merge Requests page' do
- page.within '.layout-nav' do
+ page.within '.nav-sidebar' do
click_link "Merge Requests"
end
end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 2deef9036d3..f7dd4fc21e9 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -62,7 +62,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I click link "New issue"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue')
end
end
@@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
author: project.users.first,
description: "# Description header"
)
+ wait_for_requests
end
step 'project "Shop" have "Tweet control" open issue' do
diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb
index fe94eb03acd..307902a887e 100644
--- a/features/steps/project/issues/milestones.rb
+++ b/features/steps/project/issues/milestones.rb
@@ -16,7 +16,9 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
end
step 'I click link "New Milestone"' do
- click_link "New milestone"
+ page.within('.breadcrumbs') do
+ click_link "New milestone"
+ end
end
step 'I submit new milestone "v2.3"' do
diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb
index 7254fbc2e4e..3c3bffd7223 100644
--- a/features/steps/project/merge_requests.rb
+++ b/features/steps/project/merge_requests.rb
@@ -14,7 +14,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps
end
step 'I click link "New Merge Request"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
end
end
diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb
index bb69c0d6e99..124a132d688 100644
--- a/features/steps/project/pages.rb
+++ b/features/steps/project/pages.rb
@@ -23,13 +23,13 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps
end
step 'I should see the "Pages" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
expect(page).to have_link('Pages')
end
end
step 'I should not see the "Pages" tab' do
- page.within '.sub-nav' do
+ page.within '.nav-sidebar' do
expect(page).not_to have_link('Pages')
end
end
@@ -37,7 +37,8 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps
step 'pages are deployed' do
pipeline = @project.pipelines.create(ref: 'HEAD',
sha: @project.commit('HEAD').sha,
- source: :push)
+ source: :push,
+ protected: false)
build = build(:ci_build,
project: @project,
diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb
index a7d3352b8c4..b2d08515e77 100644
--- a/features/steps/project/project_milestone.rb
+++ b/features/steps/project/project_milestone.rb
@@ -55,7 +55,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps
end
step 'I click link "Labels"' do
- page.within('.layout-nav .nav-links') do
+ page.within('.nav-sidebar') do
page.find(:xpath, "//a[@href='#tab-labels']").click
end
end
diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb
index 53a2463af53..100e674abed 100644
--- a/features/steps/project/redirects.rb
+++ b/features/steps/project/redirects.rb
@@ -18,7 +18,7 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
step 'I should see project "Community" home page' do
Gitlab.config.gitlab.should_receive(:host).and_return("www.example.com")
- page.within '.navbar-gitlab .title' do
+ page.within '.breadcrumbs .title' do
expect(page).to have_content 'Community'
end
end
diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb
index b0407d3f07d..96b7ba7549f 100644
--- a/features/steps/project/snippets.rb
+++ b/features/steps/project/snippets.rb
@@ -23,7 +23,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps
end
step 'I click link "New snippet"' do
- page.within '#content-body' do
+ page.within '.breadcrumbs' do
first(:link, "New snippet").click
end
end
diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb
index af5db05e9e8..2bb21a798aa 100644
--- a/features/steps/shared/active_tab.rb
+++ b/features/steps/shared/active_tab.rb
@@ -7,11 +7,11 @@ module SharedActiveTab
end
def ensure_active_main_tab(content)
- expect(find('.layout-nav li.active')).to have_content(content)
+ expect(find('.sidebar-top-level-items > li.active')).to have_content(content)
end
def ensure_active_sub_tab(content)
- expect(find('.sub-nav li.active')).to have_content(content)
+ expect(find('.sidebar-sub-level-items > li.active')).to have_content(content)
end
def ensure_active_sub_nav(content)
@@ -19,11 +19,11 @@ module SharedActiveTab
end
step 'no other main tabs should be active' do
- expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1)
+ expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1)
end
step 'no other sub tabs should be active' do
- expect(page).to have_selector('.sub-nav li.active', count: 1)
+ expect(page).to have_selector('.sidebar-sub-level-items > li.active', count: 1)
end
step 'no other sub navs should be active' do
diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb
index de119f2c6c0..03bc7e798e0 100644
--- a/features/steps/shared/group.rb
+++ b/features/steps/shared/group.rb
@@ -36,14 +36,12 @@ module SharedGroup
protected
def is_member_of(username, groupname, role)
- @project_count ||= 0
user = User.find_by(name: username) || create(:user, name: username)
group = Group.find_by(name: groupname) || create(:group, name: groupname)
group.add_user(user, role)
- project ||= create(:project, :repository, namespace: group, path: "project#{@project_count}")
+ project ||= create(:project, :repository, namespace: group)
create(:closed_issue_event, project: project)
project.team << [user, :master]
- @project_count += 1
end
def owned_group
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 492da38355c..0cd7b506a95 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -137,7 +137,7 @@ module SharedNote
step 'The comment with the header should not have an ID' do
page.within(".note-body > .note-text") do
- expect(page).to have_content("Comment with a header")
+ expect(page).to have_content("Comment with a header")
expect(page).not_to have_css("#comment-with-a-header")
end
end
@@ -150,15 +150,20 @@ module SharedNote
note.find('.js-note-edit').click
end
+ page.find('.current-note-edit-form textarea')
+
page.within(".current-note-edit-form") do
fill_in 'note[note]', with: '+1 Awesome!'
click_button 'Save comment'
end
+ wait_for_requests
end
step 'I should see +1 in the description' do
page.within(".note") do
expect(page).to have_content("+1 Awesome!")
end
+
+ wait_for_requests
end
end
diff --git a/features/steps/shared/project_tab.rb b/features/steps/shared/project_tab.rb
index 901f7f76ee9..5a516ee33bc 100644
--- a/features/steps/shared/project_tab.rb
+++ b/features/steps/shared/project_tab.rb
@@ -5,7 +5,7 @@ module SharedProjectTab
include SharedActiveTab
step 'the active main tab should be Project' do
- ensure_active_main_tab('Project')
+ ensure_active_main_tab('Overview')
end
step 'the active main tab should be Repository' do
@@ -53,7 +53,7 @@ module SharedProjectTab
end
step 'the active sub tab should be Home' do
- ensure_active_sub_tab('Home')
+ ensure_active_sub_tab('Details')
end
step 'the active sub tab should be Activity' do
diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb
index 4fa9b2b2494..374b611f55e 100644
--- a/lib/api/access_requests.rb
+++ b/lib/api/access_requests.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Gets a list of access requests for a #{source_type}." do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::AccessRequester
diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb
index 8e3851640da..c3d93996816 100644
--- a/lib/api/award_emoji.rb
+++ b/lib/api/award_emoji.rb
@@ -12,7 +12,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
AWARDABLES.each do |awardable_params|
awardable_string = awardable_params[:type].pluralize
awardable_id_string = "#{awardable_params[:type]}_#{awardable_params[:find_by]}"
diff --git a/lib/api/boards.rb b/lib/api/boards.rb
index 0d11c5fc971..366b0dc9a6f 100644
--- a/lib/api/boards.rb
+++ b/lib/api/boards.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all project boards' do
detail 'This feature was introduced in 8.13'
success Entities::Board
diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb
index 485b680cd5f..6314ea63197 100644
--- a/lib/api/commit_statuses.rb
+++ b/lib/api/commit_statuses.rb
@@ -5,7 +5,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include PaginationParams
before { authenticate! }
@@ -74,7 +74,8 @@ module API
source: :external,
sha: commit.sha,
ref: ref,
- user: current_user)
+ user: current_user,
+ protected: @project.protected_for?(ref))
end
status = GenericCommitStatus.running_or_pending.find_or_initialize_by(
@@ -82,7 +83,8 @@ module API
pipeline: pipeline,
name: name,
ref: ref,
- user: current_user
+ user: current_user,
+ protected: @project.protected_for?(ref)
)
optional_attributes =
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index f405c341398..281269b1190 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -17,7 +17,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of the project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authorize_admin_project }
desc "Get a specific project's deploy keys" do
diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb
index 46b936897f6..1efee9a1324 100644
--- a/lib/api/deployments.rb
+++ b/lib/api/deployments.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all deployments of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::Deployment
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 803b48dd88a..f13f2d723bb 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1,11 +1,11 @@
module API
module Entities
class UserSafe < Grape::Entity
- expose :name, :username
+ expose :id, :name, :username
end
class UserBasic < UserSafe
- expose :id, :state
+ expose :state
expose :avatar_url do |user, options|
user.avatar_url(only_path: false)
end
@@ -775,6 +775,7 @@ module API
expose :tag_list
expose :run_untagged
expose :locked
+ expose :access_level
expose :version, :revision, :platform, :architecture
expose :contacted_at
expose :token, if: lambda { |runner, options| options[:current_user].admin? || !runner.is_shared? }
diff --git a/lib/api/environments.rb b/lib/api/environments.rb
index e33269f9483..5c63ec028d9 100644
--- a/lib/api/environments.rb
+++ b/lib/api/environments.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all environments of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::Environment
diff --git a/lib/api/events.rb b/lib/api/events.rb
index dabdf579119..b0713ff1d54 100644
--- a/lib/api/events.rb
+++ b/lib/api/events.rb
@@ -67,7 +67,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "List a Project's visible events" do
success Entities::Event
end
diff --git a/lib/api/files.rb b/lib/api/files.rb
index e2ac7142bc4..1598d3c00b8 100644
--- a/lib/api/files.rb
+++ b/lib/api/files.rb
@@ -1,5 +1,7 @@
module API
class Files < Grape::API
+ FILE_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(file_path: API::NO_SLASH_URL_PART_REGEX)
+
# Prevents returning plain/text responses for files with .txt extension
after_validation { content_type "application/json" }
@@ -58,13 +60,13 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do
desc 'Get raw file contents from the repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
requires :ref, type: String, desc: 'The name of branch, tag commit'
end
- get ":id/repository/files/:file_path/raw" do
+ get ":id/repository/files/:file_path/raw", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
send_git_blob @repo, @blob
@@ -75,7 +77,7 @@ module API
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
requires :ref, type: String, desc: 'The name of branch, tag or commit'
end
- get ":id/repository/files/:file_path", requirements: { file_path: /.+/ } do
+ get ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
{
@@ -95,7 +97,7 @@ module API
params do
use :extended_file_params
end
- post ":id/repository/files/:file_path", requirements: { file_path: /.+/ } do
+ post ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
file_params = declared_params(include_missing: false)
@@ -113,7 +115,7 @@ module API
params do
use :extended_file_params
end
- put ":id/repository/files/:file_path", requirements: { file_path: /.+/ } do
+ put ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
file_params = declared_params(include_missing: false)
@@ -137,7 +139,7 @@ module API
params do
use :simple_file_params
end
- delete ":id/repository/files/:file_path", requirements: { file_path: /.+/ } do
+ delete ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
authorize! :push_code, user_project
file_params = declared_params(include_missing: false)
diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb
index b85eb59dc0a..93fa0b95857 100644
--- a/lib/api/group_milestones.rb
+++ b/lib/api/group_milestones.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of group milestones' do
success Entities::Milestone
end
diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb
index 25152f30998..92800ce6450 100644
--- a/lib/api/group_variables.rb
+++ b/lib/api/group_variables.rb
@@ -9,7 +9,7 @@ module API
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get group-level variables' do
success Entities::Variable
end
diff --git a/lib/api/groups.rb b/lib/api/groups.rb
index 8c494a54329..31a918eda60 100644
--- a/lib/api/groups.rb
+++ b/lib/api/groups.rb
@@ -89,7 +89,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Update a group. Available only for users who can administrate groups.' do
success Entities::Group
end
diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb
index ecb79317093..f57ff0f2632 100644
--- a/lib/api/helpers/internal_helpers.rb
+++ b/lib/api/helpers/internal_helpers.rb
@@ -42,6 +42,10 @@ module API
::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action])
end
+ def merge_request_urls
+ ::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
+ end
+
private
def set_project
diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb
index f8645e364ce..282af32ca94 100644
--- a/lib/api/helpers/runner.rb
+++ b/lib/api/helpers/runner.rb
@@ -1,6 +1,8 @@
module API
module Helpers
module Runner
+ include Gitlab::CurrentSettings
+
JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'.freeze
JOB_TOKEN_PARAM = :token
UPDATE_RUNNER_EVERY = 10 * 60
diff --git a/lib/api/internal.rb b/lib/api/internal.rb
index 8b007869dc3..622bd9650e4 100644
--- a/lib/api/internal.rb
+++ b/lib/api/internal.rb
@@ -68,7 +68,7 @@ module API
end
get "/merge_request_urls" do
- ::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
+ merge_request_urls
end
#
@@ -155,6 +155,21 @@ module API
# render_api_error!(e, 500)
# end
end
+
+ post '/post_receive' do
+ status 200
+
+ PostReceive.perform_async(params[:gl_repository], params[:identifier],
+ params[:changes])
+ broadcast_message = BroadcastMessage.current&.last&.message
+ reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease
+
+ {
+ merge_request_urls: merge_request_urls,
+ broadcast_message: broadcast_message,
+ reference_counter_decreased: reference_counter_decreased
+ }
+ end
end
end
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 0297023226f..e4c2c390853 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -81,7 +81,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a group'
end
- resource :groups, requirements: { id: %r{[^/]+} } do
+ resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of group issues' do
success Entities::IssueBasic
end
@@ -108,7 +108,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include TimeTrackingEndpoints
desc 'Get a list of project issues' do
diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb
index a40018b214e..5bab96398fd 100644
--- a/lib/api/jobs.rb
+++ b/lib/api/jobs.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
params :optional_scope do
optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show',
diff --git a/lib/api/labels.rb b/lib/api/labels.rb
index c0cf618ee8d..e41a1720ac1 100644
--- a/lib/api/labels.rb
+++ b/lib/api/labels.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all labels of the project' do
success Entities::Label
end
diff --git a/lib/api/members.rb b/lib/api/members.rb
index a5d3d7f25a0..22e4bdead41 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Gets a list of group or project members viewable by the authenticated user.' do
success Entities::Member
end
diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb
index 4b79eac2b8b..c3affcc6c6b 100644
--- a/lib/api/merge_request_diffs.rb
+++ b/lib/api/merge_request_diffs.rb
@@ -8,7 +8,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of merge request diff versions' do
detail 'This feature was introduced in GitLab 8.12.'
success Entities::MergeRequestDiff
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index eec8d9357aa..7bcbf9f20ff 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -72,7 +72,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
include TimeTrackingEndpoints
helpers do
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index e116448c15b..d6e7203adaf 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
NOTEABLE_TYPES.each do |noteable_type|
noteables_str = noteable_type.to_s.underscore.pluralize
diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb
index 5d113c94b22..bcc0833aa5c 100644
--- a/lib/api/notification_settings.rb
+++ b/lib/api/notification_settings.rb
@@ -54,7 +54,7 @@ module API
params do
requires :id, type: String, desc: "The #{source_type} ID"
end
- resource source_type.pluralize, requirements: { id: %r{[^/]+} } do
+ resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Get #{source_type} level notification level settings, defaults to Global" do
detail 'This feature was introduced in GitLab 8.12'
success Entities::NotificationSetting
diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb
index e3123ef4e2d..ef01cbc7875 100644
--- a/lib/api/pipeline_schedules.rb
+++ b/lib/api/pipeline_schedules.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all pipeline schedules' do
success Entities::PipelineSchedule
end
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
index e505cae3992..74b3376a1f3 100644
--- a/lib/api/pipelines.rb
+++ b/lib/api/pipelines.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The project ID'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get all Pipelines of the project' do
detail 'This feature was introduced in GitLab 8.11.'
success Entities::PipelineBasic
diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb
index 5b457bbe639..86066e2b58f 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -24,7 +24,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get project hooks' do
success Entities::ProjectHook
end
diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb
index 451998c726a..0cb209a02d0 100644
--- a/lib/api/project_milestones.rb
+++ b/lib/api/project_milestones.rb
@@ -10,7 +10,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a list of project milestones' do
success Entities::Milestone
end
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index 704e8c6718d..2ccda1c1aa1 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -7,7 +7,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
diff --git a/lib/api/projects.rb b/lib/api/projects.rb
index 78d900984ac..4845242a173 100644
--- a/lib/api/projects.rb
+++ b/lib/api/projects.rb
@@ -95,7 +95,7 @@ module API
end
end
- resource :users, requirements: { user_id: %r{[^/]+} } do
+ resource :users, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a user projects' do
success Entities::BasicProjectDetails
end
@@ -183,7 +183,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get a single project' do
success Entities::ProjectWithAccess
end
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 14d2bff9cb5..2255fb1b70d 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -9,7 +9,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
helpers do
def handle_project_member_errors(errors)
if errors[:project_access].any?
diff --git a/lib/api/runners.rb b/lib/api/runners.rb
index 68c2120cc15..d3559ef71be 100644
--- a/lib/api/runners.rb
+++ b/lib/api/runners.rb
@@ -55,7 +55,9 @@ module API
optional :tag_list, type: Array[String], desc: 'The list of tags for a runner'
optional :run_untagged, type: Boolean, desc: 'Flag indicating the runner can execute untagged jobs'
optional :locked, type: Boolean, desc: 'Flag indicating the runner is locked'
- at_least_one_of :description, :active, :tag_list, :run_untagged, :locked
+ optional :access_level, type: String, values: Ci::Runner.access_levels.keys,
+ desc: 'The access_level of the runner'
+ at_least_one_of :description, :active, :tag_list, :run_untagged, :locked, :access_level
end
put ':id' do
runner = get_runner(params.delete(:id))
@@ -87,7 +89,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authorize_admin_project }
desc 'Get runners available for project' do
diff --git a/lib/api/services.rb b/lib/api/services.rb
index ff9ddd44439..2cbd0517dc3 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -601,7 +601,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
before { authenticate! }
before { authorize_admin_project }
@@ -691,7 +691,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Trigger a slash command for #{service_slug}" do
detail 'Added in GitLab 8.13'
end
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 667ba468ce6..851b226e9e5 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -122,6 +122,13 @@ module API
optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.'
optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.'
+ ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
+ optional :"#{type}_key_restriction",
+ type: Integer,
+ values: KeyRestrictionValidator.supported_key_restrictions(type),
+ desc: "Restrictions on the complexity of uploaded #{type.upcase} keys. A value of #{ApplicationSetting::FORBIDDEN_KEY_VALUE} disables all #{type.upcase} keys."
+ end
+
optional(*::ApplicationSettingsHelper.visible_attributes)
at_least_one_of(*::ApplicationSettingsHelper.visible_attributes)
end
diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb
index 91567909998..b3e1e23031a 100644
--- a/lib/api/subscriptions.rb
+++ b/lib/api/subscriptions.rb
@@ -12,7 +12,7 @@ module API
requires :id, type: String, desc: 'The ID of a project'
requires :subscribable_id, type: String, desc: 'The ID of a resource'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
subscribable_types.each do |type, finder|
type_singularized = type.singularize
entity_class = Entities.const_get(type_singularized.camelcase)
diff --git a/lib/api/todos.rb b/lib/api/todos.rb
index 55191169dd4..ffccfebe752 100644
--- a/lib/api/todos.rb
+++ b/lib/api/todos.rb
@@ -12,7 +12,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
ISSUABLE_TYPES.each do |type, finder|
type_id_str = "#{type.singularize}_iid".to_sym
diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb
index c9fee7e5193..dd6801664b1 100644
--- a/lib/api/triggers.rb
+++ b/lib/api/triggers.rb
@@ -5,7 +5,7 @@ module API
params do
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Trigger a GitLab project pipeline' do
success Entities::Pipeline
end
diff --git a/lib/api/variables.rb b/lib/api/variables.rb
index da71787abab..d08876ae1b9 100644
--- a/lib/api/variables.rb
+++ b/lib/api/variables.rb
@@ -9,7 +9,7 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
- resource :projects, requirements: { id: %r{[^/]+} } do
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc 'Get project variables' do
success Entities::Variable
end
diff --git a/lib/email_template_interceptor.rb b/lib/email_template_interceptor.rb
index 63f9f8d7a5a..f2bf3d0fb2b 100644
--- a/lib/email_template_interceptor.rb
+++ b/lib/email_template_interceptor.rb
@@ -1,6 +1,6 @@
# Read about interceptors in http://guides.rubyonrails.org/action_mailer_basics.html#intercepting-emails
class EmailTemplateInterceptor
- include Gitlab::CurrentSettings
+ extend Gitlab::CurrentSettings
def self.delivering_email(message)
# Remove HTML part if HTML emails are disabled.
diff --git a/lib/github/import.rb b/lib/github/import.rb
index 7b848081e85..9354e142d3d 100644
--- a/lib/github/import.rb
+++ b/lib/github/import.rb
@@ -226,52 +226,54 @@ module Github
while url
response = Github::Client.new(options).get(url, state: :all, sort: :created, direction: :asc)
- response.body.each do |raw|
- representation = Github::Representation::Issue.new(raw, options)
-
- begin
- # Every pull request is an issue, but not every issue
- # is a pull request. For this reason, "shared" actions
- # for both features, like manipulating assignees, labels
- # and milestones, are provided within the Issues API.
- if representation.pull_request?
- next unless representation.has_labels?
-
- merge_request = MergeRequest.find_by!(target_project_id: project.id, iid: representation.iid)
- merge_request.update_attribute(:label_ids, label_ids(representation.labels))
- else
- next if Issue.where(iid: representation.iid, project_id: project.id).exists?
-
- author_id = user_id(representation.author, project.creator_id)
- issue = Issue.new
- issue.iid = representation.iid
- issue.project_id = project.id
- issue.title = representation.title
- issue.description = format_description(representation.description, representation.author)
- issue.state = representation.state
- issue.label_ids = label_ids(representation.labels)
- issue.milestone_id = milestone_id(representation.milestone)
- issue.author_id = author_id
- issue.assignee_ids = [user_id(representation.assignee)]
- issue.created_at = representation.created_at
- issue.updated_at = representation.updated_at
- issue.save!(validate: false)
-
- # Fetch comments
- if representation.has_comments?
- comments_url = "/repos/#{repo}/issues/#{issue.iid}/comments"
- fetch_comments(issue, :comment, comments_url)
- end
- end
- rescue => e
- error(:issue, representation.url, e.message)
- end
- end
+ response.body.each { |raw| populate_issue(raw) }
url = response.rels[:next]
end
end
+ def populate_issue(raw)
+ representation = Github::Representation::Issue.new(raw, options)
+
+ begin
+ # Every pull request is an issue, but not every issue
+ # is a pull request. For this reason, "shared" actions
+ # for both features, like manipulating assignees, labels
+ # and milestones, are provided within the Issues API.
+ if representation.pull_request?
+ return unless representation.has_labels?
+
+ merge_request = MergeRequest.find_by!(target_project_id: project.id, iid: representation.iid)
+ merge_request.update_attribute(:label_ids, label_ids(representation.labels))
+ else
+ return if Issue.where(iid: representation.iid, project_id: project.id).exists?
+
+ author_id = user_id(representation.author, project.creator_id)
+ issue = Issue.new
+ issue.iid = representation.iid
+ issue.project_id = project.id
+ issue.title = representation.title
+ issue.description = format_description(representation.description, representation.author)
+ issue.state = representation.state
+ issue.label_ids = label_ids(representation.labels)
+ issue.milestone_id = milestone_id(representation.milestone)
+ issue.author_id = author_id
+ issue.assignee_ids = [user_id(representation.assignee)]
+ issue.created_at = representation.created_at
+ issue.updated_at = representation.updated_at
+ issue.save!(validate: false)
+
+ # Fetch comments
+ if representation.has_comments?
+ comments_url = "/repos/#{repo}/issues/#{issue.iid}/comments"
+ fetch_comments(issue, :comment, comments_url)
+ end
+ end
+ rescue => e
+ error(:issue, representation.url, e.message)
+ end
+ end
+
def fetch_comments(noteable, type, url, klass = Note)
while url
comments = Github::Client.new(options).get(url)
diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb
index 3d41ac76406..cead1c7eacd 100644
--- a/lib/gitlab/asciidoc.rb
+++ b/lib/gitlab/asciidoc.rb
@@ -6,6 +6,8 @@ module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
# the resulting HTML through HTML pipeline filters.
module Asciidoc
+ extend Gitlab::CurrentSettings
+
DEFAULT_ADOC_ATTRS = [
'showtitle', 'idprefix=user-content-', 'idseparator=-', 'env=gitlab',
'env-gitlab', 'source-highlighter=html-pipeline', 'icons=font'
diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb
index 8cb4060cd97..3fd81759d25 100644
--- a/lib/gitlab/auth.rb
+++ b/lib/gitlab/auth.rb
@@ -19,6 +19,8 @@ module Gitlab
OPTIONAL_SCOPES = (AVAILABLE_SCOPES + OPENID_SCOPES - DEFAULT_SCOPES).freeze
class << self
+ include Gitlab::CurrentSettings
+
def find_for_git_client(login, password, project:, ip:)
raise "Must provide an IP for rate limiting" if ip.nil?
@@ -48,10 +50,6 @@ module Gitlab
# Avoid resource intensive login checks if password is not provided
return unless password.present?
- # Nothing to do here if internal auth is disabled and LDAP is
- # not configured
- return unless current_application_settings.password_authentication_enabled? || Gitlab::LDAP::Config.enabled?
-
Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login)
diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb
index f81f9347b4d..e19aae35a81 100644
--- a/lib/gitlab/ci/stage/seed.rb
+++ b/lib/gitlab/ci/stage/seed.rb
@@ -28,7 +28,8 @@ module Gitlab
attributes.merge(project: project,
ref: pipeline.ref,
tag: pipeline.tag,
- trigger_request: trigger)
+ trigger_request: trigger,
+ protected: protected_ref?)
end
end
@@ -43,6 +44,12 @@ module Gitlab
end
end
end
+
+ private
+
+ def protected_ref?
+ @protected_ref ||= project.protected_for?(pipeline.ref)
+ end
end
end
end
diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb
index 7fa02f3d7b3..642f0944354 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -1,5 +1,7 @@
module Gitlab
module CurrentSettings
+ extend self
+
def current_application_settings
if RequestStore.active?
RequestStore.fetch(:current_application_settings) { ensure_application_settings! }
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index fb6504bdea0..8709f82bcc4 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -47,6 +47,9 @@ module Gitlab
# Directory name of repo
attr_reader :name
+ # Relative path of repo
+ attr_reader :relative_path
+
# Rugged repo object
attr_reader :rugged
@@ -247,11 +250,17 @@ module Gitlab
branch_names + tag_names
end
+ def delete_all_refs_except(prefixes)
+ delete_refs(*all_ref_names_except(prefixes))
+ end
+
# Returns an Array of all ref names, except when it's matching pattern
#
# regexp - The pattern for ref names we don't want
- def all_ref_names_except(regexp)
- rugged.references.reject { |ref| ref.name =~ regexp }.map(&:name)
+ def all_ref_names_except(prefixes)
+ rugged.references.reject do |ref|
+ prefixes.any? { |p| ref.name.start_with?(p) }
+ end.map(&:name)
end
# Discovers the default branch based on the repository's available branches
diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb
index 3e8b83c0f90..62d1ecae676 100644
--- a/lib/gitlab/git_access.rb
+++ b/lib/gitlab/git_access.rb
@@ -35,6 +35,7 @@ module Gitlab
def check(cmd, changes)
check_protocol!
+ check_valid_actor!
check_active_user!
check_project_accessibility!
check_project_moved!
@@ -70,6 +71,14 @@ module Gitlab
private
+ def check_valid_actor!
+ return unless actor.is_a?(Key)
+
+ unless actor.valid?
+ raise UnauthorizedError, "Your SSH key #{actor.errors[:key].first}."
+ end
+ end
+
def check_protocol!
unless protocol_allowed?
raise UnauthorizedError, "Git access over #{protocol.upcase} is not allowed"
diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb
index a74a6dc6e78..177a1284f38 100644
--- a/lib/gitlab/gitaly_client/repository_service.rb
+++ b/lib/gitlab/gitaly_client/repository_service.rb
@@ -37,6 +37,22 @@ module Gitlab
request = Gitaly::ApplyGitattributesRequest.new(repository: @gitaly_repo, revision: revision)
GitalyClient.call(@storage, :repository_service, :apply_gitattributes, request)
end
+
+ def fetch_remote(remote, ssh_auth: nil, forced: false, no_tags: false)
+ request = Gitaly::FetchRemoteRequest.new(repository: @gitaly_repo, remote: remote, force: forced, no_tags: no_tags)
+
+ if ssh_auth&.ssh_import?
+ if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
+ request.ssh_key = ssh_auth.ssh_private_key
+ end
+
+ if ssh_auth.ssh_known_hosts.present?
+ request.known_hosts = ssh_auth.ssh_known_hosts
+ end
+ end
+
+ GitalyClient.call(@storage, :repository_service, :fetch_remote, request)
+ end
end
end
end
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 2d1ae6a5925..9bcc579278f 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -3,6 +3,7 @@
module Gitlab
module GonHelper
include WebpackHelper
+ include Gitlab::CurrentSettings
def add_gon_variables
gon.api_version = 'v4'
diff --git a/lib/gitlab/i18n/metadata_entry.rb b/lib/gitlab/i18n/metadata_entry.rb
new file mode 100644
index 00000000000..35d57459a3d
--- /dev/null
+++ b/lib/gitlab/i18n/metadata_entry.rb
@@ -0,0 +1,27 @@
+module Gitlab
+ module I18n
+ class MetadataEntry
+ attr_reader :entry_data
+
+ def initialize(entry_data)
+ @entry_data = entry_data
+ end
+
+ def expected_plurals
+ return nil unless plural_information
+
+ plural_information['nplurals'].to_i
+ end
+
+ private
+
+ def plural_information
+ return @plural_information if defined?(@plural_information)
+
+ if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') }
+ @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb
new file mode 100644
index 00000000000..2e02787a4f4
--- /dev/null
+++ b/lib/gitlab/i18n/po_linter.rb
@@ -0,0 +1,216 @@
+require 'simple_po_parser'
+
+module Gitlab
+ module I18n
+ class PoLinter
+ attr_reader :po_path, :translation_entries, :metadata_entry, :locale
+
+ VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze
+
+ def initialize(po_path, locale = I18n.locale.to_s)
+ @po_path = po_path
+ @locale = locale
+ end
+
+ def errors
+ @errors ||= validate_po
+ end
+
+ def validate_po
+ if parse_error = parse_po
+ return 'PO-syntax errors' => [parse_error]
+ end
+
+ validate_entries
+ end
+
+ def parse_po
+ entries = SimplePoParser.parse(po_path)
+
+ # The first entry is the metadata entry if there is one.
+ # This is an entry when empty `msgid`
+ if entries.first[:msgid].empty?
+ @metadata_entry = Gitlab::I18n::MetadataEntry.new(entries.shift)
+ else
+ return 'Missing metadata entry.'
+ end
+
+ @translation_entries = entries.map do |entry_data|
+ Gitlab::I18n::TranslationEntry.new(entry_data, metadata_entry.expected_plurals)
+ end
+
+ nil
+ rescue SimplePoParser::ParserError => e
+ @translation_entries = []
+ e.message
+ end
+
+ def validate_entries
+ errors = {}
+
+ translation_entries.each do |entry|
+ errors_for_entry = validate_entry(entry)
+ errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any?
+ end
+
+ errors
+ end
+
+ def validate_entry(entry)
+ errors = []
+
+ validate_flags(errors, entry)
+ validate_variables(errors, entry)
+ validate_newlines(errors, entry)
+ validate_number_of_plurals(errors, entry)
+ validate_unescaped_chars(errors, entry)
+
+ errors
+ end
+
+ def validate_unescaped_chars(errors, entry)
+ if entry.msgid_contains_unescaped_chars?
+ errors << 'contains unescaped `%`, escape it using `%%`'
+ end
+
+ if entry.plural_id_contains_unescaped_chars?
+ errors << 'plural id contains unescaped `%`, escape it using `%%`'
+ end
+
+ if entry.translations_contain_unescaped_chars?
+ errors << 'translation contains unescaped `%`, escape it using `%%`'
+ end
+ end
+
+ def validate_number_of_plurals(errors, entry)
+ return unless metadata_entry&.expected_plurals
+ return unless entry.translated?
+
+ if entry.has_plural? && entry.all_translations.size != metadata_entry.expected_plurals
+ errors << "should have #{metadata_entry.expected_plurals} "\
+ "#{'translations'.pluralize(metadata_entry.expected_plurals)}"
+ end
+ end
+
+ def validate_newlines(errors, entry)
+ if entry.msgid_contains_newlines?
+ errors << 'is defined over multiple lines, this breaks some tooling.'
+ end
+
+ if entry.plural_id_contains_newlines?
+ errors << 'plural is defined over multiple lines, this breaks some tooling.'
+ end
+
+ if entry.translations_contain_newlines?
+ errors << 'has translations defined over multiple lines, this breaks some tooling.'
+ end
+ end
+
+ def validate_variables(errors, entry)
+ if entry.has_singular_translation?
+ validate_variables_in_message(errors, entry.msgid, entry.singular_translation)
+ end
+
+ if entry.has_plural?
+ entry.plural_translations.each do |translation|
+ validate_variables_in_message(errors, entry.plural_id, translation)
+ end
+ end
+ end
+
+ def validate_variables_in_message(errors, message_id, message_translation)
+ message_id = join_message(message_id)
+ required_variables = message_id.scan(VARIABLE_REGEX)
+
+ validate_unnamed_variables(errors, required_variables)
+ validate_translation(errors, message_id, required_variables)
+ validate_variable_usage(errors, message_translation, required_variables)
+ end
+
+ def validate_translation(errors, message_id, used_variables)
+ variables = fill_in_variables(used_variables)
+
+ begin
+ Gitlab::I18n.with_locale(locale) do
+ translated = if message_id.include?('|')
+ FastGettext::Translation.s_(message_id)
+ else
+ FastGettext::Translation._(message_id)
+ end
+
+ translated % variables
+ end
+
+ # `sprintf` could raise an `ArgumentError` when invalid passing something
+ # other than a Hash when using named variables
+ #
+ # `sprintf` could raise `TypeError` when passing a wrong type when using
+ # unnamed variables
+ #
+ # FastGettext::Translation could raise `RuntimeError` (raised as a string),
+ # or as subclassess `NoTextDomainConfigured` & `InvalidFormat`
+ #
+ # `FastGettext::Translation` could raise `ArgumentError` as subclassess
+ # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter`
+ rescue ArgumentError, TypeError, RuntimeError => e
+ errors << "Failure translating to #{locale} with #{variables}: #{e.message}"
+ end
+ end
+
+ def fill_in_variables(variables)
+ if variables.empty?
+ []
+ elsif variables.any? { |variable| unnamed_variable?(variable) }
+ variables.map do |variable|
+ variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string
+ end
+ else
+ variables.inject({}) do |hash, variable|
+ variable_name = variable[/\w+/]
+ hash[variable_name] = Gitlab::Utils.random_string
+ hash
+ end
+ end
+ end
+
+ def validate_unnamed_variables(errors, variables)
+ if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) }
+ errors << 'is combining multiple unnamed variables'
+ end
+ end
+
+ def validate_variable_usage(errors, translation, required_variables)
+ translation = join_message(translation)
+
+ # We don't need to validate when the message is empty.
+ # In this case we fall back to the default, which has all the the
+ # required variables.
+ return if translation.empty?
+
+ found_variables = translation.scan(VARIABLE_REGEX)
+
+ missing_variables = required_variables - found_variables
+ if missing_variables.any?
+ errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]"
+ end
+
+ unknown_variables = found_variables - required_variables
+ if unknown_variables.any?
+ errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]"
+ end
+ end
+
+ def unnamed_variable?(variable_name)
+ !variable_name.start_with?('%{')
+ end
+
+ def validate_flags(errors, entry)
+ errors << "is marked #{entry.flag}" if entry.flag
+ end
+
+ def join_message(message)
+ Array(message).join
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb
new file mode 100644
index 00000000000..e6c95afca7e
--- /dev/null
+++ b/lib/gitlab/i18n/translation_entry.rb
@@ -0,0 +1,92 @@
+module Gitlab
+ module I18n
+ class TranslationEntry
+ PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze
+
+ attr_reader :nplurals, :entry_data
+
+ def initialize(entry_data, nplurals)
+ @entry_data = entry_data
+ @nplurals = nplurals
+ end
+
+ def msgid
+ entry_data[:msgid]
+ end
+
+ def plural_id
+ entry_data[:msgid_plural]
+ end
+
+ def has_plural?
+ plural_id.present?
+ end
+
+ def singular_translation
+ all_translations.first if has_singular_translation?
+ end
+
+ def all_translations
+ @all_translations ||= entry_data.fetch_values(*translation_keys)
+ .reject(&:empty?)
+ end
+
+ def translated?
+ all_translations.any?
+ end
+
+ def plural_translations
+ return [] unless has_plural?
+ return [] unless translated?
+
+ @plural_translations ||= if has_singular_translation?
+ all_translations.drop(1)
+ else
+ all_translations
+ end
+ end
+
+ def flag
+ entry_data[:flag]
+ end
+
+ def has_singular_translation?
+ nplurals > 1 || !has_plural?
+ end
+
+ def msgid_contains_newlines?
+ msgid.is_a?(Array)
+ end
+
+ def plural_id_contains_newlines?
+ plural_id.is_a?(Array)
+ end
+
+ def translations_contain_newlines?
+ all_translations.any? { |translation| translation.is_a?(Array) }
+ end
+
+ def msgid_contains_unescaped_chars?
+ contains_unescaped_chars?(msgid)
+ end
+
+ def plural_id_contains_unescaped_chars?
+ contains_unescaped_chars?(plural_id)
+ end
+
+ def translations_contain_unescaped_chars?
+ all_translations.any? { |translation| contains_unescaped_chars?(translation) }
+ end
+
+ def contains_unescaped_chars?(string)
+ string =~ PERCENT_REGEX
+ end
+
+ private
+
+ def translation_keys
+ @translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb
deleted file mode 100644
index d9a79f7c291..00000000000
--- a/lib/gitlab/key_fingerprint.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-module Gitlab
- class KeyFingerprint
- attr_reader :key, :ssh_key
-
- # Unqualified MD5 fingerprint for compatibility
- delegate :fingerprint, to: :ssh_key, allow_nil: true
-
- def initialize(key)
- @key = key
-
- @ssh_key =
- begin
- Net::SSH::KeyFactory.load_data_public_key(key)
- rescue Net::SSH::Exception, NotImplementedError
- end
- end
-
- def valid?
- ssh_key.present?
- end
-
- def type
- return unless valid?
-
- parts = ssh_key.ssh_type.split('-')
- parts.shift if parts[0] == 'ssh'
-
- parts[0].upcase
- end
-
- def bits
- return unless valid?
-
- case type
- when 'RSA'
- ssh_key.n.num_bits
- when 'DSS', 'DSA'
- ssh_key.p.num_bits
- when 'ECDSA'
- ssh_key.group.order.num_bits
- when 'ED25519'
- 256
- else
- raise "Unsupported key type: #{type}"
- end
- end
- end
-end
diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb
index d7c56463aac..7b06bb953aa 100644
--- a/lib/gitlab/metrics/influx_db.rb
+++ b/lib/gitlab/metrics/influx_db.rb
@@ -1,7 +1,7 @@
module Gitlab
module Metrics
module InfluxDb
- extend Gitlab::CurrentSettings
+ include Gitlab::CurrentSettings
extend self
MUTEX = Mutex.new
diff --git a/lib/gitlab/performance_bar.rb b/lib/gitlab/performance_bar.rb
index 56112ec2301..e73245b82c1 100644
--- a/lib/gitlab/performance_bar.rb
+++ b/lib/gitlab/performance_bar.rb
@@ -1,6 +1,6 @@
module Gitlab
module PerformanceBar
- include Gitlab::CurrentSettings
+ extend Gitlab::CurrentSettings
ALLOWED_USER_IDS_KEY = 'performance_bar_allowed_user_ids:v2'.freeze
EXPIRY_TIME = 5.minutes
diff --git a/lib/gitlab/polling_interval.rb b/lib/gitlab/polling_interval.rb
index f0c50584f07..4780675a492 100644
--- a/lib/gitlab/polling_interval.rb
+++ b/lib/gitlab/polling_interval.rb
@@ -1,6 +1,6 @@
module Gitlab
class PollingInterval
- include Gitlab::CurrentSettings
+ extend Gitlab::CurrentSettings
HEADER_NAME = 'Poll-Interval'.freeze
diff --git a/lib/gitlab/protocol_access.rb b/lib/gitlab/protocol_access.rb
index 21aefc884be..09fa14764e6 100644
--- a/lib/gitlab/protocol_access.rb
+++ b/lib/gitlab/protocol_access.rb
@@ -1,5 +1,7 @@
module Gitlab
module ProtocolAccess
+ extend Gitlab::CurrentSettings
+
def self.allowed?(protocol)
if protocol == 'web'
true
diff --git a/lib/gitlab/recaptcha.rb b/lib/gitlab/recaptcha.rb
index 4bc76ea033f..c463dd487a0 100644
--- a/lib/gitlab/recaptcha.rb
+++ b/lib/gitlab/recaptcha.rb
@@ -1,5 +1,7 @@
module Gitlab
module Recaptcha
+ extend Gitlab::CurrentSettings
+
def self.load_configurations!
if current_application_settings.recaptcha_enabled
::Recaptcha.configure do |config|
diff --git a/lib/gitlab/reference_counter.rb b/lib/gitlab/reference_counter.rb
new file mode 100644
index 00000000000..bb26f1b610a
--- /dev/null
+++ b/lib/gitlab/reference_counter.rb
@@ -0,0 +1,44 @@
+module Gitlab
+ class ReferenceCounter
+ REFERENCE_EXPIRE_TIME = 600
+
+ attr_reader :gl_repository, :key
+
+ def initialize(gl_repository)
+ @gl_repository = gl_repository
+ @key = "git-receive-pack-reference-counter:#{gl_repository}"
+ end
+
+ def value
+ Gitlab::Redis::SharedState.with { |redis| (redis.get(key) || 0).to_i }
+ end
+
+ def increase
+ redis_cmd do |redis|
+ redis.incr(key)
+ redis.expire(key, REFERENCE_EXPIRE_TIME)
+ end
+ end
+
+ def decrease
+ redis_cmd do |redis|
+ current_value = redis.decr(key)
+ if current_value < 0
+ Rails.logger.warn("Reference counter for #{gl_repository} decreased" \
+ " when its value was less than 1. Reseting the counter.")
+ redis.del(key)
+ end
+ end
+ end
+
+ private
+
+ def redis_cmd
+ Gitlab::Redis::SharedState.with { |redis| yield(redis) }
+ true
+ rescue => e
+ Rails.logger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}")
+ false
+ end
+ end
+end
diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb
index 2442c2ded3b..159d0e7952e 100644
--- a/lib/gitlab/sentry.rb
+++ b/lib/gitlab/sentry.rb
@@ -1,5 +1,7 @@
module Gitlab
module Sentry
+ extend Gitlab::CurrentSettings
+
def self.enabled?
Rails.env.production? && current_application_settings.sentry_enabled?
end
@@ -7,6 +9,8 @@ module Gitlab
def self.context(current_user = nil)
return unless self.enabled?
+ Raven.tags_context(locale: I18n.locale)
+
if current_user
Raven.user_context(
id: current_user.id,
diff --git a/lib/gitlab/shell.rb b/lib/gitlab/shell.rb
index 280a9abf03e..81ecdf43ef9 100644
--- a/lib/gitlab/shell.rb
+++ b/lib/gitlab/shell.rb
@@ -98,33 +98,24 @@ module Gitlab
# Fetch remote for repository
#
- # name - project path with namespace
+ # repository - an instance of Git::Repository
# remote - remote name
# forced - should we use --force flag?
# no_tags - should we use --no-tags flag?
#
# Ex.
- # fetch_remote("gitlab/gitlab-ci", "upstream")
+ # fetch_remote(my_repo, "upstream")
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/387
- def fetch_remote(storage, name, remote, ssh_auth: nil, forced: false, no_tags: false)
- args = [gitlab_shell_projects_path, 'fetch-remote', storage, "#{name}.git", remote, "#{Gitlab.config.gitlab_shell.git_timeout}"]
- args << '--force' if forced
- args << '--no-tags' if no_tags
-
- vars = {}
-
- if ssh_auth&.ssh_import?
- if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
- vars['GITLAB_SHELL_SSH_KEY'] = ssh_auth.ssh_private_key
- end
-
- if ssh_auth.ssh_known_hosts.present?
- vars['GITLAB_SHELL_KNOWN_HOSTS'] = ssh_auth.ssh_known_hosts
+ def fetch_remote(repository, remote, ssh_auth: nil, forced: false, no_tags: false)
+ gitaly_migrate(:fetch_remote) do |is_enabled|
+ if is_enabled
+ repository.gitaly_repository_client.fetch_remote(remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
+ else
+ storage_path = Gitlab.config.repositories.storages[repository.storage]["path"]
+ local_fetch_remote(storage_path, repository.relative_path, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags)
end
end
-
- gitlab_shell_fast_execute_raise_error(args, vars)
end
# Move repository
@@ -302,6 +293,26 @@ module Gitlab
private
+ def local_fetch_remote(storage, name, remote, ssh_auth: nil, forced: false, no_tags: false)
+ args = [gitlab_shell_projects_path, 'fetch-remote', storage, name, remote, "#{Gitlab.config.gitlab_shell.git_timeout}"]
+ args << '--force' if forced
+ args << '--no-tags' if no_tags
+
+ vars = {}
+
+ if ssh_auth&.ssh_import?
+ if ssh_auth.ssh_key_auth? && ssh_auth.ssh_private_key.present?
+ vars['GITLAB_SHELL_SSH_KEY'] = ssh_auth.ssh_private_key
+ end
+
+ if ssh_auth.ssh_known_hosts.present?
+ vars['GITLAB_SHELL_KNOWN_HOSTS'] = ssh_auth.ssh_known_hosts
+ end
+ end
+
+ gitlab_shell_fast_execute_raise_error(args, vars)
+ end
+
def gitlab_shell_fast_execute(cmd)
output, status = gitlab_shell_fast_execute_helper(cmd)
@@ -325,5 +336,13 @@ module Gitlab
# from wasting I/O by searching through GEM_PATH
Bundler.with_original_env { Popen.popen(cmd, nil, vars) }
end
+
+ def gitaly_migrate(method, &block)
+ Gitlab::GitalyClient.migrate(method, &block)
+ rescue GRPC::NotFound, GRPC::BadStatus => e
+ # Old Popen code returns [Error, output] to the caller, so we
+ # need to do the same here...
+ raise Error, e
+ end
end
end
diff --git a/lib/gitlab/ssh_public_key.rb b/lib/gitlab/ssh_public_key.rb
new file mode 100644
index 00000000000..89ca1298120
--- /dev/null
+++ b/lib/gitlab/ssh_public_key.rb
@@ -0,0 +1,71 @@
+module Gitlab
+ class SSHPublicKey
+ Technology = Struct.new(:name, :key_class, :supported_sizes)
+
+ Technologies = [
+ Technology.new(:rsa, OpenSSL::PKey::RSA, [1024, 2048, 3072, 4096]),
+ Technology.new(:dsa, OpenSSL::PKey::DSA, [1024, 2048, 3072]),
+ Technology.new(:ecdsa, OpenSSL::PKey::EC, [256, 384, 521]),
+ Technology.new(:ed25519, Net::SSH::Authentication::ED25519::PubKey, [256])
+ ].freeze
+
+ def self.technology(name)
+ Technologies.find { |tech| tech.name.to_s == name.to_s }
+ end
+
+ def self.technology_for_key(key)
+ Technologies.find { |tech| key.is_a?(tech.key_class) }
+ end
+
+ def self.supported_sizes(name)
+ technology(name)&.supported_sizes
+ end
+
+ attr_reader :key_text, :key
+
+ # Unqualified MD5 fingerprint for compatibility
+ delegate :fingerprint, to: :key, allow_nil: true
+
+ def initialize(key_text)
+ @key_text = key_text
+
+ @key =
+ begin
+ Net::SSH::KeyFactory.load_data_public_key(key_text)
+ rescue StandardError, NotImplementedError
+ end
+ end
+
+ def valid?
+ key.present?
+ end
+
+ def type
+ technology.name if valid?
+ end
+
+ def bits
+ return unless valid?
+
+ case type
+ when :rsa
+ key.n.num_bits
+ when :dsa
+ key.p.num_bits
+ when :ecdsa
+ key.group.order.num_bits
+ when :ed25519
+ 256
+ else
+ raise "Unsupported key type: #{type}"
+ end
+ end
+
+ private
+
+ def technology
+ @technology ||=
+ self.class.technology_for_key(key) || raise("Unsupported key type: #{key.class}")
+ end
+ end
+end
diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb
index 7ebec8e2cff..7393574ac13 100644
--- a/lib/gitlab/template/base_template.rb
+++ b/lib/gitlab/template/base_template.rb
@@ -18,6 +18,10 @@ module Gitlab
{ name: name, content: content }
end
+ def <=>(other)
+ name <=> other.name
+ end
+
class << self
def all(project = nil)
if categories.any?
@@ -58,7 +62,7 @@ module Gitlab
directory = category_directory(category)
files = finder(project).list_files_for(directory)
- files.map { |f| new(f, project) }
+ files.map { |f| new(f, project) }.sort
end
def category_directory(category)
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 748e0a29184..3cf26625108 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -1,8 +1,8 @@
module Gitlab
class UsageData
- include Gitlab::CurrentSettings
-
class << self
+ include Gitlab::CurrentSettings
+
def data(force_refresh: false)
Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) { uncached_data }
end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index 9670c93759e..abb3d3a02c3 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -42,5 +42,9 @@ module Gitlab
'No'
end
end
+
+ def random_string
+ Random.rand(Float::MAX.to_i).to_s(36)
+ end
end
end
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index a362a3a0bc6..e5ad9b5a40c 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -35,10 +35,7 @@ module Gitlab
when 'git_receive_pack'
Gitlab::GitalyClient.feature_enabled?(:post_receive_pack)
when 'git_upload_pack'
- Gitlab::GitalyClient.feature_enabled?(
- :post_upload_pack,
- status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT
- )
+ true
when 'info_refs'
true
else
diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake
index b48e4dce445..e1491f29b5e 100644
--- a/lib/tasks/gettext.rake
+++ b/lib/tasks/gettext.rake
@@ -19,4 +19,44 @@ namespace :gettext do
Rake::Task['gettext:pack'].invoke
Rake::Task['gettext:po_to_json'].invoke
end
+
+ desc 'Lint all po files in `locale/'
+ task lint: :environment do
+ FastGettext.silence_errors
+ files = Dir.glob(Rails.root.join('locale/*/gitlab.po'))
+
+ linters = files.map do |file|
+ locale = File.basename(File.dirname(file))
+
+ Gitlab::I18n::PoLinter.new(file, locale)
+ end
+
+ pot_file = Rails.root.join('locale/gitlab.pot')
+ linters.unshift(Gitlab::I18n::PoLinter.new(pot_file))
+
+ failed_linters = linters.select { |linter| linter.errors.any? }
+
+ if failed_linters.empty?
+ puts 'All PO files are valid.'
+ else
+ failed_linters.each do |linter|
+ report_errors_for_file(linter.po_path, linter.errors)
+ end
+
+ raise "Not all PO-files are valid: #{failed_linters.map(&:po_path).to_sentence}"
+ end
+ end
+
+ def report_errors_for_file(file, errors_for_file)
+ puts "Errors in `#{file}`:"
+
+ errors_for_file.each do |message_id, errors|
+ puts " #{message_id}"
+ errors.each do |error|
+ spaces = ' ' * 4
+ error = error.lines.join("#{spaces}")
+ puts "#{spaces}#{error}"
+ end
+ end
+ end
end
diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake
index 1206302cb76..4d485108cf6 100644
--- a/lib/tasks/import.rake
+++ b/lib/tasks/import.rake
@@ -80,7 +80,7 @@ class GithubImport
end
def visibility_level
- @repo['private'] ? Gitlab::VisibilityLevel::PRIVATE : current_application_settings.default_project_visibility
+ @repo['private'] ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::CurrentSettings.current_application_settings.default_project_visibility
end
end
diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po
index 0ac591d4927..84232be601e 100644
--- a/locale/en/gitlab.po
+++ b/locale/en/gitlab.po
@@ -82,6 +82,9 @@ msgstr ""
msgid "Add new directory"
msgstr ""
+msgid "All"
+msgstr ""
+
msgid "Archived project! Repository is read-only"
msgstr ""
@@ -222,6 +225,9 @@ msgstr ""
msgid "CiStatus|running"
msgstr ""
+msgid "Comments"
+msgstr ""
+
msgid "Commit"
msgid_plural "Commits"
msgstr[0] ""
@@ -394,6 +400,24 @@ msgstr ""
msgid "Edit Pipeline Schedule %{id}"
msgstr ""
+msgid "EventFilterBy|Filter by all"
+msgstr ""
+
+msgid "EventFilterBy|Filter by comments"
+msgstr ""
+
+msgid "EventFilterBy|Filter by issue events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by merge events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by push events"
+msgstr ""
+
+msgid "EventFilterBy|Filter by team"
+msgstr ""
+
msgid "Every day (at 4:00am)"
msgstr ""
@@ -489,6 +513,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
+msgid "Issue events"
+msgstr ""
+
msgid "Jobs for last month"
msgstr ""
@@ -518,6 +545,12 @@ msgstr ""
msgid "Last commit"
msgstr ""
+msgid "LastPushEvent|You pushed to"
+msgstr ""
+
+msgid "LastPushEvent|at"
+msgstr ""
+
msgid "Learn more in the"
msgstr ""
@@ -538,6 +571,9 @@ msgstr[1] ""
msgid "Median"
msgstr ""
+msgid "Merge events"
+msgstr ""
+
msgid "MissingSSHKeyWarningLink|add an SSH key"
msgstr ""
@@ -741,6 +777,9 @@ msgstr ""
msgid "Pipeline|with stages"
msgstr ""
+msgid "Project"
+msgstr ""
+
msgid "Project '%{project_name}' queued for deletion."
msgstr ""
@@ -774,6 +813,9 @@ msgstr ""
msgid "Project home"
msgstr ""
+msgid "ProjectActivityRSS|Subscribe"
+msgstr ""
+
msgid "ProjectFeature|Disabled"
msgstr ""
@@ -795,6 +837,9 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph"
msgstr ""
+msgid "Push events"
+msgstr ""
+
msgid "Read more"
msgstr ""
@@ -925,6 +970,9 @@ msgstr ""
msgid "Target Branch"
msgstr ""
+msgid "Team"
+msgstr ""
+
msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
msgstr ""
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5a1db208d5a..2b7c6f7ad33 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3,7 +3,6 @@
# This file is distributed under the same license as the gitlab package.
# FIRST AUTHOR , YEAR.
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po
index 4037ff731a2..670ac2d9684 100644
--- a/locale/ja/gitlab.po
+++ b/locale/ja/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 個のパイプライン"
+msgstr[0] "%d 個のパイプライン"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "CIについてのグラフ"
diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po
index 125ca220c81..df850115222 100644
--- a/locale/ko/gitlab.po
+++ b/locale/ko/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 파이프라인"
+msgstr[0] "%d 파이프라인"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "지속적인 통합에 관한 그래프 모음"
diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po
index b25234da030..eb607acf1f4 100644
--- a/locale/zh_CN/gitlab.po
+++ b/locale/zh_CN/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 条流水线"
+msgstr[0] "%d 条流水线"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持续集成数据图"
diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po
index 8a3a69a0ac0..74c7b464091 100644
--- a/locale/zh_HK/gitlab.po
+++ b/locale/zh_HK/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 條流水線"
+msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "相關持續集成的圖像集合"
diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po
index 91c1cc6bf66..1fc6b79187f 100644
--- a/locale/zh_TW/gitlab.po
+++ b/locale/zh_TW/gitlab.po
@@ -45,7 +45,7 @@ msgstr ""
msgid "1 pipeline"
msgid_plural "%d pipelines"
-msgstr[0] "1 條流水線"
+msgstr[0] "%d 條流水線"
msgid "A collection of graphs regarding Continuous Integration"
msgstr "持續整合 (CI) 相關的圖表"
@@ -1208,16 +1208,16 @@ msgid "Withdraw Access Request"
msgstr "取消權限申請"
msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
-msgstr "即將要刪除 %{group_name}。被刪除的群組完全無法救回來喔!真的「100%確定」要這麼做嗎?"
+msgstr "即將要刪除 %{group_name}。被刪除的群組無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
-msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案完全無法救回來喔!真的「100%確定」要這麼做嗎?"
+msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案無法復原!真的「確定」要這麼做嗎?"
msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
-msgstr "將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} 真的「100%確定」要這麼做嗎?"
+msgstr "將要刪除本分支專案與主幹 %{forked_from_project} 的所有關聯。 真的「確定」要這麼做嗎?"
msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
-msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
+msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「確定」要這麼做嗎?"
msgid "You can only add files when you are on a branch"
msgstr "只能在分支 (branch) 上建立檔案"
diff --git a/package.json b/package.json
index 1725658729a..feae6ca9748 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"babel-preset-latest": "^6.24.0",
"babel-preset-stage-2": "^6.22.0",
"bootstrap-sass": "^3.3.6",
+ "brace-expansion": "^1.1.8",
"compression-webpack-plugin": "^1.0.0",
"copy-webpack-plugin": "^4.0.1",
"core-js": "^2.4.1",
@@ -63,6 +64,7 @@
"vue-loader": "^11.3.4",
"vue-resource": "^1.3.4",
"vue-template-compiler": "^2.2.6",
+ "vuex": "^2.3.1",
"webpack": "^3.5.5",
"webpack-bundle-analyzer": "^2.8.2",
"webpack-stats-plugin": "^0.1.5"
diff --git a/public/404.html b/public/404.html
index 4db72be6f8c..08f328da542 100644
--- a/public/404.html
+++ b/public/404.html
@@ -72,8 +72,9 @@
404
-
The page you're looking for could not be found.
+
The page could not be found or you don't have permission to view it.
+
The resource that you are attempting to access does not exist or you don't have the necessary permissions to view it.
Make sure the address is correct and that the page hasn't moved.
Please contact your GitLab administrator if you think this is a mistake.
Go back
diff --git a/scripts/static-analysis b/scripts/static-analysis
index 52529e64b30..295b6f132c1 100755
--- a/scripts/static-analysis
+++ b/scripts/static-analysis
@@ -12,7 +12,8 @@ tasks = [
%w[bundle exec license_finder],
%w[yarn run eslint],
%w[bundle exec rubocop --require rubocop-rspec],
- %w[scripts/lint-conflicts.sh]
+ %w[scripts/lint-conflicts.sh],
+ %w[bundle exec rake gettext:lint]
]
failed_tasks = tasks.reduce({}) do |failures, task|
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 3d21b695af4..aadd3317875 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -150,6 +150,18 @@ describe Admin::UsersController do
post :update, params
end
+ context 'when the admin changes his own password' do
+ it 'updates the password' do
+ expect { update_password(admin, 'AValidPassword1') }
+ .to change { admin.reload.encrypted_password }
+ end
+
+ it 'does not set the new password to expire immediately' do
+ expect { update_password(admin, 'AValidPassword1') }
+ .not_to change { admin.reload.password_expires_at }
+ end
+ end
+
context 'when the new password is valid' do
it 'redirects to the user' do
update_password(user, 'AValidPassword1')
@@ -158,15 +170,13 @@ describe Admin::UsersController do
end
it 'updates the password' do
- update_password(user, 'AValidPassword1')
-
- expect { user.reload }.to change { user.encrypted_password }
+ expect { update_password(user, 'AValidPassword1') }
+ .to change { user.reload.encrypted_password }
end
it 'sets the new password to expire immediately' do
- update_password(user, 'AValidPassword1')
-
- expect { user.reload }.to change { user.password_expires_at }.to(a_value <= Time.now)
+ expect { update_password(user, 'AValidPassword1') }
+ .to change { user.reload.password_expires_at }.to be_within(2.seconds).of(Time.now)
end
end
@@ -184,9 +194,8 @@ describe Admin::UsersController do
end
it 'does not update the password' do
- update_password(user, 'invalid')
-
- expect { user.reload }.not_to change { user.encrypted_password }
+ expect { update_password(user, 'invalid') }
+ .not_to change { user.reload.encrypted_password }
end
end
@@ -204,9 +213,8 @@ describe Admin::UsersController do
end
it 'does not update the password' do
- update_password(user, 'AValidPassword1', 'AValidPassword2')
-
- expect { user.reload }.not_to change { user.encrypted_password }
+ expect { update_password(user, 'AValidPassword1', 'AValidPassword2') }
+ .not_to change { user.reload.encrypted_password }
end
end
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 331903a5543..59a6cfbf4f5 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -8,34 +8,43 @@ describe ApplicationController do
it 'redirects if the user is over their password expiry' do
user.password_expires_at = Time.new(2002)
+
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
expect(controller).to receive(:redirect_to)
expect(controller).to receive(:new_profile_password_path)
+
controller.send(:check_password_expiration)
end
it 'does not redirect if the user is under their password expiry' do
user.password_expires_at = Time.now + 20010101
+
expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
expect(controller).not_to receive(:redirect_to)
+
controller.send(:check_password_expiration)
end
it 'does not redirect if the user is over their password expiry but they are an ldap user' do
user.password_expires_at = Time.new(2002)
+
allow(user).to receive(:ldap_user?).and_return(true)
allow(controller).to receive(:current_user).and_return(user)
expect(controller).not_to receive(:redirect_to)
+
controller.send(:check_password_expiration)
end
- it 'does not redirect if the user is over their password expiry but sign-in is disabled' do
+ it 'redirects if the user is over their password expiry and sign-in is disabled' do
stub_application_setting(password_authentication_enabled: false)
user.password_expires_at = Time.new(2002)
+
+ expect(user.ldap_user?).to be_falsey
allow(controller).to receive(:current_user).and_return(user)
- expect(controller).not_to receive(:redirect_to)
+ expect(controller).to receive(:redirect_to)
+ expect(controller).to receive(:new_profile_password_path)
controller.send(:check_password_expiration)
end
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index 572b567cddf..be27bbb4283 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -241,13 +241,10 @@ describe AutocompleteController do
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
- expect(json_response.size).to eq(2)
+ expect(json_response.size).to eq(1)
- expect(json_response.first['id']).to eq(0)
- expect(json_response.first['name_with_namespace']).to eq 'No project'
-
- expect(json_response.last['id']).to eq authorized_project.id
- expect(json_response.last['name_with_namespace']).to eq authorized_project.name_with_namespace
+ expect(json_response.first['id']).to eq authorized_project.id
+ expect(json_response.first['name_with_namespace']).to eq authorized_project.name_with_namespace
end
end
end
@@ -265,10 +262,10 @@ describe AutocompleteController do
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
- expect(json_response.size).to eq(2)
+ expect(json_response.size).to eq(1)
- expect(json_response.last['id']).to eq authorized_search_project.id
- expect(json_response.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace
+ expect(json_response.first['id']).to eq authorized_search_project.id
+ expect(json_response.first['name_with_namespace']).to eq authorized_search_project.name_with_namespace
end
end
end
@@ -292,7 +289,7 @@ describe AutocompleteController do
it 'returns projects' do
expect(json_response).to be_kind_of(Array)
- expect(json_response.size).to eq 3 # Of a total of 4
+ expect(json_response.size).to eq 2 # Of a total of 3
end
end
end
@@ -312,9 +309,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id, offset_id: authorized_project.id)
end
- it 'returns "No project"' do
- expect(json_response.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there
- expect(json_response.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either
+ it 'returns projects' do
+ expect(json_response).to be_kind_of(Array)
+ expect(json_response.size).to eq 2 # Of a total of 3
end
end
end
@@ -331,10 +328,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id)
end
- it 'returns a single "No project"' do
+ it 'returns no projects' do
expect(json_response).to be_kind_of(Array)
- expect(json_response.size).to eq(1) # 'No project'
- expect(json_response.first['id']).to eq 0
+ expect(json_response.size).to eq(0)
end
end
end
diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb
index 2955d01fad0..cdaa88bbf5d 100644
--- a/spec/controllers/passwords_controller_spec.rb
+++ b/spec/controllers/passwords_controller_spec.rb
@@ -1,18 +1,18 @@
require 'spec_helper'
describe PasswordsController do
- describe '#check_password_authentication_available' do
+ describe '#prevent_ldap_reset' do
before do
@request.env["devise.mapping"] = Devise.mappings[:user]
end
context 'when password authentication is disabled' do
- it 'prevents a password reset' do
+ it 'allows password reset' do
stub_application_setting(password_authentication_enabled: false)
post :create
- expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ expect(response).to have_http_status(302)
end
end
@@ -22,7 +22,7 @@ describe PasswordsController do
it 'prevents a password reset' do
post :create, user: { email: user.email }
- expect(flash[:alert]).to eq 'Password authentication is unavailable.'
+ expect(flash[:alert]).to eq('Cannot reset password for LDAP user.')
end
end
end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index da8f9e8376e..5d9403c23ac 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -233,144 +233,119 @@ describe Projects::IssuesController do
end
end
- context 'when moving issue to another private project' do
- let(:another_project) { create(:project, :private) }
+ context 'Akismet is enabled' do
+ let(:project) { create(:project_empty_repo, :public) }
- context 'when user has access to move issue' do
+ before do
+ stub_application_setting(recaptcha_enabled: true)
+ allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
+ end
+
+ context 'when an issue is not identified as spam' do
before do
- another_project.team << [user, :reporter]
+ allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
+ allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false)
end
- it 'moves issue to another project' do
- move_issue
-
- expect(response).to have_http_status :found
- expect(another_project.issues).not_to be_empty
+ it 'normally updates the issue' do
+ expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo')
end
end
- context 'when user does not have access to move issue' do
- it 'responds with 404' do
- move_issue
-
- expect(response).to have_http_status :not_found
- end
- end
-
- context 'Akismet is enabled' do
- let(:project) { create(:project_empty_repo, :public) }
-
+ context 'when an issue is identified as spam' do
before do
- stub_application_setting(recaptcha_enabled: true)
- allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
+ allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true)
end
- context 'when an issue is not identified as spam' do
+ context 'when captcha is not verified' do
+ def update_spam_issue
+ update_issue(title: 'Spam Title', description: 'Spam lives here')
+ end
+
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
- allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false)
end
- it 'normally updates the issue' do
- expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo')
+ it 'rejects an issue recognized as a spam' do
+ expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true)
+ expect { update_spam_issue }.not_to change { issue.reload.title }
+ end
+
+ it 'rejects an issue recognized as a spam when recaptcha disabled' do
+ stub_application_setting(recaptcha_enabled: false)
+
+ expect { update_spam_issue }.not_to change { issue.reload.title }
+ end
+
+ it 'creates a spam log' do
+ update_spam_issue
+
+ spam_logs = SpamLog.all
+
+ expect(spam_logs.count).to eq(1)
+ expect(spam_logs.first.title).to eq('Spam Title')
+ expect(spam_logs.first.recaptcha_verified).to be_falsey
+ end
+
+ context 'as HTML' do
+ it 'renders verify template' do
+ update_spam_issue
+
+ expect(response).to render_template(:verify)
+ end
+ end
+
+ context 'as JSON' do
+ before do
+ update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json)
+ end
+
+ it 'renders json errors' do
+ expect(json_response)
+ .to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."])
+ end
+
+ it 'returns 422 status' do
+ expect(response).to have_http_status(422)
+ end
end
end
- context 'when an issue is identified as spam' do
+ context 'when captcha is verified' do
+ let(:spammy_title) { 'Whatever' }
+ let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) }
+
+ def update_verified_issue
+ update_issue({ title: spammy_title },
+ { spam_log_id: spam_logs.last.id,
+ recaptcha_verification: true })
+ end
+
before do
- allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true)
+ allow_any_instance_of(described_class).to receive(:verify_recaptcha)
+ .and_return(true)
end
- context 'when captcha is not verified' do
- def update_spam_issue
- update_issue(title: 'Spam Title', description: 'Spam lives here')
- end
+ it 'redirect to issue page' do
+ update_verified_issue
- before do
- allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
- end
-
- it 'rejects an issue recognized as a spam' do
- expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true)
- expect { update_spam_issue }.not_to change { issue.reload.title }
- end
-
- it 'rejects an issue recognized as a spam when recaptcha disabled' do
- stub_application_setting(recaptcha_enabled: false)
-
- expect { update_spam_issue }.not_to change { issue.reload.title }
- end
-
- it 'creates a spam log' do
- update_spam_issue
-
- spam_logs = SpamLog.all
-
- expect(spam_logs.count).to eq(1)
- expect(spam_logs.first.title).to eq('Spam Title')
- expect(spam_logs.first.recaptcha_verified).to be_falsey
- end
-
- context 'as HTML' do
- it 'renders verify template' do
- update_spam_issue
-
- expect(response).to render_template(:verify)
- end
- end
-
- context 'as JSON' do
- before do
- update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json)
- end
-
- it 'renders json errors' do
- expect(json_response)
- .to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."])
- end
-
- it 'returns 422 status' do
- expect(response).to have_http_status(422)
- end
- end
+ expect(response)
+ .to redirect_to(project_issue_path(project, issue))
end
- context 'when captcha is verified' do
- let(:spammy_title) { 'Whatever' }
- let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) }
+ it 'accepts an issue after recaptcha is verified' do
+ expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
+ end
- def update_verified_issue
- update_issue({ title: spammy_title },
- { spam_log_id: spam_logs.last.id,
- recaptcha_verification: true })
- end
+ it 'marks spam log as recaptcha_verified' do
+ expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
+ end
- before do
- allow_any_instance_of(described_class).to receive(:verify_recaptcha)
- .and_return(true)
- end
+ it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
+ spam_log = create(:spam_log)
- it 'redirect to issue page' do
- update_verified_issue
-
- expect(response)
- .to redirect_to(project_issue_path(project, issue))
- end
-
- it 'accepts an issue after recaptcha is verified' do
- expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title)
- end
-
- it 'marks spam log as recaptcha_verified' do
- expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
- end
-
- it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
- spam_log = create(:spam_log)
-
- expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }
- .not_to change { SpamLog.last.recaptcha_verified }
- end
+ expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) }
+ .not_to change { SpamLog.last.recaptcha_verified }
end
end
end
@@ -385,13 +360,45 @@ describe Projects::IssuesController do
put :update, params
end
+ end
+ end
+
+ describe 'POST #move' do
+ before do
+ sign_in(user)
+ project.add_developer(user)
+ end
+
+ context 'when moving issue to another private project' do
+ let(:another_project) { create(:project, :private) }
+
+ context 'when user has access to move issue' do
+ before do
+ another_project.add_reporter(user)
+ end
+
+ it 'moves issue to another project' do
+ move_issue
+
+ expect(response).to have_http_status :ok
+ expect(another_project.issues).not_to be_empty
+ end
+ end
+
+ context 'when user does not have access to move issue' do
+ it 'responds with 404' do
+ move_issue
+
+ expect(response).to have_http_status :not_found
+ end
+ end
def move_issue
- put :update,
+ post :move,
+ format: :json,
namespace_id: project.namespace.to_param,
project_id: project,
id: issue.iid,
- issue: { title: 'New title' },
move_to_project_id: another_project.id
end
end
@@ -879,4 +886,19 @@ describe Projects::IssuesController do
format: :json
end
end
+
+ describe 'GET #discussions' do
+ let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it 'returns discussion json' do
+ get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
+
+ expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note])
+ end
+ end
end
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index f280c55059c..6ffe41b8608 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -46,10 +46,13 @@ describe Projects::NotesController do
end
context 'for a discussion note' do
- let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) }
+ let(:project) { create(:project, :repository) }
+ let!(:note) { create(:discussion_note_on_merge_request, project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:discussion_html]).not_to be_nil
@@ -104,10 +107,12 @@ describe Projects::NotesController do
end
context 'for a regular note' do
- let!(:note) { create(:note, noteable: issue, project: project) }
+ let!(:note) { create(:note_on_merge_request, project: project) }
+
+ let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:html]).not_to be_nil
@@ -125,7 +130,9 @@ describe Projects::NotesController do
note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
namespace_id: project.namespace,
project_id: project,
- merge_request_diff_head_sha: 'sha'
+ merge_request_diff_head_sha: 'sha',
+ target_type: 'merge_request',
+ target_id: merge_request.id
}
end
diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb
index 4d0111302f3..83c7744a231 100644
--- a/spec/controllers/projects/pages_controller_spec.rb
+++ b/spec/controllers/projects/pages_controller_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Projects::PagesController do
let(:user) { create(:user) }
- let(:project) { create(:project, :public, :access_requestable) }
+ let(:project) { create(:project, :public) }
let(:request_params) do
{
@@ -23,6 +23,17 @@ describe Projects::PagesController do
expect(response).to have_http_status(200)
end
+
+ context 'when the project is in a subgroup' do
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, namespace: group) }
+
+ it 'returns a 404 status code' do
+ get :show, request_params
+
+ expect(response).to have_http_status(404)
+ end
+ end
end
describe 'DELETE destroy' do
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index c0e48046937..4459e227fb3 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -7,6 +7,38 @@ describe ProjectsController do
let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') }
let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
+ describe 'GET new' do
+ context 'with an authenticated user' do
+ let(:group) { create(:group) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when namespace_id param is present' do
+ context 'when user has access to the namespace' do
+ it 'renders the template' do
+ group.add_owner(user)
+
+ get :new, namespace_id: group.id
+
+ expect(response).to have_http_status(200)
+ expect(response).to render_template('new')
+ end
+ end
+
+ context 'when user does not have access to the namespace' do
+ it 'responds with status 404' do
+ get :new, namespace_id: group.id
+
+ expect(response).to have_http_status(404)
+ expect(response).not_to render_template('new')
+ end
+ end
+ end
+ end
+ end
+
describe 'GET index' do
context 'as a user' do
it 'redirects to root page' do
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index 5bba1dec7db..25ec63de94a 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -12,6 +12,7 @@ FactoryGirl.define do
started_at 'Di 29. Okt 09:51:28 CET 2013'
finished_at 'Di 29. Okt 09:53:28 CET 2013'
commands 'ls -a'
+ protected false
options do
{
@@ -226,5 +227,9 @@ FactoryGirl.define do
status 'created'
self.when 'manual'
end
+
+ trait :protected do
+ protected true
+ end
end
end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index e83a0e599a8..e5ea6b41ea3 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -4,6 +4,7 @@ FactoryGirl.define do
ref 'master'
sha '97de212e80737a608d939f648d959671fb0a0142'
status 'pending'
+ protected false
project
@@ -59,6 +60,10 @@ FactoryGirl.define do
trait :failed do
status :failed
end
+
+ trait :protected do
+ protected true
+ end
end
end
end
diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb
index 05abf60d5ce..88bb755d068 100644
--- a/spec/factories/ci/runners.rb
+++ b/spec/factories/ci/runners.rb
@@ -5,6 +5,7 @@ FactoryGirl.define do
platform "darwin"
is_shared false
active true
+ access_level :not_protected
trait :online do
contacted_at Time.now
@@ -21,5 +22,9 @@ FactoryGirl.define do
trait :inactive do
active false
end
+
+ trait :ref_protected do
+ access_level :ref_protected
+ end
end
end
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index a13b6e3596e..3f7c794b14a 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -18,5 +18,54 @@ FactoryGirl.define do
factory :write_access_key, class: 'DeployKey' do
can_push true
end
+
+ factory :rsa_key_2048 do
+ key do
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O9' \
+ '6x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5' \
+ '/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7' \
+ 'M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaC' \
+ 'rzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy0' \
+ '5qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz= dummy@gitlab.com'
+ end
+
+ factory :rsa_deploy_key_2048, class: 'DeployKey'
+ end
+
+ factory :dsa_key_2048 do
+ key do
+ 'ssh-dss AAAAB3NzaC1kc3MAAAEBAO/3/NPLA/zSFkMOCaTtGo+uos1flfQ5f038Uk+G' \
+ 'Y9AeLGzX+Srhw59GdVXmOQLYBrOt5HdGwqYcmLnE2VurUGmhtfeO5H+3p5pGJbkS0Gxp' \
+ 'YH1HRO9lWsncF3Hh1w4lYsDjkclDiSTdfTuN8F4Kb3DXNnVSCieeonp+B25F/CXagyTQ' \
+ '/pvNmHFeYgGCVdnBtFdi+xfxaZ8NKdPrGggzokbKHElDZQ4Xo5EpdcyLajgM7nB2r2Rz' \
+ 'OrmeaevKi5lV68ehRa9Yyrb7vxvwiwBwOgqR/mnN7Gnaq1jUdmJY+ct04Qwx37f5jvhv' \
+ '5gA4U40SGMoiHM8RFIN7Ksz0jsyX73MAAAAVALRWOfjfzHpK7KLz4iqDvvTUAevJAAAB' \
+ 'AEa9NZ+6y9iQ5erGsdfLTXFrhSefTG0NhghoO/5IFkSGfd8V7kzTvCHaFrcfpEA5kP8t' \
+ 'poeOG0TASB6tgGOxm1Bq4Wncry5RORBPJlAVpDGRcvZ931ddH7IgltEInS6za2uH6F/1' \
+ 'M1QfKePSLr6xJ1ZLYfP0Og5KTp1x6yMQvfwV0a+XdA+EPgaJWLWp/pWwKWa0oLUgjsIH' \
+ 'MYzuOGh5c708uZrmkzqvgtW2NgXhcIroRgynT3IfI2lP2rqqb3uuuE/qH5UCUFO+Dc3H' \
+ 'nAFNeQDT/M25AERdPYBAY5a+iPjIgO+jT7BfmfByT+AZTqZySrCyc7nNZL3YgGLK0l6A' \
+ '1GgAAAEBAN9FpFOdIXE+YEZhKl1vPmbcn+b1y5zOl6N4x1B7Q8pD/pLMziWROIS8uLzb' \
+ 'aZ0sMIWezHIkxuo1iROMeT+jtCubn7ragaN6AX7nMpxYUH9+mYZZs/fyElt6wCviVhTI' \
+ 'zM+u7VdQsnZttOOlQfogHdL+SpeAft0DsfJjlcgQnsLlHQKv6aPqCPYUST2nE7RyW/Ex' \
+ 'PrMxLtOWt0/j8RYHbwwqvyeZqBz3ESBgrS9c5tBdBfauwYUV/E7gPLOU3OZFw9ue7o+z' \
+ 'wzoTZqW6Xouy5wtWvSLQSLT5XwOslmQz8QMBxD0AQyDfEFGsBCWzmbTgKv9uqrBjubsS' \
+ 'Taja+Cf9kMo== dummy@gitlab.com'
+ end
+ end
+
+ factory :ecdsa_key_256 do
+ key do
+ 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYA' \
+ 'AABBBJZmkzTgY0fiCQ+DVReyH/fFwTFz0XoR3RUO0u+199H19KFw7mNPxRSMOVS7tEtO' \
+ 'Nj3Q7FcZXfqthHvgAzDiHsc= dummy@gitlab.com'
+ end
+ end
+
+ factory :ed25519_key_256 do
+ key do
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIETnVTgzqC1gatgSlC4zH6aYt2CAQzgJOhDRvf59ohL6 dummy@gitlab.com'
+ end
+ end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 9ebda0ba03b..7493b0a8b35 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -101,8 +101,6 @@ FactoryGirl.define do
# Test repository - https://gitlab.com/gitlab-org/gitlab-test
trait :repository do
- path { 'gitlabhq' }
-
test_repo
transient do
diff --git a/spec/features/admin/admin_active_tab_spec.rb b/spec/features/admin/admin_active_tab_spec.rb
index 07430ecd6e0..5ff791fc36a 100644
--- a/spec/features/admin/admin_active_tab_spec.rb
+++ b/spec/features/admin/admin_active_tab_spec.rb
@@ -7,15 +7,15 @@ RSpec.describe 'admin active tab' do
shared_examples 'page has active tab' do |title|
it "activates #{title} tab" do
- expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1)
- expect(page.find('.layout-nav li.active')).to have_content(title)
+ expect(page).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1)
+ expect(page.find('.nav-sidebar .sidebar-top-level-items > li.active')).to have_content(title)
end
end
shared_examples 'page has active sub tab' do |title|
it "activates #{title} sub tab" do
- expect(page).to have_selector('.sub-nav li.active', count: 1)
- expect(page.find('.sub-nav li.active')).to have_content(title)
+ expect(page).to have_selector('.sidebar-sub-level-items li.active', count: 1)
+ expect(page.find('.sidebar-sub-level-items li.active')).to have_content(title)
end
end
diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb
index 30fcb334b60..91f08dbad5d 100644
--- a/spec/features/admin/admin_hooks_spec.rb
+++ b/spec/features/admin/admin_hooks_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe 'Admin::Hooks' do
+describe 'Admin::Hooks', :js do
before do
@project = create(:project)
sign_in(create(:admin))
@@ -12,7 +12,7 @@ describe 'Admin::Hooks' do
it 'is ok' do
visit admin_root_path
- page.within '.layout-nav' do
+ page.within '.nav-sidebar' do
click_on 'Hooks'
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index dbb0ae9c86e..563818e8761 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -79,6 +79,22 @@ feature 'Admin updates settings' do
end
end
+ scenario 'Change Keys settings' do
+ select 'Are forbidden', from: 'RSA SSH keys'
+ select 'Are allowed', from: 'DSA SSH keys'
+ select 'Must be at least 384 bits', from: 'ECDSA SSH keys'
+ select 'Are forbidden', from: 'ED25519 SSH keys'
+ click_on 'Save'
+
+ forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE.to_s
+
+ expect(page).to have_content 'Application settings saved successfully'
+ expect(find_field('RSA SSH keys').value).to eq(forbidden)
+ expect(find_field('DSA SSH keys').value).to eq('0')
+ expect(find_field('ECDSA SSH keys').value).to eq('384')
+ expect(find_field('ED25519 SSH keys').value).to eq(forbidden)
+ end
+
def check_all_events
page.check('Active')
page.check('Push')
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index ce458431c55..913258ca40f 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -13,6 +13,8 @@ describe 'Issue Boards', js: true do
project.team << [user, :master]
project.team << [user2, :master]
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
sign_in(user)
end
@@ -145,6 +147,8 @@ describe 'Issue Boards', js: true do
click_button 'Add list'
wait_for_requests
+ find('.dropdown-menu-close').click
+
page.within(find('.board:nth-child(2)')) do
find('.board-delete').click
end
diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb
index 067e4337e6a..08d8cc7922b 100644
--- a/spec/features/dashboard/active_tab_spec.rb
+++ b/spec/features/dashboard/active_tab_spec.rb
@@ -7,9 +7,8 @@ RSpec.describe 'Dashboard Active Tab', js: true do
shared_examples 'page has active tab' do |title|
it "#{title} tab" do
- find('.global-dropdown-toggle').trigger('click')
- expect(page).to have_selector('.global-dropdown-menu li.active', count: 1)
- expect(find('.global-dropdown-menu li.active')).to have_content(title)
+ expect(page).to have_selector('.navbar-sub-nav li.active', count: 1)
+ expect(find('.navbar-sub-nav li.active')).to have_content(title)
end
end
@@ -21,22 +20,6 @@ RSpec.describe 'Dashboard Active Tab', js: true do
it_behaves_like 'page has active tab', 'Projects'
end
- context 'on dashboard issues' do
- before do
- visit issues_dashboard_path
- end
-
- it_behaves_like 'page has active tab', 'Issues'
- end
-
- context 'on dashboard merge requests' do
- before do
- visit merge_requests_dashboard_path
- end
-
- it_behaves_like 'page has active tab', 'Merge Requests'
- end
-
context 'on dashboard groups' do
before do
visit dashboard_groups_path
@@ -44,4 +27,12 @@ RSpec.describe 'Dashboard Active Tab', js: true do
it_behaves_like 'page has active tab', 'Groups'
end
+
+ context 'on activity projects' do
+ before do
+ visit activity_dashboard_path
+ end
+
+ it_behaves_like 'page has active tab', 'Activity'
+ end
end
diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb
index facb67ae787..ebc3d196118 100644
--- a/spec/features/dashboard/issues_filter_spec.rb
+++ b/spec/features/dashboard/issues_filter_spec.rb
@@ -50,7 +50,7 @@ feature 'Dashboard Issues filtering', :js do
it 'updates atom feed link' do
visit_issues(milestone_title: '', assignee_id: user.id)
- link = find('.nav-controls a[title="Subscribe"]')
+ link = find('.breadcrumbs a[title="Subscribe"]')
params = CGI.parse(URI.parse(link[:href]).query)
auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb
index 5f1f0c10339..e41bd7a8419 100644
--- a/spec/features/dashboard/shortcuts_spec.rb
+++ b/spec/features/dashboard/shortcuts_spec.rb
@@ -50,6 +50,6 @@ feature 'Dashboard shortcuts', :js do
end
def check_page_title(title)
- expect(find('.header-content .title')).to have_content(title)
+ expect(find('.breadcrumbs-sub-title')).to have_content(title)
end
end
diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb
deleted file mode 100644
index a7b8b702ab7..00000000000
--- a/spec/features/groups/group_name_toggle_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require 'spec_helper'
-
-feature 'Group name toggle', js: true do
- let(:group) { create(:group) }
- let(:nested_group_1) { create(:group, parent: group) }
- let(:nested_group_2) { create(:group, parent: nested_group_1) }
- let(:nested_group_3) { create(:group, parent: nested_group_2) }
-
- SMALL_SCREEN = 300
-
- before do
- sign_in(create(:user))
- end
-
- it 'is not present if enough horizontal space' do
- visit group_path(nested_group_3)
-
- container_width = page.evaluate_script("$('.title-container')[0].offsetWidth")
- title_width = page.evaluate_script("$('.title')[0].offsetWidth")
-
- expect(container_width).to be > title_width
- expect(page).not_to have_css('.group-name-toggle')
- end
-
- it 'is present if the title is longer than the container', :nested_groups do
- visit group_path(nested_group_3)
- title_width = page.evaluate_script("$('.title')[0].offsetWidth")
-
- page_height = page.current_window.size[1]
- page.current_window.resize_to(SMALL_SCREEN, page_height)
-
- find('.group-name-toggle')
- container_width = page.evaluate_script("$('.title-container')[0].offsetWidth")
-
- expect(title_width).to be > container_width
- end
-
- it 'should show the full group namespace when toggled', :nested_groups do
- page_height = page.current_window.size[1]
- page.current_window.resize_to(SMALL_SCREEN, page_height)
- visit group_path(nested_group_3)
-
- expect(page).not_to have_content(group.name)
- expect(page).to have_css('.group-path.hidable', visible: false)
-
- click_button '...'
-
- expect(page).to have_content(group.name)
- expect(page).to have_css('.group-path.hidable', visible: true)
- end
-end
diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb
index d0316cfb18d..b83bad3befb 100644
--- a/spec/features/groups/group_settings_spec.rb
+++ b/spec/features/groups/group_settings_spec.rb
@@ -65,14 +65,14 @@ feature 'Edit group settings' do
update_path(new_group_path)
visit new_project_full_path
expect(current_path).to eq(new_project_full_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.path)
end
scenario 'the old project path redirects to the new path' do
update_path(new_group_path)
visit old_project_full_path
expect(current_path).to eq(new_project_full_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs')).to have_content(project.path)
end
end
end
diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb
index 9ba9f5686f7..2577d98df6f 100644
--- a/spec/features/groups/merge_requests_spec.rb
+++ b/spec/features/groups/merge_requests_spec.rb
@@ -25,7 +25,7 @@ feature 'Group merge requests page' do
end
it 'ignores archived merge request count badges in navbar' do
- expect( page.find('[title="Merge Requests"] span.badge.count').text).to eq("1")
+ expect( page.find('[aria-label="Merge Requests"] span.badge.count').text).to eq("1")
end
it 'ignores archived merge request count badges in state-filters' do
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 20f9818b08b..4ec2e7e6012 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -158,7 +158,7 @@ feature 'Group' do
expect(page).to have_content 'successfully updated'
expect(find('#group_name').value).to eq(new_name)
- page.within ".navbar-gitlab" do
+ page.within ".breadcrumbs" do
expect(page).to have_content new_name
end
end
diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb
index 134e618feac..a29acb30163 100644
--- a/spec/features/issues/award_emoji_spec.rb
+++ b/spec/features/issues/award_emoji_spec.rb
@@ -70,13 +70,13 @@ describe 'Awards Emoji' do
it 'toggles the smiley emoji on a note', js: true do
toggle_smiley_emoji(true)
- within('.note-awards') do
+ within('.note-body') do
expect(find(emoji_counter)).to have_text("1")
end
toggle_smiley_emoji(false)
- within('.note-awards') do
+ within('.note-body') do
expect(page).not_to have_selector(emoji_counter)
end
end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index a64c1cf220b..3ea6e1c8863 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -1,26 +1,24 @@
require 'spec_helper'
describe 'Filter issues', js: true do
- include Devise::Test::IntegrationHelpers
include FilteredSearchHelpers
- let!(:group) { create(:group) }
- let!(:project) { create(:project, group: group) }
- let!(:user) { create(:user, username: 'joe', name: 'Joe') }
- let!(:user2) { create(:user, username: 'jane') }
- let!(:label) { create(:label, project: project) }
- let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
+ let(:project) { create(:project) }
+
+ # NOTE: The short name here is actually important
+ #
+ # When the name is longer, the filtered search input can end up scrolling
+ # horizontally, and PhantomJS can't handle it.
+ let(:user) { create(:user, name: 'Ann') }
let!(:bug_label) { create(:label, project: project, title: 'bug') }
let!(:caps_sensitive_label) { create(:label, project: project, title: 'CaPs') }
- let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) }
let!(:multiple_words_label) { create(:label, project: project, title: "Two words") }
-
- let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) }
+ let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) }
def expect_no_issues_list
page.within '.issues-list' do
- expect(page).not_to have_selector('.issue')
+ expect(page).to have_no_selector('.issue')
end
end
@@ -33,65 +31,64 @@ describe 'Filter issues', js: true do
end
end
- def select_search_at_index(pos)
- evaluate_script("el = document.querySelector('.filtered-search'); el.focus(); el.setSelectionRange(#{pos}, #{pos});")
- end
-
before do
- project.team << [user, :master]
- project.team << [user2, :master]
- group.add_developer(user)
- group.add_developer(user2)
+ project.add_master(user)
- sign_in(user)
+ user2 = create(:user)
- create(:issue, project: project)
- create(:issue, project: project, title: "Bug report 1")
- create(:issue, project: project, title: "Bug report 2")
- create(:issue, project: project, title: "issue with 'single quotes'")
- create(:issue, project: project, title: "issue with \"double quotes\"")
- create(:issue, project: project, title: "issue with !@\#{$%^&*()-+")
- create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user])
- create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user])
+ create(:issue, project: project, author: user2, title: "Bug report 1")
+ create(:issue, project: project, author: user2, title: "Bug report 2")
- issue = create(:issue,
+ create(:issue, project: project, author: user, title: "issue by assignee", milestone: milestone, assignees: [user])
+ create(:issue, project: project, author: user, title: "issue by assignee with searchTerm", milestone: milestone, assignees: [user])
+
+ create(:labeled_issue,
title: "Bug 2",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue.labels << bug_label
+ assignees: [user],
+ labels: [bug_label])
- issue_with_caps_label = create(:issue,
+ create(:labeled_issue,
title: "issue by assignee with searchTerm and label",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue_with_caps_label.labels << caps_sensitive_label
+ assignees: [user],
+ labels: [caps_sensitive_label])
- issue_with_everything = create(:issue,
+ create(:labeled_issue,
title: "Bug report foo was possible",
project: project,
milestone: milestone,
author: user,
- assignees: [user])
- issue_with_everything.labels << bug_label
- issue_with_everything.labels << caps_sensitive_label
+ assignees: [user],
+ labels: [bug_label, caps_sensitive_label])
- multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project)
- multiple_words_label_issue.labels << multiple_words_label
-
- future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month)
-
- create(:issue,
- title: "Issue with future milestone",
- milestone: future_milestone,
- project: project)
+ create(:labeled_issue, title: "Issue with multiple words label", project: project, labels: [multiple_words_label])
+ sign_in(user)
visit project_issues_path(project)
end
+ it 'filters by all available tokens' do
+ search_term = 'issue'
+
+ input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
+
+ wait_for_requests
+
+ expect_tokens([
+ assignee_token(user.name),
+ author_token(user.name),
+ label_token(caps_sensitive_label.title),
+ milestone_token(milestone.title)
+ ])
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search_term)
+ end
+
describe 'filter issues by author' do
context 'only author' do
it 'filters issues by searched author' do
@@ -104,59 +101,6 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
end
-
- context 'author with other filters' do
- let(:search_term) { 'issue' }
-
- it 'filters issues by searched author and text' do
- input_filtered_search("author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([author_token(user.name)])
- expect_issues_list_count(3)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([author_token(user.name), assignee_token(user.name)])
- expect_issues_list_count(3)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee, label, and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- author_token(user.name),
- assignee_token(user.name),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched author, assignee, label, milestone and text' do
- input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- author_token(user.name),
- assignee_token(user.name),
- label_token(caps_sensitive_label.title),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by assignee' do
@@ -175,66 +119,13 @@ describe 'Filter issues', js: true do
input_filtered_search('assignee:none')
expect_tokens([assignee_token('none')])
- expect_issues_list_count(8, 1)
+ expect_issues_list_count(3)
expect_filtered_search_input_empty
end
end
-
- context 'assignee with other filters' do
- let(:search_term) { 'searchTerm' }
-
- it 'filters issues by searched assignee and text' do
- input_filtered_search("assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author and text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([assignee_token(user.name), author_token(user.name)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author, label, text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- assignee_token(user.name),
- author_token(user.name),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched assignee, author, label, milestone and text' do
- input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([
- assignee_token(user.name),
- author_token(user.name),
- label_token(caps_sensitive_label.title),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by label' do
- let(:search_term) { 'bug' }
-
context 'only label' do
it 'filters issues by searched label' do
input_filtered_search("label:~#{bug_label.title}")
@@ -248,7 +139,7 @@ describe 'Filter issues', js: true do
input_filtered_search('label:none')
expect_tokens([label_token('none', false)])
- expect_issues_list_count(9, 1)
+ expect_issues_list_count(8)
expect_filtered_search_input_empty
end
@@ -275,13 +166,13 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
- it 'does not show issues' do
+ it 'does not show issues for unused labels' do
new_label = create(:label, project: project, title: 'new_label')
input_filtered_search("label:~#{new_label.title}")
expect_tokens([label_token(new_label.title)])
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input_empty
end
end
@@ -344,95 +235,10 @@ describe 'Filter issues', js: true do
end
end
- context 'label with other filters' do
- it 'filters issues by searched label and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} #{search_term}")
-
- expect_tokens([label_token(caps_sensitive_label.title)])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([label_token(caps_sensitive_label.title), author_token(user.name)])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author, assignee and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, author, assignee, milestone and text' do
- input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name),
- milestone_token(milestone.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
- end
-
context 'multiple labels with other filters' do
- it 'filters issues by searched label, label2, and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} #{search_term}")
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, label2, author and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched label, label2, author, assignee and text' do
- input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- label_token(bug_label.title),
- label_token(caps_sensitive_label.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(1)
- expect_filtered_search_input(search_term)
- end
-
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
+ search_term = 'bug'
+
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
wait_for_requests
@@ -450,15 +256,10 @@ describe 'Filter issues', js: true do
end
context 'issue label clicked' do
- before do
+ it 'filters and displays in search bar' do
find('.issues-list .issue .issue-main-info .issuable-info a .label', text: multiple_words_label.title).click
- end
- it 'filters' do
expect_issues_list_count(1)
- end
-
- it 'displays in search bar' do
expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_filtered_search_input_empty
end
@@ -479,11 +280,15 @@ describe 'Filter issues', js: true do
input_filtered_search("milestone:none")
expect_tokens([milestone_token('none', false)])
- expect_issues_list_count(7, 1)
+ expect_issues_list_count(3)
expect_filtered_search_input_empty
end
it 'filters issues by upcoming milestones' do
+ create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
+ create(:issue, project: project, milestone: future_milestone, author: user)
+ end
+
input_filtered_search("milestone:upcoming")
expect_tokens([milestone_token('upcoming', false)])
@@ -501,7 +306,7 @@ describe 'Filter issues', js: true do
it 'filters issues by milestone containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
- create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone)
+ create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone:%#{special_milestone.title}")
@@ -510,70 +315,16 @@ describe 'Filter issues', js: true do
expect_filtered_search_input_empty
end
- it 'does not show issues' do
- new_milestone = create(:milestone, title: "new", project: project)
+ it 'does not show issues for unused milestones' do
+ new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone:%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title)])
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input_empty
end
end
-
- context 'milestone with other filters' do
- let(:search_term) { 'bug' }
-
- it 'filters issues by searched milestone and text' do
- input_filtered_search("milestone:%#{milestone.title} #{search_term}")
-
- expect_tokens([milestone_token(milestone.title)])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author, assignee and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name),
- assignee_token(user.name)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
-
- it 'filters issues by searched milestone, author, assignee, label and text' do
- input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} #{search_term}")
-
- wait_for_requests
-
- expect_tokens([
- milestone_token(milestone.title),
- author_token(user.name),
- assignee_token(user.name),
- label_token(bug_label.title)
- ])
- expect_issues_list_count(2)
- expect_filtered_search_input(search_term)
- end
- end
end
describe 'filter issues by text' do
@@ -582,7 +333,7 @@ describe 'Filter issues', js: true do
search = 'Bug'
input_filtered_search(search)
- expect_issues_list_count(4, 1)
+ expect_issues_list_count(4)
expect_filtered_search_input(search)
end
@@ -603,112 +354,50 @@ describe 'Filter issues', js: true do
end
it 'filters issues by searched text containing single quotes' do
- search = '\'single quotes\''
+ issue = create(:issue, project: project, author: user, title: "issue with 'single quotes'")
+
+ search = "'single quotes'"
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'filters issues by searched text containing double quotes' do
+ issue = create(:issue, project: project, author: user, title: "issue with \"double quotes\"")
+
search = '"double quotes"'
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'filters issues by searched text containing special characters' do
+ issue = create(:issue, project: project, author: user, title: "issue with !@\#{$%^&*()-+")
+
search = '!@#{$%^&*()-+'
input_filtered_search(search)
expect_issues_list_count(1)
expect_filtered_search_input(search)
+ expect(page).to have_content(issue.title)
end
it 'does not show any issues' do
search = 'testing'
input_filtered_search(search)
- expect_no_issues_list()
+ expect_no_issues_list
expect_filtered_search_input(search)
end
end
context 'searched text with other filters' do
- it 'filters issues by searched text and author' do
- # After searching, all search terms are placed at the end
- input_filtered_search("bug author:@#{user.username}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author and more text' do
- input_filtered_search("bug author:@#{user.username} report")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report')
- end
-
- it 'filters issues by searched text, author and assignee' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, more text and assignee' do
- input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report')
- end
-
- it 'filters issues by searched text, author, more text, assignee and even more text' do
- input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee and label' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, text, assignee, text, label and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee, label and milestone' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}")
-
- expect_issues_list_count(2)
- expect_filtered_search_input('bug')
- end
-
- it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} milestone:%#{milestone.title} foo")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug report foo')
- end
-
- it 'filters issues by searched text, author, assignee, multiple labels and milestone' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}")
-
- expect_issues_list_count(1)
- expect_filtered_search_input('bug')
- end
-
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
- input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo")
+ input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo")
expect_issues_list_count(1)
expect_filtered_search_input('bug report foo')
@@ -746,7 +435,9 @@ describe 'Filter issues', js: true do
end
end
- describe 'retains filter when switching issue states' do
+ describe 'switching issue states' do
+ let!(:closed_issue) { create(:issue, :closed, project: project, title: 'closed bug') }
+
before do
input_filtered_search('bug')
@@ -754,25 +445,21 @@ describe 'Filter issues', js: true do
expect_issues_list_count(4, 1)
end
- it 'open state' do
- find('.issues-state-filters [data-state="closed"]').click
- wait_for_requests
-
- find('.issues-state-filters [data-state="opened"]').click
- wait_for_requests
-
- expect(page).to have_selector('.issues-list .issue', count: 4)
- end
-
- it 'closed state' do
+ it 'maintains filter' do
+ # Closed
find('.issues-state-filters [data-state="closed"]').click
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 1)
- expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title)
- end
+ expect(page).to have_link(closed_issue.title)
- it 'all state' do
+ # Opened
+ find('.issues-state-filters [data-state="opened"]').click
+ wait_for_requests
+
+ expect(page).to have_selector('.issues-list .issue', count: 4)
+
+ # All
find('.issues-state-filters [data-state="all"]').click
wait_for_requests
@@ -781,34 +468,39 @@ describe 'Filter issues', js: true do
end
describe 'RSS feeds' do
- it 'updates atom feed link for project issues' do
- visit project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id)
- link = find_link('Subscribe')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
- expect(params).to include('rss_token' => [user.rss_token])
- expect(params).to include('milestone_title' => [milestone.title])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
- expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ before do
+ group.add_developer(user)
end
- it 'updates atom feed link for group issues' do
- visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id)
- link = find('.nav-controls a', text: 'Subscribe')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+ shared_examples 'updates atom feed link' do |type|
+ it "for #{type}" do
+ visit path
- expect(params).to include('rss_token' => [user.rss_token])
- expect(params).to include('milestone_title' => [milestone.title])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('rss_token' => [user.rss_token])
- expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ link = find_link('Subscribe')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expected = {
+ 'rss_token' => [user.rss_token],
+ 'milestone_title' => [milestone.title],
+ 'assignee_id' => [user.id.to_s]
+ }
+
+ expect(params).to include(expected)
+ expect(auto_discovery_params).to include(expected)
+ end
+ end
+
+ it_behaves_like 'updates atom feed link', :project do
+ let(:path) { project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id) }
+ end
+
+ it_behaves_like 'updates atom feed link', :group do
+ let(:path) { issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) }
end
end
@@ -821,7 +513,7 @@ describe 'Filter issues', js: true do
input_filtered_search("milestone:", submit: false)
within('#js-dropdown-milestone') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 2)
+ expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
end
end
@@ -829,7 +521,7 @@ describe 'Filter issues', js: true do
input_filtered_search("label:", submit: false)
within('#js-dropdown-label') do
- expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5)
+ expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
end
end
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 14a555fde10..4ae54fd6f4e 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -28,6 +28,8 @@ describe 'Visual tokens', js: true do
sign_in(user)
create(:issue, project: project)
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
visit project_issues_path(project)
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index b84635c5134..c6cf6265645 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do
it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('testing')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('testing')
+ find('#note-body').native.send_keys('@')
end
expect(page).not_to have_selector('.atwho-view')
@@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt select the first item for non-assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':')
end
expect(page).to have_selector('.atwho-container')
@@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do
end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
- note = find('#note_note')
+ note = find('#note-body')
# Number.
page.within '.timeline-content-form' do
@@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys("@#{user.name[0...8]}")
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys("@#{user.name[0...8]}")
end
expect(page).to have_selector('.atwho-container')
@@ -112,8 +112,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for non-assignee dropdowns if a query is entered' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':1')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':1')
end
expect(page).to have_selector('.atwho-container')
@@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("~#{label.title[0]}")
@@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do
end
it "shows dropdown after a new line" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('test')
note.native.send_keys(:enter)
@@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do
end
it "does not show dropdown when preceded with a special character" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("@")
@@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do
end
it "does not throw an error if no labels exist" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys('~')
@@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for assignee values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("@#{user.username[0]}")
@@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for emoji values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys(":cartwheel")
@@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete after non-word character' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys("@#{user.username[0..2]}!")
+ find('#note-body').native.send_keys("@#{user.username[0..2]}!")
end
expect(page).not_to have_selector('.atwho-view')
@@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete if there is no space before' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys("hello:#{user.username[0..2]}")
+ find('#note-body').native.send_keys("hello:#{user.username[0..2]}")
end
expect(page).not_to have_selector('.atwho-view')
end
it 'triggers autocomplete after selecting a quick action' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys('/as')
diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb
index 8c23fcd483b..634ea111dc1 100644
--- a/spec/features/issues/markdown_toolbar_spec.rb
+++ b/spec/features/issues/markdown_toolbar_spec.rb
@@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do
end
it "doesn't include first new line when adding bold" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('bold')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ find('#note-body').native.send_keys('bold')
- page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 9)')
+ page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 9)')
first('.toolbar-btn').click
- expect(find('#note_note')[:value]).to eq("test\n**bold**\n")
+ expect(find('#note-body')[:value]).to eq("test\n**bold**\n")
end
it "doesn't include first new line when adding underline" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('underline')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ find('#note-body').native.send_keys('underline')
- page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 50)')
+ page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 50)')
find('.toolbar-btn:nth-child(2)').click
- expect(find('#note_note')[:value]).to eq("test\n*underline*\n")
+ expect(find('#note-body')[:value]).to eq("test\n*underline*\n")
end
end
diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb
index 494c309c9ea..b2724945da4 100644
--- a/spec/features/issues/move_spec.rb
+++ b/spec/features/issues/move_spec.rb
@@ -15,11 +15,11 @@ feature 'issue move to another project' do
background do
old_project.team << [user, :guest]
- edit_issue(issue)
+ visit issue_path(issue)
end
scenario 'moving issue to another project not allowed' do
- expect(page).to have_no_selector('#move_to_project_id')
+ expect(page).to have_no_selector('.js-sidebar-move-issue-block')
end
end
@@ -34,12 +34,14 @@ feature 'issue move to another project' do
old_project.team << [user, :reporter]
new_project.team << [user, :reporter]
- edit_issue(issue)
+ visit issue_path(issue)
end
scenario 'moving issue to another project', js: true do
- find('#issuable-move', visible: false).set(new_project.id)
- click_button('Save changes')
+ find('.js-move-issue').trigger('click')
+ wait_for_requests
+ all('.js-move-issue-dropdown-item')[0].click
+ find('.js-move-issue-confirmation-button').click
expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}")
expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}")
@@ -50,13 +52,12 @@ feature 'issue move to another project' do
scenario 'searching project dropdown', js: true do
new_project_search.team << [user, :reporter]
- page.within '.detail-page-description' do
- first('.select2-choice').click
- end
+ find('.js-move-issue').trigger('click')
+ wait_for_requests
- fill_in('s2id_autogen1_search', with: new_project_search.name)
+ page.within '.js-sidebar-move-issue-block' do
+ fill_in('sidebar-move-issue-dropdown-search', with: new_project_search.name)
- page.within '.select2-drop' do
expect(page).to have_content(new_project_search.name)
expect(page).not_to have_content(new_project.name)
end
@@ -68,10 +69,10 @@ feature 'issue move to another project' do
background { another_project.team << [user, :guest] }
scenario 'browsing projects in projects select' do
- click_link 'Move to a different project'
+ find('.js-move-issue').trigger('click')
+ wait_for_requests
- page.within '.select2-results' do
- expect(page).to have_content 'No project'
+ page.within '.js-sidebar-move-issue-block' do
expect(page).to have_content new_project.name_with_namespace
end
end
@@ -89,11 +90,6 @@ feature 'issue move to another project' do
end
end
- def edit_issue(issue)
- visit issue_path(issue)
- page.within('.issuable-actions') { first(:link, 'Edit').click }
- end
-
def issue_path(issue)
project_issue_path(issue.project, issue)
end
diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb
index 62dbc3efb01..793572851da 100644
--- a/spec/features/issues/note_polling_spec.rb
+++ b/spec/features/issues/note_polling_spec.rb
@@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do
it 'displays the new comment' do
note = create(:note, noteable: issue, project: project, note: 'Looks good!')
- page.execute_script('notes.refresh();')
+ wait_for_requests
expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
end
@@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
- expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
-
- update_note(existing_note, updated_text)
-
- expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
- end
-
it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
@@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
end
- it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do
+ it 'when editing but have not changed anything, and an update comes in, show warning and does not update the note' do
click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
update_note(existing_note, updated_text)
- expect(page).to have_field("note[note]", with: updated_text)
- end
-
- it 'when editing but you changed some things, and an update comes in, show a warning' do
- click_edit_action(existing_note)
-
- expect(page).to have_field("note[note]", with: note_text)
-
- find("#note_#{existing_note.id} .js-note-text").set('something random')
- update_note(existing_note, updated_text)
-
+ expect(page).not_to have_field("note[note]", with: updated_text)
expect(page).to have_selector(".alert")
end
@@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do
expect(page).to have_field("note[note]", with: note_text)
- find("#note_#{existing_note.id} .js-note-text").set('something random')
-
update_note(existing_note, updated_text)
find("#note_#{existing_note.id} .note-edit-cancel").click
@@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
+ it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
update_note(existing_note, updated_text)
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
- expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
end
end
@@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
end
- it 'has .original-note-content to compare against' do
+ it 'shows the system note' do
expect(page).to have_selector("#note_#{system_note.id}", text: note_text)
- expect(page).to have_selector("#note_#{system_note.id} .original-note-content", count: 1, visible: false)
end
end
end
def update_note(note, new_text)
note.update(note: new_text)
- page.execute_script('notes.refresh();')
+ wait_for_requests
end
def click_edit_action(note)
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
index 4b63cc844f3..9261acda9dc 100644
--- a/spec/features/issues/user_uses_slash_commands_spec.rb
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -155,5 +155,114 @@ feature 'Issues > User uses quick actions', js: true do
end
end
end
+
+ describe 'move the issue to another project' do
+ let(:issue) { create(:issue, project: project) }
+
+ context 'when the project is valid', js: true do
+ let(:target_project) { create(:project, :public) }
+
+ before do
+ target_project.team << [user, :master]
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'moves the issue' do
+ write_note("/move #{target_project.full_path}")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is valid but the user not authorized', js: true do
+ let(:project_unauthorized) {create(:project, :public)}
+
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'does not move the issue' do
+ write_note("/move #{project_unauthorized.full_path}")
+
+ expect(page).not_to have_content 'Commands applied'
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the project is invalid', js: true do
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'does not move the issue' do
+ write_note("/move not/valid")
+
+ expect(page).not_to have_content 'Commands applied'
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the user issues multiple commands', js: true do
+ let(:target_project) { create(:project, :public) }
+ let(:milestone) { create(:milestone, title: '1.0', project: project) }
+ let(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
+ let(:bug) { create(:label, project: project, title: 'bug') }
+ let(:wontfix) { create(:label, project: project, title: 'wontfix') }
+ let(:bug_target) { create(:label, project: target_project, title: 'bug') }
+ let(:wontfix_target) { create(:label, project: target_project, title: 'wontfix') }
+
+ before do
+ target_project.team << [user, :master]
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'applies the commands to both issues and moves the issue' do
+ write_note("/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"\n/move #{target_project.full_path}")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'Closed'
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+
+ it 'moves the issue and applies the commands to both issues' do
+ write_note("/move #{target_project.full_path}\n/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"")
+
+ expect(page).to have_content 'Commands applied'
+ expect(issue.reload).to be_closed
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'Closed'
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+ end
+ end
end
end
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index 3ffc80622f5..11db1105d91 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -271,17 +271,21 @@ describe 'Issues' do
it 'filters by none' do
visit project_issues_path(project, due_date: Issue::NoDueDate.name)
- expect(page).not_to have_content('foo')
- expect(page).not_to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).not_to have_content('foo')
+ expect(page).not_to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
it 'filters by any' do
visit project_issues_path(project, due_date: Issue::AnyDueDate.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
it 'filters by due this week' do
@@ -291,9 +295,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::DueThisWeek.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).not_to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).not_to have_content('baz')
+ end
end
it 'filters by due this month' do
@@ -303,9 +309,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::DueThisMonth.name)
- expect(page).to have_content('foo')
- expect(page).to have_content('bar')
- expect(page).not_to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).to have_content('foo')
+ expect(page).to have_content('bar')
+ expect(page).not_to have_content('baz')
+ end
end
it 'filters by overdue' do
@@ -315,9 +323,11 @@ describe 'Issues' do
visit project_issues_path(project, due_date: Issue::Overdue.name)
- expect(page).not_to have_content('foo')
- expect(page).not_to have_content('bar')
- expect(page).to have_content('baz')
+ page.within '.issues-holder' do
+ expect(page).not_to have_content('foo')
+ expect(page).not_to have_content('bar')
+ expect(page).to have_content('baz')
+ end
end
end
@@ -567,7 +577,9 @@ describe 'Issues' do
it 'redirects to signin then back to new issue after signin' do
visit project_issues_path(project)
- click_link 'New issue'
+ page.within '.breadcrumbs' do
+ click_link 'New issue'
+ end
expect(current_path).to eq new_user_session_path
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index d7f3d91e625..96e8027a54d 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -13,7 +13,9 @@ feature 'Create New Merge Request', js: true do
it 'selects the source branch sha when a tag with the same name exists' do
visit project_merge_requests_path(project)
- click_link 'New merge request'
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
@@ -26,7 +28,9 @@ feature 'Create New Merge Request', js: true do
it 'selects the target branch sha when a tag with the same name exists' do
visit project_merge_requests_path(project)
- click_link 'New merge request'
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
@@ -40,7 +44,9 @@ feature 'Create New Merge Request', js: true do
it 'generates a diff for an orphaned branch' do
visit project_merge_requests_path(project)
- page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request')
+ page.within '.content' do
+ click_link 'New merge request'
+ end
expect(page).to have_content('Source branch')
expect(page).to have_content('Target branch')
diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb
index c4f02311f13..e77f1f92731 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -21,6 +21,8 @@ feature 'Diff note avatars', js: true do
before do
project.team << [user, :master]
sign_in user
+
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
end
context 'discussion tab' do
diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb
index a8f5dc275e4..e9068f722d5 100644
--- a/spec/features/merge_requests/diffs_spec.rb
+++ b/spec/features/merge_requests/diffs_spec.rb
@@ -88,7 +88,7 @@ feature 'Diffs URL', js: true do
visit diffs_project_merge_request_path(project, merge_request)
# Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax
- find("[id=\"#{changelog_id}\"] .js-edit-blob").click
+ find("[id=\"#{changelog_id}\"] .js-edit-blob").trigger('click')
expect(page).to have_selector('.js-fork-suggestion-button', count: 1)
expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1)
diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
index b1215f9ba63..dcc70338d7f 100644
--- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb
@@ -70,7 +70,7 @@ feature 'Mini Pipeline Graph', :js do
it 'should show tooltip when hovered' do
toggle.hover
- expect(toggle.find(:xpath, '..')).to have_selector('.tooltip')
+ expect(page).to have_selector('.tooltip')
end
end
@@ -117,7 +117,7 @@ feature 'Mini Pipeline Graph', :js do
it 'should show tooltip when hovered' do
build_item.hover
- expect(build_item.find(:xpath, '..')).to have_selector('.tooltip')
+ expect(page).to have_selector('.tooltip')
end
end
end
diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
index f89dd38e5cd..877f305120e 100644
--- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb
@@ -6,6 +6,8 @@ feature 'Merge requests > User posts diff notes', :js do
let(:project) { merge_request.source_project }
before do
+ allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true)
+
project.add_developer(user)
sign_in(user)
end
diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb
index a22d548eef3..96f6df587e1 100644
--- a/spec/features/participants_autocomplete_spec.rb
+++ b/spec/features/participants_autocomplete_spec.rb
@@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do
sign_in(user)
end
- shared_examples "open suggestions when typing @" do
+ shared_examples "open suggestions when typing @" do |resource_name|
before do
page.within('.new-note') do
- find('#note_note').send_keys('@')
+ if resource_name == 'issue'
+ find('#note-body').send_keys('@')
+ else
+ find('#note_note').send_keys('@')
+ end
end
end
@@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do
visit project_issue_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'issue'
end
context 'adding a new note on a Merge Request' do
@@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do
visit project_merge_request_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'merge_request'
end
context 'adding a new note on a Commit' do
@@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do
visit project_commit_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'commit'
end
end
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index dcd0449dbcb..171e061e60e 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -43,14 +43,14 @@ feature 'Profile > Account' do
update_username(new_username)
visit new_project_path
expect(current_path).to eq(new_project_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content(project.path)
end
scenario 'the old project path redirects to the new path' do
update_username(new_username)
visit old_project_path
expect(current_path).to eq(new_project_path)
- expect(find('h1.title')).to have_content(project.path)
+ expect(find('.breadcrumbs-sub-title')).to have_content(project.path)
end
end
end
diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb
index 6541ea6bf57..aa71c4dbba4 100644
--- a/spec/features/profiles/keys_spec.rb
+++ b/spec/features/profiles/keys_spec.rb
@@ -28,6 +28,23 @@ feature 'Profile > SSH Keys' do
expect(page).to have_content("Title: #{attrs[:title]}")
expect(page).to have_content(attrs[:key])
end
+
+ context 'when only DSA and ECDSA keys are allowed' do
+ before do
+ forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE
+ stub_application_setting(rsa_key_restriction: forbidden, ed25519_key_restriction: forbidden)
+ end
+
+ scenario 'shows a validation error' do
+ attrs = attributes_for(:key)
+
+ fill_in('Key', with: attrs[:key])
+ fill_in('Title', with: attrs[:title])
+ click_button('Add key')
+
+ expect(page).to have_content('Key type is forbidden. Must be DSA or ECDSA')
+ end
+ end
end
scenario 'User sees their keys' do
diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb
index 2c757f99a27..225d4c16841 100644
--- a/spec/features/profiles/password_spec.rb
+++ b/spec/features/profiles/password_spec.rb
@@ -53,12 +53,12 @@ describe 'Profile > Password' do
context 'Regular user' do
let(:user) { create(:user) }
- it 'renders 404 when sign-in is disabled' do
+ it 'renders 200 when sign-in is disabled' do
stub_application_setting(password_authentication_enabled: false)
visit edit_profile_password_path
- expect(page).to have_http_status(404)
+ expect(page).to have_http_status(200)
end
end
diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb
index 2385e1d9333..98c7ef57a51 100644
--- a/spec/features/projects/guest_navigation_menu_spec.rb
+++ b/spec/features/projects/guest_navigation_menu_spec.rb
@@ -13,8 +13,8 @@ describe 'Guest navigation menu' do
it 'shows allowed tabs only' do
visit project_path(project)
- within('.layout-nav') do
- expect(page).to have_content 'Project'
+ within('.nav-sidebar') do
+ expect(page).to have_content 'Overview'
expect(page).to have_content 'Issues'
expect(page).to have_content 'Wiki'
diff --git a/spec/features/projects/issuable_counts_caching_spec.rb b/spec/features/projects/issuable_counts_caching_spec.rb
deleted file mode 100644
index 1804d9dc244..00000000000
--- a/spec/features/projects/issuable_counts_caching_spec.rb
+++ /dev/null
@@ -1,132 +0,0 @@
-require 'spec_helper'
-
-describe 'Issuable counts caching', :use_clean_rails_memory_store_caching do
- let!(:member) { create(:user) }
- let!(:member_2) { create(:user) }
- let!(:non_member) { create(:user) }
- let!(:project) { create(:project, :public) }
- let!(:open_issue) { create(:issue, project: project) }
- let!(:confidential_issue) { create(:issue, :confidential, project: project, author: non_member) }
- let!(:closed_issue) { create(:issue, :closed, project: project) }
-
- before do
- project.add_developer(member)
- project.add_developer(member_2)
- end
-
- it 'caches issuable counts correctly for non-members' do
- # We can't use expect_any_instance_of because that uses a single instance.
- counts = 0
-
- allow_any_instance_of(IssuesFinder).to receive(:count_by_state).and_wrap_original do |m, *args|
- counts += 1
-
- m.call(*args)
- end
-
- aggregate_failures 'only counts once on first load with no params, and caches for later loads' do
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
- end
-
- aggregate_failures 'uses counts from cache on load from non-member' do
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(non_member)
- end
-
- aggregate_failures 'does not use the same cache for a member' do
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_out(member)
- end
-
- aggregate_failures 'uses the same cache for all members' do
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'shares caches when params are passed' do
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .to change { counts }.by(1)
-
- sign_in(member)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .to change { counts }.by(1)
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project, author_username: non_member.username) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'resets caches on issue close' do
- Issues::CloseService.new(project, member).execute(open_issue)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .to change { counts }.by(1)
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
-
- aggregate_failures 'does not reset caches on issue update' do
- Issues::UpdateService.new(project, member, title: 'new title').execute(open_issue)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(non_member)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_in(member_2)
-
- expect { visit project_issues_path(project) }
- .not_to change { counts }
-
- sign_out(member_2)
- end
- end
-end
diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb
index 24c9f708456..0fbe1ddb2a5 100644
--- a/spec/features/projects/members/user_requests_access_spec.rb
+++ b/spec/features/projects/members/user_requests_access_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-feature 'Projects > Members > User requests access' do
+feature 'Projects > Members > User requests access', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :access_requestable, :repository) }
let(:master) { project.owner }
@@ -46,11 +46,10 @@ feature 'Projects > Members > User requests access' do
expect(project.requesters.exists?(user_id: user)).to be_truthy
- page.within('.layout-nav .nav-links') do
+ page.within('.nav-sidebar') do
click_link('Members')
end
- visit project_project_members_path(project)
page.within('.content') do
expect(page).not_to have_content(user.name)
end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index 22fb1223739..cd3dc72d3c6 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -1,6 +1,8 @@
require 'spec_helper'
feature 'New project' do
+ include Select2Helper
+
let(:user) { create(:admin) }
before do
@@ -68,26 +70,10 @@ feature 'New project' do
expect(namespace.text).to eq group.name
end
-
- context 'on validation error' do
- before do
- fill_in('project_path', with: 'private-group-project')
- choose('Internal')
- click_button('Create project')
-
- expect(page).to have_css '.project-edit-errors .alert.alert-danger'
- end
-
- it 'selects the group namespace' do
- namespace = find('#project_namespace_id option[selected]')
-
- expect(namespace.text).to eq group.name
- end
- end
end
context 'with subgroup namespace' do
- let(:group) { create(:group, :private, owner: user) }
+ let(:group) { create(:group, owner: user) }
let(:subgroup) { create(:group, parent: group) }
before do
@@ -101,6 +87,41 @@ feature 'New project' do
expect(namespace.text).to eq subgroup.full_path
end
end
+
+ context 'when changing namespaces dynamically', :js do
+ let(:public_group) { create(:group, :public) }
+ let(:internal_group) { create(:group, :internal) }
+ let(:private_group) { create(:group, :private) }
+
+ before do
+ public_group.add_owner(user)
+ internal_group.add_owner(user)
+ private_group.add_owner(user)
+ visit new_project_path(namespace_id: public_group.id)
+ end
+
+ it 'enables the correct visibility options' do
+ select2(user.namespace_id, from: '#project_namespace_id')
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).not_to be_disabled
+
+ select2(public_group.id, from: '#project_namespace_id')
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).not_to be_disabled
+
+ select2(internal_group.id, from: '#project_namespace_id')
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).to be_disabled
+
+ select2(private_group.id, from: '#project_namespace_id')
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).to be_disabled
+ expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).to be_disabled
+ end
+ end
end
context 'Import project options' do
diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb
index 80d91e5915f..5d77cd1ccd5 100644
--- a/spec/features/projects/project_settings_spec.rb
+++ b/spec/features/projects/project_settings_spec.rb
@@ -46,7 +46,7 @@ describe 'Edit Project Settings' do
context 'when changing project name' do
it 'renames the repository' do
rename_project(project, name: 'bar')
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'with emojis' do
@@ -74,7 +74,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(project.namespace, 'bar')
visit new_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
specify 'the project is accessible via a redirect from the old path' do
@@ -83,7 +83,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(project.namespace, 'bar')
visit old_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'and a new project is added with the same path' do
@@ -93,7 +93,7 @@ describe 'Edit Project Settings' do
new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
visit old_path
expect(current_path).to eq(old_path)
- expect(find('h1.title')).to have_content(new_project.name)
+ expect(find('.breadcrumbs')).to have_content(new_project.name)
end
end
end
@@ -120,7 +120,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(group, project)
visit new_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
specify 'the project is accessible via a redirect from the old path' do
@@ -129,7 +129,7 @@ describe 'Edit Project Settings' do
new_path = namespace_project_path(group, project)
visit old_path
expect(current_path).to eq(new_path)
- expect(find('h1.title')).to have_content(project.name)
+ expect(find('.breadcrumbs')).to have_content(project.name)
end
context 'and a new project is added with the same path' do
@@ -139,7 +139,7 @@ describe 'Edit Project Settings' do
new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz')
visit old_path
expect(current_path).to eq(old_path)
- expect(find('h1.title')).to have_content(new_project.name)
+ expect(find('.breadcrumbs')).to have_content(new_project.name)
end
end
end
diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb
index aaf64d42515..b2b39dbd24c 100644
--- a/spec/features/projects/sub_group_issuables_spec.rb
+++ b/spec/features/projects/sub_group_issuables_spec.rb
@@ -24,7 +24,7 @@ describe 'Subgroup Issuables', :js, :nested_groups do
end
def expect_to_have_full_subgroup_title
- title = find('.title-container')
+ title = find('.breadcrumbs-links')
expect(title).not_to have_selector '.initializing'
expect(title).to have_content 'group / subgroup / project'
diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb
index 3bf25221e36..9b6864eb90f 100644
--- a/spec/features/reportable_note/commit_spec.rb
+++ b/spec/features/reportable_note/commit_spec.rb
@@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
context 'a diff note' do
@@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
index 21e96f6f103..f5a1950e48e 100644
--- a/spec/features/reportable_note/issue_spec.rb
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do
visit project_issue_path(project, issue)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'issue'
end
diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb
index bb296546e06..1f69257f7ed 100644
--- a/spec/features/reportable_note/merge_request_spec.rb
+++ b/spec/features/reportable_note/merge_request_spec.rb
@@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do
context 'a normal note' do
let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) }
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'merge_request'
end
context 'a diff note' do
let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'merge_request'
end
end
diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb
index f1e48ed46be..98ef50b78de 100644
--- a/spec/features/reportable_note/snippets_spec.rb
+++ b/spec/features/reportable_note/snippets_spec.rb
@@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do
visit project_snippet_path(project, snippet)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'snippet'
end
end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 785cfeb34bd..c7f0e342809 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -43,6 +43,21 @@ feature 'Runners' do
expect(page).not_to have_content(specific_runner.display_name)
end
+ scenario 'user edits the runner to be protected' do
+ visit runners_path(project)
+
+ within '.activated-specific-runners' do
+ first('.edit-runner > a').click
+ end
+
+ expect(page.find_field('runner[access_level]')).not_to be_checked
+
+ check 'runner_access_level'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'Protected Yes'
+ end
+
context 'when a runner has a tag' do
background do
specific_runner.update(tag_list: ['tag'])
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index 31d509455ba..05a089641f1 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -160,7 +160,7 @@ describe "Search" do
fill_in 'search', with: 'gitlab'
find('#search').native.send_keys(:enter)
- page.within '.title' do
+ page.within '.breadcrumbs-sub-title' do
expect(page).to have_content 'Search'
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 580258f77eb..aeb0534b733 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -181,7 +181,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -189,21 +189,20 @@ feature 'Task Lists' do
expect(page).to have_selector('.note ul input[checked]', count: 2)
end
- it 'contains the required selectors' do
+ it 'contains the required selectors', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note .js-task-list-container')
expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox')
- expect(page).to have_selector('.note .js-task-list-container .js-task-list-field')
end
- it 'is only editable by author' do
+ it 'is only editable by author', :js do
visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container')
- logout(:user)
+ gitlab_sign_out
- login_as(user2)
+ gitlab_sign_in(user2)
visit current_path
expect(page).not_to have_selector('.js-task-list-container')
end
@@ -215,7 +214,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -230,7 +229,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb
index 53cad623a35..e1c95590af1 100644
--- a/spec/features/uploads/user_uploads_file_to_note_spec.rb
+++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb
@@ -10,6 +10,7 @@ feature 'User uploads file to note' do
before do
sign_in(user)
visit project_issue_path(project, issue)
+ wait_for_requests
end
context 'before uploading' do
diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json
index 2f12b671dec..1030f323a1f 100644
--- a/spec/fixtures/api/schemas/entities/merge_request.json
+++ b/spec/fixtures/api/schemas/entities/merge_request.json
@@ -18,6 +18,8 @@
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] },
"human_total_time_spent": { "type": ["integer", "null"] },
+ "milestone": { "type": ["object", "null"] },
+ "labels": { "type": ["array", "null"] },
"in_progress_merge_commit_sha": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] },
"merge_commit_sha": { "type": ["string", "null"] },
diff --git a/spec/fixtures/fuzzy.po b/spec/fixtures/fuzzy.po
new file mode 100644
index 00000000000..99b7d12b91a
--- /dev/null
+++ b/spec/fixtures/fuzzy.po
@@ -0,0 +1,27 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR
, 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
+
+#, fuzzy
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Схема"
diff --git a/spec/fixtures/invalid.po b/spec/fixtures/invalid.po
new file mode 100644
index 00000000000..039a56e9fc0
--- /dev/null
+++ b/spec/fixtures/invalid.po
@@ -0,0 +1,25 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR , 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
+msgstr[1] "%d cambios"
+
+But this doesn't even look like an PO-entry
\ No newline at end of file
diff --git a/spec/fixtures/missing_metadata.po b/spec/fixtures/missing_metadata.po
new file mode 100644
index 00000000000..b1999c933f1
--- /dev/null
+++ b/spec/fixtures/missing_metadata.po
@@ -0,0 +1,4 @@
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
diff --git a/spec/fixtures/missing_plurals.po b/spec/fixtures/missing_plurals.po
new file mode 100644
index 00000000000..09ca0c82718
--- /dev/null
+++ b/spec/fixtures/missing_plurals.po
@@ -0,0 +1,22 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR , 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
diff --git a/spec/fixtures/multiple_plurals.po b/spec/fixtures/multiple_plurals.po
new file mode 100644
index 00000000000..84b17b13ffa
--- /dev/null
+++ b/spec/fixtures/multiple_plurals.po
@@ -0,0 +1,26 @@
+# Arthur Charron , 2017. #zanata
+# Huang Tao , 2017. #zanata
+# Kohei Ota , 2017. #zanata
+# Taisuke Inoue , 2017. #zanata
+# Takuya Noguchi , 2017. #zanata
+# YANO Tethurou , 2017. #zanata
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2017-08-06 11:23-0400\n"
+"Last-Translator: Taisuke Inoue \n"
+"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/"
+"project/view/GitLab)\n"
+"Language: ja\n"
+"X-Generator: Zanata 3.9.6\n"
+"Plural-Forms: nplurals=3; plural=n\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d個のコミット"
+msgstr[1] "%d個のコミット"
+msgstr[2] "missing a variable"
diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po
new file mode 100644
index 00000000000..f5bc86f39a7
--- /dev/null
+++ b/spec/fixtures/newlines.po
@@ -0,0 +1,48 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR , 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-12 12:35-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "1 commit"
+msgid_plural "%d commits"
+msgstr[0] "1 cambio"
+msgstr[1] "%d cambios"
+
+msgid ""
+"You are going to remove %{group_name}.\n"
+"Removed groups CANNOT be restored!\n"
+"Are you ABSOLUTELY sure?"
+msgstr ""
+"Va a eliminar %{group_name}.\n"
+"¡El grupo eliminado NO puede ser restaurado!\n"
+"¿Estás TOTALMENTE seguro?"
+
+msgid "With plural"
+msgid_plural "with plurals"
+msgstr[0] "first"
+msgstr[1] "second"
+msgstr[2] ""
+"with"
+"multiple"
+"lines"
+
+msgid "multiline plural id"
+msgid_plural ""
+"Plural"
+"Id"
+msgstr[0] "first"
+msgstr[1] "second"
diff --git a/spec/fixtures/unescaped_chars.po b/spec/fixtures/unescaped_chars.po
new file mode 100644
index 00000000000..fbafe523fb3
--- /dev/null
+++ b/spec/fixtures/unescaped_chars.po
@@ -0,0 +1,21 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR , 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?"
diff --git a/spec/fixtures/valid.po b/spec/fixtures/valid.po
new file mode 100644
index 00000000000..e43fd5fea15
--- /dev/null
+++ b/spec/fixtures/valid.po
@@ -0,0 +1,1136 @@
+# Spanish translations for gitlab package.
+# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the gitlab package.
+# FIRST AUTHOR , 2017.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: gitlab 1.0.0\n"
+"Report-Msgid-Bugs-To: \n"
+"PO-Revision-Date: 2017-07-13 12:10-0500\n"
+"Language-Team: Spanish\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"Last-Translator: Bob Van Landuyt \n"
+"X-Generator: Poedit 2.0.2\n"
+
+msgid "%d commit"
+msgid_plural "%d commits"
+msgstr[0] "%d cambio"
+msgstr[1] "%d cambios"
+
+msgid "%s additional commit has been omitted to prevent performance issues."
+msgid_plural "%s additional commits have been omitted to prevent performance issues."
+msgstr[0] "%s cambio adicional ha sido omitido para evitar problemas de rendimiento."
+msgstr[1] "%s cambios adicionales han sido omitidos para evitar problemas de rendimiento."
+
+msgid "%{commit_author_link} committed %{commit_timeago}"
+msgstr "%{commit_author_link} cambió %{commit_timeago}"
+
+msgid "1 pipeline"
+msgid_plural "%d pipelines"
+msgstr[0] "1 pipeline"
+msgstr[1] "%d pipelines"
+
+msgid "A collection of graphs regarding Continuous Integration"
+msgstr "Una colección de gráficos sobre Integración Continua"
+
+msgid "About auto deploy"
+msgstr "Acerca del auto despliegue"
+
+msgid "Active"
+msgstr "Activo"
+
+msgid "Activity"
+msgstr "Actividad"
+
+msgid "Add Changelog"
+msgstr "Agregar Changelog"
+
+msgid "Add Contribution guide"
+msgstr "Agregar guía de contribución"
+
+msgid "Add License"
+msgstr "Agregar Licencia"
+
+msgid "Add an SSH key to your profile to pull or push via SSH."
+msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH."
+
+msgid "Add new directory"
+msgstr "Agregar nuevo directorio"
+
+msgid "Archived project! Repository is read-only"
+msgstr "¡Proyecto archivado! El repositorio es de solo lectura"
+
+msgid "Are you sure you want to delete this pipeline schedule?"
+msgstr "¿Estás seguro que deseas eliminar esta programación del pipeline?"
+
+msgid "Attach a file by drag & drop or %{upload_link}"
+msgstr "Adjunte un archivo arrastrando & soltando o %{upload_link}"
+
+msgid "Branch"
+msgid_plural "Branches"
+msgstr[0] "Rama"
+msgstr[1] "Ramas"
+
+msgid "Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}"
+msgstr "La rama %{branch_name} fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"
+
+msgid "BranchSwitcherPlaceholder|Search branches"
+msgstr "Buscar ramas"
+
+msgid "BranchSwitcherTitle|Switch branch"
+msgstr "Cambiar rama"
+
+msgid "Branches"
+msgstr "Ramas"
+
+msgid "Browse Directory"
+msgstr "Examinar directorio"
+
+msgid "Browse File"
+msgstr "Examinar archivo"
+
+msgid "Browse Files"
+msgstr "Examinar archivos"
+
+msgid "Browse files"
+msgstr "Examinar archivos"
+
+msgid "ByAuthor|by"
+msgstr "por"
+
+msgid "CI configuration"
+msgstr "Configuración de CI"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "ChangeTypeActionLabel|Pick into branch"
+msgstr "Escoger en la rama"
+
+msgid "ChangeTypeActionLabel|Revert in branch"
+msgstr "Revertir en la rama"
+
+msgid "ChangeTypeAction|Cherry-pick"
+msgstr "Cherry-pick"
+
+msgid "ChangeTypeAction|Revert"
+msgstr "Revertir"
+
+msgid "Changelog"
+msgstr "Changelog"
+
+msgid "Charts"
+msgstr "Gráficos"
+
+msgid "Cherry-pick this commit"
+msgstr "Escoger este cambio"
+
+msgid "Cherry-pick this merge request"
+msgstr "Escoger esta solicitud de fusión"
+
+msgid "CiStatusLabel|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusLabel|created"
+msgstr "creado"
+
+msgid "CiStatusLabel|failed"
+msgstr "fallido"
+
+msgid "CiStatusLabel|manual action"
+msgstr "acción manual"
+
+msgid "CiStatusLabel|passed"
+msgstr "pasó"
+
+msgid "CiStatusLabel|passed with warnings"
+msgstr "pasó con advertencias"
+
+msgid "CiStatusLabel|pending"
+msgstr "pendiente"
+
+msgid "CiStatusLabel|skipped"
+msgstr "omitido"
+
+msgid "CiStatusLabel|waiting for manual action"
+msgstr "esperando acción manual"
+
+msgid "CiStatusText|blocked"
+msgstr "bloqueado"
+
+msgid "CiStatusText|canceled"
+msgstr "cancelado"
+
+msgid "CiStatusText|created"
+msgstr "creado"
+
+msgid "CiStatusText|failed"
+msgstr "fallado"
+
+msgid "CiStatusText|manual"
+msgstr "manual"
+
+msgid "CiStatusText|passed"
+msgstr "pasó"
+
+msgid "CiStatusText|pending"
+msgstr "pendiente"
+
+msgid "CiStatusText|skipped"
+msgstr "omitido"
+
+msgid "CiStatus|running"
+msgstr "en ejecución"
+
+msgid "Commit"
+msgid_plural "Commits"
+msgstr[0] "Cambio"
+msgstr[1] "Cambios"
+
+msgid "Commit duration in minutes for last 30 commits"
+msgstr "Duración de los cambios en minutos para los últimos 30"
+
+msgid "Commit message"
+msgstr "Mensaje del cambio"
+
+msgid "CommitBoxTitle|Commit"
+msgstr "Cambio"
+
+msgid "CommitMessage|Add %{file_name}"
+msgstr "Agregar %{file_name}"
+
+msgid "Commits"
+msgstr "Cambios"
+
+msgid "Commits feed"
+msgstr "Feed de cambios"
+
+msgid "Commits|History"
+msgstr "Historial"
+
+msgid "Committed by"
+msgstr "Enviado por"
+
+msgid "Compare"
+msgstr "Comparar"
+
+msgid "Contribution guide"
+msgstr "Guía de contribución"
+
+msgid "Contributors"
+msgstr "Contribuidores"
+
+msgid "Copy URL to clipboard"
+msgstr "Copiar URL al portapapeles"
+
+msgid "Copy commit SHA to clipboard"
+msgstr "Copiar SHA del cambio al portapapeles"
+
+msgid "Create New Directory"
+msgstr "Crear Nuevo Directorio"
+
+msgid "Create a personal access token on your account to pull or push via %{protocol}."
+msgstr "Crear un token de acceso personal en tu cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Create directory"
+msgstr "Crear directorio"
+
+msgid "Create empty bare repository"
+msgstr "Crear repositorio vacío"
+
+msgid "Create merge request"
+msgstr "Crear solicitud de fusión"
+
+msgid "Create new..."
+msgstr "Crear nuevo..."
+
+msgid "CreateNewFork|Fork"
+msgstr "Bifurcar"
+
+msgid "CreateTag|Tag"
+msgstr "Etiqueta"
+
+msgid "CreateTokenToCloneLink|create a personal access token"
+msgstr "crear un token de acceso personal"
+
+msgid "Cron Timezone"
+msgstr "Zona horaria del Cron"
+
+msgid "Cron syntax"
+msgstr "Sintaxis de Cron"
+
+msgid "Custom notification events"
+msgstr "Eventos de notificaciones personalizadas"
+
+msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}."
+msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}."
+
+msgid "Cycle Analytics"
+msgstr "Cycle Analytics"
+
+msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project."
+msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."
+
+msgid "CycleAnalyticsStage|Code"
+msgstr "Código"
+
+msgid "CycleAnalyticsStage|Issue"
+msgstr "Incidencia"
+
+msgid "CycleAnalyticsStage|Plan"
+msgstr "Planificación"
+
+msgid "CycleAnalyticsStage|Production"
+msgstr "Producción"
+
+msgid "CycleAnalyticsStage|Review"
+msgstr "Revisión"
+
+msgid "CycleAnalyticsStage|Staging"
+msgstr "Puesta en escena"
+
+msgid "CycleAnalyticsStage|Test"
+msgstr "Pruebas"
+
+msgid "Define a custom pattern with cron syntax"
+msgstr "Definir un patrón personalizado con la sintaxis de cron"
+
+msgid "Delete"
+msgstr "Eliminar"
+
+msgid "Deploy"
+msgid_plural "Deploys"
+msgstr[0] "Despliegue"
+msgstr[1] "Despliegues"
+
+msgid "Description"
+msgstr "Descripción"
+
+msgid "Directory name"
+msgstr "Nombre del directorio"
+
+msgid "Don't show again"
+msgstr "No mostrar de nuevo"
+
+msgid "Download"
+msgstr "Descargar"
+
+msgid "Download tar"
+msgstr "Descargar tar"
+
+msgid "Download tar.bz2"
+msgstr "Descargar tar.bz2"
+
+msgid "Download tar.gz"
+msgstr "Descargar tar.gz"
+
+msgid "Download zip"
+msgstr "Descargar zip"
+
+msgid "DownloadArtifacts|Download"
+msgstr "Descargar"
+
+msgid "DownloadCommit|Email Patches"
+msgstr "Parches por correo electrónico"
+
+msgid "DownloadCommit|Plain Diff"
+msgstr "Diferencias en texto plano"
+
+msgid "DownloadSource|Download"
+msgstr "Descargar"
+
+msgid "Edit"
+msgstr "Editar"
+
+msgid "Edit Pipeline Schedule %{id}"
+msgstr "Editar Programación del Pipeline %{id}"
+
+msgid "Every day (at 4:00am)"
+msgstr "Todos los días (a las 4:00 am)"
+
+msgid "Every month (on the 1st at 4:00am)"
+msgstr "Todos los meses (el día 1 a las 4:00 am)"
+
+msgid "Every week (Sundays at 4:00am)"
+msgstr "Todas las semanas (domingos a las 4:00 am)"
+
+msgid "Failed to change the owner"
+msgstr "Error al cambiar el propietario"
+
+msgid "Failed to remove the pipeline schedule"
+msgstr "Error al eliminar la programación del pipeline"
+
+msgid "Files"
+msgstr "Archivos"
+
+msgid "Filter by commit message"
+msgstr "Filtrar por mensaje del cambio"
+
+msgid "Find by path"
+msgstr "Buscar por ruta"
+
+msgid "Find file"
+msgstr "Buscar archivo"
+
+msgid "FirstPushedBy|First"
+msgstr "Primer"
+
+msgid "FirstPushedBy|pushed by"
+msgstr "enviado por"
+
+msgid "Fork"
+msgid_plural "Forks"
+msgstr[0] "Bifurcación"
+msgstr[1] "Bifurcaciones"
+
+msgid "ForkedFromProjectPath|Forked from"
+msgstr "Bifurcado de"
+
+msgid "From issue creation until deploy to production"
+msgstr "Desde la creación de la incidencia hasta el despliegue a producción"
+
+msgid "From merge request merge until deploy to production"
+msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción"
+
+msgid "Go to your fork"
+msgstr "Ir a tu bifurcación"
+
+msgid "GoToYourFork|Fork"
+msgstr "Bifurcación"
+
+msgid "Home"
+msgstr "Inicio"
+
+msgid "Housekeeping successfully started"
+msgstr "Servicio de limpieza iniciado con éxito"
+
+msgid "Import repository"
+msgstr "Importar repositorio"
+
+msgid "Interval Pattern"
+msgstr "Patrón de intervalo"
+
+msgid "Introducing Cycle Analytics"
+msgstr "Introducción a Cycle Analytics"
+
+msgid "Jobs for last month"
+msgstr "Trabajos del mes pasado"
+
+msgid "Jobs for last week"
+msgstr "Trabajos de la semana pasada"
+
+msgid "Jobs for last year"
+msgstr "Trabajos del año pasado"
+
+msgid "LFSStatus|Disabled"
+msgstr "Deshabilitado"
+
+msgid "LFSStatus|Enabled"
+msgstr "Habilitado"
+
+msgid "Last %d day"
+msgid_plural "Last %d days"
+msgstr[0] "Último %d día"
+msgstr[1] "Últimos %d días"
+
+msgid "Last Pipeline"
+msgstr "Último Pipeline"
+
+msgid "Last Update"
+msgstr "Última actualización"
+
+msgid "Last commit"
+msgstr "Último cambio"
+
+msgid "Learn more in the"
+msgstr "Más información en la"
+
+msgid "Learn more in the|pipeline schedules documentation"
+msgstr "documentación sobre la programación de pipelines"
+
+msgid "Leave group"
+msgstr "Abandonar grupo"
+
+msgid "Leave project"
+msgstr "Abandonar proyecto"
+
+msgid "Limited to showing %d event at most"
+msgid_plural "Limited to showing %d events at most"
+msgstr[0] "Limitado a mostrar máximo %d evento"
+msgstr[1] "Limitado a mostrar máximo %d eventos"
+
+msgid "Median"
+msgstr "Mediana"
+
+msgid "MissingSSHKeyWarningLink|add an SSH key"
+msgstr "agregar una clave SSH"
+
+msgid "New Issue"
+msgid_plural "New Issues"
+msgstr[0] "Nueva incidencia"
+msgstr[1] "Nuevas incidencias"
+
+msgid "New Pipeline Schedule"
+msgstr "Nueva Programación del Pipeline"
+
+msgid "New branch"
+msgstr "Nueva rama"
+
+msgid "New directory"
+msgstr "Nuevo directorio"
+
+msgid "New file"
+msgstr "Nuevo archivo"
+
+msgid "New issue"
+msgstr "Nueva incidencia"
+
+msgid "New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "New schedule"
+msgstr "Nueva programación"
+
+msgid "New snippet"
+msgstr "Nuevo fragmento de código"
+
+msgid "New tag"
+msgstr "Nueva etiqueta"
+
+msgid "No repository"
+msgstr "No hay repositorio"
+
+msgid "No schedules"
+msgstr "No hay programaciones"
+
+msgid "Not available"
+msgstr "No disponible"
+
+msgid "Not enough data"
+msgstr "No hay suficientes datos"
+
+msgid "Notification events"
+msgstr "Eventos de notificación"
+
+msgid "NotificationEvent|Close issue"
+msgstr "Cerrar incidencia"
+
+msgid "NotificationEvent|Close merge request"
+msgstr "Cerrar solicitud de fusión"
+
+msgid "NotificationEvent|Failed pipeline"
+msgstr "Pipeline fallido"
+
+msgid "NotificationEvent|Merge merge request"
+msgstr "Integrar solicitud de fusión"
+
+msgid "NotificationEvent|New issue"
+msgstr "Nueva incidencia"
+
+msgid "NotificationEvent|New merge request"
+msgstr "Nueva solicitud de fusión"
+
+msgid "NotificationEvent|New note"
+msgstr "Nueva nota"
+
+msgid "NotificationEvent|Reassign issue"
+msgstr "Reasignar incidencia"
+
+msgid "NotificationEvent|Reassign merge request"
+msgstr "Reasignar solicitud de fusión"
+
+msgid "NotificationEvent|Reopen issue"
+msgstr "Reabrir incidencia"
+
+msgid "NotificationEvent|Successful pipeline"
+msgstr "Pipeline exitoso"
+
+msgid "NotificationLevel|Custom"
+msgstr "Personalizado"
+
+msgid "NotificationLevel|Disabled"
+msgstr "Deshabilitado"
+
+msgid "NotificationLevel|Global"
+msgstr "Global"
+
+msgid "NotificationLevel|On mention"
+msgstr "Cuando me mencionan"
+
+msgid "NotificationLevel|Participate"
+msgstr "Participación"
+
+msgid "NotificationLevel|Watch"
+msgstr "Vigilancia"
+
+msgid "OfSearchInADropdown|Filter"
+msgstr "Filtrar"
+
+msgid "OpenedNDaysAgo|Opened"
+msgstr "Abierto"
+
+msgid "Options"
+msgstr "Opciones"
+
+msgid "Owner"
+msgstr "Propietario"
+
+msgid "Pipeline"
+msgstr "Pipeline"
+
+msgid "Pipeline Health"
+msgstr "Estado del Pipeline"
+
+msgid "Pipeline Schedule"
+msgstr "Programación del Pipeline"
+
+msgid "Pipeline Schedules"
+msgstr "Programaciones de los Pipelines"
+
+msgid "PipelineCharts|Failed:"
+msgstr "Fallidos:"
+
+msgid "PipelineCharts|Overall statistics"
+msgstr "Estadísticas generales"
+
+msgid "PipelineCharts|Success ratio:"
+msgstr "Ratio de éxito"
+
+msgid "PipelineCharts|Successful:"
+msgstr "Exitosos:"
+
+msgid "PipelineCharts|Total:"
+msgstr "Total:"
+
+msgid "PipelineSchedules|Activated"
+msgstr "Activado"
+
+msgid "PipelineSchedules|Active"
+msgstr "Activos"
+
+msgid "PipelineSchedules|All"
+msgstr "Todos"
+
+msgid "PipelineSchedules|Inactive"
+msgstr "Inactivos"
+
+msgid "PipelineSchedules|Input variable key"
+msgstr "Ingrese nombre de clave"
+
+msgid "PipelineSchedules|Input variable value"
+msgstr "Ingrese el valor de la variable"
+
+msgid "PipelineSchedules|Next Run"
+msgstr "Próxima Ejecución"
+
+msgid "PipelineSchedules|None"
+msgstr "Ninguno"
+
+msgid "PipelineSchedules|Provide a short description for this pipeline"
+msgstr "Proporcione una descripción breve para este pipeline"
+
+msgid "PipelineSchedules|Remove variable row"
+msgstr "Eliminar fila de variable"
+
+msgid "PipelineSchedules|Take ownership"
+msgstr "Tomar posesión"
+
+msgid "PipelineSchedules|Target"
+msgstr "Destino"
+
+msgid "PipelineSchedules|Variables"
+msgstr "Variables"
+
+msgid "PipelineSheduleIntervalPattern|Custom"
+msgstr "Personalizado"
+
+msgid "Pipelines"
+msgstr "Pipelines"
+
+msgid "Pipelines charts"
+msgstr "Gráficos de los pipelines"
+
+msgid "Pipeline|all"
+msgstr "todos"
+
+msgid "Pipeline|success"
+msgstr "exitósos"
+
+msgid "Pipeline|with stage"
+msgstr "con etapa"
+
+msgid "Pipeline|with stages"
+msgstr "con etapas"
+
+msgid "Project '%{project_name}' queued for deletion."
+msgstr "Proyecto ‘%{project_name}’ en cola para eliminación."
+
+msgid "Project '%{project_name}' was successfully created."
+msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente."
+
+msgid "Project '%{project_name}' was successfully updated."
+msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente."
+
+msgid "Project '%{project_name}' will be deleted."
+msgstr "Proyecto ‘%{project_name}’ será eliminado."
+
+msgid "Project access must be granted explicitly to each user."
+msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario."
+
+msgid "Project export could not be deleted."
+msgstr "No se pudo eliminar la exportación del proyecto."
+
+msgid "Project export has been deleted."
+msgstr "La exportación del proyecto ha sido eliminada."
+
+msgid "Project export link has expired. Please generate a new export from your project settings."
+msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto."
+
+msgid "Project export started. A download link will be sent by email."
+msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico."
+
+msgid "Project home"
+msgstr "Inicio del proyecto"
+
+msgid "ProjectFeature|Disabled"
+msgstr "Deshabilitada"
+
+msgid "ProjectFeature|Everyone with access"
+msgstr "Todos con acceso"
+
+msgid "ProjectFeature|Only team members"
+msgstr "Solo miembros del equipo"
+
+msgid "ProjectFileTree|Name"
+msgstr "Nombre"
+
+msgid "ProjectLastActivity|Never"
+msgstr "Nunca"
+
+msgid "ProjectLifecycle|Stage"
+msgstr "Etapa"
+
+msgid "ProjectNetworkGraph|Graph"
+msgstr "Historial gráfico"
+
+msgid "Read more"
+msgstr "Leer más"
+
+msgid "Readme"
+msgstr "Léeme"
+
+msgid "RefSwitcher|Branches"
+msgstr "Ramas"
+
+msgid "RefSwitcher|Tags"
+msgstr "Etiquetas"
+
+msgid "Related Commits"
+msgstr "Cambios Relacionados"
+
+msgid "Related Deployed Jobs"
+msgstr "Trabajos Desplegados Relacionados"
+
+msgid "Related Issues"
+msgstr "Incidencias Relacionadas"
+
+msgid "Related Jobs"
+msgstr "Trabajos Relacionados"
+
+msgid "Related Merge Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Related Merged Requests"
+msgstr "Solicitudes de fusión Relacionadas"
+
+msgid "Remind later"
+msgstr "Recordar después"
+
+msgid "Remove project"
+msgstr "Eliminar proyecto"
+
+msgid "Request Access"
+msgstr "Solicitar acceso"
+
+msgid "Revert this commit"
+msgstr "Revertir este cambio"
+
+msgid "Revert this merge request"
+msgstr "Revertir esta solicitud de fusión"
+
+msgid "Save pipeline schedule"
+msgstr "Guardar programación del pipeline"
+
+msgid "Schedule a new pipeline"
+msgstr "Programar un nuevo pipeline"
+
+msgid "Scheduling Pipelines"
+msgstr "Programación de Pipelines"
+
+msgid "Search branches and tags"
+msgstr "Buscar ramas y etiquetas"
+
+msgid "Select Archive Format"
+msgstr "Seleccionar formato de archivo"
+
+msgid "Select a timezone"
+msgstr "Selecciona una zona horaria"
+
+msgid "Select target branch"
+msgstr "Selecciona una rama de destino"
+
+msgid "Set a password on your account to pull or push via %{protocol}."
+msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de %{protocol}."
+
+msgid "Set up CI"
+msgstr "Configurar CI"
+
+msgid "Set up Koding"
+msgstr "Configurar Koding"
+
+msgid "Set up auto deploy"
+msgstr "Configurar auto despliegue"
+
+msgid "SetPasswordToCloneLink|set a password"
+msgstr "establecer una contraseña"
+
+msgid "Showing %d event"
+msgid_plural "Showing %d events"
+msgstr[0] "Mostrando %d evento"
+msgstr[1] "Mostrando %d eventos"
+
+msgid "Source code"
+msgstr "Código fuente"
+
+msgid "StarProject|Star"
+msgstr "Destacar"
+
+msgid "Start a %{new_merge_request} with these changes"
+msgstr "Iniciar una %{new_merge_request} con estos cambios"
+
+msgid "Switch branch/tag"
+msgstr "Cambiar rama/etiqueta"
+
+msgid "Tag"
+msgid_plural "Tags"
+msgstr[0] "Etiqueta"
+msgstr[1] "Etiquetas"
+
+msgid "Tags"
+msgstr "Etiquetas"
+
+msgid "Target Branch"
+msgstr "Rama de destino"
+
+msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request."
+msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión."
+
+msgid "The collection of events added to the data gathered for that stage."
+msgstr "La colección de eventos agregados a los datos recopilados para esa etapa."
+
+msgid "The fork relationship has been removed."
+msgstr "La relación con la bifurcación se ha eliminado."
+
+msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage."
+msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."
+
+msgid "The phase of the development lifecycle."
+msgstr "La etapa del ciclo de vida de desarrollo."
+
+msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user."
+msgstr "La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado."
+
+msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit."
+msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio."
+
+msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle."
+msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."
+
+msgid "The project can be accessed by any logged in user."
+msgstr "El proyecto puede ser accedido por cualquier usuario conectado."
+
+msgid "The project can be accessed without any authentication."
+msgstr "El proyecto puede accederse sin ninguna autenticación."
+
+msgid "The repository for this project does not exist."
+msgstr "El repositorio para este proyecto no existe."
+
+msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request."
+msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."
+
+msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
+msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."
+
+msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
+msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."
+
+msgid "The time taken by each data entry gathered by that stage."
+msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa."
+
+msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
+msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."
+
+msgid "This means you can not push code until you create an empty repository or import existing one."
+msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente."
+
+msgid "Time before an issue gets scheduled"
+msgstr "Tiempo antes de que una incidencia sea programada"
+
+msgid "Time before an issue starts implementation"
+msgstr "Tiempo antes de que empieze la implementación de una incidencia"
+
+msgid "Time between merge request creation and merge/close"
+msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"
+
+msgid "Time until first merge request"
+msgstr "Tiempo hasta la primera solicitud de fusión"
+
+msgid "Timeago|%s days ago"
+msgstr "hace %s días"
+
+msgid "Timeago|%s days remaining"
+msgstr "%s días restantes"
+
+msgid "Timeago|%s hours remaining"
+msgstr "%s horas restantes"
+
+msgid "Timeago|%s minutes ago"
+msgstr "hace %s minutos"
+
+msgid "Timeago|%s minutes remaining"
+msgstr "%s minutos restantes"
+
+msgid "Timeago|%s months ago"
+msgstr "hace %s meses"
+
+msgid "Timeago|%s months remaining"
+msgstr "%s meses restantes"
+
+msgid "Timeago|%s seconds remaining"
+msgstr "%s segundos restantes"
+
+msgid "Timeago|%s weeks ago"
+msgstr "hace %s semanas"
+
+msgid "Timeago|%s weeks remaining"
+msgstr "%s semanas restantes"
+
+msgid "Timeago|%s years ago"
+msgstr "hace %s años"
+
+msgid "Timeago|%s years remaining"
+msgstr "%s años restantes"
+
+msgid "Timeago|1 day remaining"
+msgstr "1 día restante"
+
+msgid "Timeago|1 hour remaining"
+msgstr "1 hora restante"
+
+msgid "Timeago|1 minute remaining"
+msgstr "1 minuto restante"
+
+msgid "Timeago|1 month remaining"
+msgstr "1 mes restante"
+
+msgid "Timeago|1 week remaining"
+msgstr "1 semana restante"
+
+msgid "Timeago|1 year remaining"
+msgstr "1 año restante"
+
+msgid "Timeago|Past due"
+msgstr "Atrasado"
+
+msgid "Timeago|a day ago"
+msgstr "hace un día"
+
+msgid "Timeago|a month ago"
+msgstr "hace un mes"
+
+msgid "Timeago|a week ago"
+msgstr "hace una semana"
+
+msgid "Timeago|a while"
+msgstr "hace un momento"
+
+msgid "Timeago|a year ago"
+msgstr "hace un año"
+
+msgid "Timeago|about %s hours ago"
+msgstr "hace alrededor de %s horas"
+
+msgid "Timeago|about a minute ago"
+msgstr "hace alrededor de 1 minuto"
+
+msgid "Timeago|about an hour ago"
+msgstr "hace alrededor de 1 hora"
+
+msgid "Timeago|in %s days"
+msgstr "en %s días"
+
+msgid "Timeago|in %s hours"
+msgstr "en %s horas"
+
+msgid "Timeago|in %s minutes"
+msgstr "en %s minutos"
+
+msgid "Timeago|in %s months"
+msgstr "en %s meses"
+
+msgid "Timeago|in %s seconds"
+msgstr "en %s segundos"
+
+msgid "Timeago|in %s weeks"
+msgstr "en %s semanas"
+
+msgid "Timeago|in %s years"
+msgstr "en %s años"
+
+msgid "Timeago|in 1 day"
+msgstr "en 1 día"
+
+msgid "Timeago|in 1 hour"
+msgstr "en 1 hora"
+
+msgid "Timeago|in 1 minute"
+msgstr "en 1 minuto"
+
+msgid "Timeago|in 1 month"
+msgstr "en 1 mes"
+
+msgid "Timeago|in 1 week"
+msgstr "en 1 semana"
+
+msgid "Timeago|in 1 year"
+msgstr "en 1 año"
+
+msgid "Timeago|less than a minute ago"
+msgstr "hace menos de 1 minuto"
+
+msgid "Time|hr"
+msgid_plural "Time|hrs"
+msgstr[0] "hr"
+msgstr[1] "hrs"
+
+msgid "Time|min"
+msgid_plural "Time|mins"
+msgstr[0] "min"
+msgstr[1] "mins"
+
+msgid "Time|s"
+msgstr "s"
+
+msgid "Total Time"
+msgstr "Tiempo Total"
+
+msgid "Total test time for all commits/merges"
+msgstr "Tiempo total de pruebas para todos los cambios o integraciones"
+
+msgid "Unstar"
+msgstr "No Destacar"
+
+msgid "Upload New File"
+msgstr "Subir nuevo archivo"
+
+msgid "Upload file"
+msgstr "Subir archivo"
+
+msgid "UploadLink|click to upload"
+msgstr "Hacer clic para subir"
+
+msgid "Use your global notification setting"
+msgstr "Utiliza tu configuración de notificación global"
+
+msgid "View open merge request"
+msgstr "Ver solicitud de fusión abierta"
+
+msgid "VisibilityLevel|Internal"
+msgstr "Interno"
+
+msgid "VisibilityLevel|Private"
+msgstr "Privado"
+
+msgid "VisibilityLevel|Public"
+msgstr "Público"
+
+msgid "VisibilityLevel|Unknown"
+msgstr "Desconocido"
+
+msgid "Want to see the data? Please ask an administrator for access."
+msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador."
+
+msgid "We don't have enough data to show this stage."
+msgstr "No hay suficientes datos para mostrar en esta etapa."
+
+msgid "Withdraw Access Request"
+msgstr "Retirar Solicitud de Acceso"
+
+msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{group_name}. ¡El grupo eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?"
+msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?"
+msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?"
+
+msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?"
+msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?"
+
+msgid "You can only add files when you are on a branch"
+msgstr "Solo puedes agregar archivos cuando estás en una rama"
+
+msgid "You have reached your project limit"
+msgstr "Has alcanzado el límite de tu proyecto"
+
+msgid "You must sign in to star a project"
+msgstr "Debes iniciar sesión para destacar un proyecto"
+
+msgid "You need permission."
+msgstr "Necesitas permisos."
+
+msgid "You will not get any notifications via email"
+msgstr "No recibirás ninguna notificación por correo electrónico"
+
+msgid "You will only receive notifications for the events you choose"
+msgstr "Solo recibirás notificaciones de los eventos que elijas"
+
+msgid "You will only receive notifications for threads you have participated in"
+msgstr "Solo recibirás notificaciones de los temas en los que has participado"
+
+msgid "You will receive notifications for any activity"
+msgstr "Recibirás notificaciones por cualquier actividad"
+
+msgid "You will receive notifications only for comments in which you were @mentioned"
+msgstr "Recibirás notificaciones solo para los comentarios en los que se te mencionó"
+
+msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account"
+msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta"
+
+msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile"
+msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil"
+
+msgid "Your name"
+msgstr "Tu nombre"
+
+msgid "day"
+msgid_plural "days"
+msgstr[0] "día"
+msgstr[1] "días"
+
+msgid "new merge request"
+msgstr "nueva solicitud de fusión"
+
+msgid "notification emails"
+msgstr "correos electrónicos de notificación"
+
+msgid "parent"
+msgid_plural "parents"
+msgstr[0] "padre"
+msgstr[1] "padres"
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index c654151564e..04620f6d88c 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -95,13 +95,13 @@ describe BlobHelper do
it 'returns a link with the proper route' do
link = edit_blob_link(project, 'master', 'README.md')
- expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md')
+ expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md")
end
it 'returns a link with the passed link_opts on the expected route' do
link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 })
- expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10')
+ expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md?mr_id=10")
end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 7789cfa3554..ead3e28438e 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -59,112 +59,6 @@ describe IssuablesHelper do
.to eq('All 42')
end
end
-
- describe 'counter caching based on issuable type and params', :use_clean_rails_memory_store_caching do
- let(:params) do
- {
- scope: 'created-by-me',
- state: 'opened',
- utf8: '✓',
- author_id: '11',
- assignee_id: '18',
- label_name: %w(bug discussion documentation),
- milestone_title: 'v4.0',
- sort: 'due_date_asc',
- namespace_id: 'gitlab-org',
- project_id: 'gitlab-ce',
- page: 2
- }.with_indifferent_access
- end
-
- let(:issues_finder) { IssuesFinder.new(nil, params) }
- let(:merge_requests_finder) { MergeRequestsFinder.new(nil, params) }
-
- before do
- allow(helper).to receive(:issues_finder).and_return(issues_finder)
- allow(helper).to receive(:merge_requests_finder).and_return(merge_requests_finder)
- end
-
- it 'returns the cached value when called for the same issuable type & with the same params' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
-
- expect(issues_finder).not_to receive(:count_by_state)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
- end
-
- it 'takes confidential status into account when searching for issues' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('42')
-
- expect(issues_finder).to receive(:user_cannot_see_confidential_issues?).twice.and_return(false)
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 40)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('40')
-
- expect(issues_finder).to receive(:user_can_see_all_confidential_issues?).and_return(true)
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 45)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to include('45')
- end
-
- it 'does not take confidential status into account when searching for merge requests' do
- expect(merge_requests_finder).to receive(:count_by_state).and_return(opened: 42)
- expect(merge_requests_finder).not_to receive(:user_cannot_see_confidential_issues?)
- expect(merge_requests_finder).not_to receive(:user_can_see_all_confidential_issues?)
-
- expect(helper.issuables_state_counter_text(:merge_requests, :opened))
- .to include('42')
- end
-
- it 'does not take some keys into account in the cache key' do
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
- expect(issues_finder).to receive(:params).and_return({
- author_id: '11',
- state: 'foo',
- sort: 'foo',
- utf8: 'foo',
- page: 'foo'
- }.with_indifferent_access)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
-
- expect(issues_finder).not_to receive(:count_by_state)
- expect(issues_finder).to receive(:params).and_return({
- author_id: '11',
- state: 'bar',
- sort: 'bar',
- utf8: 'bar',
- page: 'bar'
- }.with_indifferent_access)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
- end
-
- it 'does not take params order into account in the cache key' do
- expect(issues_finder).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
- expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
-
- expect(issues_finder).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
- expect(issues_finder).not_to receive(:count_by_state)
-
- expect(helper.issuables_state_counter_text(:issues, :opened))
- .to eq('Open 42')
- end
- end
end
describe '#issuable_reference' do
diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb
index 5eba03ef576..fa8cfda3b86 100644
--- a/spec/helpers/version_check_helper_spec.rb
+++ b/spec/helpers/version_check_helper_spec.rb
@@ -4,7 +4,7 @@ describe VersionCheckHelper do
describe '#version_status_badge' do
it 'should return nil if not dev environment and not enabled' do
allow(Rails.env).to receive(:production?) { false }
- allow(current_application_settings).to receive(:version_check_enabled) { false }
+ allow(helper.current_application_settings).to receive(:version_check_enabled) { false }
expect(helper.version_status_badge).to be(nil)
end
@@ -12,7 +12,7 @@ describe VersionCheckHelper do
context 'when production and enabled' do
before do
allow(Rails.env).to receive(:production?) { true }
- allow(current_application_settings).to receive(:version_check_enabled) { true }
+ allow(helper.current_application_settings).to receive(:version_check_enabled) { true }
allow_any_instance_of(VersionCheck).to receive(:url) { 'https://version.host.com/check.svg?gitlab_info=xxx' }
@image_tag = helper.version_status_badge
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index c3cccbb0d95..5077c89d7b4 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -58,35 +58,82 @@ describe VisibilityLevelHelper do
end
end
- describe "skip_level?" do
+ describe "disallowed_visibility_level?" do
describe "forks" do
let(:project) { create(:project, :internal) }
let(:fork_project) { create(:project, forked_from_project: project) }
- it "skips levels" do
- expect(skip_level?(fork_project, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
- expect(skip_level?(fork_project, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
- expect(skip_level?(fork_project, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ it "disallows levels" do
+ expect(disallowed_visibility_level?(fork_project, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
+ expect(disallowed_visibility_level?(fork_project, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
+ expect(disallowed_visibility_level?(fork_project, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
end
end
describe "non-forked project" do
let(:project) { create(:project, :internal) }
- it "skips levels" do
- expect(skip_level?(project, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
- expect(skip_level?(project, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
- expect(skip_level?(project, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ it "disallows levels" do
+ expect(disallowed_visibility_level?(project, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
+ expect(disallowed_visibility_level?(project, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
+ expect(disallowed_visibility_level?(project, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
end
end
- describe "Snippet" do
+ describe "group" do
+ let(:group) { create(:group, :internal) }
+
+ it "disallows levels" do
+ expect(disallowed_visibility_level?(group, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
+ expect(disallowed_visibility_level?(group, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
+ expect(disallowed_visibility_level?(group, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ end
+ end
+
+ describe "sub-group" do
+ let(:group) { create(:group, :private) }
+ let(:subgroup) { create(:group, :private, parent: group) }
+
+ it "disallows levels" do
+ expect(disallowed_visibility_level?(subgroup, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
+ expect(disallowed_visibility_level?(subgroup, Gitlab::VisibilityLevel::INTERNAL)).to be_truthy
+ expect(disallowed_visibility_level?(subgroup, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ end
+ end
+
+ describe "snippet" do
let(:snippet) { create(:snippet, :internal) }
- it "skips levels" do
- expect(skip_level?(snippet, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
- expect(skip_level?(snippet, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
- expect(skip_level?(snippet, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ it "disallows levels" do
+ expect(disallowed_visibility_level?(snippet, Gitlab::VisibilityLevel::PUBLIC)).to be_falsey
+ expect(disallowed_visibility_level?(snippet, Gitlab::VisibilityLevel::INTERNAL)).to be_falsey
+ expect(disallowed_visibility_level?(snippet, Gitlab::VisibilityLevel::PRIVATE)).to be_falsey
+ end
+ end
+ end
+
+ describe "disallowed_visibility_level_description" do
+ let(:group) { create(:group, :internal) }
+ let!(:subgroup) { create(:group, :internal, parent: group) }
+ let!(:project) { create(:project, :internal, group: group) }
+
+ describe "project" do
+ it "provides correct description for disabled levels" do
+ expect(disallowed_visibility_level?(project, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
+ expect(strip_tags disallowed_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC, project))
+ .to include "the visibility of #{project.group.name} is internal"
+ end
+ end
+
+ describe "group" do
+ it "provides correct description for disabled levels" do
+ expect(disallowed_visibility_level?(group, Gitlab::VisibilityLevel::PRIVATE)).to be_truthy
+ expect(disallowed_visibility_level_description(Gitlab::VisibilityLevel::PRIVATE, group))
+ .to include "it contains projects with higher visibility", "it contains sub-groups with higher visibility"
+
+ expect(disallowed_visibility_level?(subgroup, Gitlab::VisibilityLevel::PUBLIC)).to be_truthy
+ expect(strip_tags disallowed_visibility_level_description(Gitlab::VisibilityLevel::PUBLIC, subgroup))
+ .to include "the visibility of #{group.name} is internal"
end
end
end
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 8e056882108..a22b71fd1dc 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -25,9 +25,10 @@ import '~/lib/utils/common_utils';
};
describe('AwardsHandler', function() {
- preloadFixtures('issues/issue_with_comment.html.raw');
+ preloadFixtures('merge_requests/diff_comment.html.raw');
beforeEach(function(done) {
- loadFixtures('issues/issue_with_comment.html.raw');
+ loadFixtures('merge_requests/diff_comment.html.raw');
+ $('body').data('page', 'projects:merge_requests:show');
loadAwardsHandler(true).then((obj) => {
awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
@@ -139,7 +140,7 @@ import '~/lib/utils/common_utils';
});
describe('::getAwardUrl', function() {
return it('returns the url for request', function() {
- return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/issues-project/issues/1/toggle_award_emoji');
+ return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1/toggle_award_emoji');
});
});
describe('::addAward and ::checkMutuality', function() {
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index 6dc48f9a293..f62bf43adb9 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -1,119 +1,111 @@
-/* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */
-
import '~/behaviors/quick_submit';
-(function() {
- describe('Quick Submit behavior', function() {
- var keydownEvent;
- preloadFixtures('issues/open-issue.html.raw');
- beforeEach(function() {
- loadFixtures('issues/open-issue.html.raw');
- $('form').submit(function(e) {
- // Prevent a form submit from moving us off the testing page
- return e.preventDefault();
- });
- this.spies = {
- submit: spyOnEvent('form', 'submit')
- };
+describe('Quick Submit behavior', () => {
+ const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
- this.textarea = $('.js-quick-submit textarea').first();
- });
- it('does not respond to other keyCodes', function() {
- this.textarea.trigger(keydownEvent({
- keyCode: 32
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('does not respond to Enter alone', function() {
- this.textarea.trigger(keydownEvent({
- ctrlKey: false,
- metaKey: false
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('does not respond to repeated events', function() {
- this.textarea.trigger(keydownEvent({
- repeat: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- it('disables input of type submit', function() {
- const submitButton = $('.js-quick-submit input[type=submit]');
- this.textarea.trigger(keydownEvent());
+ preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
- expect(submitButton).toBeDisabled();
+ beforeEach(() => {
+ loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
+ $('body').attr('data-page', 'projects:merge_requests:show');
+ $('form').submit((e) => {
+ // Prevent a form submit from moving us off the testing page
+ e.preventDefault();
});
- it('disables button of type submit', function() {
- const submitButton = $('.js-quick-submit input[type=submit]');
- this.textarea.trigger(keydownEvent());
-
- expect(submitButton).toBeDisabled();
- });
- it('only clicks one submit', function() {
- const existingSubmit = $('.js-quick-submit input[type=submit]');
- // Add an extra submit button
- const newSubmit = $('');
- newSubmit.insertAfter(this.textarea);
-
- const oldClick = spyOnEvent(existingSubmit, 'click');
- const newClick = spyOnEvent(newSubmit, 'click');
-
- this.textarea.trigger(keydownEvent());
-
- expect(oldClick).not.toHaveBeenTriggered();
- expect(newClick).toHaveBeenTriggered();
- });
- // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
- // only run the tests that apply to the current platform
- if (navigator.userAgent.match(/Macintosh/)) {
- it('responds to Meta+Enter', function() {
- this.textarea.trigger(keydownEvent());
- return expect(this.spies.submit).toHaveBeenTriggered();
- });
- it('excludes other modifier keys', function() {
- this.textarea.trigger(keydownEvent({
- altKey: true
- }));
- this.textarea.trigger(keydownEvent({
- ctrlKey: true
- }));
- this.textarea.trigger(keydownEvent({
- shiftKey: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- } else {
- it('responds to Ctrl+Enter', function() {
- this.textarea.trigger(keydownEvent());
- return expect(this.spies.submit).toHaveBeenTriggered();
- });
- it('excludes other modifier keys', function() {
- this.textarea.trigger(keydownEvent({
- altKey: true
- }));
- this.textarea.trigger(keydownEvent({
- metaKey: true
- }));
- this.textarea.trigger(keydownEvent({
- shiftKey: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- }
- return keydownEvent = function(options) {
- var defaults;
- if (navigator.userAgent.match(/Macintosh/)) {
- defaults = {
- keyCode: 13,
- metaKey: true
- };
- } else {
- defaults = {
- keyCode: 13,
- ctrlKey: true
- };
- }
- return $.Event('keydown', $.extend({}, defaults, options));
+ this.spies = {
+ submit: spyOnEvent('form', 'submit'),
};
+
+ this.textarea = $('.js-quick-submit textarea').first();
});
-}).call(window);
+
+ it('does not respond to other keyCodes', () => {
+ this.textarea.trigger(keydownEvent({
+ keyCode: 32,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+
+ it('does not respond to Enter alone', () => {
+ this.textarea.trigger(keydownEvent({
+ ctrlKey: false,
+ metaKey: false,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+
+ it('does not respond to repeated events', () => {
+ this.textarea.trigger(keydownEvent({
+ repeat: true,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+
+ it('disables input of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('disables button of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('only clicks one submit', () => {
+ const existingSubmit = $('.js-quick-submit input[type=submit]');
+ // Add an extra submit button
+ const newSubmit = $('');
+ newSubmit.insertAfter(this.textarea);
+
+ const oldClick = spyOnEvent(existingSubmit, 'click');
+ const newClick = spyOnEvent(newSubmit, 'click');
+
+ this.textarea.trigger(keydownEvent());
+
+ expect(oldClick).not.toHaveBeenTriggered();
+ expect(newClick).toHaveBeenTriggered();
+ });
+ // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
+ // only run the tests that apply to the current platform
+ if (navigator.userAgent.match(/Macintosh/)) {
+ describe('In Macintosh', () => {
+ it('responds to Meta+Enter', () => {
+ this.textarea.trigger(keydownEvent());
+ return expect(this.spies.submit).toHaveBeenTriggered();
+ });
+
+ it('excludes other modifier keys', () => {
+ this.textarea.trigger(keydownEvent({
+ altKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ ctrlKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ shiftKey: true,
+ }));
+ return expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+ });
+ } else {
+ it('responds to Ctrl+Enter', () => {
+ this.textarea.trigger(keydownEvent());
+ return expect(this.spies.submit).toHaveBeenTriggered();
+ });
+
+ it('excludes other modifier keys', () => {
+ this.textarea.trigger(keydownEvent({
+ altKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ metaKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ shiftKey: true,
+ }));
+ return expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+ }
+});
diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/blob.rb
index 2dffc42b0ef..81e8a51a902 100644
--- a/spec/javascripts/fixtures/blob.rb
+++ b/spec/javascripts/fixtures/blob.rb
@@ -17,6 +17,10 @@ describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'blob/show.html.raw' do |example|
get(:show,
namespace_id: project.namespace,
diff --git a/spec/javascripts/fixtures/branches.rb b/spec/javascripts/fixtures/branches.rb
index bb3bdf7c215..4fc072d2585 100644
--- a/spec/javascripts/fixtures/branches.rb
+++ b/spec/javascripts/fixtures/branches.rb
@@ -17,6 +17,10 @@ describe Projects::BranchesController, '(JavaScript fixtures)', type: :controlle
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'branches/new_branch.html.raw' do |example|
get :new,
namespace_id: project.namespace.to_param,
diff --git a/spec/javascripts/fixtures/dashboard.rb b/spec/javascripts/fixtures/dashboard.rb
index 793ffa7c220..7fa351680c9 100644
--- a/spec/javascripts/fixtures/dashboard.rb
+++ b/spec/javascripts/fixtures/dashboard.rb
@@ -17,6 +17,10 @@ describe Dashboard::ProjectsController, '(JavaScript fixtures)', type: :controll
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'dashboard/user-callout.html.raw' do |example|
rendered = render_template('shared/_user_callout')
store_frontend_fixture(rendered, example.description)
diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb
index bea161c514f..580894ceaf9 100644
--- a/spec/javascripts/fixtures/deploy_keys.rb
+++ b/spec/javascripts/fixtures/deploy_keys.rb
@@ -16,6 +16,10 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
render_views
it 'deploy_keys/keys.json' do |example|
diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb
index d3ad50af1b9..0ee2f82dfd6 100644
--- a/spec/javascripts/fixtures/issues.rb
+++ b/spec/javascripts/fixtures/issues.rb
@@ -17,6 +17,10 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'issues/open-issue.html.raw' do |example|
render_issue(example.description, create(:issue, project: project))
end
diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb
index 83a96797506..87d131dfe28 100644
--- a/spec/javascripts/fixtures/jobs.rb
+++ b/spec/javascripts/fixtures/jobs.rb
@@ -21,6 +21,10 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'builds/build-with-artifacts.html.raw' do |example|
get :show,
namespace_id: project.namespace.to_param,
diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb
index 814f065f3a4..b730d557e21 100644
--- a/spec/javascripts/fixtures/labels.rb
+++ b/spec/javascripts/fixtures/labels.rb
@@ -19,6 +19,10 @@ describe 'Labels (JavaScript fixtures)' do
clean_frontend_fixtures('labels/')
end
+ after do
+ remove_repository(project)
+ end
+
describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do
render_views
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index f97a5d2b5de..4bc2205e642 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -37,6 +37,10 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'merge_requests/merge_request_with_task_list.html.raw' do |example|
create(:ci_build, :pending, pipeline: pipeline)
@@ -55,6 +59,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
end
+ it 'merge_requests/merge_request_with_comment.html.raw' do |example|
+ create(:note_on_merge_request, author: admin, project: project, noteable: merge_request, note: '- [ ] Task List Item')
+ render_merge_request(example.description, merge_request)
+ end
+
private
def render_merge_request(fixture_file_name, merge_request)
diff --git a/spec/javascripts/fixtures/merge_requests_diffs.rb b/spec/javascripts/fixtures/merge_requests_diffs.rb
index 6e0a97d2e3f..ddce00bc0fe 100644
--- a/spec/javascripts/fixtures/merge_requests_diffs.rb
+++ b/spec/javascripts/fixtures/merge_requests_diffs.rb
@@ -29,6 +29,10 @@ describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'merge_request_diffs/inline_changes_tab_with_comments.json' do |example|
create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request)
create(:note_on_merge_request, author: admin, project: project, noteable: merge_request)
diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb
index f09d44a49d1..2a100e7fab5 100644
--- a/spec/javascripts/fixtures/projects.rb
+++ b/spec/javascripts/fixtures/projects.rb
@@ -17,6 +17,10 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'projects/dashboard.html.raw' do |example|
get :show,
namespace_id: project.namespace.to_param,
diff --git a/spec/javascripts/fixtures/prometheus_service.rb b/spec/javascripts/fixtures/prometheus_service.rb
index 7968c9425f2..f95f8038ffb 100644
--- a/spec/javascripts/fixtures/prometheus_service.rb
+++ b/spec/javascripts/fixtures/prometheus_service.rb
@@ -18,6 +18,10 @@ describe Projects::ServicesController, '(JavaScript fixtures)', type: :controlle
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'services/prometheus/prometheus_service.html.raw' do |example|
get :edit,
namespace_id: namespace,
diff --git a/spec/javascripts/fixtures/raw.rb b/spec/javascripts/fixtures/raw.rb
index 25f5a3b0bb3..82770beb39b 100644
--- a/spec/javascripts/fixtures/raw.rb
+++ b/spec/javascripts/fixtures/raw.rb
@@ -10,6 +10,10 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do
clean_frontend_fixtures('blob/notebook/')
end
+ after do
+ remove_repository(project)
+ end
+
it 'blob/notebook/basic.json' do |example|
blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb')
diff --git a/spec/javascripts/fixtures/services.rb b/spec/javascripts/fixtures/services.rb
index 80915c32a74..9280ed5a7f1 100644
--- a/spec/javascripts/fixtures/services.rb
+++ b/spec/javascripts/fixtures/services.rb
@@ -18,6 +18,10 @@ describe Projects::ServicesController, '(JavaScript fixtures)', type: :controlle
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'services/edit_service.html.raw' do |example|
get :edit,
namespace_id: namespace,
diff --git a/spec/javascripts/fixtures/snippet.rb b/spec/javascripts/fixtures/snippet.rb
index 01bfb87b0c1..fa97f352e31 100644
--- a/spec/javascripts/fixtures/snippet.rb
+++ b/spec/javascripts/fixtures/snippet.rb
@@ -18,6 +18,10 @@ describe SnippetsController, '(JavaScript fixtures)', type: :controller do
sign_in(admin)
end
+ after do
+ remove_repository(project)
+ end
+
it 'snippets/show.html.raw' do |example|
get(:show, id: snippet.to_param)
diff --git a/spec/javascripts/fixtures/todos.rb b/spec/javascripts/fixtures/todos.rb
index ba630365c18..426b854fe8b 100644
--- a/spec/javascripts/fixtures/todos.rb
+++ b/spec/javascripts/fixtures/todos.rb
@@ -15,6 +15,10 @@ describe 'Todos (JavaScript fixtures)' do
clean_frontend_fixtures('todos/')
end
+ after do
+ remove_repository(project)
+ end
+
describe Dashboard::TodosController, '(JavaScript fixtures)', type: :controller do
render_views
diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js
index 0847e463577..4588bf3d971 100644
--- a/spec/javascripts/fly_out_nav_spec.js
+++ b/spec/javascripts/fly_out_nav_spec.js
@@ -5,12 +5,14 @@ import {
canShowActiveSubItems,
mouseEnterTopItems,
mouseLeaveTopItem,
+ getOpenMenu,
setOpenMenu,
mousePos,
getHideSubItemsInterval,
documentMouseMove,
getHeaderHeight,
setSidebar,
+ subItemsMouseLeave,
} from '~/fly_out_nav';
import bp from '~/breakpoints';
@@ -314,4 +316,29 @@ describe('Fly out sidebar navigation', () => {
).toBeTruthy();
});
});
+
+ describe('subItemsMouseLeave', () => {
+ beforeEach(() => {
+ el.innerHTML = '';
+
+ setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ });
+
+ it('hides subMenu if element is not hovered', () => {
+ subItemsMouseLeave(el);
+
+ expect(
+ getOpenMenu(),
+ ).toBeNull();
+ });
+
+ it('does not hide subMenu if element is hovered', () => {
+ el.classList.add('is-over');
+ subItemsMouseLeave(el);
+
+ expect(
+ getOpenMenu(),
+ ).not.toBeNull();
+ });
+ });
});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 81ce18bf2fb..39065814bc2 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -34,16 +34,14 @@ describe('Issuable output', () => {
propsData: {
canUpdate: true,
canDestroy: true,
- canMove: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
issuableRef: '#1',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
isConfidential: false,
projectNamespace: '/',
projectPath: '/',
@@ -226,7 +224,7 @@ describe('Issuable output', () => {
});
});
- it('redirects if issue is moved', (done) => {
+ it('redirects if returned web_url has changed', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
@@ -250,23 +248,6 @@ describe('Issuable output', () => {
});
});
- it('does not update issuable if project move confirm is false', (done) => {
- spyOn(window, 'confirm').and.returnValue(false);
- spyOn(vm.service, 'updateIssuable');
-
- vm.store.formState.move_to_project_id = 1;
-
- vm.updateIssuable();
-
- setTimeout(() => {
- expect(
- vm.service.updateIssuable,
- ).not.toHaveBeenCalled();
-
- done();
- });
- });
-
it('closes form on error', (done) => {
spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => {
diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js
index df8189d9290..299f88e7778 100644
--- a/spec/javascripts/issue_show/components/fields/description_spec.js
+++ b/spec/javascripts/issue_show/components/fields/description_spec.js
@@ -25,8 +25,8 @@ describe('Description field component', () => {
vm = new Component({
el,
propsData: {
- markdownPreviewUrl: '/',
- markdownDocs: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
formState: store.formState,
},
}).$mount();
diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js
deleted file mode 100644
index 86d35c33ff4..00000000000
--- a/spec/javascripts/issue_show/components/fields/project_move_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Vue from 'vue';
-import projectMove from '~/issue_show/components/fields/project_move.vue';
-
-describe('Project move field component', () => {
- let vm;
- let formState;
-
- beforeEach((done) => {
- const Component = Vue.extend(projectMove);
-
- formState = {
- move_to_project_id: 0,
- };
-
- vm = new Component({
- propsData: {
- formState,
- projectsAutocompleteUrl: '/autocomplete',
- },
- }).$mount();
-
- Vue.nextTick(done);
- });
-
- it('mounts select2 element', () => {
- expect(
- vm.$el.querySelector('.select2-container'),
- ).not.toBeNull();
- });
-
- it('updates formState on change', () => {
- $(vm.$refs['move-dropdown']).val(2).trigger('change');
-
- expect(
- formState.move_to_project_id,
- ).toBe(2);
- });
-});
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
index 9a85223208c..6e89528a3ea 100644
--- a/spec/javascripts/issue_show/components/form_spec.js
+++ b/spec/javascripts/issue_show/components/form_spec.js
@@ -12,15 +12,13 @@ describe('Inline edit form component', () => {
vm = new Component({
propsData: {
canDestroy: true,
- canMove: true,
formState: {
title: 'b',
description: 'a',
lockedWarningVisible: false,
},
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
projectPath: '/',
projectNamespace: '/',
},
diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js
new file mode 100644
index 00000000000..cca5ec887a3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_comment_form_spec.js
@@ -0,0 +1,134 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueCommentForm from '~/notes/components/issue_comment_form.vue';
+import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data';
+import { keyboardDownEvent } from '../../issue_show/helpers';
+
+describe('issue_comment_form component', () => {
+ let vm;
+ const Component = Vue.extend(issueCommentForm);
+ let mountComponent;
+
+ beforeEach(() => {
+ mountComponent = () => new Component({
+ store,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user is logged in', () => {
+ beforeEach(() => {
+ store.dispatch('setUserData', userDataMock);
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = mountComponent();
+ });
+
+ it('should render user avatar with link', () => {
+ expect(vm.$el.querySelector('.timeline-icon .user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
+ });
+
+ describe('textarea', () => {
+ it('should render textarea with placeholder', () => {
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should support quick actions', () => {
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('data-supports-quick-actions'),
+ ).toEqual('true');
+ });
+
+ it('should link to markdown docs', () => {
+ const { markdownDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ it('should link to quick actions docs', () => {
+ const { quickActionsDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
+ });
+
+ describe('edit mode', () => {
+ it('should enter edit mode when arrow up is pressed', () => {
+ spyOn(vm, 'editCurrentUserLastNote').and.callThrough();
+ vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
+ vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(38, true));
+
+ expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
+ });
+ });
+
+ describe('event enter', () => {
+ it('should save note when cmd/ctrl+enter is pressed', () => {
+ spyOn(vm, 'handleSave').and.callThrough();
+ vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
+ vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(vm.handleSave).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('actions', () => {
+ it('should be possible to close the issue', () => {
+ expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close issue');
+ });
+
+ it('should render comment button as disabled', () => {
+ expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual('disabled');
+ });
+
+ it('should enable comment button if it has note', (done) => {
+ vm.note = 'Foo';
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual(null);
+ done();
+ });
+ });
+
+ it('should update buttons texts when it has note', (done) => {
+ vm.note = 'Foo';
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Comment & close issue');
+ expect(vm.$el.querySelector('.js-note-discard')).toBeDefined();
+ done();
+ });
+ });
+ });
+
+ describe('issue is confidential', () => {
+ it('shows information warning', (done) => {
+ store.dispatch('setIssueData', Object.assign(issueDataMock, { confidential: true }));
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined();
+ done();
+ });
+ });
+ });
+ });
+
+ describe('user is not logged in', () => {
+ beforeEach(() => {
+ store.dispatch('setUserData', null);
+ store.dispatch('setIssueData', loggedOutIssueData);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = mountComponent();
+ });
+
+ it('should render signed out widget', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply');
+ });
+
+ it('should not render submission form', () => {
+ expect(vm.$el.querySelector('textarea')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_discussion_spec.js b/spec/javascripts/notes/components/issue_discussion_spec.js
new file mode 100644
index 00000000000..05c6b57f93e
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_discussion_spec.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueDiscussion from '~/notes/components/issue_discussion.vue';
+import { issueDataMock, discussionMock, notesDataMock } from '../mock_data';
+
+describe('issue_discussion component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueDiscussion);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note: discussionMock,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render user avatar', () => {
+ expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined();
+ });
+
+ it('should render discussion header', () => {
+ expect(vm.$el.querySelector('.discussion-header')).toBeDefined();
+ expect(vm.$el.querySelectorAll('.notes li').length).toEqual(discussionMock.notes.length);
+ });
+
+ describe('actions', () => {
+ it('should render reply button', () => {
+ expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...');
+ });
+
+ it('should toggle reply form', (done) => {
+ vm.$el.querySelector('.js-vue-discussion-reply').click();
+ Vue.nextTick(() => {
+ expect(vm.$refs.noteForm).toBeDefined();
+ expect(vm.isReplying).toEqual(true);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_actions_spec.js b/spec/javascripts/notes/components/issue_note_actions_spec.js
new file mode 100644
index 00000000000..7bcc061f167
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_actions_spec.js
@@ -0,0 +1,91 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueActions from '~/notes/components/issue_note_actions.vue';
+import { userDataMock } from '../mock_data';
+
+describe('issse_note_actions component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(issueActions);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user is logged in', () => {
+ let props;
+
+ beforeEach(() => {
+ props = {
+ accessLevel: 'Master',
+ authorId: 26,
+ canDelete: true,
+ canEdit: true,
+ canReportAsAbuse: true,
+ noteId: 539,
+ reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
+ };
+
+ store.dispatch('setUserData', userDataMock);
+
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should render access level badge', () => {
+ expect(vm.$el.querySelector('.note-role').textContent.trim()).toEqual(props.accessLevel);
+ });
+
+ it('should render emoji link', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
+ });
+
+ describe('actions dropdown', () => {
+ it('should be possible to edit the comment', () => {
+ expect(vm.$el.querySelector('.js-note-edit')).toBeDefined();
+ });
+
+ it('should be possible to report as abuse', () => {
+ expect(vm.$el.querySelector(`a[href="${props.reportAbusePath}"]`)).toBeDefined();
+ });
+
+ it('should be possible to delete comment', () => {
+ expect(vm.$el.querySelector('.js-note-delete')).toBeDefined();
+ });
+ });
+ });
+
+ describe('user is not logged in', () => {
+ let props;
+
+ beforeEach(() => {
+ store.dispatch('setUserData', {});
+ props = {
+ accessLevel: 'Master',
+ authorId: 26,
+ canDelete: false,
+ canEdit: false,
+ canReportAsAbuse: false,
+ noteId: 539,
+ reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
+ };
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should not render emoji link', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toEqual(null);
+ });
+
+ it('should not render actions dropdown', () => {
+ expect(vm.$el.querySelector('.more-actions')).toEqual(null);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_app_spec.js b/spec/javascripts/notes/components/issue_note_app_spec.js
new file mode 100644
index 00000000000..22e91c4c40f
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_app_spec.js
@@ -0,0 +1,255 @@
+import Vue from 'vue';
+import issueNotesApp from '~/notes/components/issue_notes_app.vue';
+import service from '~/notes/services/issue_notes_service';
+import * as mockData from '../mock_data';
+
+describe('issue_note_app', () => {
+ let mountComponent;
+ let vm;
+
+ const individualNoteInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(mockData.individualNoteServerResponse), {
+ status: 200,
+ }));
+ };
+
+ const discussionNoteInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify(mockData.discussionNoteServerResponse), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ const IssueNotesApp = Vue.extend(issueNotesApp);
+
+ mountComponent = (data) => {
+ const props = data || {
+ issueData: mockData.issueDataMock,
+ notesData: mockData.notesDataMock,
+ userData: mockData.userDataMock,
+ };
+
+ return new IssueNotesApp({
+ propsData: props,
+ }).$mount();
+ };
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('set data', () => {
+ const responseInterceptor = (request, next) => {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 200,
+ }));
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(responseInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, responseInterceptor);
+ });
+
+ it('should set notes data', () => {
+ expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock);
+ });
+
+ it('should set issue data', () => {
+ expect(vm.$store.state.issueData).toEqual(mockData.issueDataMock);
+ });
+
+ it('should set user data', () => {
+ expect(vm.$store.state.userData).toEqual(mockData.userDataMock);
+ });
+
+ it('should fetch notes', () => {
+ expect(vm.$store.state.notes).toEqual([]);
+ });
+ });
+
+ describe('render', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('should render list of notes', (done) => {
+ const note = mockData.individualNoteServerResponse[0].notes[0];
+
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(),
+ ).toEqual(note.author.name);
+
+ expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(note.note_html);
+ done();
+ }, 0);
+ });
+
+ it('should render form', () => {
+ expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should render form comment button as disabled', () => {
+ expect(
+ vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled'),
+ ).toEqual('disabled');
+ });
+ });
+
+ describe('while fetching data', () => {
+ beforeEach(() => {
+ vm = mountComponent();
+ });
+
+ it('should render loading icon', () => {
+ expect(vm.$el.querySelector('.js-loading')).toBeDefined();
+ });
+
+ it('should render form', () => {
+ expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
+ expect(
+ vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+ });
+
+ describe('update note', () => {
+ describe('individual note', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('renders edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
+ done();
+ });
+ }, 0);
+ });
+
+ it('calls the service to update the note', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ done();
+ });
+ }, 0);
+ });
+ });
+
+ describe('dicussion note', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(discussionNoteInterceptor);
+ spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, discussionNoteInterceptor);
+ });
+
+ it('renders edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
+ done();
+ });
+ }, 0);
+ });
+
+ it('updates the note and resets the edit form', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ expect(service.updateNote).toHaveBeenCalled();
+ done();
+ });
+ }, 0);
+ });
+ });
+ });
+
+ describe('new note form', () => {
+ beforeEach(() => {
+ vm = mountComponent();
+ });
+
+ it('should render markdown docs url', () => {
+ const { markdownDocsPath } = mockData.notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ it('should render quick action docs url', () => {
+ const { quickActionsDocsPath } = mockData.notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
+ });
+ });
+
+ describe('edit form', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(individualNoteInterceptor);
+ vm = mountComponent();
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
+ });
+
+ it('should render markdown docs url', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ const { markdownDocsPath } = mockData.notesDataMock;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(),
+ ).toEqual('Markdown is supported');
+ done();
+ });
+ }, 0);
+ });
+
+ it('should not render quick actions docs url', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('.js-note-edit').click();
+ const { quickActionsDocsPath } = mockData.notesDataMock;
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`),
+ ).toEqual(null);
+ done();
+ });
+ }, 0);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_attachment_spec.js b/spec/javascripts/notes/components/issue_note_attachment_spec.js
new file mode 100644
index 00000000000..8f33b874ad6
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_attachment_spec.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import issueNoteAttachment from '~/notes/components/issue_note_attachment.vue';
+
+describe('issue note attachment', () => {
+ it('should render properly', () => {
+ const props = {
+ attachment: {
+ filename: 'dk.png',
+ image: true,
+ url: '/dk.png',
+ },
+ };
+
+ const Component = Vue.extend(issueNoteAttachment);
+ const vm = new Component({
+ propsData: props,
+ }).$mount();
+
+ expect(vm.$el.classList.contains('note-attachment')).toBeTruthy();
+ expect(vm.$el.querySelector('img').src).toContain(props.attachment.url);
+ expect(vm.$el.querySelector('a').href).toContain(props.attachment.url);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_awards_list_spec.js b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
new file mode 100644
index 00000000000..3b6c34f1494
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import awardsNote from '~/notes/components/issue_note_awards_list.vue';
+import { issueDataMock, notesDataMock } from '../mock_data';
+
+describe('issue_note_awards_list component', () => {
+ let vm;
+ let awardsMock;
+
+ beforeEach(() => {
+ const Component = Vue.extend(awardsNote);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+ awardsMock = [
+ {
+ name: 'flag_tz',
+ user: { id: 1, name: 'Administrator', username: 'root' },
+ },
+ {
+ name: 'cartwheel_tone3',
+ user: { id: 12, name: 'Bobbie Stehr', username: 'erin' },
+ },
+ ];
+
+ vm = new Component({
+ store,
+ propsData: {
+ awards: awardsMock,
+ noteAuthorId: 2,
+ noteId: 545,
+ toggleAwardPath: '/gitlab-org/gitlab-ce/notes/545/toggle_award_emoji',
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render awarded emojis', () => {
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined();
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]')).toBeDefined();
+ });
+
+ it('should be possible to remove awareded emoji', () => {
+ spyOn(vm, 'handleAward').and.callThrough();
+ vm.$el.querySelector('.js-awards-block button').click();
+
+ expect(vm.handleAward).toHaveBeenCalledWith('flag_tz');
+ });
+
+ it('should be possible to add new emoji', () => {
+ expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_body_spec.js b/spec/javascripts/notes/components/issue_note_body_spec.js
new file mode 100644
index 00000000000..81f07ed47cc
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_body_spec.js
@@ -0,0 +1,46 @@
+
+import Vue from 'vue';
+import store from '~/notes/stores';
+import noteBody from '~/notes/components/issue_note_body.vue';
+import { issueDataMock, notesDataMock, note } from '../mock_data';
+
+describe('issue_note_body component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(noteBody);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note,
+ canEdit: true,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render the note', () => {
+ expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ });
+
+ it('should be render form if user is editing', (done) => {
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
+ done();
+ });
+ });
+
+ it('should render awards list', () => {
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
+ expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_edited_text_spec.js b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
new file mode 100644
index 00000000000..6603241eb64
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import issueNoteEditedText from '~/notes/components/issue_note_edited_text.vue';
+
+describe('issue_note_edited_text', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteEditedText);
+ props = {
+ actionText: 'Edited',
+ className: 'foo-bar',
+ editedAt: '2017-08-04T09:52:31.062Z',
+ editedBy: {
+ avatar_url: 'path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ };
+
+ vm = new Component({
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render block with provided className', () => {
+ expect(vm.$el.className).toEqual(props.className);
+ });
+
+ it('should render provided actionText', () => {
+ expect(vm.$el.textContent).toContain(props.actionText);
+ });
+
+ it('should render provided user information', () => {
+ const authorLink = vm.$el.querySelector('.js-vue-author');
+
+ expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
+ expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_form_spec.js b/spec/javascripts/notes/components/issue_note_form_spec.js
new file mode 100644
index 00000000000..a90dbcb72b5
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_form_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueNoteForm from '~/notes/components/issue_note_form.vue';
+import { issueDataMock, notesDataMock } from '../mock_data';
+import { keyboardDownEvent } from '../../issue_show/helpers';
+
+describe('issue_note_form component', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteForm);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ props = {
+ isEditing: false,
+ noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.',
+ noteId: 545,
+ };
+
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('conflicts editing', () => {
+ it('should show conflict message if note changes outside the component', (done) => {
+ vm.isEditing = true;
+ vm.noteBody = 'Foo';
+ const message = 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.';
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-conflict-edit-warning').textContent.replace(/\s+/g, ' ').trim(),
+ ).toEqual(message);
+ done();
+ });
+ });
+ });
+
+ describe('form', () => {
+ it('should render text area with placeholder', () => {
+ expect(
+ vm.$el.querySelector('textarea').getAttribute('placeholder'),
+ ).toEqual('Write a comment or drag your files here...');
+ });
+
+ it('should link to markdown docs', () => {
+ const { markdownDocsPath } = notesDataMock;
+ expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
+ });
+
+ describe('keyboard events', () => {
+ describe('up', () => {
+ it('should ender edit mode', () => {
+ spyOn(vm, 'editMyLastNote').and.callThrough();
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(38, true));
+
+ expect(vm.editMyLastNote).toHaveBeenCalled();
+ });
+ });
+
+ describe('enter', () => {
+ it('should submit note', () => {
+ spyOn(vm, 'handleUpdate').and.callThrough();
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true));
+
+ expect(vm.handleUpdate).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('actions', () => {
+ it('should be possible to cancel', (done) => {
+ spyOn(vm, 'cancelHandler').and.callThrough();
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('.note-edit-cancel').click();
+
+ Vue.nextTick(() => {
+ expect(vm.cancelHandler).toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+
+ it('should be possible to update the note', (done) => {
+ vm.isEditing = true;
+
+ Vue.nextTick(() => {
+ vm.$el.querySelector('textarea').value = 'Foo';
+ vm.$el.querySelector('.js-vue-issue-save').click();
+
+ Vue.nextTick(() => {
+ expect(vm.isSubmitting).toEqual(true);
+ done();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_header_spec.js b/spec/javascripts/notes/components/issue_note_header_spec.js
new file mode 100644
index 00000000000..83ea18508ae
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_header_spec.js
@@ -0,0 +1,94 @@
+import Vue from 'vue';
+import issueNoteHeader from '~/notes/components/issue_note_header.vue';
+import store from '~/notes/stores';
+
+describe('issue_note_header component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(issueNoteHeader);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('individual note', () => {
+ beforeEach(() => {
+ vm = new Component({
+ store,
+ propsData: {
+ actionText: 'commented',
+ actionTextHtml: '',
+ author: {
+ avatar_url: null,
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ createdAt: '2017-08-02T10:51:58.559Z',
+ includeToggle: false,
+ noteId: 1394,
+ },
+ }).$mount();
+ });
+
+ it('should render user information', () => {
+ expect(
+ vm.$el.querySelector('.note-header-author-name').textContent.trim(),
+ ).toEqual('Root');
+ expect(
+ vm.$el.querySelector('.note-header-info a').getAttribute('href'),
+ ).toEqual('/root');
+ });
+
+ it('should render timestamp link', () => {
+ expect(vm.$el.querySelector('a[href="#note_1394"]')).toBeDefined();
+ });
+ });
+
+ describe('discussion', () => {
+ beforeEach(() => {
+ vm = new Component({
+ store,
+ propsData: {
+ actionText: 'started a discussion',
+ actionTextHtml: '',
+ author: {
+ avatar_url: null,
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ createdAt: '2017-08-02T10:51:58.559Z',
+ includeToggle: true,
+ noteId: 1395,
+ },
+ }).$mount();
+ });
+
+ it('should render toggle button', () => {
+ expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
+ });
+
+ it('should toggle the disucssion icon', (done) => {
+ expect(
+ vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
+ ).toEqual(true);
+
+ vm.$el.querySelector('.js-vue-toggle-button').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
+ ).toEqual(true);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
new file mode 100644
index 00000000000..f20d9ce9268
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import issueNoteSignedOut from '~/notes/components/issue_note_signed_out_widget.vue';
+import store from '~/notes/stores';
+import { notesDataMock } from '../mock_data';
+
+describe('issue_note_signed_out_widget component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNoteSignedOut);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render sign in link provided in the store', () => {
+ expect(
+ vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent,
+ ).toEqual('sign in');
+ });
+
+ it('should render register link provided in the store', () => {
+ expect(
+ vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent,
+ ).toEqual('register');
+ });
+
+ it('should render information text', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply');
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_spec.js b/spec/javascripts/notes/components/issue_note_spec.js
new file mode 100644
index 00000000000..7ef85d5b4f0
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_spec.js
@@ -0,0 +1,44 @@
+
+import Vue from 'vue';
+import store from '~/notes/stores';
+import issueNote from '~/notes/components/issue_note.vue';
+import { issueDataMock, notesDataMock, note } from '../mock_data';
+
+describe('issue_note', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issueNote);
+
+ store.dispatch('setIssueData', issueDataMock);
+ store.dispatch('setNotesData', notesDataMock);
+
+ vm = new Component({
+ store,
+ propsData: {
+ note,
+ },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render user information', () => {
+ expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(note.author.avatar_url);
+ });
+
+ it('should render note header content', () => {
+ expect(vm.$el.querySelector('.note-header .note-header-author-name').textContent.trim()).toEqual(note.author.name);
+ expect(vm.$el.querySelector('.note-header .note-headline-meta').textContent.trim()).toContain('commented');
+ });
+
+ it('should render note actions', () => {
+ expect(vm.$el.querySelector('.note-actions')).toBeDefined();
+ });
+
+ it('should render issue body', () => {
+ expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
new file mode 100644
index 00000000000..6e5275087f3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import issuePlaceholderNote from '~/notes/components/issue_placeholder_note.vue';
+import store from '~/notes/stores';
+import { userDataMock } from '../mock_data';
+
+describe('issue placeholder system note component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(issuePlaceholderNote);
+ store.dispatch('setUserData', userDataMock);
+ vm = new Component({
+ store,
+ propsData: { note: { body: 'Foo' } },
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('user information', () => {
+ it('should render user avatar with link', () => {
+ expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
+ expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(userDataMock.avatar_url);
+ });
+ });
+
+ describe('note content', () => {
+ it('should render note header information', () => {
+ expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(userDataMock.path);
+ expect(vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim()).toEqual(`@${userDataMock.username}`);
+ });
+
+ it('should render note body', () => {
+ expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo');
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
new file mode 100644
index 00000000000..d508a49f710
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import placeholderSystemNote from '~/notes/components/issue_placeholder_system_note.vue';
+
+describe('issue placeholder system note component', () => {
+ let mountComponent;
+ beforeEach(() => {
+ const PlaceholderSystemNote = Vue.extend(placeholderSystemNote);
+
+ mountComponent = props => new PlaceholderSystemNote({
+ propsData: {
+ note: {
+ body: props,
+ },
+ },
+ }).$mount();
+ });
+
+ it('should render system note placeholder with plain text', () => {
+ const vm = mountComponent('This is a placeholder');
+
+ expect(vm.$el.tagName).toEqual('LI');
+ expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual('This is a placeholder');
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_system_note_spec.js b/spec/javascripts/notes/components/issue_system_note_spec.js
new file mode 100644
index 00000000000..c317ce32716
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_system_note_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import issueSystemNote from '~/notes/components/issue_system_note.vue';
+import store from '~/notes/stores';
+
+describe('issue system note', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ props = {
+ note: {
+ id: 1424,
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'path',
+ path: '/root',
+ },
+ note_html: 'closed
',
+ system_note_icon_name: 'icon_status_closed',
+ created_at: '2017-08-02T10:51:58.559Z',
+ },
+ };
+
+ store.dispatch('setTargetNoteHash', `note_${props.note.id}`);
+
+ const Component = Vue.extend(issueSystemNote);
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+
+ it('should render a list item with correct id', () => {
+ expect(vm.$el.getAttribute('id')).toEqual(`note_${props.note.id}`);
+ });
+
+ it('should render target class is note is target note', () => {
+ expect(vm.$el.classList).toContain('target');
+ });
+
+ it('should render svg icon', () => {
+ expect(vm.$el.querySelector('.timeline-icon svg')).toBeDefined();
+ });
+
+ it('should render note header component', () => {
+ expect(
+ vm.$el.querySelector('.system-note-message').innerHTML,
+ ).toEqual(props.note.note_html);
+ });
+});
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
new file mode 100644
index 00000000000..89ba3a002b7
--- /dev/null
+++ b/spec/javascripts/notes/mock_data.js
@@ -0,0 +1,449 @@
+/* eslint-disable */
+export const notesDataMock = {
+ discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json',
+ lastFetchedAt: '1501862675',
+ markdownDocsPath: '/help/user/markdown',
+ newSessionPath: '/users/sign_in?redirect_to_referer=yes',
+ notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
+ quickActionsDocsPath: '/help/user/project/quick_actions',
+ registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
+};
+
+export const userDataMock = {
+ avatar_url: 'mock_path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+};
+
+export const issueDataMock = {
+ assignees: [],
+ author_id: 1,
+ branch_name: null,
+ confidential: false,
+ create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue',
+ created_at: '2017-02-07T10:11:18.395Z',
+ current_user: {
+ can_create_note: true,
+ can_update: true,
+ },
+ deleted_at: null,
+ description: '',
+ due_date: null,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ id: 98,
+ iid: 26,
+ labels: [],
+ lock_version: null,
+ milestone: null,
+ milestone_id: null,
+ moved_to_id: null,
+ preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
+ project_id: 2,
+ state: 'opened',
+ time_estimate: 0,
+ title: '14',
+ total_time_spent: 0,
+ updated_at: '2017-08-04T09:53:01.226Z',
+ updated_by_id: 1,
+ web_url: '/gitlab-org/gitlab-ce/issues/26',
+};
+
+export const lastFetchedAt = '1501862675';
+
+export const individualNote = {
+ expanded: true,
+ id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ individual_note: true,
+ notes: [{
+ id: 1390,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'test',
+ path: '/root',
+ },
+ created_at: '2017-08-01T17: 09: 33.762Z',
+ updated_at: '2017-08-01T17: 09: 33.762Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: null,
+ human_access: 'Owner',
+ note: 'sdfdsaf',
+ note_html: 'sdfdsaf
',
+ current_user: { can_edit: true },
+ discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+ emoji_awardable: true,
+ award_emoji: [
+ { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
+ { name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
+ ],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1390',
+ }],
+ reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
+};
+
+export const note = {
+ "id": 546,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root",
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "path": "/root"
+ },
+ "created_at": "2017-08-10T15:24:03.087Z",
+ "updated_at": "2017-08-10T15:24:03.087Z",
+ "system": false,
+ "noteable_id": 67,
+ "noteable_type": "Issue",
+ "noteable_iid": 7,
+ "type": null,
+ "human_access": "Owner",
+ "note": "Vel id placeat reprehenderit sit numquam.",
+ "note_html": "Vel id placeat reprehenderit sit numquam.
",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0",
+ "emoji_awardable": true,
+ "award_emoji": [{
+ "name": "baseball",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root"
+ }
+ }, {
+ "name": "bath_tone3",
+ "user": {
+ "id": 1,
+ "name": "Administrator",
+ "username": "root"
+ }
+ }],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/546"
+ }
+
+export const discussionMock = {
+ id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ expanded: true,
+ notes: [{
+ id: 1395,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:51:58.559Z',
+ updated_at: '2017-08-02T10:51:58.559Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'THIS IS A DICUSSSION!',
+ note_html: 'THIS IS A DICUSSSION!
',
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1395',
+ }, {
+ id: 1396,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-02T10:56:50.980Z',
+ updated_at: '2017-08-03T14:19:35.691Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'sadfasdsdgdsf',
+ note_html: 'sadfasdsdgdsf
',
+ last_edited_at: '2017-08-03T14:19:35.691Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1396',
+ }, {
+ id: 1437,
+ attachment: {
+ url: null,
+ filename: null,
+ image: false,
+ },
+ author: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ created_at: '2017-08-03T18:11:18.780Z',
+ updated_at: '2017-08-04T09:52:31.062Z',
+ system: false,
+ noteable_id: 98,
+ noteable_type: 'Issue',
+ type: 'DiscussionNote',
+ human_access: 'Owner',
+ note: 'adsfasf Should disappear',
+ note_html: 'adsfasf Should disappear
',
+ last_edited_at: '2017-08-04T09:52:31.062Z',
+ last_edited_by: {
+ id: 1,
+ name: 'Root',
+ username: 'root',
+ state: 'active',
+ avatar_url: null,
+ path: '/root',
+ },
+ current_user: {
+ can_edit: true,
+ },
+ discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
+ emoji_awardable: true,
+ award_emoji: [],
+ toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji',
+ report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
+ path: '/gitlab-org/gitlab-ce/notes/1437',
+ }],
+ individual_note: false,
+};
+
+export const loggedOutIssueData = {
+ "id": 98,
+ "iid": 26,
+ "author_id": 1,
+ "description": "",
+ "lock_version": 1,
+ "milestone_id": null,
+ "state": "opened",
+ "title": "asdsa",
+ "updated_by_id": 1,
+ "created_at": "2017-02-07T10:11:18.395Z",
+ "updated_at": "2017-08-08T10:22:51.564Z",
+ "deleted_at": null,
+ "time_estimate": 0,
+ "total_time_spent": 0,
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "milestone": null,
+ "labels": [],
+ "branch_name": null,
+ "confidential": false,
+ "assignees": [{
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "web_url": "http://localhost:3000/root"
+ }],
+ "due_date": null,
+ "moved_to_id": null,
+ "project_id": 2,
+ "web_url": "/gitlab-org/gitlab-ce/issues/26",
+ "current_user": {
+ "can_create_note": false,
+ "can_update": false
+ },
+ "create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue",
+ "preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue"
+}
+
+export const individualNoteServerResponse = [{
+ "id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "expanded": true,
+ "notes": [{
+ "id": 1390,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-01T17:09:33.762Z",
+ "updated_at": "2017-08-01T17:09:33.762Z",
+ "system": false,
+ "noteable_id": 98,
+ "noteable_type": "Issue",
+ "type": null,
+ "human_access": "Owner",
+ "note": "sdfdsaf",
+ "note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
+ "emoji_awardable": true,
+ "award_emoji": [{
+ "name": "baseball",
+ "user": {
+ "id": 1,
+ "name": "Root",
+ "username": "root"
+ }
+ }, {
+ "name": "art",
+ "user": {
+ "id": 1,
+ "name": "Root",
+ "username": "root"
+ }
+ }],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1390"
+ }],
+ "individual_note": true
+ }, {
+ "id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "expanded": true,
+ "notes": [{
+ "id": 1391,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-02T10:51:38.685Z",
+ "updated_at": "2017-08-02T10:51:38.685Z",
+ "system": false,
+ "noteable_id": 98,
+ "noteable_type": "Issue",
+ "type": null,
+ "human_access": "Owner",
+ "note": "New note!",
+ "note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
+ "emoji_awardable": true,
+ "award_emoji": [],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1391"
+ }],
+ "individual_note": true
+}];
+
+export const discussionNoteServerResponse = [{
+ "id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "expanded": true,
+ "notes": [{
+ "id": 1471,
+ "attachment": {
+ "url": null,
+ "filename": null,
+ "image": false
+ },
+ "author": {
+ "id": 1,
+ "name": "Root",
+ "username": "root",
+ "state": "active",
+ "avatar_url": null,
+ "path": "/root"
+ },
+ "created_at": "2017-08-08T16:53:00.666Z",
+ "updated_at": "2017-08-08T16:53:00.666Z",
+ "system": false,
+ "noteable_id": 124,
+ "noteable_type": "Issue",
+ "noteable_iid": 29,
+ "type": "DiscussionNote",
+ "human_access": "Owner",
+ "note": "Adding a comment",
+ "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e",
+ "current_user": {
+ "can_edit": true
+ },
+ "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
+ "emoji_awardable": true,
+ "award_emoji": [],
+ "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji",
+ "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1",
+ "path": "/gitlab-org/gitlab-ce/notes/1471"
+ }],
+ "individual_note": false
+}];
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
new file mode 100644
index 00000000000..72d362acb2f
--- /dev/null
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -0,0 +1,62 @@
+
+import * as actions from '~/notes/stores/actions';
+import testAction from './helpers';
+import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Actions Notes Store', () => {
+ describe('setNotesData', () => {
+ it('should set received notes data', (done) => {
+ testAction(actions.setNotesData, null, { notesData: {} }, [
+ { type: 'SET_NOTES_DATA', payload: notesDataMock },
+ ], done);
+ });
+ });
+
+ describe('setIssueData', () => {
+ it('should set received issue data', (done) => {
+ testAction(actions.setIssueData, null, { issueData: {} }, [
+ { type: 'SET_ISSUE_DATA', payload: issueDataMock },
+ ], done);
+ });
+ });
+
+ describe('setUserData', () => {
+ it('should set received user data', (done) => {
+ testAction(actions.setUserData, null, { userData: {} }, [
+ { type: 'SET_USER_DATA', payload: userDataMock },
+ ], done);
+ });
+ });
+
+ describe('setLastFetchedAt', () => {
+ it('should set received timestamp', (done) => {
+ testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [
+ { type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' },
+ ], done);
+ });
+ });
+
+ describe('setInitialNotes', () => {
+ it('should set initial notes', (done) => {
+ testAction(actions.setInitialNotes, null, { notes: [] }, [
+ { type: 'SET_INITIAL_NOTES', payload: [individualNote] },
+ ], done);
+ });
+ });
+
+ describe('setTargetNoteHash', () => {
+ it('should set target note hash', (done) => {
+ testAction(actions.setTargetNoteHash, null, { notes: [] }, [
+ { type: 'SET_TARGET_NOTE_HASH', payload: 'hash' },
+ ], done);
+ });
+ });
+
+ describe('toggleDiscussion', () => {
+ it('should toggle discussion', (done) => {
+ testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [
+ { type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } },
+ ], done);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
new file mode 100644
index 00000000000..48ee1bf9a52
--- /dev/null
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -0,0 +1,58 @@
+import * as getters from '~/notes/stores/getters';
+import { notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Getters Notes Store', () => {
+ let state;
+ beforeEach(() => {
+ state = {
+ notes: [individualNote],
+ targetNoteHash: 'hash',
+ lastFetchedAt: 'timestamp',
+
+ notesData: notesDataMock,
+ userData: userDataMock,
+ issueData: issueDataMock,
+ };
+ });
+ describe('notes', () => {
+ it('should return all notes in the store', () => {
+ expect(getters.notes(state)).toEqual([individualNote]);
+ });
+ });
+
+ describe('targetNoteHash', () => {
+ it('should return `targetNoteHash`', () => {
+ expect(getters.targetNoteHash(state)).toEqual('hash');
+ });
+ });
+
+ describe('getNotesData', () => {
+ it('should return all data in `notesData`', () => {
+ expect(getters.getNotesData(state)).toEqual(notesDataMock);
+ });
+ });
+
+ describe('getIssueData', () => {
+ it('should return all data in `issueData`', () => {
+ expect(getters.getIssueData(state)).toEqual(issueDataMock);
+ });
+ });
+
+ describe('getUserData', () => {
+ it('should return all data in `userData`', () => {
+ expect(getters.getUserData(state)).toEqual(userDataMock);
+ });
+ });
+
+ describe('notesById', () => {
+ it('should return the note for the given id', () => {
+ expect(getters.notesById(state)).toEqual({ 1390: individualNote.notes[0] });
+ });
+ });
+
+ describe('getCurrentUserLastNote', () => {
+ it('should return the last note of the current user', () => {
+ expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/helpers.js b/spec/javascripts/notes/stores/helpers.js
new file mode 100644
index 00000000000..2d386fe1da5
--- /dev/null
+++ b/spec/javascripts/notes/stores/helpers.js
@@ -0,0 +1,37 @@
+/* eslint-disable */
+
+/**
+ * helper for testing action with expected mutations
+ * https://vuex.vuejs.org/en/testing.html
+ */
+export default (action, payload, state, expectedMutations, done) => {
+ let count = 0;
+
+ // mock commit
+ const commit = (type, payload) => {
+ const mutation = expectedMutations[count];
+
+ try {
+ expect(mutation.type).to.equal(type);
+ if (payload) {
+ expect(mutation.payload).to.deep.equal(payload);
+ }
+ } catch (error) {
+ done(error);
+ }
+
+ count++;
+ if (count >= expectedMutations.length) {
+ done();
+ }
+ };
+
+ // call the action with mocked store and arguments
+ action({ commit, state }, payload);
+
+ // check if no mutations should have been dispatched
+ if (expectedMutations.length === 0) {
+ expect(count).to.equal(0);
+ done();
+ }
+};
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
new file mode 100644
index 00000000000..a38f29c1e39
--- /dev/null
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -0,0 +1,207 @@
+import mutations from '~/notes/stores/mutations';
+import { note, discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
+
+describe('Mutation Notes Store', () => {
+ describe('ADD_NEW_NOTE', () => {
+ it('should add a new note to an array of notes', () => {
+ const state = { notes: [] };
+ mutations.ADD_NEW_NOTE(state, note);
+
+ expect(state).toEqual({
+ notes: [{
+ expanded: true,
+ id: note.discussion_id,
+ individual_note: true,
+ notes: [note],
+ reply_id: note.discussion_id,
+ }],
+ });
+ });
+ });
+
+ describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
+ it('should add a reply to a specific discussion', () => {
+ const state = { notes: [discussionMock] };
+ const newReply = Object.assign({}, note, { discussion_id: discussionMock.id });
+ mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
+
+ expect(state.notes[0].notes.length).toEqual(4);
+ });
+ });
+
+ describe('DELETE_NOTE', () => {
+ it('should delete a note ', () => {
+ const state = { notes: [discussionMock] };
+ const toDelete = discussionMock.notes[0];
+ const lengthBefore = discussionMock.notes.length;
+
+ mutations.DELETE_NOTE(state, toDelete);
+
+ expect(state.notes[0].notes.length).toEqual(lengthBefore - 1);
+ });
+ });
+
+ describe('REMOVE_PLACEHOLDER_NOTES', () => {
+ it('should remove all placeholder notes in indivudal notes and discussion', () => {
+ const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true });
+ const state = { notes: [placeholderNote] };
+ mutations.REMOVE_PLACEHOLDER_NOTES(state);
+
+ expect(state.notes).toEqual([]);
+ });
+ });
+
+ describe('SET_NOTES_DATA', () => {
+ it('should set an object with notesData', () => {
+ const state = {
+ notesData: {},
+ };
+
+ mutations.SET_NOTES_DATA(state, notesDataMock);
+ expect(state.notesData).toEqual(notesDataMock);
+ });
+ });
+
+ describe('SET_ISSUE_DATA', () => {
+ it('should set the issue data', () => {
+ const state = {
+ issueData: {},
+ };
+
+ mutations.SET_ISSUE_DATA(state, issueDataMock);
+ expect(state.issueData).toEqual(issueDataMock);
+ });
+ });
+
+ describe('SET_USER_DATA', () => {
+ it('should set the user data', () => {
+ const state = {
+ userData: {},
+ };
+
+ mutations.SET_USER_DATA(state, userDataMock);
+ expect(state.userData).toEqual(userDataMock);
+ });
+ });
+
+ describe('SET_INITIAL_NOTES', () => {
+ it('should set the initial notes received', () => {
+ const state = {
+ notes: [],
+ };
+
+ mutations.SET_INITIAL_NOTES(state, [note]);
+ expect(state.notes).toEqual([note]);
+ });
+ });
+
+ describe('SET_LAST_FETCHED_AT', () => {
+ it('should set timestamp', () => {
+ const state = {
+ lastFetchedAt: [],
+ };
+
+ mutations.SET_LAST_FETCHED_AT(state, 'timestamp');
+ expect(state.lastFetchedAt).toEqual('timestamp');
+ });
+ });
+
+ describe('SET_TARGET_NOTE_HASH', () => {
+ it('should set the note hash', () => {
+ const state = {
+ targetNoteHash: [],
+ };
+
+ mutations.SET_TARGET_NOTE_HASH(state, 'hash');
+ expect(state.targetNoteHash).toEqual('hash');
+ });
+ });
+
+ describe('SHOW_PLACEHOLDER_NOTE', () => {
+ it('should set a placeholder note', () => {
+ const state = {
+ notes: [],
+ };
+ mutations.SHOW_PLACEHOLDER_NOTE(state, note);
+ expect(state.notes[0].isPlaceholderNote).toEqual(true);
+ });
+ });
+
+ describe('TOGGLE_AWARD', () => {
+ it('should add award if user has not reacted yet', () => {
+ const state = {
+ notes: [note],
+ userData: userDataMock,
+ };
+
+ const data = {
+ note,
+ awardName: 'cartwheel',
+ };
+
+ mutations.TOGGLE_AWARD(state, data);
+ const lastIndex = state.notes[0].award_emoji.length - 1;
+
+ expect(state.notes[0].award_emoji[lastIndex]).toEqual({
+ name: 'cartwheel',
+ user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username },
+ });
+ });
+
+ it('should remove award if user already reacted', () => {
+ const state = {
+ notes: [note],
+ userData: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ },
+ };
+
+ const data = {
+ note,
+ awardName: 'bath_tone3',
+ };
+ mutations.TOGGLE_AWARD(state, data);
+ expect(state.notes[0].award_emoji.length).toEqual(2);
+ });
+ });
+
+ describe('TOGGLE_DISCUSSION', () => {
+ it('should open a closed discussion', () => {
+ const discussion = Object.assign({}, discussionMock, { expanded: false });
+
+ const state = {
+ notes: [discussion],
+ };
+
+ mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id });
+
+ expect(state.notes[0].expanded).toEqual(true);
+ });
+
+ it('should close a opened discussion', () => {
+ const state = {
+ notes: [discussionMock],
+ };
+
+ mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id });
+
+ expect(state.notes[0].expanded).toEqual(false);
+ });
+ });
+
+ describe('UPDATE_NOTE', () => {
+ it('should update a note', () => {
+ const state = {
+ notes: [individualNote],
+ };
+
+ const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' });
+
+ mutations.UPDATE_NOTE(state, updated);
+
+ expect(state.notes[0].notes[0].note).toEqual('Foo');
+ });
+ });
+});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 2c096ed08a8..8c5ad8914b0 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -32,14 +32,14 @@ import '~/notes';
describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert';
- var commentsTemplate = 'issues/issue_with_comment.html.raw';
+ var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate);
beforeEach(function () {
loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads';
- $('body').data('page', 'projects:issues:show');
+ $('body').data('page', 'projects:merge_requets:show');
});
describe('task lists', function() {
@@ -53,17 +53,19 @@ import '~/notes';
it('modifies the Markdown field', function() {
const changeEvent = document.createEvent('HTMLEvents');
changeEvent.initEvent('change', true, true);
- $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent);
- expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
+ $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent);
+
+ expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item');
});
it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH');
- expect(req.url).toBe('http://test.host/frontend-fixtures/issues-project/notes/1');
+ expect(req.url).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1.json');
return expect(req.data.note).not.toBe(null);
});
- $('.js-task-list-field').trigger('tasklist:changed');
+
+ $('.js-task-list-field.js-note-text').trigger('tasklist:changed');
});
});
diff --git a/spec/javascripts/pretty_time_spec.js b/spec/javascripts/pretty_time_spec.js
index de99e7e3894..0a6c479a95b 100644
--- a/spec/javascripts/pretty_time_spec.js
+++ b/spec/javascripts/pretty_time_spec.js
@@ -76,6 +76,87 @@ import '~/lib/utils/pretty_time';
expect(aboveOneWeek.days).toBe(3);
expect(aboveOneWeek.weeks).toBe(173);
});
+
+ it('should correctly accept a custom param for hoursPerDay', function () {
+ const parser = prettyTime.parseSeconds;
+ const config = { hoursPerDay: 24 };
+
+ const aboveOneHour = parser(4800, config);
+
+ expect(aboveOneHour.minutes).toBe(20);
+ expect(aboveOneHour.hours).toBe(1);
+ expect(aboveOneHour.days).toBe(0);
+ expect(aboveOneHour.weeks).toBe(0);
+
+ const aboveOneDay = parser(110000, config);
+
+ expect(aboveOneDay.minutes).toBe(33);
+ expect(aboveOneDay.hours).toBe(6);
+ expect(aboveOneDay.days).toBe(1);
+ expect(aboveOneDay.weeks).toBe(0);
+
+ const aboveOneWeek = parser(25000000, config);
+
+ expect(aboveOneWeek.minutes).toBe(26);
+ expect(aboveOneWeek.hours).toBe(8);
+ expect(aboveOneWeek.days).toBe(4);
+
+ expect(aboveOneWeek.weeks).toBe(57);
+ });
+
+ it('should correctly accept a custom param for daysPerWeek', function () {
+ const parser = prettyTime.parseSeconds;
+ const config = { daysPerWeek: 7 };
+
+ const aboveOneHour = parser(4800, config);
+
+ expect(aboveOneHour.minutes).toBe(20);
+ expect(aboveOneHour.hours).toBe(1);
+ expect(aboveOneHour.days).toBe(0);
+ expect(aboveOneHour.weeks).toBe(0);
+
+ const aboveOneDay = parser(110000, config);
+
+ expect(aboveOneDay.minutes).toBe(33);
+ expect(aboveOneDay.hours).toBe(6);
+ expect(aboveOneDay.days).toBe(3);
+ expect(aboveOneDay.weeks).toBe(0);
+
+ const aboveOneWeek = parser(25000000, config);
+
+ expect(aboveOneWeek.minutes).toBe(26);
+ expect(aboveOneWeek.hours).toBe(0);
+ expect(aboveOneWeek.days).toBe(0);
+
+ expect(aboveOneWeek.weeks).toBe(124);
+ });
+
+ it('should correctly accept custom params for daysPerWeek and hoursPerDay', function () {
+ const parser = prettyTime.parseSeconds;
+ const config = { daysPerWeek: 55, hoursPerDay: 14 };
+
+ const aboveOneHour = parser(4800, config);
+
+ expect(aboveOneHour.minutes).toBe(20);
+ expect(aboveOneHour.hours).toBe(1);
+ expect(aboveOneHour.days).toBe(0);
+ expect(aboveOneHour.weeks).toBe(0);
+
+ const aboveOneDay = parser(110000, config);
+
+ expect(aboveOneDay.minutes).toBe(33);
+ expect(aboveOneDay.hours).toBe(2);
+ expect(aboveOneDay.days).toBe(2);
+ expect(aboveOneDay.weeks).toBe(0);
+
+ const aboveOneWeek = parser(25000000, config);
+
+ expect(aboveOneWeek.minutes).toBe(26);
+ expect(aboveOneWeek.hours).toBe(0);
+ expect(aboveOneWeek.days).toBe(1);
+
+ expect(aboveOneWeek.weeks).toBe(9);
+ });
});
describe('stringifyTime', function () {
diff --git a/spec/javascripts/project_select_combo_button_spec.js b/spec/javascripts/project_select_combo_button_spec.js
index 021804e0769..dda83645c92 100644
--- a/spec/javascripts/project_select_combo_button_spec.js
+++ b/spec/javascripts/project_select_combo_button_spec.js
@@ -32,11 +32,6 @@ describe('Project Select Combo Button', function () {
this.comboButton = new ProjectSelectComboButton(this.projectSelectInput);
});
- it('newItemBtn is disabled', function () {
- expect(this.newItemBtn.hasAttribute('disabled')).toBe(true);
- expect(this.newItemBtn.classList.contains('disabled')).toBe(true);
- });
-
it('newItemBtn href is null', function () {
expect(this.newItemBtn.getAttribute('href')).toBe('');
});
@@ -53,11 +48,6 @@ describe('Project Select Combo Button', function () {
this.comboButton = new ProjectSelectComboButton(this.projectSelectInput);
});
- it('newItemBtn is not disabled', function () {
- expect(this.newItemBtn.hasAttribute('disabled')).toBe(false);
- expect(this.newItemBtn.classList.contains('disabled')).toBe(false);
- });
-
it('newItemBtn href is correctly set', function () {
expect(this.newItemBtn.getAttribute('href')).toBe(this.defaults.projectMeta.url);
});
@@ -82,11 +72,6 @@ describe('Project Select Combo Button', function () {
.trigger('change');
});
- it('newItemBtn is not disabled', function () {
- expect(this.newItemBtn.hasAttribute('disabled')).toBe(false);
- expect(this.newItemBtn.classList.contains('disabled')).toBe(false);
- });
-
it('newItemBtn href is correctly set', function () {
expect(this.newItemBtn.getAttribute('href'))
.toBe('http://myothercoolproject.com/issues/new');
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index 3515dfbc60b..a912e150e9b 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,78 +1,74 @@
-/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */
import '~/copy_as_gfm';
import '~/shortcuts_issuable';
-(function() {
- describe('ShortcutsIssuable', function() {
- var fixtureName = 'issues/open-issue.html.raw';
- preloadFixtures(fixtureName);
- beforeEach(function() {
- loadFixtures(fixtureName);
- document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
- this.shortcut = new ShortcutsIssuable();
- });
- describe('replyWithSelectedText', function() {
- var stubSelection;
- // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
- stubSelection = function(html) {
- window.gl.utils.getSelectedFragment = function() {
- var node = document.createElement('div');
- node.innerHTML = html;
- return node;
- };
+describe('ShortcutsIssuable', () => {
+ const fixtureName = 'merge_requests/diff_comment.html.raw';
+ preloadFixtures(fixtureName);
+ beforeEach(() => {
+ loadFixtures(fixtureName);
+ document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
+ this.shortcut = new ShortcutsIssuable(true);
+ });
+ describe('replyWithSelectedText', () => {
+ // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
+ const stubSelection = (html) => {
+ window.gl.utils.getSelectedFragment = () => {
+ const node = document.createElement('div');
+ node.innerHTML = html;
+ return node;
};
- beforeEach(function() {
- this.selector = 'form.js-main-target-form textarea#note_note';
+ };
+ beforeEach(() => {
+ this.selector = '.js-main-target-form #note_note';
+ });
+ describe('with empty selection', () => {
+ it('does not return an error', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('');
});
- describe('with empty selection', function() {
- it('does not return an error', function() {
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe('');
- });
- it('triggers `focus`', function() {
- this.shortcut.replyWithSelectedText();
- expect(document.activeElement).toBe(document.querySelector(this.selector));
- });
+ it('triggers `focus`', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect(document.activeElement).toBe(document.querySelector(this.selector));
});
- describe('with any selection', function() {
- beforeEach(function() {
- stubSelection('Selected text.
');
- });
- it('leaves existing input intact', function() {
- $(this.selector).val('This text was already here.');
- expect($(this.selector).val()).toBe('This text was already here.');
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n");
- });
- it('triggers `input`', function() {
- var triggered = false;
- $(this.selector).on('input', function() {
- triggered = true;
- });
- this.shortcut.replyWithSelectedText();
- expect(triggered).toBe(true);
- });
- it('triggers `focus`', function() {
- this.shortcut.replyWithSelectedText();
- expect(document.activeElement).toBe(document.querySelector(this.selector));
- });
+ });
+ describe('with any selection', () => {
+ beforeEach(() => {
+ stubSelection('Selected text.
');
});
- describe('with a one-line selection', function() {
- it('quotes the selection', function() {
- stubSelection('This text has been selected.
');
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> This text has been selected.\n\n");
- });
+ it('leaves existing input intact', () => {
+ $(this.selector).val('This text was already here.');
+ expect($(this.selector).val()).toBe('This text was already here.');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
});
- describe('with a multi-line selection', function() {
- it('quotes the selected lines as a group', function() {
- stubSelection("Selected line one.
\n\nSelected line two.
\n\nSelected line three.
");
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
+ it('triggers `input`', () => {
+ let triggered = false;
+ $(this.selector).on('input', () => {
+ triggered = true;
});
+ this.shortcut.replyWithSelectedText(true);
+ expect(triggered).toBe(true);
+ });
+ it('triggers `focus`', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect(document.activeElement).toBe(document.querySelector(this.selector));
+ });
+ });
+ describe('with a one-line selection', () => {
+ it('quotes the selection', () => {
+ stubSelection('This text has been selected.
');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('> This text has been selected.\n\n');
+ });
+ });
+ describe('with a multi-line selection', () => {
+ it('quotes the selected lines as a group', () => {
+ stubSelection('Selected line one.
\n\nSelected line two.
\n\nSelected line three.
');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n');
});
});
});
-}).call(window);
+});
diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js
index 9b8373df29e..53e4c68beb3 100644
--- a/spec/javascripts/shortcuts_spec.js
+++ b/spec/javascripts/shortcuts_spec.js
@@ -1,6 +1,6 @@
/* global Shortcuts */
describe('Shortcuts', () => {
- const fixtureName = 'issues/issue_with_comment.html.raw';
+ const fixtureName = 'merge_requests/diff_comment.html.raw';
const createEvent = (type, target) => $.Event(type, {
target,
});
diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js
index 9fc8667ecc9..e2b6bcabc98 100644
--- a/spec/javascripts/sidebar/mock_data.js
+++ b/spec/javascripts/sidebar/mock_data.js
@@ -66,17 +66,57 @@ const sidebarMockData = {
},
labels: [],
},
+ '/autocomplete/projects?project_id=15': [
+ {
+ 'id': 0,
+ 'name_with_namespace': 'No project',
+ }, {
+ 'id': 20,
+ 'name_with_namespace': 'foo / bar',
+ },
+ ],
},
'PUT': {
'/gitlab-org/gitlab-shell/issues/5.json': {
data: {},
},
},
+ 'POST': {
+ '/gitlab-org/gitlab-shell/issues/5/move': {
+ id: 123,
+ iid: 5,
+ author_id: 1,
+ description: 'some description',
+ lock_version: 5,
+ milestone_id: null,
+ state: 'opened',
+ title: 'some title',
+ updated_by_id: 1,
+ created_at: '2017-06-27T19:54:42.437Z',
+ updated_at: '2017-08-18T03:39:49.222Z',
+ deleted_at: null,
+ time_estimate: 0,
+ total_time_spent: 0,
+ human_time_estimate: null,
+ human_total_time_spent: null,
+ branch_name: null,
+ confidential: false,
+ assignees: [],
+ due_date: null,
+ moved_to_id: null,
+ project_id: 7,
+ milestone: null,
+ labels: [],
+ web_url: '/root/some-project/issues/5',
+ },
+ },
};
export default {
mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
+ moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
+ projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
editable: true,
currentUser: {
id: 1,
@@ -85,6 +125,7 @@ export default {
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
},
rootPath: '/',
+ fullPath: '/gitlab-org/gitlab-shell',
},
time: {
time_estimate: 3600,
diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js
index e246f41ee82..3aa8ca5db0d 100644
--- a/spec/javascripts/sidebar/sidebar_mediator_spec.js
+++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js
@@ -30,7 +30,7 @@ describe('Sidebar mediator', () => {
expect(resp.status).toEqual(200);
done();
})
- .catch(() => {});
+ .catch(done.fail);
});
it('fetches the data', () => {
@@ -38,4 +38,42 @@ describe('Sidebar mediator', () => {
this.mediator.fetch();
expect(this.mediator.service.get).toHaveBeenCalled();
});
+
+ it('sets moveToProjectId', () => {
+ const projectId = 7;
+ spyOn(this.mediator.store, 'setMoveToProjectId').and.callThrough();
+
+ this.mediator.setMoveToProjectId(projectId);
+
+ expect(this.mediator.store.setMoveToProjectId).toHaveBeenCalledWith(projectId);
+ });
+
+ it('fetches autocomplete projects', (done) => {
+ const searchTerm = 'foo';
+ spyOn(this.mediator.service, 'getProjectsAutocomplete').and.callThrough();
+ spyOn(this.mediator.store, 'setAutocompleteProjects').and.callThrough();
+
+ this.mediator.fetchAutocompleteProjects(searchTerm)
+ .then(() => {
+ expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm);
+ expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled();
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('moves issue', (done) => {
+ const moveToProjectId = 7;
+ this.mediator.store.setMoveToProjectId(moveToProjectId);
+ spyOn(this.mediator.service, 'moveIssue').and.callThrough();
+ spyOn(gl.utils, 'visitUrl');
+
+ this.mediator.moveIssue()
+ .then(() => {
+ expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId);
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5');
+ done();
+ })
+ .catch(done.fail);
+ });
});
diff --git a/spec/javascripts/sidebar/sidebar_move_issue_spec.js b/spec/javascripts/sidebar/sidebar_move_issue_spec.js
new file mode 100644
index 00000000000..8b0d51bbcc8
--- /dev/null
+++ b/spec/javascripts/sidebar/sidebar_move_issue_spec.js
@@ -0,0 +1,142 @@
+import Vue from 'vue';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+import SidebarStore from '~/sidebar/stores/sidebar_store';
+import SidebarService from '~/sidebar/services/sidebar_service';
+import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue';
+import Mock from './mock_data';
+
+describe('SidebarMoveIssue', () => {
+ beforeEach(() => {
+ Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
+ this.mediator = new SidebarMediator(Mock.mediator);
+ this.$content = $(`
+
+ `);
+ this.$toggleButton = this.$content.find('.js-toggle');
+ this.$confirmButton = this.$content.find('.js-confirm-button');
+
+ this.sidebarMoveIssue = new SidebarMoveIssue(
+ this.mediator,
+ this.$toggleButton,
+ this.$confirmButton,
+ );
+ this.sidebarMoveIssue.init();
+ });
+
+ afterEach(() => {
+ SidebarService.singleton = null;
+ SidebarStore.singleton = null;
+ SidebarMediator.singleton = null;
+
+ this.sidebarMoveIssue.destroy();
+
+ Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
+ });
+
+ describe('init', () => {
+ it('should initialize the dropdown and listeners', () => {
+ spyOn(this.sidebarMoveIssue, 'initDropdown');
+ spyOn(this.sidebarMoveIssue, 'addEventListeners');
+
+ this.sidebarMoveIssue.init();
+
+ expect(this.sidebarMoveIssue.initDropdown).toHaveBeenCalled();
+ expect(this.sidebarMoveIssue.addEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('destroy', () => {
+ it('should remove the listeners', () => {
+ spyOn(this.sidebarMoveIssue, 'removeEventListeners');
+
+ this.sidebarMoveIssue.destroy();
+
+ expect(this.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe('initDropdown', () => {
+ it('should initialize the gl_dropdown', () => {
+ spyOn($.fn, 'glDropdown');
+
+ this.sidebarMoveIssue.initDropdown();
+
+ expect($.fn.glDropdown).toHaveBeenCalled();
+ });
+ });
+
+ describe('onConfirmClicked', () => {
+ it('should move the issue with valid project ID', () => {
+ spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.resolve());
+ this.mediator.setMoveToProjectId(7);
+
+ this.sidebarMoveIssue.onConfirmClicked();
+
+ expect(this.mediator.moveIssue).toHaveBeenCalled();
+ expect(this.$confirmButton.attr('disabled')).toBe('disabled');
+ expect(this.$confirmButton.hasClass('is-loading')).toBe(true);
+ });
+
+ it('should remove loading state from confirm button on failure', (done) => {
+ spyOn(window, 'Flash');
+ spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.reject());
+ this.mediator.setMoveToProjectId(7);
+
+ this.sidebarMoveIssue.onConfirmClicked();
+
+ expect(this.mediator.moveIssue).toHaveBeenCalled();
+ // Wait for the move issue request to fail
+ setTimeout(() => {
+ expect(window.Flash).toHaveBeenCalled();
+ expect(this.$confirmButton.attr('disabled')).toBe(undefined);
+ expect(this.$confirmButton.hasClass('is-loading')).toBe(false);
+ done();
+ });
+ });
+
+ it('should not move the issue with id=0', () => {
+ spyOn(this.mediator, 'moveIssue');
+ this.mediator.setMoveToProjectId(0);
+
+ this.sidebarMoveIssue.onConfirmClicked();
+
+ expect(this.mediator.moveIssue).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should set moveToProjectId on dropdown item "No project" click', (done) => {
+ spyOn(this.mediator, 'setMoveToProjectId');
+
+ // Open the dropdown
+ this.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setTimeout(() => {
+ this.$content.find('.js-move-issue-dropdown-item').eq(0).trigger('click');
+
+ expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
+ expect(this.$confirmButton.attr('disabled')).toBe('disabled');
+ done();
+ }, 0);
+ });
+
+ it('should set moveToProjectId on dropdown item click', (done) => {
+ spyOn(this.mediator, 'setMoveToProjectId');
+
+ // Open the dropdown
+ this.$toggleButton.dropdown('toggle');
+
+ // Wait for the autocomplete request to finish
+ setTimeout(() => {
+ this.$content.find('.js-move-issue-dropdown-item').eq(1).trigger('click');
+
+ expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(20);
+ expect(this.$confirmButton.attr('disabled')).toBe(undefined);
+ done();
+ }, 0);
+ });
+});
diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js
index 91a4dd669a7..a4bd8ba8d88 100644
--- a/spec/javascripts/sidebar/sidebar_service_spec.js
+++ b/spec/javascripts/sidebar/sidebar_service_spec.js
@@ -5,7 +5,11 @@ import Mock from './mock_data';
describe('Sidebar service', () => {
beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
- this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json');
+ this.service = new SidebarService({
+ endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
+ moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
+ projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
+ });
});
afterEach(() => {
@@ -19,7 +23,7 @@ describe('Sidebar service', () => {
expect(resp).toBeDefined();
done();
})
- .catch(() => {});
+ .catch(done.fail);
});
it('updates the data', (done) => {
@@ -28,6 +32,24 @@ describe('Sidebar service', () => {
expect(resp).toBeDefined();
done();
})
- .catch(() => {});
+ .catch(done.fail);
+ });
+
+ it('gets projects for autocomplete', (done) => {
+ this.service.getProjectsAutocomplete()
+ .then((resp) => {
+ expect(resp).toBeDefined();
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('moves the issue to another project', (done) => {
+ this.service.moveIssue(123)
+ .then((resp) => {
+ expect(resp).toBeDefined();
+ done();
+ })
+ .catch(done.fail);
});
});
diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js
index b3fa156eb64..69eb3839d67 100644
--- a/spec/javascripts/sidebar/sidebar_store_spec.js
+++ b/spec/javascripts/sidebar/sidebar_store_spec.js
@@ -82,4 +82,18 @@ describe('Sidebar store', () => {
expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate);
expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent);
});
+
+ it('set autocomplete projects', () => {
+ const projects = [{ id: 0 }];
+ this.store.setAutocompleteProjects(projects);
+
+ expect(this.store.autocompleteProjects).toEqual(projects);
+ });
+
+ it('set move to project ID', () => {
+ const projectId = 7;
+ this.store.setMoveToProjectId(projectId);
+
+ expect(this.store.moveToProjectId).toEqual(projectId);
+ });
});
diff --git a/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
new file mode 100644
index 00000000000..6df08f3ebe7
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import confidentialIssue from '~/vue_shared/components/issue/confidential_issue_warning.vue';
+
+describe('Confidential Issue Warning Component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(confidentialIssue);
+ vm = new Component().$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render confidential issue warning information', () => {
+ expect(vm.$el.querySelector('i').className).toEqual('fa fa-eye-slash');
+ expect(vm.$el.querySelector('span').textContent.trim()).toEqual('This is a confidential issue. Your comment will not be visible to the public.');
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js
index 291e19c9f3c..60a5c2ae74e 100644
--- a/spec/javascripts/vue_shared/components/markdown/field_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js
@@ -16,8 +16,8 @@ describe('Markdown field component', () => {
},
template: `