summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/issue_templates/Feature proposal.md2
-rw-r--r--.gitlab/issue_templates/Security developer workflow.md7
-rw-r--r--.gitlab/merge_request_templates/Security Release.md7
-rw-r--r--CHANGELOG.md14
-rw-r--r--GITLAB_WORKHORSE_VERSION2
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js33
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js42
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue39
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue20
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue40
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue22
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue7
-rw-r--r--app/assets/javascripts/notes/stores/actions.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js5
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue65
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue25
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue55
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue59
-rw-r--r--app/assets/javascripts/serverless/components/url.vue38
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js4
-rw-r--r--app/assets/stylesheets/framework/images.scss3
-rw-r--r--app/assets/stylesheets/pages/serverless.scss3
-rw-r--r--app/controllers/concerns/issuable_actions.rb3
-rw-r--r--app/controllers/projects/environments_controller.rb16
-rw-r--r--app/controllers/projects/merge_requests_controller.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb3
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/sorting_helper.rb20
-rw-r--r--app/helpers/users_helper.rb9
-rw-r--r--app/models/clusters/concerns/application_version.rb4
-rw-r--r--app/models/commit.rb2
-rw-r--r--app/models/commit_collection.rb9
-rw-r--r--app/models/concerns/noteable.rb15
-rw-r--r--app/models/discussion.rb6
-rw-r--r--app/models/environment.rb12
-rw-r--r--app/models/individual_note_discussion.rb8
-rw-r--r--app/models/merge_request.rb10
-rw-r--r--app/models/merge_request_diff.rb116
-rw-r--r--app/models/merge_request_diff_file.rb14
-rw-r--r--app/models/repository.rb4
-rw-r--r--app/models/sent_notification.rb25
-rw-r--r--app/models/user.rb4
-rw-r--r--app/serializers/merge_request_widget_commit_entity.rb7
-rw-r--r--app/serializers/merge_request_widget_entity.rb20
-rw-r--r--app/services/merge_requests/merge_service.rb31
-rw-r--r--app/services/merge_requests/squash_service.rb33
-rw-r--r--app/services/notes/build_service.rb2
-rw-r--r--app/services/notes/create_service.rb4
-rw-r--r--app/uploaders/external_diff_uploader.rb23
-rw-r--r--app/views/admin/users/_user.html.haml55
-rw-r--r--app/views/admin/users/_user_detail.html.haml17
-rw-r--r--app/views/admin/users/index.html.haml96
-rw-r--r--app/views/events/_events.html.haml16
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml2
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml19
-rw-r--r--app/views/shared/groups/_list.html.haml14
-rw-r--r--app/views/shared/projects/_list.html.haml32
-rw-r--r--app/views/snippets/_snippets.html.haml13
-rwxr-xr-xbin/secpick16
-rw-r--r--changelogs/unreleased/28500-empty-states-for-profile-page.yml5
-rw-r--r--changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml5
-rw-r--r--changelogs/unreleased/52568-external-mr-diffs.yml5
-rw-r--r--changelogs/unreleased/56014-better-squash-commit-messages.yml6
-rw-r--r--changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml5
-rw-r--r--changelogs/unreleased/cluster_application_version_updated.yml5
-rw-r--r--changelogs/unreleased/fj-regression-external-wiki-url.yml5
-rw-r--r--changelogs/unreleased/introduce-environment-search-endpoint.yml5
-rw-r--r--changelogs/unreleased/knative-list.yml5
-rw-r--r--changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml5
-rw-r--r--changelogs/unreleased/sh-fix-detect-host-keys.yml5
-rw-r--r--changelogs/unreleased/sh-fix-issue-9357.yml5
-rw-r--r--changelogs/unreleased/sh-fix-oauth2-callback-caps.yml5
-rw-r--r--changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml5
-rw-r--r--changelogs/unreleased/workhorse-8-3-0.yml5
-rw-r--r--config/gitlab.yml.example29
-rw-r--r--config/initializers/1_settings.rb8
-rw-r--r--config/locales/de.yml1
-rw-r--r--config/locales/en.yml1
-rw-r--r--config/locales/es.yml1
-rw-r--r--config/routes/project.rb1
-rw-r--r--db/migrate/20190109153125_add_merge_request_external_diffs.rb25
-rw-r--r--db/schema.rb7
-rw-r--r--doc/administration/index.md1
-rw-r--r--doc/administration/merge_request_diffs.md154
-rw-r--r--doc/development/file_storage.md2
-rw-r--r--doc/user/project/merge_requests/squash_and_merge.md16
-rw-r--r--lib/api/entities.rb4
-rw-r--r--lib/gitlab/import_export/import_export.yml5
-rw-r--r--locale/gitlab.pot132
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb73
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb17
-rw-r--r--spec/factories/commits.rb10
-rw-r--r--spec/features/admin/admin_users_spec.rb42
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb17
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb32
-rw-r--r--spec/features/merge_requests/user_squashes_merge_request_spec.rb2
-rw-r--r--spec/features/projects/serverless/functions_spec.rb9
-rw-r--r--spec/features/users/overview_spec.rb4
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_widget.json6
-rw-r--r--spec/helpers/users_helper_spec.rb68
-rw-r--r--spec/javascripts/behaviors/copy_as_gfm_spec.js33
-rw-r--r--spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js126
-rw-r--r--spec/javascripts/notes/components/note_actions/reply_button_spec.js46
-rw-r--r--spec/javascripts/notes/components/note_actions_spec.js149
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js14
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js23
-rw-r--r--spec/javascripts/serverless/components/environment_row_spec.js81
-rw-r--r--spec/javascripts/serverless/components/function_row_spec.js33
-rw-r--r--spec/javascripts/serverless/components/functions_spec.js68
-rw-r--r--spec/javascripts/serverless/components/url_spec.js28
-rw-r--r--spec/javascripts/serverless/mock_data.js79
-rw-r--r--spec/javascripts/serverless/stores/serverless_store_spec.js36
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js4
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb26
-rw-r--r--spec/models/clusters/applications/cert_manager_spec.rb16
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb15
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb15
-rw-r--r--spec/models/clusters/applications/knative_spec.rb15
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb15
-rw-r--r--spec/models/clusters/applications/runner_spec.rb15
-rw-r--r--spec/models/commit_collection_spec.rb11
-rw-r--r--spec/models/environment_spec.rb70
-rw-r--r--spec/models/merge_request_diff_spec.rb189
-rw-r--r--spec/models/merge_request_spec.rb48
-rw-r--r--spec/models/sent_notification_spec.rb34
-rw-r--r--spec/requests/api/releases_spec.rb25
-rw-r--r--spec/serializers/merge_request_widget_commit_entity_spec.rb21
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb22
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb2
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb71
-rw-r--r--spec/services/notes/build_service_spec.rb40
-rw-r--r--spec/services/notes/create_service_spec.rb37
-rw-r--r--spec/support/helpers/features/responsive_table_helpers.rb32
-rw-r--r--spec/support/helpers/stub_configuration.rb4
-rw-r--r--spec/support/helpers/stub_object_storage.rb7
-rw-r--r--spec/support/shared_examples/models/cluster_application_initial_status.rb29
-rw-r--r--spec/support/shared_examples/models/cluster_application_status_shared_examples.rb40
-rw-r--r--spec/uploaders/external_diff_uploader_spec.rb67
141 files changed, 2868 insertions, 655 deletions
diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md
index 0b22c7bc26b..1bb8d33ff63 100644
--- a/.gitlab/issue_templates/Feature proposal.md
+++ b/.gitlab/issue_templates/Feature proposal.md
@@ -39,7 +39,7 @@ Existing personas are: (copy relevant personas out of this comment, and delete a
### What does success look like, and how can we measure that?
-<!--- Define both the success metrics and acceptance criteria. Note thet success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
+<!--- Define both the success metrics and acceptance criteria. Note that success metrics indicate the desired business outcomes, while acceptance criteria indicate when the solution is working correctly. If there is no way to measure success, link to an issue that will implement a way to measure this -->
### Links / references
diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md
index 4bc4215d21b..da2e2a7f87f 100644
--- a/.gitlab/issue_templates/Security developer workflow.md
+++ b/.gitlab/issue_templates/Security developer workflow.md
@@ -20,10 +20,9 @@ Set the title to: `Description of the original issue`
- [ ] Once the MR is ready to be merged, create MRs targetting the last 3 releases, plus the current RC if between the 7th and 22nd of the month.
- [ ] At this point, it might be easy to squash the commits from the MR into one
- You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation]
- - [ ] Create the branch `security-X-Y` from `X-Y-stable` if it doesn't exist (and make sure it's up to date with stable)
- - [ ] Create each MR targetting the security branch `security-X-Y`
- - [ ] Add the ~security label and prefix with the version `WIP: [X.Y]` the title of the MR
-- [ ] Add the ~"Merge into Security" label to all of the MRs.
+ - [ ] Create each MR targetting the stable branch `stable-X-Y`, using the "Security Release" merge request template.
+ - Every merge request will have its own set of TODOs, so make sure to
+ complete those.
- [ ] Make sure all MRs have a link in the [links section](#links)
[secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script
diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md
index 9a0979f27a7..adf70053f8f 100644
--- a/.gitlab/merge_request_templates/Security Release.md
+++ b/.gitlab/merge_request_templates/Security Release.md
@@ -4,6 +4,9 @@ This MR should be created on `dev.gitlab.org`.
See [the general developer security release guidelines](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md).
+This merge request _must not_ close the corresponding security issue _unless_ it
+targets master.
+
-->
## Related issues
@@ -12,7 +15,7 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
## Developer checklist
- [ ] Link to the developer security workflow issue on `dev.gitlab.org`
-- [ ] MR targets `master` or `security-X-Y` for backports
+- [ ] MR targets `master`, or `stable-X-Y` for backports
- [ ] Milestone is set for the version this MR applies to
- [ ] Title of this MR is the same as for all backports
- [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security`
@@ -25,4 +28,4 @@ See [the general developer security release guidelines](https://gitlab.com/gitla
- [ ] Correct milestone is applied and the title is matching across all backports
- [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines
-/label ~security ~"Merge into Security"
+/label ~security
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4985c607d57..e220d61b316 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 11.7.5 (2019-02-06)
+
+### Fixed (8 changes)
+
+- Fix import handling errors in Bitbucket Server importer. !24499
+- Adjusts suggestions unable to be applied. !24603
+- Fix 500 errors with legacy appearance logos. !24615
+- Fix form functionality for edit tag page. !24645
+- Update Workhorse to v8.0.2. !24870
+- Downcase aliased OAuth2 callback providers. !24877
+- Fix Detect Host Keys not working. !24884
+- Changed external wiki query method to prevent attribute caching. !24907
+
+
## 11.7.2 (2019-01-29)
### Fixed (1 change)
diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION
index fbb9ea12de3..2bf50aaf17a 100644
--- a/GITLAB_WORKHORSE_VERSION
+++ b/GITLAB_WORKHORSE_VERSION
@@ -1 +1 @@
-8.2.0
+8.3.0
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 947d019c725..52d9f2f0322 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -1,8 +1,5 @@
import $ from 'jquery';
-import { DOMParser } from 'prosemirror-model';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-import schema from './schema';
-import markdownSerializer from './serializer';
export class CopyAsGFM {
constructor() {
@@ -39,9 +36,13 @@ export class CopyAsGFM {
div.appendChild(el.cloneNode(true));
const html = div.innerHTML;
- clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
- clipboardData.setData('text/html', html);
+ CopyAsGFM.nodeToGFM(el)
+ .then(res => {
+ clipboardData.setData('text/plain', el.textContent);
+ clipboardData.setData('text/x-gfm', res);
+ clipboardData.setData('text/html', html);
+ })
+ .catch(() => {});
}
static pasteGFM(e) {
@@ -137,11 +138,21 @@ export class CopyAsGFM {
}
static nodeToGFM(node) {
- const wrapEl = document.createElement('div');
- wrapEl.appendChild(node.cloneNode(true));
- const doc = DOMParser.fromSchema(schema).parse(wrapEl);
-
- return markdownSerializer.serialize(doc);
+ return Promise.all([
+ import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
+ ])
+ .then(([prosemirrorModel, schema, markdownSerializer]) => {
+ const { DOMParser } = prosemirrorModel;
+ const wrapEl = document.createElement('div');
+ wrapEl.appendChild(node.cloneNode(true));
+ const doc = DOMParser.fromSchema(schema.default).parse(wrapEl);
+
+ const res = markdownSerializer.default.serialize(doc);
+ return res;
+ })
+ .catch(() => {});
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 0eb067d4963..680f2031409 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -64,26 +64,30 @@ export default class ShortcutsIssuable extends Shortcuts {
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
const blockquoteEl = document.createElement('blockquote');
blockquoteEl.appendChild(el);
- const text = CopyAsGFM.nodeToGFM(blockquoteEl);
-
- if (text.trim() === '') {
- return false;
- }
-
- // If replyField already has some content, add a newline before our quote
- const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
- $replyField
- .val((a, current) => `${current}${separator}${text}\n\n`)
- .trigger('input')
- .trigger('change');
-
- // Trigger autosize
- const event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- $replyField.get(0).dispatchEvent(event);
+ CopyAsGFM.nodeToGFM(blockquoteEl)
+ .then(text => {
+ if (text.trim() === '') {
+ return false;
+ }
+
+ // If replyField already has some content, add a newline before our quote
+ const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
+ $replyField
+ .val((a, current) => `${current}${separator}${text}\n\n`)
+ .trigger('input')
+ .trigger('change');
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ $replyField.get(0).dispatchEvent(event);
+
+ // Focus the input field
+ $replyField.focus();
- // Focus the input field
- $replyField.focus();
+ return false;
+ })
+ .catch(() => {});
return false;
}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index a0d6b91ff02..ec0e33a1927 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -3,12 +3,14 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import Icon from '~/vue_shared/components/icon.vue';
let debouncedResize;
export default {
components: {
GlAreaChart,
+ Icon,
},
inheritAttrs: false,
props: {
@@ -47,6 +49,12 @@ export default {
},
data() {
return {
+ tooltip: {
+ title: '',
+ content: '',
+ isDeployment: false,
+ sha: '',
+ },
width: 0,
height: 0,
scatterSymbol: undefined,
@@ -148,8 +156,17 @@ export default {
},
methods: {
formatTooltipText(params) {
- const [date, value] = params;
- return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ const [seriesData] = params.seriesData;
+ this.tooltip.isDeployment = seriesData.componentSubType === 'scatter';
+ this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ if (this.tooltip.isDeployment) {
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === seriesData.value[0],
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ } else {
+ this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`;
+ }
},
getScatterSymbol() {
getSvgIconPathContent('rocket')
@@ -184,6 +201,22 @@ export default {
:thresholds="alertData"
:width="width"
:height="height"
- />
+ >
+ <template slot="tooltipTitle">
+ <div v-if="tooltip.isDeployment">
+ {{ __('Deployed') }}
+ </div>
+ {{ tooltip.title }}
+ </template>
+ <template slot="tooltipContent">
+ <div v-if="tooltip.isDeployment" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ {{ tooltip.sha }}
+ </div>
+ <template v-else>
+ {{ tooltip.content }}
+ </template>
+ </template>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index d99694b06e9..394f2a80a67 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -2,11 +2,13 @@
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import ReplyButton from './note_actions/reply_button.vue';
export default {
name: 'NoteActions',
components: {
Icon,
+ ReplyButton,
GlLoadingIcon,
},
directives: {
@@ -21,6 +23,11 @@ export default {
type: [String, Number],
required: true,
},
+ discussionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
noteUrl: {
type: String,
required: false,
@@ -36,6 +43,10 @@ export default {
required: false,
default: null,
},
+ showReply: {
+ type: Boolean,
+ required: true,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -80,6 +91,9 @@ export default {
},
computed: {
...mapGetters(['getUserDataByProp']),
+ showReplyButton() {
+ return gon.features && gon.features.replyToIndividualNotes && this.showReply;
+ },
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -153,6 +167,12 @@ export default {
<icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" />
</a>
</div>
+ <reply-button
+ v-if="showReplyButton"
+ ref="replyButton"
+ class="js-reply-button"
+ :note-id="discussionId"
+ />
<div v-if="canEdit" class="note-actions-item">
<button
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
new file mode 100644
index 00000000000..b2f9d7f128a
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReplyButton',
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ noteId: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['convertToDiscussion']),
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions-item">
+ <gl-button
+ ref="button"
+ v-gl-tooltip.bottom
+ class="note-action-button"
+ variant="transparent"
+ :title="__('Reply to comment')"
+ @click="convertToDiscussion(noteId)"
+ >
+ <icon name="comment" css-classes="link-highlight" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index e26cce1c47f..b7e9f7c2028 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -398,6 +398,7 @@ Please check your network connection and try again.`;
:line="line"
:commit="commit"
:help-page-path="helpPagePath"
+ :show-reply-button="canReply"
@handleDeleteNote="deleteNoteHandler"
>
<note-edited-text
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3c48d81ed05..56108a58010 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -29,6 +29,11 @@ export default {
type: Object,
required: true,
},
+ discussion: {
+ type: Object,
+ required: false,
+ default: null,
+ },
line: {
type: Object,
required: false,
@@ -54,7 +59,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']),
+ ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
},
@@ -80,6 +85,19 @@ export default {
isTarget() {
return this.targetNoteHash === this.noteAnchorId;
},
+ discussionId() {
+ if (this.discussion) {
+ return this.discussion.id;
+ }
+ return '';
+ },
+ showReplyButton() {
+ if (!this.discussion || !this.getNoteableData.current_user.can_create_note) {
+ return false;
+ }
+
+ return this.discussion.individual_note && !this.commentsDisabled;
+ },
actionText() {
if (!this.commit) {
return '';
@@ -231,6 +249,7 @@ export default {
:note-id="note.id"
:note-url="note.noteable_note_url"
:access-level="note.human_access"
+ :show-reply="showReplyButton"
:can-edit="note.current_user.can_edit"
:can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
@@ -241,6 +260,7 @@ export default {
:is-resolved="note.resolved"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
+ :discussion-id="discussionId"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 5edceea043c..6d72b72e628 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -199,7 +199,12 @@ export default {
:key="discussion.id"
:note="discussion.notes[0]"
/>
- <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ <noteable-note
+ v-else
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ :discussion="discussion"
+ />
</template>
<noteable-discussion
v-else
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 2105a62cecb..ff65f14d529 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -426,5 +426,8 @@ export const submitSuggestion = (
});
};
+export const convertToDiscussion = ({ commit }, noteId) =>
+ commit(types.CONVERT_TO_DISCUSSION, noteId);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index df943c155f4..2bffedad336 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,6 +17,7 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
+export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 33d39ad2ec9..d167f8ef421 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -264,4 +264,9 @@ export default {
).length;
state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
+
+ [types.CONVERT_TO_DISCUSSION](state, discussionId) {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ Object.assign(discussion, { individual_note: false });
+ },
};
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
new file mode 100644
index 00000000000..4d18c5c4bdd
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -0,0 +1,65 @@
+<script>
+import FunctionRow from './function_row.vue';
+import ItemCaret from '~/groups/components/item_caret.vue';
+
+export default {
+ components: {
+ ItemCaret,
+ FunctionRow,
+ },
+ props: {
+ env: {
+ type: Array,
+ required: true,
+ },
+ envName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
+ computed: {
+ envId() {
+ if (this.envName === '*') {
+ return 'env-global';
+ }
+
+ return `env-${this.envName}`;
+ },
+ isOpenClass() {
+ return {
+ 'is-open': this.isOpen,
+ };
+ },
+ },
+ methods: {
+ toggleOpen() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="envId" :class="isOpenClass" class="group-row has-children">
+ <div
+ class="group-row-contents d-flex justify-content-end align-items-center"
+ role="button"
+ @click.stop="toggleOpen"
+ >
+ <div class="folder-toggle-wrap d-flex align-items-center">
+ <item-caret :is-group-open="isOpen" />
+ </div>
+ <div class="group-text flex-grow title namespace-title prepend-left-default">
+ {{ envName }}
+ </div>
+ </div>
+ <ul v-if="isOpen" class="content-list group-list-tree">
+ <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" />
+ </ul>
+ </li>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 2b1c21f041b..4f89ad69129 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,13 +1,11 @@
<script>
import PodBox from './pod_box.vue';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import Icon from '~/vue_shared/components/icon.vue';
+import Url from './url.vue';
export default {
components: {
- Icon,
PodBox,
- ClipboardButton,
+ Url,
},
props: {
func: {
@@ -36,24 +34,9 @@ export default {
<section id="serverless-function-details">
<h3>{{ name }}</h3>
<div class="append-bottom-default">
- <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div>
- </div>
- <div class="clipboard-group append-bottom-default">
- <div class="label label-monospace">{{ funcUrl }}</div>
- <clipboard-button
- :text="String(funcUrl)"
- :title="s__('ServerlessDetails|Copy URL to clipboard')"
- class="input-group-text js-clipboard-btn"
- />
- <a
- :href="funcUrl"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="input-group-text btn btn-default"
- >
- <icon name="external-link" />
- </a>
+ <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
+ <url :uri="funcUrl" />
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 44bfae388cb..773d18781fd 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,9 +1,12 @@
<script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import Url from './url.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
export default {
components: {
Timeago,
+ Url,
},
props: {
func: {
@@ -16,13 +19,18 @@ export default {
return this.func.name;
},
description() {
- return this.func.description;
+ const desc = this.func.description.split('\n');
+ if (desc.length > 1) {
+ return desc[1];
+ }
+
+ return desc[0];
},
detailUrl() {
return this.func.detail_url;
},
- environment() {
- return this.func.environment_scope;
+ targetUrl() {
+ return this.func.url;
},
image() {
return this.func.image;
@@ -31,25 +39,34 @@ export default {
return this.func.created_at;
},
},
+ methods: {
+ checkClass(element) {
+ if (element.closest('.no-expand') === null) {
+ return true;
+ }
+
+ return false;
+ },
+ openDetails(e) {
+ if (this.checkClass(e.target)) {
+ visitUrl(this.detailUrl);
+ }
+ },
+ },
};
</script>
<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-20 section-wrap">
- <a :href="detailUrl">{{ name }}</a>
- </div>
- <div class="table-section section-10">{{ environment }}</div>
- <div class="table-section section-40 section-wrap">
- <span class="line-break">{{ description }}</span>
+ <li :id="name" class="group-row">
+ <div class="group-row-contents" role="button" @click="openDetails">
+ <p class="float-right text-right">
+ <span>{{ image }}</span
+ ><br />
+ <timeago :time="timestamp" />
+ </p>
+ <b>{{ name }}</b>
+ <div v-for="line in description.split('\n')" :key="line">{{ line }}</div>
+ <url :uri="targetUrl" class="prepend-top-8 no-expand" />
</div>
- <div class="table-section section-20">{{ image }}</div>
- <div class="table-section section-10"><timeago :time="timestamp" /></div>
- </div>
+ </li>
</template>
-
-<style>
-.line-break {
- white-space: pre;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 9606a78410e..4bde409f906 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,19 +1,21 @@
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
+import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
+ EnvironmentRow,
FunctionRow,
EmptyState,
GlSkeletonLoading,
},
props: {
functions: {
- type: Array,
+ type: Object,
required: true,
- default: () => [],
+ default: () => ({}),
},
installed: {
type: Boolean,
@@ -45,33 +47,21 @@ export default {
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
- <div class="ci-table js-services-list function-element">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Function') }}
- </div>
- <div class="table-section section-10" role="rowheader">
- {{ s__('Serverless|Cluster Env') }}
- </div>
- <div class="table-section section-40" role="rowheader">
- {{ s__('Serverless|Description') }}
- </div>
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Runtime') }}
- </div>
- <div class="table-section section-10" role="rowheader">
- {{ s__('Serverless|Last Update') }}
- </div>
+ <template v-if="loadingData">
+ <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
+ </template>
+ <template v-else>
+ <div class="groups-list-tree-container">
+ <ul class="content-list group-list-tree">
+ <environment-row
+ v-for="(env, index) in functions"
+ :key="index"
+ :env="env"
+ :env-name="index"
+ />
+ </ul>
</div>
- <template v-if="loadingData">
- <div v-for="j in 3" :key="j" class="gl-responsive-table-row">
- <gl-skeleton-loading />
- </div>
- </template>
- <template v-else>
- <function-row v-for="f in functions" :key="f.name" :func="f" />
- </template>
- </div>
+ </template>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
@@ -111,16 +101,3 @@ export default {
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section>
</template>
-
-<style>
-.top-area {
- border-bottom: 0;
-}
-
-.function-element {
- border-bottom: 1px solid #e5e5e5;
- border-bottom-color: rgb(229, 229, 229);
- border-bottom-style: solid;
- border-bottom-width: 1px;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
new file mode 100644
index 00000000000..ca53bf6c52a
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ ClipboardButton,
+ },
+ props: {
+ uri: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="clipboard-group">
+ <div class="url-text-field label label-monospace">{{ uri }}</div>
+ <clipboard-button
+ :text="uri"
+ :title="s__('ServerlessURL|Copy URL to clipboard')"
+ class="input-group-text js-clipboard-btn"
+ />
+ <gl-button
+ :href="uri"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="input-group-text btn btn-default"
+ >
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
index 774c15b5b12..816d55a03f9 100644
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ b/app/assets/javascripts/serverless/stores/serverless_store.js
@@ -1,7 +1,7 @@
export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = {
- functions: [],
+ functions: {},
hasFunctionData: true,
loadingData: true,
installed: knativeInstalled,
@@ -10,8 +10,13 @@ export default class ServerlessStore {
};
}
- updateFunctionsFromServer(functions = []) {
- this.state.functions = functions;
+ updateFunctionsFromServer(upstreamFunctions = []) {
+ this.state.functions = upstreamFunctions.reduce((rv, func) => {
+ const envs = rv;
+ envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
+
+ return envs;
+ }, {});
}
updateLoadingState(loadingData) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index ab194e84ab4..36cac230d9d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -32,10 +32,10 @@ export default class MergeRequestStore {
this.sourceBranchProtected = data.source_branch_protected;
this.conflictsDocsPath = data.conflicts_docs_path;
this.mergeStatus = data.merge_status;
- this.commitMessage = data.merge_commit_message;
+ this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merge_commit_sha;
this.mergeCommitSha = data.merge_commit_sha;
- this.commitMessageWithDescription = data.merge_commit_message_with_description;
+ this.commitMessageWithDescription = data.default_merge_commit_message_with_description;
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index a20920e2503..d78c707192f 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -38,7 +38,10 @@
svg {
fill: currentColor;
+}
+.square,
+svg {
$svg-sizes: 8 10 12 14 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss
new file mode 100644
index 00000000000..a5b73492380
--- /dev/null
+++ b/app/assets/stylesheets/pages/serverless.scss
@@ -0,0 +1,3 @@
+.url-text-field {
+ cursor: text;
+}
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 8ef3b6502df..cd3fa641e89 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -7,6 +7,9 @@ module IssuableActions
included do
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action only: :show do
+ push_frontend_feature_flag(:reply_to_individual_notes)
+ end
end
def permitted_keys
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 4e85de25c6b..79685e8b675 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -158,6 +158,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def search
+ respond_to do |format|
+ format.json do
+ environment_names = search_environment_names
+
+ render json: environment_names, status: environment_names.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
@@ -181,6 +191,12 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def search_environment_names
+ return [] unless params[:query]
+
+ project.environments.for_name_like(params[:query]).pluck_names
+ end
+
def serialize_environments(request, response, nested = false)
EnvironmentSerializer
.new(project: @project, current_user: @current_user)
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 7c4dc95529a..5cf7fa3422d 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -240,7 +240,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def merge_params_attributes
- [:should_remove_source_branch, :commit_message, :squash]
+ [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash]
end
def merge_when_pipeline_succeeds_active?
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index fb740b6fb1c..47b915b451e 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -39,7 +39,8 @@ module Types
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
- field :merge_commit_message, GraphQL::STRING_TYPE, null: true
+ field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage"
+ field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index df318de740a..5a42e581867 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -25,4 +25,8 @@ module ProfilesHelper
end
end
end
+
+ def user_profile?
+ params[:controller] == 'users'
+ end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 02762897c89..07ec129dea3 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -128,7 +128,9 @@ module SortingHelper
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_last_activity => sort_title_recently_last_activity,
+ sort_value_oldest_last_activity => sort_title_oldest_last_activity
}
end
@@ -317,6 +319,14 @@ module SortingHelper
s_('SortOptions|Most stars')
end
+ def sort_title_oldest_last_activity
+ s_('SortOptions|Oldest last activity')
+ end
+
+ def sort_title_recently_last_activity
+ s_('SortOptions|Recent last activity')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -445,4 +455,12 @@ module SortingHelper
def sort_value_most_stars
'stars_desc'
end
+
+ def sort_value_oldest_last_activity
+ 'last_activity_on_asc'
+ end
+
+ def sort_value_recently_last_activity
+ 'last_activity_on_desc'
+ end
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 73c1402eae5..73ca17c6605 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -74,6 +74,15 @@ module UsersHelper
Gitlab.config.gitlab.impersonation_enabled
end
+ def user_badges_in_admin_section(user)
+ [].tap do |badges|
+ badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked?
+ badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
+ badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
+ badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user
+ end
+ end
+
private
def get_profile_tabs
diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb
index ccad74dc35a..e355de23df6 100644
--- a/app/models/clusters/concerns/application_version.rb
+++ b/app/models/clusters/concerns/application_version.rb
@@ -7,8 +7,8 @@ module Clusters
included do
state_machine :status do
- after_transition any => [:installing] do |application|
- application.update(version: application.class.const_get(:VERSION))
+ before_transition any => [:installed, :updated] do |application|
+ application.version = application.class.const_get(:VERSION)
end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 982e13e2845..f412d252e5c 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -379,7 +379,7 @@ class Commit
end
def merge_commit?
- parents.size > 1
+ parent_ids.size > 1
end
def merged_merge_request(current_user)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 885f61beb05..42ec5b5e664 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -3,6 +3,7 @@
# A collection of Commit instances for a specific project and Git reference.
class CommitCollection
include Enumerable
+ include Gitlab::Utils::StrongMemoize
attr_reader :project, :ref, :commits
@@ -20,11 +21,17 @@ class CommitCollection
end
def committers
- emails = commits.reject(&:merge_commit?).map(&:committer_email).uniq
+ emails = without_merge_commits.map(&:committer_email).uniq
User.by_any_email(emails)
end
+ def without_merge_commits
+ strong_memoize(:without_merge_commits) do
+ commits.reject(&:merge_commit?)
+ end
+ end
+
# Sets the pipeline status for every commit.
#
# Setting this status ahead of time removes the need for running a query for
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 29476654bf7..3c74034b527 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -1,9 +1,18 @@
# frozen_string_literal: true
module Noteable
- # Names of all implementers of `Noteable` that support resolvable notes.
+ extend ActiveSupport::Concern
+
+ # `Noteable` class names that support resolvable notes.
RESOLVABLE_TYPES = %w(MergeRequest).freeze
+ class_methods do
+ # `Noteable` class names that support replying to individual notes.
+ def replyable_types
+ %w(Issue MergeRequest)
+ end
+ end
+
def base_class_name
self.class.base_class.name
end
@@ -26,6 +35,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_replying_to_individual_notes?
+ supports_discussions? && self.class.replyable_types.include?(base_class_name)
+ end
+
def supports_suggestion?
false
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index dbc7b6e67be..f2678e0597d 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -17,6 +17,8 @@ class Discussion
:for_commit?,
:for_merge_request?,
+ :save,
+
to: :first_note
def project_id
@@ -116,6 +118,10 @@ class Discussion
false
end
+ def can_convert_to_discussion?
+ false
+ end
+
def new_discussion?
notes.length == 1
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index cdfe3b7c023..1fc088b12ae 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -50,6 +50,14 @@ class Environment < ActiveRecord::Base
end
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
+
+ ##
+ # Search environments which have names like the given query.
+ # Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
+ scope :for_name_like, -> (query, limit: 5) do
+ where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit)
+ end
+
scope :for_project, -> (project) { where(project_id: project) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
@@ -70,6 +78,10 @@ class Environment < ActiveRecord::Base
end
end
+ def self.pluck_names
+ pluck(:name)
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
index 07ee7470ea2..aab0ff93468 100644
--- a/app/models/individual_note_discussion.rb
+++ b/app/models/individual_note_discussion.rb
@@ -13,6 +13,14 @@ class IndividualNoteDiscussion < Discussion
true
end
+ def can_convert_to_discussion?
+ noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes)
+ end
+
+ def convert_to_discussion!
+ first_note.becomes!(Discussion.note_class).to_discussion
+ end
+
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 84cb8e1c50b..2035bffd829 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -939,7 +939,7 @@ class MergeRequest < ActiveRecord::Base
self.target_project.repository.branch_exists?(self.target_branch)
end
- def merge_commit_message(include_description: false)
+ def default_merge_commit_message(include_description: false)
closes_issues_references = visible_closing_issues_for.map do |issue|
issue.to_reference(target_project)
end
@@ -959,6 +959,13 @@ class MergeRequest < ActiveRecord::Base
message.join("\n\n")
end
+ # Returns the oldest multi-line commit message, or the MR title if none found
+ def default_squash_commit_message
+ strong_memoize(:default_squash_commit_message) do
+ commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title
+ end
+ end
+
def reset_merge_when_pipeline_succeeds
return unless merge_when_pipeline_succeeds?
@@ -967,6 +974,7 @@ class MergeRequest < ActiveRecord::Base
if merge_params
merge_params.delete('should_remove_source_branch')
merge_params.delete('commit_message')
+ merge_params.delete('squash_commit_message')
end
self.save
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index a3029a54604..712347e76ed 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -7,6 +7,7 @@ class MergeRequestDiff < ActiveRecord::Base
include IgnorableColumn
include EachBatch
include Gitlab::Utils::StrongMemoize
+ include ObjectStorage::BackgroundMove
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
@@ -15,9 +16,13 @@ class MergeRequestDiff < ActiveRecord::Base
:st_diffs
belongs_to :merge_request
+
manual_inverse_association :merge_request, :merge_request_diff
- has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_files,
+ -> { order(:merge_request_diff_id, :relative_order) },
+ inverse_of: :merge_request_diff
+
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
state_machine :state, initial: :empty do
@@ -45,10 +50,14 @@ class MergeRequestDiff < ActiveRecord::Base
scope :recent, -> { order(id: :desc).limit(100) }
+ mount_uploader :external_diff, ExternalDiffUploader
+
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ after_save :update_external_diff_store, if: :external_diff_changed?
+
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
@@ -241,10 +250,97 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
+ # Carrierwave defines `write_uploader` dynamically on this class, so `super`
+ # does not work. Alias the carrierwave method so we can call it when needed
+ alias_method :carrierwave_write_uploader, :write_uploader
+
+ # The `external_diff`, `external_diff_store`, and `stored_externally`
+ # columns were introduced in GitLab 11.8, but some background migration specs
+ # use factories that rely on current code with an old schema. Without these
+ # `has_attribute?` guards, they fail with a `MissingAttributeError`.
+ #
+ # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990
+
+ def write_uploader(column, identifier)
+ carrierwave_write_uploader(column, identifier) if has_attribute?(column)
+ end
+
+ def update_external_diff_store
+ update_column(:external_diff_store, external_diff.object_store) if
+ has_attribute?(:external_diff_store)
+ end
+
+ def external_diff_changed?
+ super if has_attribute?(:external_diff)
+ end
+
+ def stored_externally
+ super if has_attribute?(:stored_externally)
+ end
+ alias_method :stored_externally?, :stored_externally
+
+ # If enabled, yields the external file containing the diff. Otherwise, yields
+ # nil. This method is not thread-safe, but it *is* re-entrant, which allows
+ # multiple merge_request_diff_files to load their data efficiently
+ def opening_external_diff
+ return yield(nil) unless stored_externally?
+ return yield(@external_diff_file) if @external_diff_file
+
+ external_diff.open do |file|
+ begin
+ @external_diff_file = file
+
+ yield(@external_diff_file)
+ ensure
+ @external_diff_file = nil
+ end
+ end
+ end
+
private
def create_merge_request_diff_files(diffs)
- rows = diffs.map.with_index do |diff, index|
+ rows =
+ if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled
+ build_external_merge_request_diff_files(diffs)
+ else
+ build_merge_request_diff_files(diffs)
+ end
+
+ # Faster inserts
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ end
+
+ def build_external_merge_request_diff_files(diffs)
+ rows = build_merge_request_diff_files(diffs)
+ tempfile = build_external_diff_tempfile(rows)
+
+ self.external_diff = tempfile
+ self.stored_externally = true
+
+ rows
+ ensure
+ tempfile&.unlink
+ end
+
+ def build_external_diff_tempfile(rows)
+ Tempfile.open(external_diff.filename) do |file|
+ rows.inject(0) do |offset, row|
+ data = row.delete(:diff)
+ row[:external_diff_offset] = offset
+ row[:external_diff_size] = data.size
+
+ file.write(data)
+
+ offset + data.size
+ end
+
+ file
+ end
+ end
+
+ def build_merge_request_diff_files(diffs)
+ diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
binary: false,
merge_request_diff_id: self.id,
@@ -261,18 +357,20 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
end
-
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
end
def load_diffs(options)
- collection = merge_request_diff_files
+ # Ensure all diff files operate on the same external diff file instance if
+ # present. This reduces file open/close overhead.
+ opening_external_diff do
+ collection = merge_request_diff_files
- if paths = options[:paths]
- collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
- end
+ if paths = options[:paths]
+ collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
+ end
- Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ end
end
def load_commits
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index a9f110bec5c..e8d936e265c 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -4,7 +4,7 @@ class MergeRequestDiffFile < ActiveRecord::Base
include Gitlab::EncodingHelper
include DiffFile
- belongs_to :merge_request_diff
+ belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
def utf8_diff
return '' if diff.blank?
@@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base
end
def diff
- binary? ? super.unpack('m0').first : super
+ content =
+ if merge_request_diff&.stored_externally?
+ merge_request_diff.opening_external_diff do |file|
+ file.seek(external_diff_offset)
+ file.read(external_diff_size)
+ end
+ else
+ super
+ end
+
+ binary? ? content.unpack('m0').first : content
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 9ae13fbaa80..bfd2608bed4 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1030,12 +1030,12 @@ class Repository
remote_branch: merge_request.target_branch)
end
- def squash(user, merge_request)
+ def squash(user, merge_request, message)
raw.squash(user, merge_request.id, branch: merge_request.target_branch,
start_sha: merge_request.diff_start_sha,
end_sha: merge_request.diff_head_sha,
author: merge_request.author,
- message: merge_request.title)
+ message: message)
end
def update_submodule(user, submodule, commit_sha, message:, branch:)
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index e65b3df0fb6..6caab24143b 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
- attrs[:in_reply_to_discussion_id] = note.discussion_id
+ attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion?
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base
private
def reply_params
- attrs = {
+ {
noteable_type: self.noteable_type,
noteable_id: self.noteable_id,
- commit_id: self.commit_id
+ commit_id: self.commit_id,
+ in_reply_to_discussion_id: self.in_reply_to_discussion_id
}
-
- if self.in_reply_to_discussion_id.present?
- attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id
- else
- # Remove in GitLab 10.0, when we will not support replying to SentNotifications
- # that don't have `in_reply_to_discussion_id` anymore.
- attrs.merge!(
- type: self.note_type,
-
- # LegacyDiffNote
- line_code: self.line_code,
-
- # DiffNote
- position: self.position.to_json
- )
- end
-
- attrs
end
def note_valid
diff --git a/app/models/user.rb b/app/models/user.rb
index 691abe3175f..9c091ac366c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -267,6 +267,8 @@ class User < ApplicationRecord
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
@@ -337,6 +339,8 @@ class User < ApplicationRecord
case order_method.to_s
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
+ when 'last_activity_on_desc' then order_recent_last_activity
+ when 'last_activity_on_asc' then order_oldest_last_activity
else
order_by(order_method)
end
diff --git a/app/serializers/merge_request_widget_commit_entity.rb b/app/serializers/merge_request_widget_commit_entity.rb
new file mode 100644
index 00000000000..50a5c44a6ad
--- /dev/null
+++ b/app/serializers/merge_request_widget_commit_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestWidgetCommitEntity < Grape::Entity
+ expose :safe_message, as: :message
+ expose :short_id
+ expose :title
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index f42abf06e1e..2142ceb6122 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -56,10 +56,23 @@ class MergeRequestWidgetEntity < IssuableEntity
merge_request.diff_head_sha.presence
end
- expose :merge_commit_message
expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? }
+
expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)}
+ expose :default_squash_commit_message
+ expose :default_merge_commit_message
+
+ expose :default_merge_commit_message_with_description do |merge_request|
+ merge_request.default_merge_commit_message(include_description: true)
+ end
+
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
+ merge_request.commits.without_merge_commits
+ end
+
+ expose :commits_count
+
# Booleans
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -77,7 +90,6 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :branch_missing?, as: :branch_missing
- expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
expose :can_be_merged?, as: :can_be_merged
expose :mergeable?, as: :mergeable
@@ -205,10 +217,6 @@ class MergeRequestWidgetEntity < IssuableEntity
ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
end
- expose :merge_commit_message_with_description do |merge_request|
- merge_request.merge_commit_message(include_description: true)
- end
-
expose :diverged_commits_count do |merge_request|
if merge_request.open? && merge_request.diverged_from_target_branch?
merge_request.diverged_commits_count
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 70a67baa01c..449997bcf07 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -8,6 +8,8 @@ module MergeRequests
# Executed when you do merge via GitLab UI
#
class MergeService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
MergeError = Class.new(StandardError)
attr_reader :merge_request, :source
@@ -37,15 +39,10 @@ module MergeRequests
end
def source
- return merge_request.diff_head_sha unless merge_request.squash
-
- squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request)
-
- case squash_result[:status]
- when :success
- squash_result[:squash_sha]
- when :error
- raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ if merge_request.squash
+ squash_sha!
+ else
+ merge_request.diff_head_sha
end
end
@@ -82,8 +79,22 @@ module MergeRequests
merge_request.update!(merge_commit_sha: commit_id)
end
+ def squash_sha!
+ strong_memoize(:squash_sha) do
+ params[:merge_request] = merge_request
+ squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute
+
+ case squash_result[:status]
+ when :success
+ squash_result[:squash_sha]
+ when :error
+ raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ end
+ end
+ end
+
def try_merge
- message = params[:commit_message] || merge_request.merge_commit_message
+ message = params[:commit_message] || merge_request.default_merge_commit_message
repository.merge(current_user, source, merge_request, message)
rescue Gitlab::Git::PreReceiveError => e
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index a439a380255..9d1a5d5e6d4 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,15 +2,10 @@
module MergeRequests
class SquashService < MergeRequests::WorkingCopyBaseService
- def execute(merge_request)
- @merge_request = merge_request
- @repository = target_project.repository
-
- squash || error('Failed to squash. Should be done manually.')
- end
-
- def squash
- if merge_request.commits_count < 2
+ def execute
+ # If performing a squash would result in no change, then
+ # immediately return a success message without performing a squash
+ if merge_request.commits_count < 2 && message.nil?
return success(squash_sha: merge_request.diff_head_sha)
end
@@ -18,7 +13,13 @@ module MergeRequests
return error('Squash task canceled: another squash is already in progress.')
end
- squash_sha = repository.squash(current_user, merge_request)
+ squash! || error('Failed to squash. Should be done manually.')
+ end
+
+ private
+
+ def squash!
+ squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message)
success(squash_sha: squash_sha)
rescue => e
@@ -26,5 +27,17 @@ module MergeRequests
log_error(e.message)
false
end
+
+ def repository
+ target_project.repository
+ end
+
+ def merge_request
+ params[:merge_request]
+ end
+
+ def message
+ params[:squash_commit_message].presence
+ end
end
end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index bae98ede561..541f3e0d23c 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -15,6 +15,8 @@ module Notes
return note
end
+ discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion?
+
params.merge!(discussion.reply_attributes)
should_resolve = discussion.resolved?
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index c4546f30235..b975c3a8cb6 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -34,6 +34,10 @@ module Notes
end
if !only_commands && note.save
+ if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
+ note.discussion.convert_to_discussion!.save(touch: false)
+ end
+
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb
new file mode 100644
index 00000000000..d2707cd0777
--- /dev/null
+++ b/app/uploaders/external_diff_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ExternalDiffUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.external_diffs
+
+ alias_method :upload, :model
+
+ def filename
+ "diff-#{model.id}"
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.model_name.plural, "mr-#{model.merge_request_id}")
+ end
+end
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index a4e2c3252af..be7bfa958b2 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,36 +1,37 @@
-%li.flex-row
- .user-avatar
- = image_tag avatar_icon_for_user(user), class: "avatar", alt: ''
- .row-main-content
- .user-name.row-title.str-truncated-100
- = link_to user.name, [:admin, user], class: "js-user-link", data: { user_id: user.id }
- - if user.blocked?
- %span.badge.badge-danger blocked
- - if user.admin?
- %span.badge.badge-success Admin
- - if user.external?
- %span.badge.badge-secondary External
- - if user == current_user
- %span It's you!
- .row-second-line.str-truncated-100
- = mail_to user.email, user.email
- .controls
- = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn'
- - unless user == current_user
- .dropdown.inline
- %a.dropdown-new.btn.btn-default#project-settings-button{ href: '#', data: { toggle: 'dropdown' } }
+.gl-responsive-table-row{ role: 'row' }
+ .table-section.section-40
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Name')
+ .table-mobile-content
+ = render 'user_detail', user: user
+ .table-section.section-25
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Created on')
+ .table-mobile-content
+ = l(user.created_at.to_date, format: :admin)
+ .table-section.section-15
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Last activity')
+ .table-mobile-content
+ = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin)
+ .table-section.section-20.table-button-footer
+ .table-action-buttons
+ = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default'
+ - unless user == current_user
+ %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } }
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
- Settings
+ = _('Settings')
%li
- if user.ldap_blocked?
- %span.small Cannot unblock LDAP blocked users
+ %span.small
+ = s_('AdminUsers|Cannot unblock LDAP blocked users')
- elsif user.blocked?
- = link_to 'Unblock', unblock_admin_user_path(user), method: :put
+ = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put
+ = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
@@ -42,7 +43,7 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user')
@@ -51,6 +52,6 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
new file mode 100644
index 00000000000..3319b4bad3a
--- /dev/null
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -0,0 +1,17 @@
+.flex-list
+ .flex-row
+ = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ .row-main-content
+ .row-title.str-truncated-100
+ = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id }
+
+ = render_if_exists 'admin/users/user_detail_note', user: user
+
+ - user_badges_in_admin_section(user).each do |badge|
+ - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
+ %span{ class: css_badge }
+ = badge[:text]
+
+ .row-second-line.str-truncated-100
+ = mail_to user.email, user.email, class: 'text-secondary'
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 600120c4f05..c3d5ce0fe70 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -2,72 +2,78 @@
- page_title "Users"
%div{ class: container_class }
- .prepend-top-default
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left
+ = icon('angle-left')
+ .fade-right
+ = icon('angle-right')
+ %ul.nav-links.nav.nav-tabs.scrolling-tabs
+ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
+ = link_to admin_users_path do
+ = s_('AdminUsers|Active')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
+ = link_to admin_users_path(filter: "admins") do
+ = s_('AdminUsers|Admins')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_enabled') do
+ = s_('AdminUsers|2FA Enabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_disabled') do
+ = s_('AdminUsers|2FA Disabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
+ = link_to admin_users_path(filter: 'external') do
+ = s_('AdminUsers|External')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
+ = link_to admin_users_path(filter: "blocked") do
+ = s_('AdminUsers|Blocked')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
+ = link_to admin_users_path(filter: "wop") do
+ = s_('AdminUsers|Without projects')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ .nav-controls
+ = render_if_exists 'admin/users/admin_email_users'
+ = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn btn-success btn-search float-right'
+
+ .filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder
- = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false
+ = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false
- if @sort.present?
= hidden_field_tag :sort, @sort
= icon("search", class: "search-icon")
- = button_tag 'Search users' if Rails.env.test?
+ = button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
- toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
- Sort by
+ = s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
- = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search'
- .top-area.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.nav.nav-tabs.scrolling-tabs
- = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
- = link_to admin_users_path do
- Active
- %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
- = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
- = link_to admin_users_path(filter: "admins") do
- Admins
- %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
- = link_to admin_users_path(filter: 'two_factor_enabled') do
- 2FA Enabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
- = link_to admin_users_path(filter: 'two_factor_disabled') do
- 2FA Disabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
- = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
- = link_to admin_users_path(filter: 'external') do
- External
- %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
- = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
- = link_to admin_users_path(filter: "blocked") do
- Blocked
- %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
- = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
- = link_to admin_users_path(filter: "wop") do
- Without projects
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ - if @users.empty?
+ .nothing-here-block.border-top-0
+ = s_('AdminUsers|No users found')
+ - else
+ .table-holder
+ .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-40{ role: 'rowheader' }= _('Name')
+ .table-section.section-25{ role: 'rowheader' }= _('Created on')
+ .table-section.section-15{ role: 'rowheader' }= _('Last activity')
- %ul.flex-list.content-list
- - if @users.empty?
- %li
- .nothing-here-block No users found.
- - else
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
#delete-user-modal
-
diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml
index 6ae4c334f7f..e1b7804c5a7 100644
--- a/app/views/events/_events.html.haml
+++ b/app/views/events/_events.html.haml
@@ -1,4 +1,18 @@
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
+- visitor_empty_message = _('No activities found')
+
- if @events.present?
= render partial: 'events/event', collection: @events
- else
- .nothing-here-block= _("No activities found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index 9d4574c4590..6aafa85e99a 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -8,3 +8,5 @@
.col-lg-9
= render 'projects/services/prometheus/metrics', project: @project
+
+= render_if_exists 'projects/services/prometheus/external_alerts', project: @project
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
new file mode 100644
index 00000000000..6da40e1b059
--- /dev/null
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -0,0 +1,19 @@
+- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
+- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
+
+.nothing-here-block
+ .svg-content
+ = image_tag illustration_path, size: '75'
+ .text-content
+ - if user_profile? and current_user.present? and current_user.username == params[:username]
+ %h5= current_user_empty_message_header
+
+ - if current_user_empty_message_description.present?
+ %p= current_user_empty_message_description
+
+ - if secondary_button_link.present?
+ = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted'
+
+ = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
+ - else
+ %h5= visitor_empty_message
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index f50a6bd4d6a..c5b39c7db08 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,3 +1,10 @@
+- illustration_path = 'illustrations/profile-page/groups.svg'
+- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.')
+- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- visitor_empty_message = s_('GroupsEmptyState|No groups found')
+
- if groups.any?
- user = local_assigns[:user]
@@ -5,4 +12,9 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group, user: user
- else
- .nothing-here-block= s_("GroupsEmptyState|No groups found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 7d90d9ca4a5..13847cd9be1 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -14,6 +14,17 @@
- skip_pagination = false unless local_assigns[:skip_pagination] == true
- compact_mode = false unless local_assigns[:compact_mode] == true
- css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
+- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
+- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
+- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
+- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
+- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
+- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
+- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
+- primary_button_label = _('New project')
+- primary_button_link = new_project_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@@ -33,9 +44,18 @@
%span &nbsp;you have no access to.
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- .nothing-here-block
- .svg-content.svg-130
- = image_tag 'illustrations/profile-page/personal-project.svg'
- %div
- %span
- = s_('UserProfile|This user doesn\'t have any personal projects')
+ - if @contributed_projects
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
+ current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: contributed_projects_visitor_empty_message }
+ - else
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
+ current_user_empty_message_header: own_projects_current_user_empty_message_header,
+ current_user_empty_message_description: own_projects_current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: own_projects_visitor_empty_message }
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 69d41f8fe5e..dab247da251 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,10 +1,21 @@
- link_project = local_assigns.fetch(:link_project, false)
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
+- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
+- primary_button_label = _('New snippet')
+- primary_button_link = new_snippet_path
+- visitor_empty_message = s_('UserProfile|No snippets found.')
.snippets-list-holder
%ul.content-list
= render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
- .nothing-here-block= _("Nothing here.")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
= paginate @snippets, theme: 'gitlab'
diff --git a/bin/secpick b/bin/secpick
index be120a304c9..ca9d901120d 100755
--- a/bin/secpick
+++ b/bin/secpick
@@ -10,6 +10,7 @@ using Rainbow
module Secpick
BRANCH_PREFIX = 'security'.freeze
+ STABLE_PREFIX = 'stable'.freeze
DEFAULT_REMOTE = 'dev'.freeze
NEW_MR_URL = 'https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/new'.freeze
@@ -36,16 +37,16 @@ module Secpick
branch.freeze
end
- def security_branch
- "#{BRANCH_PREFIX}-#{@options[:version]}".tap do |name|
+ def stable_branch
+ "#{STABLE_PREFIX}-#{@options[:version]}".tap do |name|
name << "-ee" if ee?
end.freeze
end
def git_commands
- ["git fetch #{@options[:remote]} #{security_branch}",
- "git checkout #{security_branch}",
- "git pull #{@options[:remote]} #{security_branch}",
+ ["git fetch #{@options[:remote]} #{stable_branch}",
+ "git checkout #{stable_branch}",
+ "git pull #{@options[:remote]} #{stable_branch}",
"git checkout -B #{source_branch}",
"git cherry-pick #{@options[:sha]}",
"git push #{@options[:remote]} #{source_branch}",
@@ -56,9 +57,8 @@ module Secpick
{
merge_request: {
source_branch: source_branch,
- target_branch: security_branch,
- title: "[#{@options[:version].tr('-', '.')}] ",
- description: '/label ~security ~"Merge into Security"'
+ target_branch: stable_branch,
+ description: '/label ~security'
}
}
end
diff --git a/changelogs/unreleased/28500-empty-states-for-profile-page.yml b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
new file mode 100644
index 00000000000..53f840521ae
--- /dev/null
+++ b/changelogs/unreleased/28500-empty-states-for-profile-page.yml
@@ -0,0 +1,5 @@
+---
+title: Refresh empty states for profile page tabs
+merge_request: 24549
+author:
+type: changed
diff --git a/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml b/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml
new file mode 100644
index 00000000000..0fbf6314a27
--- /dev/null
+++ b/changelogs/unreleased/43681-display-last-activity-and-created-at-datetimes-for-users-in-admin-users.yml
@@ -0,0 +1,5 @@
+---
+title: Display last activity and created at datetimes for users
+merge_request: 24181
+author:
+type: added
diff --git a/changelogs/unreleased/52568-external-mr-diffs.yml b/changelogs/unreleased/52568-external-mr-diffs.yml
new file mode 100644
index 00000000000..b1c9d5cb809
--- /dev/null
+++ b/changelogs/unreleased/52568-external-mr-diffs.yml
@@ -0,0 +1,5 @@
+---
+title: Allow merge request diffs to be placed into an object store
+merge_request: 24276
+author:
+type: added
diff --git a/changelogs/unreleased/56014-better-squash-commit-messages.yml b/changelogs/unreleased/56014-better-squash-commit-messages.yml
new file mode 100644
index 00000000000..b08d584ac0a
--- /dev/null
+++ b/changelogs/unreleased/56014-better-squash-commit-messages.yml
@@ -0,0 +1,6 @@
+---
+title: Default squash commit message is now selected from the longest commit when
+ squashing merge requests
+merge_request: 24518
+author:
+type: changed
diff --git a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml b/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
deleted file mode 100644
index b19b4d650fd..00000000000
--- a/changelogs/unreleased/56424-fix-gl-form-init-tag-editing.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix form functionality for edit tag page
-merge_request: 24645
-author:
-type: fixed
diff --git a/changelogs/unreleased/cluster_application_version_updated.yml b/changelogs/unreleased/cluster_application_version_updated.yml
new file mode 100644
index 00000000000..34fe55dcc5e
--- /dev/null
+++ b/changelogs/unreleased/cluster_application_version_updated.yml
@@ -0,0 +1,5 @@
+---
+title: Update cluster application version on updated and installed status
+merge_request: 24810
+author:
+type: other
diff --git a/changelogs/unreleased/fj-regression-external-wiki-url.yml b/changelogs/unreleased/fj-regression-external-wiki-url.yml
deleted file mode 100644
index d4f21dab982..00000000000
--- a/changelogs/unreleased/fj-regression-external-wiki-url.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Changed external wiki query method to prevent attribute caching
-merge_request: 24907
-author:
-type: fixed
diff --git a/changelogs/unreleased/introduce-environment-search-endpoint.yml b/changelogs/unreleased/introduce-environment-search-endpoint.yml
new file mode 100644
index 00000000000..01851ba7d27
--- /dev/null
+++ b/changelogs/unreleased/introduce-environment-search-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce Internal API for searching environment names
+merge_request: 24923
+author:
+type: added
diff --git a/changelogs/unreleased/knative-list.yml b/changelogs/unreleased/knative-list.yml
new file mode 100644
index 00000000000..754d8e172cf
--- /dev/null
+++ b/changelogs/unreleased/knative-list.yml
@@ -0,0 +1,5 @@
+---
+title: Modified Knative list view to provide more details
+merge_request: 24072
+author: Chris Baumbauer
+type: changed
diff --git a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml b/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
deleted file mode 100644
index 3ba62b92413..00000000000
--- a/changelogs/unreleased/osw-adjusts-suggestions-unable-to-be-applied.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Adjusts suggestions unable to be applied
-merge_request: 24603
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-fix-detect-host-keys.yml b/changelogs/unreleased/sh-fix-detect-host-keys.yml
deleted file mode 100644
index 993d7c35b18..00000000000
--- a/changelogs/unreleased/sh-fix-detect-host-keys.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix Detect Host Keys not working
-merge_request: 24884
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-fix-issue-9357.yml b/changelogs/unreleased/sh-fix-issue-9357.yml
deleted file mode 100644
index 756cd6047b8..00000000000
--- a/changelogs/unreleased/sh-fix-issue-9357.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix 500 errors with legacy appearance logos
-merge_request: 24615
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml b/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml
deleted file mode 100644
index 8d17900cb79..00000000000
--- a/changelogs/unreleased/sh-fix-oauth2-callback-caps.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Downcase aliased OAuth2 callback providers
-merge_request: 24877
-author:
-type: fixed
diff --git a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml b/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
deleted file mode 100644
index 8c0b000220f..00000000000
--- a/changelogs/unreleased/sh-remove-bitbucket-mirror-constant.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix import handling errors in Bitbucket Server importer
-merge_request: 24499
-author:
-type: fixed
diff --git a/changelogs/unreleased/workhorse-8-3-0.yml b/changelogs/unreleased/workhorse-8-3-0.yml
new file mode 100644
index 00000000000..6ae01d64ae5
--- /dev/null
+++ b/changelogs/unreleased/workhorse-8-3-0.yml
@@ -0,0 +1,5 @@
+---
+title: Update Workhorse to v8.3.0
+merge_request: 24959
+author:
+type: other
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 6fc33e8971e..be23166cb7b 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -166,6 +166,23 @@ production: &base
# aws_signature_version: 4 # For creation of signed URLs. Set to 2 if provider does not support v4.
# endpoint: 'https://s3.amazonaws.com' # default: nil - Useful for S3 compliant services such as DigitalOcean Spaces
+ ## Merge request external diff storage
+ external_diffs:
+ # If disabled (the default), the diffs are in-database. Otherwise, they can
+ # be stored on disk, or in object storage
+ enabled: false
+ # The location where external diffs are stored (default: shared/lfs-external-diffs).
+ # storage_path: shared/external-diffs
+ # object_store:
+ # enabled: false
+ # remote_directory: external-diffs
+ # background_upload: false
+ # proxy_download: false
+ # connection:
+ # provider: AWS
+ # aws_access_key_id: AWS_ACCESS_KEY_ID
+ # aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ # region: us-east-1
## Git LFS
lfs:
@@ -733,6 +750,18 @@ test:
<<: *base
gravatar:
enabled: true
+ external_diffs:
+ enabled: false
+ # The location where external diffs are stored (default: shared/external-diffs).
+ # storage_path: shared/external-diffs
+ object_store:
+ enabled: false
+ remote_directory: external-diffs # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: us-east-1
lfs:
enabled: false
# The location where LFS objects are stored (default: shared/lfs-objects).
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 1aed41e02ab..dfcf1e648b4 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -216,6 +216,14 @@ Settings.pages['admin'] ||= Settingslogic.new({})
Settings.pages.admin['certificate'] ||= ''
#
+# External merge request diffs
+#
+Settings['external_diffs'] ||= Settingslogic.new({})
+Settings.external_diffs['enabled'] = false if Settings.external_diffs['enabled'].nil?
+Settings.external_diffs['storage_path'] = Settings.absolute(Settings.external_diffs['storage_path'] || File.join(Settings.shared['path'], 'external-diffs'))
+Settings.external_diffs['object_store'] = ObjectStoreSettings.parse(Settings.external_diffs['object_store'])
+
+#
# Git LFS
#
Settings['lfs'] ||= Settingslogic.new({})
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 38c3711c6c7..554e9913faa 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -43,6 +43,7 @@ de:
default: "%d.%m.%Y"
long: "%e. %B %Y"
short: "%e. %b"
+ admin: "%e %b, %Y"
month_names:
-
- Januar
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0a43a1d9a6b..e8dbc033a7c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -55,6 +55,7 @@ en:
default: "%Y-%m-%d"
long: "%B %d, %Y"
short: "%b %d"
+ admin: "%e %b, %Y"
month_names:
-
- January
diff --git a/config/locales/es.yml b/config/locales/es.yml
index fdc52b4ae11..78c07583933 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -42,6 +42,7 @@ es:
default: "%d/%m/%Y"
long: "%d de %B de %Y"
short: "%d de %b"
+ admin: "%e %b, %Y"
month_names:
-
- enero
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 21793e7756a..d730479cf2b 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -224,6 +224,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
collection do
get :metrics, action: :metrics_redirect
get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ }
+ get :search
end
resources :deployments, only: [:index] do
diff --git a/db/migrate/20190109153125_add_merge_request_external_diffs.rb b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
new file mode 100644
index 00000000000..c67903c7f67
--- /dev/null
+++ b/db/migrate/20190109153125_add_merge_request_external_diffs.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddMergeRequestExternalDiffs < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ # Allow the merge request diff to store details about an external file
+ add_column :merge_request_diffs, :external_diff, :string
+ add_column :merge_request_diffs, :external_diff_store, :integer
+ add_column :merge_request_diffs, :stored_externally, :boolean
+
+ # The diff for each file is mapped to a range in the external file
+ add_column :merge_request_diff_files, :external_diff_offset, :integer
+ add_column :merge_request_diff_files, :external_diff_size, :integer
+
+ # If the diff is in object storage, it will be null in the database
+ change_column_null :merge_request_diff_files, :diff, true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4b6e4992056..20c8dab4c3e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1203,8 +1203,10 @@ ActiveRecord::Schema.define(version: 20190131122559) do
t.string "b_mode", null: false
t.text "new_path", null: false
t.text "old_path", null: false
- t.text "diff", null: false
+ t.text "diff"
t.boolean "binary"
+ t.integer "external_diff_offset"
+ t.integer "external_diff_size"
t.index ["merge_request_diff_id", "relative_order"], name: "index_merge_request_diff_files_on_mr_diff_id_and_order", unique: true, using: :btree
end
@@ -1218,6 +1220,9 @@ ActiveRecord::Schema.define(version: 20190131122559) do
t.string "head_commit_sha"
t.string "start_commit_sha"
t.integer "commits_count"
+ t.string "external_diff"
+ t.integer "external_diff_store"
+ t.boolean "stored_externally"
t.index ["merge_request_id", "id"], name: "index_merge_request_diffs_on_merge_request_id_and_id", using: :btree
end
diff --git a/doc/administration/index.md b/doc/administration/index.md
index 0b673d61139..184754cd467 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -48,6 +48,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Third party offers](../user/admin_area/settings/third_party_offers.md)
- [Compliance](compliance.md): A collection of features from across the application that you may configure to help ensure that your GitLab instance and DevOps workflow meet compliance standards.
- [Diff limits](../user/admin_area/diff_limits.md): Configure the diff rendering size limits of branch comparison pages.
+- [Merge request diffs](merge_request_diffs.md): Configure the diffs shown on merge requests
- [Broadcast Messages](../user/admin_area/broadcast_messages.md): Send messages to GitLab users through the UI.
#### Customizing GitLab's appearance
diff --git a/doc/administration/merge_request_diffs.md b/doc/administration/merge_request_diffs.md
new file mode 100644
index 00000000000..94620c3d3a0
--- /dev/null
+++ b/doc/administration/merge_request_diffs.md
@@ -0,0 +1,154 @@
+# Merge request diffs administration
+
+> **Notes:**
+> - External merge request diffs introduced in GitLab 11.8
+
+Merge request diffs are size-limited copies of diffs associated with merge
+requests. When viewing a merge request, diffs are sourced from these copies
+wherever possible as a performance optimization.
+
+By default, merge request diffs are stored in the database, in a table named
+`merge_request_diff_files`. Larger installations may find this table grows too
+large, in which case, switching to external storage is recommended.
+
+### Using external storage
+
+Merge request diffs can be stored on disk, or in object storage. In general, it
+is better to store the diffs in the database than on disk.
+
+To enable external storage of merge request diffs:
+
+---
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ ```
+
+1. _The external diffs will be stored in in
+ `/var/opt/gitlab/gitlab-rails/shared/external-diffs`._ To change the path,
+ for example to `/mnt/storage/external-diffs`, edit `/etc/gitlab/gitlab.rb`
+ and add the following line:
+
+ ```ruby
+ gitlab_rails['external_diffs_storage_path'] = "/mnt/storage/external-diffs"
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ ```
+
+1. _The external diffs will be stored in
+ `/home/git/gitlab/shared/external-diffs`._ To change the path, for example
+ to `/mnt/storage/external-diffs`, edit `/home/git/gitlab/config/gitlab.yml`
+ and add or amend the following lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ storage_path: /mnt/storage/external-diffs
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
+
+### Using object storage
+
+Instead of storing the external diffs on disk, we recommended you use an object
+store like AWS S3 instead. This configuration relies on valid AWS credentials to
+be configured already.
+
+### Object Storage Settings
+
+For source installations, these settings are nested under `external_diffs:` and
+then `object_store:`. On omnibus installs, they are prefixed by
+`external_diffs_object_store_`.
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable/disable object storage | `false` |
+| `remote_directory` | The bucket name where external diffs will be stored| |
+| `direct_upload` | Set to true to enable direct upload of external diffs without the need of local shared storage. Option may be removed once we decide to support only single storage for all files. | `false` |
+| `background_upload` | Set to false to disable automatic upload. Option may be removed once upload is direct to S3 | `true` |
+| `proxy_download` | Set to true to enable proxying all files served. Option allows to reduce egress traffic as this allows clients to download directly from remote storage instead of proxying all data | `false` |
+| `connection` | Various connection options described below | |
+
+#### S3 compatible connection settings
+
+The connection settings match those provided by [Fog](https://github.com/fog), and are as follows:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `provider` | Always `AWS` for compatible hosts | AWS |
+| `aws_access_key_id` | AWS credentials, or compatible | |
+| `aws_secret_access_key` | AWS credentials, or compatible | |
+| `aws_signature_version` | AWS signature version to use. 2 or 4 are valid options. Digital Ocean Spaces and other providers may need 2. | 4 |
+| `region` | AWS region | us-east-1 |
+| `host` | S3 compatible host for when not using AWS, e.g. `localhost` or `storage.example.com` | s3.amazonaws.com |
+| `endpoint` | Can be used when configuring an S3 compatible service such as [Minio](https://www.minio.io), by entering a URL such as `http://127.0.0.1:9000` | (optional) |
+| `path_style` | Set to true to use `host/bucket_name/object` style paths instead of `bucket_name.host/object`. Leave as false for AWS S3 | false |
+| `use_iam_profile` | Set to true to use IAM profile instead of access keys | false
+
+**In Omnibus installations:**
+
+1. Edit `/etc/gitlab/gitlab.rb` and add the following lines by replacing with
+ the values you want:
+
+ ```ruby
+ gitlab_rails['external_diffs_enabled'] = true
+ gitlab_rails['external_diffs_object_store_enabled'] = true
+ gitlab_rails['external_diffs_object_store_remote_directory'] = "external-diffs"
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'aws_access_key_id' => 'AWS_ACCESS_KEY_ID',
+ 'aws_secret_access_key' => 'AWS_SECRET_ACCESS_KEY'
+ }
+ ```
+
+ NOTE: if you are using AWS IAM profiles, be sure to omit the
+ AWS access key and secret access key/value pairs. For example:
+
+ ```ruby
+ gitlab_rails['external_diffs_object_store_connection'] = {
+ 'provider' => 'AWS',
+ 'region' => 'eu-central-1',
+ 'use_iam_profile' => true
+ }
+ ```
+
+1. Save the file and [reconfigure GitLab][] for the changes to take effect.
+
+---
+
+**In installations from source:**
+
+1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following
+ lines:
+
+ ```yaml
+ external_diffs:
+ enabled: true
+ object_store:
+ enabled: true
+ remote_directory: "external-diffs" # The bucket name
+ connection:
+ provider: AWS # Only AWS supported at the moment
+ aws_access_key_id: AWS_ACCESS_KEY_ID
+ aws_secret_access_key: AWS_SECRET_ACCESS_KEY
+ region: eu-central-1
+ ```
+
+1. Save the file and [restart GitLab][] for the changes to take effect.
diff --git a/doc/development/file_storage.md b/doc/development/file_storage.md
index b90dc90e424..597812c8c49 100644
--- a/doc/development/file_storage.md
+++ b/doc/development/file_storage.md
@@ -18,6 +18,7 @@ There are many places where file uploading is used, according to contexts:
- Issues/MR/Notes Legacy Markdown attachments
- CI Artifacts (archive, metadata, trace)
- LFS Objects
+ - Merge request diffs
## Disk storage
@@ -37,6 +38,7 @@ they are still not 100% standardized. You can see them below:
| Issues/MR/Notes Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
| CI Artifacts (CE) | yes | shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id (:disk_hash is SHA256 digest of project_id) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
+| External merge request diffs | yes | shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id | `ExternalDiffUploader` | MergeRequestDiff |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
while in EE they inherit the `ObjectStorage` and store files in and S3 API compatible object store.
diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md
index 1b57331dbe7..34cba867e2c 100644
--- a/doc/user/project/merge_requests/squash_and_merge.md
+++ b/doc/user/project/merge_requests/squash_and_merge.md
@@ -18,10 +18,14 @@ Into a single commit on merge:
![A squashed commit followed by a merge commit][squashed-commit]
-The squashed commit's commit message is the merge request title. And note that
-the squashed commit is still followed by a merge commit, as the merge
-method for this example repository uses a merge commit. Squashing also works
-with the fast-forward merge strategy, see
+The squashed commit's commit message will be either:
+
+- Taken from the first multi-line commit message in the merge.
+- The merge request's title if no multi-line commit message is found.
+
+Note that the squashed commit is still followed by a merge commit,
+as the merge method for this example repository uses a merge commit.
+Squashing also works with the fast-forward merge strategy, see
[squashing and fast-forward merge](#squash-and-fast-forward-merge) for more
details.
@@ -34,7 +38,7 @@ you'd rather not include them in your target branch.
With squash and merge, when the merge request is ready to be merged,
all you have to do is enable squashing before you press merge to join
-the commits include in the merge request into a single commit.
+the commits in the merge request into a single commit.
This way, the history of your base branch remains clean with
meaningful commit messages and is simpler to [revert] if necessary.
@@ -56,7 +60,7 @@ This can then be overridden at the time of accepting the merge request:
The squashed commit has the following metadata:
-- Message: the title of the merge request.
+- Message: the message of the squash commit.
- Author: the author of the merge request.
- Committer: the user who initiated the squash.
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 9f1394571d8..a1f0efa3c68 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1116,7 +1116,9 @@ module API
class Release < TagRelease
expose :name
- expose :description_html
+ expose :description_html do |entity|
+ MarkupHelper.markdown_field(entity, :description)
+ end
expose :created_at
expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? }
expose :commit, using: Entities::Commit
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index add7ee58da6..099677a791c 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -130,9 +130,14 @@ excluded_attributes:
snippets:
- :expired_at
merge_request_diff:
+ - :external_diff
+ - :stored_externally
+ - :external_diff_store
- :st_diffs
merge_request_diff_files:
- :diff
+ - :external_diff_offset
+ - :external_diff_size
issues:
- :milestone_id
merge_requests:
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 6687cdd5feb..9ec590f90d8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -468,9 +468,30 @@ msgstr ""
msgid "AdminSettings|When creating a new environment variable it will be protected by default."
msgstr ""
+msgid "AdminUsers|2FA Disabled"
+msgstr ""
+
+msgid "AdminUsers|2FA Enabled"
+msgstr ""
+
+msgid "AdminUsers|Active"
+msgstr ""
+
+msgid "AdminUsers|Admin"
+msgstr ""
+
+msgid "AdminUsers|Admins"
+msgstr ""
+
msgid "AdminUsers|Block user"
msgstr ""
+msgid "AdminUsers|Blocked"
+msgstr ""
+
+msgid "AdminUsers|Cannot unblock LDAP blocked users"
+msgstr ""
+
msgid "AdminUsers|Delete User %{username} and contributions?"
msgstr ""
@@ -483,12 +504,39 @@ msgstr ""
msgid "AdminUsers|Delete user and contributions"
msgstr ""
+msgid "AdminUsers|External"
+msgstr ""
+
+msgid "AdminUsers|It's you!"
+msgstr ""
+
+msgid "AdminUsers|New user"
+msgstr ""
+
+msgid "AdminUsers|No users found"
+msgstr ""
+
+msgid "AdminUsers|Search by name, email or username"
+msgstr ""
+
+msgid "AdminUsers|Search users"
+msgstr ""
+
+msgid "AdminUsers|Sort by"
+msgstr ""
+
msgid "AdminUsers|To confirm, type %{projectName}"
msgstr ""
msgid "AdminUsers|To confirm, type %{username}"
msgstr ""
+msgid "AdminUsers|User will be blocked"
+msgstr ""
+
+msgid "AdminUsers|Without projects"
+msgstr ""
+
msgid "Advanced permissions, Large File Storage and Two-Factor authentication settings."
msgstr ""
@@ -711,6 +759,9 @@ msgstr ""
msgid "Archived projects"
msgstr ""
+msgid "Are you sure"
+msgstr ""
+
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
@@ -909,6 +960,9 @@ msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
+msgid "Avatar for %{name}"
+msgstr ""
+
msgid "Avatar will be removed. Are you sure?"
msgstr ""
@@ -1014,6 +1068,9 @@ msgstr ""
msgid "Bitbucket import"
msgstr ""
+msgid "Block"
+msgstr ""
+
msgid "Blocked"
msgstr ""
@@ -2348,6 +2405,9 @@ msgstr ""
msgid "Created by me"
msgstr ""
+msgid "Created on"
+msgstr ""
+
msgid "Created on:"
msgstr ""
@@ -2623,6 +2683,9 @@ msgstr ""
msgid "DeployTokens|Your new project deploy token has been created."
msgstr ""
+msgid "Deployed"
+msgstr ""
+
msgid "Deployed to"
msgstr ""
@@ -4121,6 +4184,9 @@ msgstr[1] ""
msgid "Last Pipeline"
msgstr ""
+msgid "Last activity"
+msgstr ""
+
msgid "Last commit"
msgstr ""
@@ -4836,9 +4902,6 @@ msgstr ""
msgid "Notes|Show history only"
msgstr ""
-msgid "Nothing here."
-msgstr ""
-
msgid "Notification events"
msgstr ""
@@ -5959,6 +6022,9 @@ msgstr ""
msgid "Reopen milestone"
msgstr ""
+msgid "Reply to comment"
+msgstr ""
+
msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr ""
@@ -6360,9 +6426,6 @@ msgstr ""
msgid "Serverless"
msgstr ""
-msgid "ServerlessDetails|Copy URL to clipboard"
-msgstr ""
-
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
@@ -6375,19 +6438,13 @@ msgstr ""
msgid "ServerlessDetails|pods in use"
msgstr ""
-msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
-msgstr ""
-
-msgid "Serverless|An error occurred while retrieving serverless components"
-msgstr ""
-
-msgid "Serverless|Cluster Env"
+msgid "ServerlessURL|Copy URL to clipboard"
msgstr ""
-msgid "Serverless|Description"
+msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
-msgid "Serverless|Function"
+msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Getting started with serverless"
@@ -6399,18 +6456,12 @@ msgstr ""
msgid "Serverless|Install Knative"
msgstr ""
-msgid "Serverless|Last Update"
-msgstr ""
-
msgid "Serverless|Learn more about Serverless"
msgstr ""
msgid "Serverless|No functions available"
msgstr ""
-msgid "Serverless|Runtime"
-msgstr ""
-
msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:"
msgstr ""
@@ -6680,6 +6731,9 @@ msgstr ""
msgid "SortOptions|Oldest joined"
msgstr ""
+msgid "SortOptions|Oldest last activity"
+msgstr ""
+
msgid "SortOptions|Oldest sign in"
msgstr ""
@@ -6692,6 +6746,9 @@ msgstr ""
msgid "SortOptions|Priority"
msgstr ""
+msgid "SortOptions|Recent last activity"
+msgstr ""
+
msgid "SortOptions|Recent sign in"
msgstr ""
@@ -7680,6 +7737,9 @@ msgstr ""
msgid "Unable to load the diff. %{button_try_again}"
msgstr ""
+msgid "Unblock"
+msgstr ""
+
msgid "Undo"
msgstr ""
@@ -7833,12 +7893,24 @@ msgstr ""
msgid "UserProfile|Edit profile"
msgstr ""
+msgid "UserProfile|Explore public groups to find projects to contribute to."
+msgstr ""
+
msgid "UserProfile|Groups"
msgstr ""
+msgid "UserProfile|Groups are the best way to manage projects and members."
+msgstr ""
+
+msgid "UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!"
+msgstr ""
+
msgid "UserProfile|Most Recent Activity"
msgstr ""
+msgid "UserProfile|No snippets found."
+msgstr ""
+
msgid "UserProfile|Overview"
msgstr ""
@@ -7851,6 +7923,9 @@ msgstr ""
msgid "UserProfile|Snippets"
msgstr ""
+msgid "UserProfile|Snippets in GitLab can either be private, internal, or public."
+msgstr ""
+
msgid "UserProfile|Subscribe"
msgstr ""
@@ -7860,12 +7935,27 @@ msgstr ""
msgid "UserProfile|This user has a private profile"
msgstr ""
+msgid "UserProfile|This user hasn't contributed to any projects"
+msgstr ""
+
msgid "UserProfile|View all"
msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
+msgid "UserProfile|You can create a group for several dependent projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any personal projects."
+msgstr ""
+
+msgid "UserProfile|You haven't created any snippets."
+msgstr ""
+
+msgid "UserProfile|Your projects can be available publicly, internally, or privately, at your choice."
+msgstr ""
+
msgid "Users"
msgstr ""
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index a4d494a820f..aa97a417a98 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -422,6 +422,79 @@ describe Projects::EnvironmentsController do
end
end
+ describe 'GET #search' do
+ before do
+ create(:environment, name: 'staging', project: project)
+ create(:environment, name: 'review/patch-1', project: project)
+ create(:environment, name: 'review/patch-2', project: project)
+ end
+
+ let(:query) { 'pro' }
+
+ it 'responds with status code 200' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('production')
+ end
+
+ context 'when query is review' do
+ let(:query) { 'review' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response).to contain_exactly('review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns matched results' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(json_response)
+ .to contain_exactly('production', 'staging', 'review/patch-1', 'review/patch-2')
+ end
+ end
+
+ context 'when query is review/patch-3' do
+ let(:query) { 'review/patch-3' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'patch' }
+
+ it 'responds with status code 204' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'review%' }
+
+ it 'prevents wildcard injection' do
+ get :search, params: environment_params(format: :json, query: query)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index ca5ff9b1e3b..79f97aa4170 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -387,6 +387,23 @@ describe Projects::MergeRequestsController do
end
end
+ context 'when a squash commit message is passed' do
+ let(:message) { 'My custom squash commit message' }
+
+ it 'passes the same message to SquashService' do
+ params = { squash: '1', squash_commit_message: message }
+
+ expect_next_instance_of(MergeRequests::SquashService, project, user, params.merge(merge_request: merge_request)) do |squash_service|
+ expect(squash_service).to receive(:execute).and_return({
+ status: :success,
+ squash_sha: SecureRandom.hex(20)
+ })
+ end
+
+ merge_with_sha(params)
+ end
+ end
+
context 'when the pipeline succeeds is passed' do
let!(:head_pipeline) do
create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch, head_pipeline_of: merge_request)
diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb
index 818f7b046f6..2bcc4b6cf52 100644
--- a/spec/factories/commits.rb
+++ b/spec/factories/commits.rb
@@ -16,14 +16,24 @@ FactoryBot.define do
commit
end
+
project
+ skip_create # Commits cannot be persisted
+
initialize_with do
new(git_commit, project)
end
after(:build) do |commit, evaluator|
allow(commit).to receive(:author).and_return(evaluator.author || build_stubbed(:author))
+ allow(commit).to receive(:parent_ids).and_return([])
+ end
+
+ trait :merge_commit do
+ after(:build) do |commit|
+ allow(commit).to receive(:parent_ids).and_return(Array.new(2) { SecureRandom.hex(20) })
+ end
end
trait :without_author do
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 931095936a6..b1c6f308bc6 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -1,13 +1,13 @@
require 'spec_helper'
describe "Admin::Users" do
- include Spec::Support::Helpers::Features::ListRowsHelpers
+ include Spec::Support::Helpers::Features::ResponsiveTableHelpers
let!(:user) do
create(:omniauth_user, provider: 'twitter', extern_uid: '123456')
end
- let!(:current_user) { create(:admin) }
+ let!(:current_user) { create(:admin, last_activity_on: 5.days.ago) }
before do
sign_in(current_user)
@@ -25,6 +25,8 @@ describe "Admin::Users" do
it "has users list" do
expect(page).to have_content(current_user.email)
expect(page).to have_content(current_user.name)
+ expect(page).to have_content(current_user.created_at.strftime("%e %b, %Y"))
+ expect(page).to have_content(current_user.last_activity_on.strftime("%e %b, %Y"))
expect(page).to have_content(user.email)
expect(page).to have_content(user.name)
expect(page).to have_link('Block', href: block_admin_user_path(user))
@@ -32,10 +34,24 @@ describe "Admin::Users" do
expect(page).to have_button('Delete user and contributions')
end
+ describe "view extra user information", :js do
+ it 'does not have the user popover open' do
+ expect(page).not_to have_selector('#__BV_popover_1__')
+ end
+
+ it 'shows the user popover on hover' do
+ first_user_link = page.first('.js-user-link')
+
+ first_user_link.hover
+
+ expect(page).to have_selector('#__BV_popover_1__')
+ end
+ end
+
describe 'search and sort' do
before do
- create(:user, name: 'Foo Bar')
- create(:user, name: 'Foo Baz')
+ create(:user, name: 'Foo Bar', last_activity_on: 3.days.ago)
+ create(:user, name: 'Foo Baz', last_activity_on: 2.days.ago)
create(:user, name: 'Dmitriy')
end
@@ -75,6 +91,24 @@ describe "Admin::Users" do
expect(first_row.text).to include('Foo Bar')
expect(second_row.text).to include('Foo Baz')
end
+
+ it 'sorts users by recent last activity' do
+ visit admin_users_path(search_query: 'Foo')
+
+ sort_by('Recent last activity')
+
+ expect(first_row.text).to include('Foo Baz')
+ expect(second_row.text).to include('Foo Bar')
+ end
+
+ it 'sorts users by oldest last activity' do
+ visit admin_users_path(search_query: 'Foo')
+
+ sort_by('Oldest last activity')
+
+ expect(first_row.text).to include('Foo Bar')
+ expect(second_row.text).to include('Foo Baz')
+ end
end
describe 'Two-factor Authentication filters' do
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index 16754035076..60ddb02da2c 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -843,6 +843,7 @@ describe 'Copy as GFM', :js do
def verify(selector, gfm, target: nil)
html = html_for_selector(selector)
output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target)
+ wait_for_requests
expect(output_gfm.strip).to eq(gfm.strip)
end
end
@@ -861,6 +862,9 @@ describe 'Copy as GFM', :js do
def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
js = <<~JS
(function(html) {
+ // Setting it off so the import already starts
+ window.CopyAsGFM.nodeToGFM(document.createElement('div'));
+
var transformer = window.CopyAsGFM[#{transformer.inspect}];
var node = document.createElement('div');
@@ -875,9 +879,18 @@ describe 'Copy as GFM', :js do
node = transformer(node, target);
if (!node) return null;
- return window.CopyAsGFM.nodeToGFM(node);
+
+ window.gfmCopytestRes = null;
+ window.CopyAsGFM.nodeToGFM(node)
+ .then((res) => {
+ window.gfmCopytestRes = res;
+ });
})("#{escape_javascript(html)}")
JS
- page.evaluate_script(js)
+ page.execute_script(js)
+
+ loop until page.evaluate_script('window.gfmCopytestRes !== null')
+
+ page.evaluate_script('window.gfmCopytestRes')
end
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index ee5f5377ca6..1bbcf455ac7 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -66,6 +66,38 @@ describe 'Merge request > User posts notes', :js do
is_expected.to have_css('.js-note-text', visible: true)
end
end
+
+ describe 'when reply_to_individual_notes feature flag is not set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'does not show a reply button' do
+ expect(page).to have_no_selector('.js-reply-button')
+ end
+ end
+
+ describe 'when reply_to_individual_notes feature flag is set' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ visit project_merge_request_path(project, merge_request)
+ end
+
+ it 'shows a reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ expect(reply_button).to have_selector('.ic-comment')
+ end
+
+ it 'shows reply placeholder when clicking reply button' do
+ reply_button = find('.js-reply-button', match: :first)
+
+ reply_button.click
+
+ expect(page).to have_selector('.discussion-reply-holder')
+ end
+ end
end
describe 'when previewing a note' do
diff --git a/spec/features/merge_requests/user_squashes_merge_request_spec.rb b/spec/features/merge_requests/user_squashes_merge_request_spec.rb
index 47f9f10815c..bf9c55cf22c 100644
--- a/spec/features/merge_requests/user_squashes_merge_request_spec.rb
+++ b/spec/features/merge_requests/user_squashes_merge_request_spec.rb
@@ -14,7 +14,7 @@ describe 'User squashes a merge request', :js do
latest_master_commits = project.repository.commits_between(original_head.sha, 'master').map(&:raw)
squash_commit = an_object_having_attributes(sha: a_string_matching(/\h{40}/),
- message: "Csv\n",
+ message: a_string_starting_with(project.merge_requests.first.default_squash_commit_message),
author_name: user.name,
committer_name: user.name)
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index 766c63725b3..aa71669de98 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -1,6 +1,10 @@
+# frozen_string_literal: true
+
require 'spec_helper'
describe 'Functions', :js do
+ include KubernetesHelpers
+
let(:project) { create(:project) }
let(:user) { create(:user) }
@@ -34,11 +38,14 @@ describe 'Functions', :js do
end
context 'when the user has a cluster and knative installed and visits the serverless page' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:project) { knative.cluster.project }
before do
+ stub_kubeclient_knative_services
+ stub_kubeclient_service_pods
visit project_serverless_functions_path(project)
end
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 3708f0ee477..3db9ae7a951 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -34,7 +34,7 @@ describe 'Overview tab on a user profile', :js do
it 'does not show any entries in the list of activities' do
page.within('.activities-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('No activities found')
+ expect(page).to have_content('Join or create a group to start contributing by commenting on issues or submitting merge requests!')
expect(page).not_to have_selector('.event-item')
end
end
@@ -96,7 +96,7 @@ describe 'Overview tab on a user profile', :js do
it 'it shows an empty project list with an info message' do
page.within('.projects-block') do
expect(page).to have_selector('.loading', visible: false)
- expect(page).to have_content('This user doesn\'t have any personal projects')
+ expect(page).to have_content('You haven\'t created any personal projects.')
expect(page).not_to have_selector('.project-row')
end
end
diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json
index 1bd39a46830..67c209f3fc3 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json
@@ -44,7 +44,7 @@
"merge_user": { "type": ["object", "null"] },
"diff_head_sha": { "type": ["string", "null"] },
"diff_head_commit_short_id": { "type": ["string", "null"] },
- "merge_commit_message": { "type": ["string", "null"] },
+ "default_merge_commit_message": { "type": ["string", "null"] },
"pipeline": { "type": ["object", "null"] },
"merge_pipeline": { "type": ["object", "null"] },
"work_in_progress": { "type": "boolean" },
@@ -102,7 +102,9 @@
"new_blob_path": { "type": ["string", "null"] },
"merge_check_path": { "type": "string" },
"ci_environments_status_path": { "type": "string" },
- "merge_commit_message_with_description": { "type": "string" },
+ "default_merge_commit_message_with_description": { "type": "string" },
+ "default_squash_commit_message": { "type": "string" },
+ "commits_without_merge_commits": { "type": "array" },
"diverged_commits_count": { "type": "integer" },
"commit_change_content_path": { "type": "string" },
"merge_commit_path": { "type": ["string", "null"] },
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index ab67a5ab847..f3649495493 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -100,4 +100,72 @@ describe UsersHelper do
end
end
end
+
+ describe '#user_badges_in_admin_section' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'with a blocked user' do
+ it "returns the blocked badge" do
+ blocked_user = create(:user, state: 'blocked')
+
+ badges = helper.user_badges_in_admin_section(blocked_user)
+
+ expect(badges).to eq([text: "Blocked", variant: "danger"])
+ end
+ end
+
+ context 'with an admin user' do
+ it "returns the admin badge" do
+ admin_user = create(:admin)
+
+ badges = helper.user_badges_in_admin_section(admin_user)
+
+ expect(badges).to eq([text: "Admin", variant: "success"])
+ end
+ end
+
+ context 'with an external user' do
+ it 'returns the external badge' do
+ external_user = create(:user, external: true)
+
+ badges = helper.user_badges_in_admin_section(external_user)
+
+ expect(badges).to eq([text: "External", variant: "secondary"])
+ end
+ end
+
+ context 'with the current user' do
+ it 'returns the "It\'s You" badge' do
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to eq([text: "It's you!", variant: nil])
+ end
+ end
+
+ context 'with an external blocked admin' do
+ it 'returns the blocked, admin and external badges' do
+ user = create(:admin, state: 'blocked', external: true)
+
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to eq([
+ { text: "Blocked", variant: "danger" },
+ { text: "Admin", variant: "success" },
+ { text: "External", variant: "secondary" }
+ ])
+ end
+ end
+
+ context 'get badges for normal user' do
+ it 'returns no badges' do
+ user = create(:user)
+
+ badges = helper.user_badges_in_admin_section(user)
+
+ expect(badges).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js
index 6179a02ce16..ca849f75860 100644
--- a/spec/javascripts/behaviors/copy_as_gfm_spec.js
+++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js
@@ -1,4 +1,4 @@
-import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
+import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
describe('CopyAsGFM', () => {
describe('CopyAsGFM.pasteGFM', () => {
@@ -79,27 +79,46 @@ describe('CopyAsGFM', () => {
return clipboardData;
};
+ beforeAll(done => {
+ initCopyAsGFM();
+
+ // Fake call to nodeToGfm so the import of lazy bundle happened
+ CopyAsGFM.nodeToGFM(document.createElement('div'))
+ .then(() => {
+ done();
+ })
+ .catch(done.fail);
+ });
+
beforeEach(() => spyOn(clipboardData, 'setData'));
describe('list handling', () => {
- it('uses correct gfm for unordered lists', () => {
+ it('uses correct gfm for unordered lists', done => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'UL');
+
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '* List Item1\n\n* List Item2';
+ setTimeout(() => {
+ const expectedGFM = '* List Item1\n\n* List Item2';
- expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ done();
+ });
});
- it('uses correct gfm for ordered lists', () => {
+ it('uses correct gfm for ordered lists', done => {
const selection = stubSelection('<li>List Item1</li><li>List Item2</li>\n', 'OL');
+
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '1. List Item1\n\n1. List Item2';
+ setTimeout(() => {
+ const expectedGFM = '1. List Item1\n\n1. List Item2';
- expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
index fe827bb1e18..4843a0386b5 100644
--- a/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -3,17 +3,26 @@
*/
import $ from 'jquery';
-import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm';
+import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
-initCopyAsGFM();
-
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
describe('ShortcutsIssuable', function() {
const fixtureName = 'snippets/show.html.raw';
preloadFixtures(fixtureName);
+ beforeAll(done => {
+ initCopyAsGFM();
+
+ // Fake call to nodeToGfm so the import of lazy bundle happened
+ CopyAsGFM.nodeToGFM(document.createElement('div'))
+ .then(() => {
+ done();
+ })
+ .catch(done.fail);
+ });
+
beforeEach(() => {
loadFixtures(fixtureName);
$('body').append(
@@ -63,17 +72,22 @@ describe('ShortcutsIssuable', function() {
stubSelection('<p>Selected text.</p>');
});
- it('leaves existing input intact', () => {
+ it('leaves existing input intact', done => {
$(FORM_SELECTOR).val('This text was already here.');
expect($(FORM_SELECTOR).val()).toBe('This text was already here.');
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe(
+ 'This text was already here.\n\n> Selected text.\n\n',
+ );
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -81,36 +95,48 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
describe('with a one-line selection', () => {
- it('quotes the selection', () => {
+ it('quotes the selection', done => {
stubSelection('<p>This text has been selected.</p>');
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> This text has been selected.\n\n');
+ done();
+ });
});
});
describe('with a multi-line selection', () => {
- it('quotes the selected lines as a group', () => {
+ it('quotes the selected lines as a group', done => {
stubSelection(
'<p>Selected line one.</p>\n<p>Selected line two.</p>\n<p>Selected line three.</p>',
);
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe(
- '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n',
- );
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe(
+ '> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n',
+ );
+ done();
+ });
});
});
@@ -119,17 +145,23 @@ describe('ShortcutsIssuable', function() {
stubSelection('<p>Selected text.</p>', true);
});
- it('does not add anything to the input', () => {
+ it('does not add anything to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
@@ -138,20 +170,26 @@ describe('ShortcutsIssuable', function() {
stubSelection('<div class="md">Selected text.</div><p>Invalid selected text.</p>', true);
});
- it('only adds the valid part to the input', () => {
+ it('only adds the valid part to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> Selected text.\n\n');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -159,7 +197,10 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
});
@@ -183,20 +224,26 @@ describe('ShortcutsIssuable', function() {
});
});
- it('adds the quoted selection to the input', () => {
+ it('adds the quoted selection to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('> *Selected text.*\n\n');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
- it('triggers `input`', () => {
+ it('triggers `input`', done => {
let triggered = false;
$(FORM_SELECTOR).on('input', () => {
triggered = true;
@@ -204,7 +251,10 @@ describe('ShortcutsIssuable', function() {
ShortcutsIssuable.replyWithSelectedText(true);
- expect(triggered).toBe(true);
+ setTimeout(() => {
+ expect(triggered).toBe(true);
+ done();
+ });
});
});
@@ -228,17 +278,23 @@ describe('ShortcutsIssuable', function() {
});
});
- it('does not add anything to the input', () => {
+ it('does not add anything to the input', done => {
ShortcutsIssuable.replyWithSelectedText(true);
- expect($(FORM_SELECTOR).val()).toBe('');
+ setTimeout(() => {
+ expect($(FORM_SELECTOR).val()).toBe('');
+ done();
+ });
});
- it('triggers `focus`', () => {
+ it('triggers `focus`', done => {
const spy = spyOn(document.querySelector(FORM_SELECTOR), 'focus');
ShortcutsIssuable.replyWithSelectedText(true);
- expect(spy).toHaveBeenCalled();
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled();
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/notes/components/note_actions/reply_button_spec.js b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
new file mode 100644
index 00000000000..11e1664a3f4
--- /dev/null
+++ b/spec/javascripts/notes/components/note_actions/reply_button_spec.js
@@ -0,0 +1,46 @@
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+
+describe('ReplyButton', () => {
+ const noteId = 'dummy-note-id';
+
+ let wrapper;
+ let convertToDiscussion;
+
+ beforeEach(() => {
+ const localVue = createLocalVue();
+ convertToDiscussion = jasmine.createSpy('convertToDiscussion');
+
+ localVue.use(Vuex);
+ const store = new Vuex.Store({
+ actions: {
+ convertToDiscussion,
+ },
+ });
+
+ wrapper = mount(ReplyButton, {
+ propsData: {
+ noteId,
+ },
+ store,
+ sync: false,
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('dispatches convertToDiscussion with note ID on click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(convertToDiscussion).toHaveBeenCalledTimes(1);
+ const [, payload] = convertToDiscussion.calls.argsFor(0);
+
+ expect(payload).toBe(noteId);
+ });
+});
diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js
index b102b7aecf7..0c1962912b4 100644
--- a/spec/javascripts/notes/components/note_actions_spec.js
+++ b/spec/javascripts/notes/components/note_actions_spec.js
@@ -2,14 +2,38 @@ import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createStore from '~/notes/stores';
import noteActions from '~/notes/components/note_actions.vue';
+import { TEST_HOST } from 'spec/test_constants';
import { userDataMock } from '../mock_data';
describe('noteActions', () => {
let wrapper;
let store;
+ let props;
+
+ const createWrapper = propsData => {
+ const localVue = createLocalVue();
+ return shallowMount(noteActions, {
+ store,
+ propsData,
+ localVue,
+ sync: false,
+ });
+ };
beforeEach(() => {
store = createStore();
+ props = {
+ accessLevel: 'Maintainer',
+ authorId: 26,
+ canDelete: true,
+ canEdit: true,
+ canAwardEmoji: true,
+ canReportAsAbuse: true,
+ noteId: '539',
+ noteUrl: `${TEST_HOST}/group/project/merge_requests/1#note_1`,
+ reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
+ showReply: false,
+ };
});
afterEach(() => {
@@ -17,31 +41,10 @@ describe('noteActions', () => {
});
describe('user is logged in', () => {
- let props;
-
beforeEach(() => {
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
- canDelete: true,
- canEdit: true,
- canAwardEmoji: true,
- canReportAsAbuse: true,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- 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);
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
- });
+ wrapper = createWrapper(props);
});
it('should render access level badge', () => {
@@ -91,28 +94,14 @@ describe('noteActions', () => {
});
describe('user is not logged in', () => {
- let props;
-
beforeEach(() => {
store.dispatch('setUserData', {});
- props = {
- accessLevel: 'Maintainer',
- authorId: 26,
+ wrapper = createWrapper({
+ ...props,
canDelete: false,
canEdit: false,
canAwardEmoji: false,
canReportAsAbuse: false,
- noteId: '539',
- noteUrl: 'https://localhost:3000/group/project/merge_requests/1#note_1',
- reportAbusePath:
- '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
- };
- const localVue = createLocalVue();
- wrapper = shallowMount(noteActions, {
- store,
- propsData: props,
- localVue,
- sync: false,
});
});
@@ -124,4 +113,88 @@ describe('noteActions', () => {
expect(wrapper.find('.more-actions').exists()).toBe(false);
});
});
+
+ describe('with feature flag replyToIndividualNotes enabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: true,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('shows a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(true);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with feature flag replyToIndividualNotes disabled', () => {
+ beforeEach(() => {
+ gon.features = {
+ replyToIndividualNotes: false,
+ };
+ });
+
+ afterEach(() => {
+ gon.features = {};
+ });
+
+ describe('for showReply = true', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: true,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+
+ describe('for showReply = false', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ ...props,
+ showReply: false,
+ });
+ });
+
+ it('does not show a reply button', () => {
+ const replyButton = wrapper.find({ ref: 'replyButton' });
+
+ expect(replyButton.exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 2e3cd5e8f36..73f960dd21e 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -585,4 +585,18 @@ describe('Actions Notes Store', () => {
);
});
});
+
+ describe('convertToDiscussion', () => {
+ it('commits CONVERT_TO_DISCUSSION with noteId', done => {
+ const noteId = 'dummy-note-id';
+ testAction(
+ actions.convertToDiscussion,
+ noteId,
+ {},
+ [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }],
+ [],
+ done,
+ );
+ });
+ });
});
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
index b6b2c7d60a5..4f8d3069bb5 100644
--- a/spec/javascripts/notes/stores/mutation_spec.js
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -517,4 +517,27 @@ describe('Notes Store mutations', () => {
);
});
});
+
+ describe('CONVERT_TO_DISCUSSION', () => {
+ let discussion;
+ let state;
+
+ beforeEach(() => {
+ discussion = {
+ id: 42,
+ individual_note: true,
+ };
+ state = { discussions: [discussion] };
+ });
+
+ it('toggles individual_note', () => {
+ mutations.CONVERT_TO_DISCUSSION(state, discussion.id);
+
+ expect(discussion.individual_note).toBe(false);
+ });
+
+ it('throws if discussion was not found', () => {
+ expect(() => mutations.CONVERT_TO_DISCUSSION(state, 99)).toThrow();
+ });
+ });
});
diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/javascripts/serverless/components/environment_row_spec.js
new file mode 100644
index 00000000000..bdf7a714910
--- /dev/null
+++ b/spec/javascripts/serverless/components/environment_row_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+
+import environmentRowComponent from '~/serverless/components/environment_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+const createComponent = (env, envName) =>
+ mountComponent(Vue.extend(environmentRowComponent), { env, envName });
+
+describe('environment row component', () => {
+ describe('default global cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ vm = createComponent(store.state.functions['*'], '*');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-global');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(2);
+ expect(vm.$el.id).toEqual('env-global');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
+
+ vm.$destroy();
+ });
+
+ it('opens and closes correctly', () => {
+ expect(vm.isOpen).toBe(true);
+
+ vm.toggleOpen();
+ Vue.nextTick(() => {
+ expect(vm.isOpen).toBe(false);
+ });
+
+ vm.$destroy();
+ });
+ });
+
+ describe('default named cluster case', () => {
+ let vm;
+
+ beforeEach(() => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
+ vm = createComponent(store.state.functions.test, 'test');
+ });
+
+ it('has the correct envId', () => {
+ expect(vm.envId).toEqual('env-test');
+ vm.$destroy();
+ });
+
+ it('is open by default', () => {
+ expect(vm.isOpenClass).toEqual({ 'is-open': true });
+ vm.$destroy();
+ });
+
+ it('generates correct output', () => {
+ expect(vm.$el.querySelectorAll('li').length).toEqual(1);
+ expect(vm.$el.id).toEqual('env-test');
+ expect(vm.$el.classList.contains('is-open')).toBe(true);
+ expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
+
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/javascripts/serverless/components/function_row_spec.js
new file mode 100644
index 00000000000..6933a8f6c87
--- /dev/null
+++ b/spec/javascripts/serverless/components/function_row_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+
+import functionRowComponent from '~/serverless/components/function_row.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+import { mockServerlessFunction } from '../mock_data';
+
+const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
+
+describe('functionRowComponent', () => {
+ it('Parses the function details correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
+ expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
+ expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
+ expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
+ mockServerlessFunction.url,
+ );
+
+ vm.$destroy();
+ });
+
+ it('handles clicks correctly', () => {
+ const vm = createComponent(mockServerlessFunction);
+
+ expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
+ expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
+ expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js
new file mode 100644
index 00000000000..85cfe71281f
--- /dev/null
+++ b/spec/javascripts/serverless/components/functions_spec.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import functionsComponent from '~/serverless/components/functions.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import ServerlessStore from '~/serverless/stores/serverless_store';
+
+import { mockServerlessFunctions } from '../mock_data';
+
+const createComponent = (
+ functions,
+ installed = true,
+ loadingData = true,
+ hasFunctionData = true,
+) => {
+ const component = Vue.extend(functionsComponent);
+
+ return mountComponent(component, {
+ functions,
+ installed,
+ clustersPath: '/testClusterPath',
+ helpPath: '/helpPath',
+ loadingData,
+ hasFunctionData,
+ });
+};
+
+describe('functionsComponent', () => {
+ it('should render empty state when Knative is not installed', () => {
+ const vm = createComponent({}, false);
+
+ expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'Getting started with serverless',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render a loading component', () => {
+ const vm = createComponent({});
+
+ expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
+ expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
+ });
+
+ it('should render empty state when there is no function data', () => {
+ const vm = createComponent({}, true, false, false);
+
+ expect(
+ vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
+ ).toBe(true);
+
+ expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
+ 'No functions available',
+ );
+
+ vm.$destroy();
+ });
+
+ it('should render the functions list', () => {
+ const store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ store.updateFunctionsFromServer(mockServerlessFunctions);
+ const vm = createComponent(store.state.functions, true, false);
+
+ expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
+ expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
+ });
+});
diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/javascripts/serverless/components/url_spec.js
new file mode 100644
index 00000000000..21a879a49bb
--- /dev/null
+++ b/spec/javascripts/serverless/components/url_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+
+import urlComponent from '~/serverless/components/url.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const createComponent = uri => {
+ const component = Vue.extend(urlComponent);
+
+ return mountComponent(component, {
+ uri,
+ });
+};
+
+describe('urlComponent', () => {
+ it('should render correctly', () => {
+ const uri = 'http://testfunc.apps.example.com';
+ const vm = createComponent(uri);
+
+ expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
+ expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
+ uri,
+ );
+
+ expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/mock_data.js b/spec/javascripts/serverless/mock_data.js
new file mode 100644
index 00000000000..ecd393b174c
--- /dev/null
+++ b/spec/javascripts/serverless/mock_data.js
@@ -0,0 +1,79 @@
+export const mockServerlessFunctions = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunctionsDiffEnv = [
+ {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+ },
+ {
+ name: 'testfunc2',
+ namespace: 'tm-example',
+ environment_scope: 'test',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc2',
+ podcount: null,
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc2.tm-example.apps.example.com',
+ description: 'A second test service\nThis one with additional descriptions',
+ image: 'knative-test-echo-buildtemplate',
+ },
+];
+
+export const mockServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'A test service',
+ image: 'knative-test-container-buildtemplate',
+};
+
+export const mockMultilineServerlessFunction = {
+ name: 'testfunc1',
+ namespace: 'tm-example',
+ environment_scope: '*',
+ cluster_id: 46,
+ detail_url: '/testuser/testproj/serverless/functions/*/testfunc1',
+ podcount: '3',
+ created_at: '2019-02-05T01:01:23Z',
+ url: 'http://testfunc1.tm-example.apps.example.com',
+ description: 'testfunc1\nA test service line\\nWith additional services',
+ image: 'knative-test-container-buildtemplate',
+};
diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js
new file mode 100644
index 00000000000..72fd903d7d1
--- /dev/null
+++ b/spec/javascripts/serverless/stores/serverless_store_spec.js
@@ -0,0 +1,36 @@
+import ServerlessStore from '~/serverless/stores/serverless_store';
+import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+
+describe('Serverless Functions Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new ServerlessStore(false, '/cluster_path', 'help_path');
+ });
+
+ describe('#updateFunctionsFromServer', () => {
+ it('should pass an empty hash object', () => {
+ store.updateFunctionsFromServer();
+
+ expect(store.state.functions).toEqual({});
+ });
+
+ it('should group functions to one global environment', () => {
+ const mockServerlessData = mockServerlessFunctions;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(2);
+ });
+
+ it('should group functions to multiple environments', () => {
+ const mockServerlessData = mockServerlessFunctionsDiffEnv;
+ store.updateFunctionsFromServer(mockServerlessData);
+
+ expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
+ expect(store.state.functions['*'].length).toEqual(1);
+ expect(store.state.functions.test.length).toEqual(1);
+ expect(store.state.functions.test[0].name).toEqual('testfunc2');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index 072e98fc0e8..75b197fb2ba 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -58,7 +58,7 @@ export default {
merge_user: null,
diff_head_sha: '104096c51715e12e7ae41f9333e9fa35b73f385d',
diff_head_commit_short_id: '104096c5',
- merge_commit_message:
+ default_merge_commit_message:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
pipeline: {
id: 172,
@@ -213,7 +213,7 @@ export default {
merge_check_path: '/root/acets-app/merge_requests/22/merge_check',
ci_environments_status_url: '/root/acets-app/merge_requests/22/ci_environments_status',
project_archived: false,
- merge_commit_message_with_description:
+ default_merge_commit_message_with_description:
"Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
diverged_commits_count: 0,
only_allow_merge_if_pipeline_succeeds: false,
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index e5420ea6bea..50e473c459e 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -155,11 +155,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
it_behaves_like "checks permissions on noteable"
end
- context "when everything is fine" do
- before do
- setup_attachment
- end
-
+ shared_examples 'a reply to existing comment' do
it "creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last
@@ -168,7 +164,21 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.")
expect(new_note.in_reply_to?(note)).to be_truthy
+
+ if note.part_of_discussion?
+ expect(new_note.discussion_id).to eq(note.discussion_id)
+ else
+ expect(new_note.discussion_id).not_to eq(note.discussion_id)
+ end
end
+ end
+
+ context "when everything is fine" do
+ before do
+ setup_attachment
+ end
+
+ it_behaves_like 'a reply to existing comment'
it "adds all attachments" do
receiver.execute
@@ -207,4 +217,10 @@ describe Gitlab::Email::Handler::CreateNoteHandler do
end
end
end
+
+ context "when note is not a discussion" do
+ let(:note) { create(:note_on_merge_request, project: project) }
+
+ it_behaves_like 'a reply to existing comment'
+ end
end
diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb
index 8e14abe098d..79a06c35459 100644
--- a/spec/models/clusters/applications/cert_manager_spec.rb
+++ b/spec/models/clusters/applications/cert_manager_spec.rb
@@ -4,20 +4,8 @@ describe Clusters::Applications::CertManager do
let(:cert_manager) { create(:clusters_applications_cert_managers) }
include_examples 'cluster application core specs', :clusters_applications_cert_managers
-
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_cert_managers, :scheduled, version: 'v0.4.0') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('v0.5.2')
- end
- end
- end
+ include_examples 'cluster application status specs', :clusters_applications_cert_managers
+ include_examples 'cluster application initial status specs'
describe '#install_command' do
let(:cluster_issuer_file) { { "cluster_issuer.yaml": "---\napiVersion: certmanager.k8s.io/v1alpha1\nkind: ClusterIssuer\nmetadata:\n name: letsencrypt-prod\nspec:\n acme:\n server: https://acme-v02.api.letsencrypt.org/directory\n email: admin@example.com\n privateKeySecretRef:\n name: letsencrypt-prod\n http01: {}\n" } }
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index 52c347229c6..6d48131d1cc 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -8,6 +8,7 @@ describe Clusters::Applications::Ingress do
include_examples 'cluster application core specs', :clusters_applications_ingress
include_examples 'cluster application status specs', :clusters_applications_ingress
include_examples 'cluster application helm specs', :clusters_applications_ingress
+ include_examples 'cluster application initial status specs'
before do
allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
@@ -26,20 +27,6 @@ describe Clusters::Applications::Ingress do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_ingress, :scheduled, version: '0.22.0') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('1.1.2')
- end
- end
- end
-
describe '#make_installed!' do
before do
application.make_installed!
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index 391e5425384..b73a243f6e0 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -2,6 +2,7 @@ require 'rails_helper'
describe Clusters::Applications::Jupyter do
include_examples 'cluster application core specs', :clusters_applications_jupyter
+ include_examples 'cluster application status specs', :clusters_applications_jupyter
include_examples 'cluster application helm specs', :clusters_applications_jupyter
it { is_expected.to belong_to(:oauth_application) }
@@ -26,20 +27,6 @@ describe Clusters::Applications::Jupyter do
end
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_jupyter, :scheduled, version: 'v0.5') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('v0.6')
- end
- end
- end
-
describe '#install_command' do
let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
let!(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb
index 35818be8deb..5519615d52d 100644
--- a/spec/models/clusters/applications/knative_spec.rb
+++ b/spec/models/clusters/applications/knative_spec.rb
@@ -9,6 +9,7 @@ describe Clusters::Applications::Knative do
include_examples 'cluster application core specs', :clusters_applications_knative
include_examples 'cluster application status specs', :clusters_applications_knative
include_examples 'cluster application helm specs', :clusters_applications_knative
+ include_examples 'cluster application initial status specs'
before do
allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in)
@@ -34,20 +35,6 @@ describe Clusters::Applications::Knative do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_knative, :scheduled, version: '0.2.2') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('0.2.2')
- end
- end
- end
-
describe '#make_installed' do
subject { described_class.installed }
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index e50ba67c493..073fbded8ac 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -6,6 +6,7 @@ describe Clusters::Applications::Prometheus do
include_examples 'cluster application core specs', :clusters_applications_prometheus
include_examples 'cluster application status specs', :clusters_applications_prometheus
include_examples 'cluster application helm specs', :clusters_applications_prometheus
+ include_examples 'cluster application initial status specs'
describe '.installed' do
subject { described_class.installed }
@@ -19,20 +20,6 @@ describe Clusters::Applications::Prometheus do
it { is_expected.to contain_exactly(cluster) }
end
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_prometheus, :scheduled, version: '6.7.2') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('6.7.3')
- end
- end
- end
-
describe 'transition to installed' do
let(:project) { create(:project) }
let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) }
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index 8ad41e997c2..96b7b02dbaf 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -6,23 +6,10 @@ describe Clusters::Applications::Runner do
include_examples 'cluster application core specs', :clusters_applications_runner
include_examples 'cluster application status specs', :clusters_applications_runner
include_examples 'cluster application helm specs', :clusters_applications_runner
+ include_examples 'cluster application initial status specs'
it { is_expected.to belong_to(:runner) }
- describe '#make_installing!' do
- before do
- application.make_installing!
- end
-
- context 'application install previously errored with older version' do
- let(:application) { create(:clusters_applications_runner, :scheduled, version: '0.1.30') }
-
- it 'updates the application version' do
- expect(application.reload.version).to eq('0.1.45')
- end
- end
- end
-
describe '.installed' do
subject { described_class.installed }
diff --git a/spec/models/commit_collection_spec.rb b/spec/models/commit_collection_spec.rb
index 005005b236b..12e59b35428 100644
--- a/spec/models/commit_collection_spec.rb
+++ b/spec/models/commit_collection_spec.rb
@@ -35,6 +35,17 @@ describe CommitCollection do
end
end
+ describe '#without_merge_commits' do
+ it 'returns all commits except merge commits' do
+ collection = described_class.new(project, [
+ build(:commit),
+ build(:commit, :merge_commit)
+ ])
+
+ expect(collection.without_merge_commits.size).to eq(1)
+ end
+ end
+
describe '#with_pipeline_status' do
it 'sets the pipeline status for every commit so no additional queries are necessary' do
create(
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 9a3f1f1c5a1..2d554326f05 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -41,6 +41,76 @@ describe Environment do
end
end
+ describe '.for_name_like' do
+ subject { project.environments.for_name_like(query, limit: limit) }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+ let(:query) { 'pro' }
+ let(:limit) { 5 }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+
+ context 'when query is production' do
+ let(:query) { 'production' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is productionA' do
+ let(:query) { 'productionA' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query is empty' do
+ let(:query) { '' }
+
+ it 'returns a found name' do
+ is_expected.to include(environment)
+ end
+ end
+
+ context 'when query is nil' do
+ let(:query) { }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(NoMethodError)
+ end
+ end
+
+ context 'when query is partially matched in the middle of environment name' do
+ let(:query) { 'duction' }
+
+ it 'returns empty array' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when query contains a wildcard character' do
+ let(:query) { 'produc%' }
+
+ it 'prevents wildcard injection' do
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ describe '.pluck_names' do
+ subject { described_class.pluck_names }
+
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+
+ it 'plucks names' do
+ is_expected.to eq(%w[production])
+ end
+ end
+
describe '#expire_etag_cache' do
let(:store) { Gitlab::EtagCaching::Store.new }
diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb
index 33e984dc399..1849d3bac12 100644
--- a/spec/models/merge_request_diff_spec.rb
+++ b/spec/models/merge_request_diff_spec.rb
@@ -46,7 +46,7 @@ describe MergeRequestDiff do
it { expect(first_diff.reload).not_to be_latest }
end
- describe '#diffs' do
+ shared_examples_for 'merge request diffs' do
let(:merge_request) { create(:merge_request, :with_diffs) }
let!(:diff) { merge_request.merge_request_diff.reload }
@@ -91,98 +91,110 @@ describe MergeRequestDiff do
diff.diffs.diff_files
end
end
- end
- describe '#raw_diffs' do
- context 'when the :ignore_whitespace_change option is set' do
- it 'creates a new compare object instead of loading from the DB' do
- expect(diff_with_commits).not_to receive(:load_diffs)
- expect(diff_with_commits.compare).to receive(:diffs).and_call_original
+ describe '#raw_diffs' do
+ context 'when the :ignore_whitespace_change option is set' do
+ it 'creates a new compare object instead of using preprocessed data' do
+ expect(diff_with_commits).not_to receive(:load_diffs)
+ expect(diff_with_commits.compare).to receive(:diffs).and_call_original
- diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ diff_with_commits.raw_diffs(ignore_whitespace_change: true)
+ end
end
- end
- context 'when the raw diffs are empty' do
- before do
- MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
- end
+ context 'when the raw diffs are empty' do
+ before do
+ MergeRequestDiffFile.where(merge_request_diff_id: diff_with_commits.id).delete_all
+ end
- it 'returns an empty DiffCollection' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).to be_empty
+ it 'returns an empty DiffCollection' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).to be_empty
+ end
end
- end
- context 'when the raw diffs exist' do
- it 'returns the diffs' do
- expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
- expect(diff_with_commits.raw_diffs).not_to be_empty
- end
+ context 'when the raw diffs exist' do
+ it 'returns the diffs' do
+ expect(diff_with_commits.raw_diffs).to be_a(Gitlab::Git::DiffCollection)
+ expect(diff_with_commits.raw_diffs).not_to be_empty
+ end
- context 'when the :paths option is set' do
- let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
+ context 'when the :paths option is set' do
+ let(:diffs) { diff_with_commits.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) }
- it 'only returns diffs that match the (old path, new path) given' do
- expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
- end
+ it 'only returns diffs that match the (old path, new path) given' do
+ expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb')
+ end
- it 'only serializes diff files found by query' do
- expect(diff_with_commits.merge_request_diff_files.count).to be > 10
- expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
+ it 'only serializes diff files found by query' do
+ expect(diff_with_commits.merge_request_diff_files.count).to be > 10
+ expect_any_instance_of(MergeRequestDiffFile).to receive(:to_hash).once
- diffs
- end
+ diffs
+ end
- it 'uses the diffs from the DB' do
- expect(diff_with_commits).to receive(:load_diffs)
+ it 'uses the preprocessed diffs' do
+ expect(diff_with_commits).to receive(:load_diffs)
- diffs
+ diffs
+ end
end
end
end
- end
- describe '#save_diffs' do
- it 'saves collected state' do
- mr_diff = create(:merge_request).merge_request_diff
+ describe '#save_diffs' do
+ it 'saves collected state' do
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.collected?).to be_truthy
- end
+ expect(mr_diff.collected?).to be_truthy
+ end
- it 'saves overflow state' do
- allow(Commit).to receive(:max_diff_options)
- .and_return(max_lines: 0, max_files: 0)
+ it 'saves overflow state' do
+ allow(Commit).to receive(:max_diff_options)
+ .and_return(max_lines: 0, max_files: 0)
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.overflow?).to be_truthy
- end
+ expect(mr_diff.overflow?).to be_truthy
+ end
- it 'saves empty state' do
- allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
- .and_return([])
+ it 'saves empty state' do
+ allow_any_instance_of(described_class).to receive_message_chain(:compare, :commits)
+ .and_return([])
- mr_diff = create(:merge_request).merge_request_diff
+ mr_diff = create(:merge_request).merge_request_diff
- expect(mr_diff.empty?).to be_truthy
- end
+ expect(mr_diff.empty?).to be_truthy
+ end
- it 'expands collapsed diffs before saving' do
- mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
+ it 'expands collapsed diffs before saving' do
+ mr_diff = create(:merge_request, source_branch: 'expand-collapse-lines', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: 'expand-collapse/file-5.txt')
- expect(diff_file.diff).not_to be_empty
+ expect(diff_file.diff).not_to be_empty
+ end
+
+ it 'saves binary diffs correctly' do
+ path = 'files/images/icn-time-tracking.pdf'
+ mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
+ diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+
+ expect(diff_file).to be_binary
+ expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ end
end
+ end
- it 'saves binary diffs correctly' do
- path = 'files/images/icn-time-tracking.pdf'
- mr_diff = create(:merge_request, source_branch: 'add-pdf-text-binary', target_branch: 'master').merge_request_diff
- diff_file = mr_diff.merge_request_diff_files.find_by(new_path: path)
+ describe 'internal diffs configured' do
+ include_examples 'merge request diffs'
+ end
- expect(diff_file).to be_binary
- expect(diff_file.diff).to eq(mr_diff.compare.diffs(paths: [path]).to_a.first.diff)
+ describe 'external diffs configured' do
+ before do
+ stub_external_diffs_setting(enabled: true)
end
+
+ include_examples 'merge request diffs'
end
describe '#commit_shas' do
@@ -245,4 +257,55 @@ describe MergeRequestDiff do
expect(subject.modified_paths).to eq(%w{foo bar baz})
end
end
+
+ describe '#opening_external_diff' do
+ subject(:diff) { diff_with_commits }
+
+ context 'external diffs disabled' do
+ it { expect(diff.external_diff).not_to be_exists }
+
+ it 'yields nil' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(nil)
+ end
+ end
+
+ context 'external diffs enabled' do
+ let(:test_dir) { 'tmp/tests/external-diffs' }
+
+ around do |example|
+ FileUtils.mkdir_p(test_dir)
+
+ begin
+ example.run
+ ensure
+ FileUtils.rm_rf(test_dir)
+ end
+ end
+
+ before do
+ stub_external_diffs_setting(enabled: true, storage_path: test_dir)
+ end
+
+ it { expect(diff.external_diff).to be_exists }
+
+ it 'yields an open file' do
+ expect { |b| diff.opening_external_diff(&b) }.to yield_with_args(File)
+ end
+
+ it 'is re-entrant' do
+ outer_file_a =
+ diff.opening_external_diff do |outer_file|
+ diff.opening_external_diff do |inner_file|
+ expect(outer_file).to eq(inner_file)
+ end
+
+ outer_file
+ end
+
+ diff.opening_external_diff do |outer_file_b|
+ expect(outer_file_a).not_to eq(outer_file_b)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index b62f973ad1e..afa87b8a62d 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -82,6 +82,38 @@ describe MergeRequest do
end
end
+ describe '#default_squash_commit_message' do
+ let(:project) { subject.project }
+
+ def commit_collection(commit_hashes)
+ raw_commits = commit_hashes.map { |raw| Commit.from_hash(raw, project) }
+
+ CommitCollection.new(project, raw_commits)
+ end
+
+ it 'returns the oldest multiline commit message' do
+ commits = commit_collection([
+ { message: 'Singleline', parent_ids: [] },
+ { message: "Second multiline\nCommit message", parent_ids: [] },
+ { message: "First multiline\nCommit message", parent_ids: [] }
+ ])
+
+ expect(subject).to receive(:commits).and_return(commits)
+
+ expect(subject.default_squash_commit_message).to eq("First multiline\nCommit message")
+ end
+
+ it 'returns the merge request title if there are no multiline commits' do
+ commits = commit_collection([
+ { message: 'Singleline', parent_ids: [] }
+ ])
+
+ expect(subject).to receive(:commits).and_return(commits)
+
+ expect(subject.default_squash_commit_message).to eq(subject.title)
+ end
+ end
+
describe 'modules' do
subject { described_class }
@@ -920,18 +952,18 @@ describe MergeRequest do
end
end
- describe '#merge_commit_message' do
+ describe '#default_merge_commit_message' do
it 'includes merge information as the title' do
request = build(:merge_request, source_branch: 'source', target_branch: 'target')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("Merge branch 'source' into 'target'\n\n")
end
it 'includes its title in the body' do
request = build(:merge_request, title: 'Remove all technical debt')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("Remove all technical debt\n\n")
end
@@ -943,34 +975,34 @@ describe MergeRequest do
allow(subject.project).to receive(:default_branch).and_return(subject.target_branch)
subject.cache_merge_request_closes_issues!
- expect(subject.merge_commit_message)
+ expect(subject.default_merge_commit_message)
.to match("Closes #{issue.to_reference}")
end
it 'includes its reference in the body' do
request = build_stubbed(:merge_request)
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.to match("See merge request #{request.to_reference(full: true)}")
end
it 'excludes multiple linebreak runs when description is blank' do
request = build(:merge_request, title: 'Title', description: nil)
- expect(request.merge_commit_message).not_to match("Title\n\n\n\n")
+ expect(request.default_merge_commit_message).not_to match("Title\n\n\n\n")
end
it 'includes its description in the body' do
request = build(:merge_request, description: 'By removing all code')
- expect(request.merge_commit_message(include_description: true))
+ expect(request.default_merge_commit_message(include_description: true))
.to match("By removing all code\n\n")
end
it 'does not includes its description in the body' do
request = build(:merge_request, description: 'By removing all code')
- expect(request.merge_commit_message)
+ expect(request.default_merge_commit_message)
.not_to match("By removing all code\n\n")
end
end
diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb
index 677613b7980..6c35ed8f649 100644
--- a/spec/models/sent_notification_spec.rb
+++ b/spec/models/sent_notification_spec.rb
@@ -36,19 +36,41 @@ describe SentNotification do
end
end
+ shared_examples 'a successful sent notification' do
+ it 'creates a new SentNotification' do
+ expect { subject }.to change { described_class.count }.by(1)
+ end
+ end
+
describe '.record' do
let(:issue) { create(:issue) }
- it 'creates a new SentNotification' do
- expect { described_class.record(issue, user.id) }.to change { described_class.count }.by(1)
- end
+ subject { described_class.record(issue, user.id) }
+
+ it_behaves_like 'a successful sent notification'
end
describe '.record_note' do
- let(:note) { create(:diff_note_on_merge_request) }
+ subject { described_class.record_note(note, note.author.id) }
- it 'creates a new SentNotification' do
- expect { described_class.record_note(note, note.author.id) }.to change { described_class.count }.by(1)
+ context 'for a discussion note' do
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'sets in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to eq(note.discussion_id)
+ end
+ end
+
+ context 'for an individual note' do
+ let(:note) { create(:note_on_merge_request) }
+
+ it_behaves_like 'a successful sent notification'
+
+ it 'does not set in_reply_to_discussion_id' do
+ expect(subject.in_reply_to_discussion_id).to be_nil
+ end
end
end
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 811e23fb854..1f317971a66 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -127,6 +127,31 @@ describe API::Releases do
.to match_array(release.sources.map(&:url))
end
+ context "when release description contains confidential issue's link" do
+ let(:confidential_issue) do
+ create(:issue,
+ :confidential,
+ project: project,
+ title: 'A vulnerability')
+ end
+
+ let!(:release) do
+ create(:release,
+ project: project,
+ tag: 'v0.1',
+ sha: commit.id,
+ author: maintainer,
+ description: "This is confidential #{confidential_issue.to_reference}")
+ end
+
+ it "does not expose confidential issue's title" do
+ get api("/projects/#{project.id}/releases/v0.1", maintainer)
+
+ expect(json_response['description_html']).to include(confidential_issue.to_reference)
+ expect(json_response['description_html']).not_to include('A vulnerability')
+ end
+ end
+
context 'when release has link asset' do
let!(:link) do
create(:release_link,
diff --git a/spec/serializers/merge_request_widget_commit_entity_spec.rb b/spec/serializers/merge_request_widget_commit_entity_spec.rb
new file mode 100644
index 00000000000..ce83978c49a
--- /dev/null
+++ b/spec/serializers/merge_request_widget_commit_entity_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe MergeRequestWidgetCommitEntity do
+ let(:project) { create(:project, :repository) }
+ let(:commit) { project.commit }
+ let(:request) { double('request') }
+
+ let(:entity) do
+ described_class.new(commit, request: request)
+ end
+
+ context 'as json' do
+ subject { entity.as_json }
+
+ it { expect(subject[:message]).to eq(commit.safe_message) }
+ it { expect(subject[:short_id]).to eq(commit.short_id) }
+ it { expect(subject[:title]).to eq(commit.title) }
+ end
+end
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index 376698a16df..4dbd79f2fc0 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -188,9 +188,14 @@ describe MergeRequestWidgetEntity do
.to eq("/#{resource.project.full_path}/merge_requests/#{resource.iid}.diff")
end
- it 'has merge_commit_message_with_description' do
- expect(subject[:merge_commit_message_with_description])
- .to eq(resource.merge_commit_message(include_description: true))
+ it 'has default_merge_commit_message_with_description' do
+ expect(subject[:default_merge_commit_message_with_description])
+ .to eq(resource.default_merge_commit_message(include_description: true))
+ end
+
+ it 'has default_squash_commit_message' do
+ expect(subject[:default_squash_commit_message])
+ .to eq(resource.default_squash_commit_message)
end
describe 'new_blob_path' do
@@ -272,4 +277,15 @@ describe MergeRequestWidgetEntity do
expect(entity[:rebase_path]).to be_nil
end
end
+
+ describe 'commits_without_merge_commits' do
+ it 'should not include merge commits' do
+ # Mock all but the first 5 commits to be merge commits
+ resource.commits.each_with_index do |commit, i|
+ expect(commit).to receive(:merge_commit?).at_least(:once).and_return(i > 4)
+ end
+
+ expect(subject[:commits_without_merge_commits].size).to eq(5)
+ end
+ end
end
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 5d96b5ce27c..04a62aa454d 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -258,7 +258,7 @@ describe MergeRequests::MergeService do
it 'logs and saves error if there is an error when squashing' do
error_message = 'Failed to squash. Should be done manually'
- allow_any_instance_of(MergeRequests::SquashService).to receive(:squash).and_return(nil)
+ allow_any_instance_of(MergeRequests::SquashService).to receive(:squash!).and_return(nil)
merge_request.update(squash: true)
service.execute(merge_request)
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index 53bce15735c..2713652873e 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe MergeRequests::SquashService do
include GitHelpers
- let(:service) { described_class.new(project, user, {}) }
+ let(:service) { described_class.new(project, user, { merge_request: merge_request }) }
let(:user) { project.owner }
let(:project) { create(:project, :repository) }
let(:repository) { project.repository.raw }
@@ -31,32 +31,49 @@ describe MergeRequests::SquashService do
shared_examples 'the squash succeeds' do
it 'returns the squashed commit SHA' do
- result = service.execute(merge_request)
+ result = service.execute
expect(result).to match(status: :success, squash_sha: a_string_matching(/\h{40}/))
expect(result[:squash_sha]).not_to eq(merge_request.diff_head_sha)
end
it 'cleans up the temporary directory' do
- service.execute(merge_request)
+ service.execute
expect(File.exist?(squash_dir_path)).to be(false)
end
it 'does not keep the branch push event' do
- expect { service.execute(merge_request) }.not_to change { Event.count }
+ expect { service.execute }.not_to change { Event.count }
+ end
+
+ context 'when there is a single commit in the merge request' do
+ before do
+ expect(merge_request).to receive(:commits_count).at_least(:once).and_return(1)
+ end
+
+ it 'will skip performing the squash, as the outcome would be the same' do
+ expect(merge_request.target_project.repository).not_to receive(:squash)
+
+ service.execute
+ end
+
+ it 'will still perform the squash when a custom squash commit message has been provided' do
+ service = described_class.new(project, user, { merge_request: merge_request, squash_commit_message: 'A custom commit message' })
+
+ expect(merge_request.target_project.repository).to receive(:squash).and_return('sha')
+
+ service.execute
+ end
end
context 'the squashed commit' do
- let(:squash_sha) { service.execute(merge_request)[:squash_sha] }
+ let(:squash_sha) { service.execute[:squash_sha] }
let(:squash_commit) { project.repository.commit(squash_sha) }
- it 'copies the author info and message from the merge request' do
+ it 'copies the author info from the merge request' do
expect(squash_commit.author_name).to eq(merge_request.author.name)
expect(squash_commit.author_email).to eq(merge_request.author.email)
-
- # Commit messages have a trailing newline, but titles don't.
- expect(squash_commit.message.chomp).to eq(merge_request.title)
end
it 'sets the current user as the committer' do
@@ -72,21 +89,37 @@ describe MergeRequests::SquashService do
expect(squash_diff.patch.length).to eq(mr_diff.patch.length)
expect(squash_commit.sha).not_to eq(merge_request.diff_head_sha)
end
+
+ it 'has a default squash commit message if no message was provided' do
+ expect(squash_commit.message.chomp).to eq(merge_request.default_squash_commit_message.chomp)
+ end
+
+ context 'if a message was provided' do
+ let(:service) { described_class.new(project, user, { merge_request: merge_request, squash_commit_message: message }) }
+ let(:message) { 'My custom message' }
+ let(:squash_sha) { service.execute[:squash_sha] }
+
+ it 'has the same message as the message provided' do
+ expect(squash_commit.message.chomp).to eq(message)
+ end
+ end
end
end
describe '#execute' do
context 'when there is only one commit in the merge request' do
+ let(:merge_request) { merge_request_with_one_commit }
+
it 'returns that commit SHA' do
- result = service.execute(merge_request_with_one_commit)
+ result = service.execute
- expect(result).to match(status: :success, squash_sha: merge_request_with_one_commit.diff_head_sha)
+ expect(result).to match(status: :success, squash_sha: merge_request.diff_head_sha)
end
it 'does not perform any git actions' do
expect(repository).not_to receive(:popen)
- service.execute(merge_request_with_one_commit)
+ service.execute
end
end
@@ -116,12 +149,11 @@ describe MergeRequests::SquashService do
expect(service).to receive(:log_error).with(log_error)
expect(service).to receive(:log_error).with(error)
- service.execute(merge_request)
+ service.execute
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: a_string_including('squash'))
+ expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
end
end
@@ -131,23 +163,22 @@ describe MergeRequests::SquashService do
let(:error) { 'A test error' }
before do
- allow(merge_request).to receive(:commits_count).and_raise(error)
+ allow(merge_request.target_project.repository).to receive(:squash).and_raise(error)
end
it 'logs the MR reference and exception' do
expect(service).to receive(:log_error).with(a_string_including("#{project.full_path}#{merge_request.to_reference}"))
expect(service).to receive(:log_error).with(error)
- service.execute(merge_request)
+ service.execute
end
it 'returns an error' do
- expect(service.execute(merge_request)).to match(status: :error,
- message: a_string_including('squash'))
+ expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
it 'cleans up the temporary directory' do
- service.execute(merge_request)
+ service.execute
expect(File.exist?(squash_dir_path)).to be(false)
end
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index 9aaccb4bffe..af4daff336b 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -123,6 +123,46 @@ describe Notes::BuildService do
end
end
+ context 'when replying to individual note' do
+ let(:note) { create(:note_on_issue) }
+
+ subject { described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute }
+
+ shared_examples 'an individual note reply' do
+ it 'builds another individual note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(Note)
+ expect(subject.discussion_id).not_to eq(note.discussion_id)
+ end
+ end
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it_behaves_like 'an individual note reply'
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'sets the note up to be in reply to that note' do
+ expect(subject).to be_valid
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(note.discussion_id)
+ end
+
+ context 'when noteable does not support replies' do
+ let(:note) { create(:note_on_commit) }
+
+ it_behaves_like 'an individual note reply'
+ end
+ end
+ end
+
it 'builds a note without saving it' do
new_note = described_class.new(project,
author,
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 1b9ba42cfd6..48f1d696ff6 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -278,5 +278,42 @@ describe Notes::CreateService do
expect(note.note).to eq(':smile:')
end
end
+
+ context 'reply to individual note' do
+ let(:existing_note) { create(:note_on_issue, noteable: issue, project: project) }
+ let(:reply_opts) { opts.merge(in_reply_to_discussion_id: existing_note.discussion_id) }
+
+ subject { described_class.new(project, user, reply_opts).execute }
+
+ context 'when reply_to_individual_notes is disabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: false)
+ end
+
+ it 'creates an individual note' do
+ expect(subject.type).to eq(nil)
+ expect(subject.discussion_id).not_to eq(existing_note.discussion_id)
+ end
+
+ it 'does not convert existing note' do
+ expect { subject }.not_to change { existing_note.reload.type }
+ end
+ end
+
+ context 'when reply_to_individual_notes is enabled' do
+ before do
+ stub_feature_flags(reply_to_individual_notes: true)
+ end
+
+ it 'creates a DiscussionNote in reply to existing note' do
+ expect(subject).to be_a(DiscussionNote)
+ expect(subject.discussion_id).to eq(existing_note.discussion_id)
+ end
+
+ it 'converts existing note to DiscussionNote' do
+ expect { subject }.to change { existing_note.reload.type }.from(nil).to('DiscussionNote')
+ end
+ end
+ end
end
end
diff --git a/spec/support/helpers/features/responsive_table_helpers.rb b/spec/support/helpers/features/responsive_table_helpers.rb
new file mode 100644
index 00000000000..7a175219fe9
--- /dev/null
+++ b/spec/support/helpers/features/responsive_table_helpers.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+# These helpers allow you to access rows in a responsive table
+#
+# Usage:
+# describe "..." do
+# include Spec::Support::Helpers::Features::ResponsiveTableHelpers
+# ...
+#
+# expect(first_row.text).to include("John Doe")
+# expect(second_row.text).to include("John Smith")
+#
+# Note:
+# index starts at 1 as index 0 is expected to be the table header
+#
+#
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module ResponsiveTableHelpers
+ def first_row
+ page.all('.gl-responsive-table-row')[1]
+ end
+
+ def second_row
+ page.all('.gl-responsive-table-row')[2]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb
index 2851cd9733c..ff21bbe28ca 100644
--- a/spec/support/helpers/stub_configuration.rb
+++ b/spec/support/helpers/stub_configuration.rb
@@ -56,6 +56,10 @@ module StubConfiguration
allow(Gitlab.config.lfs).to receive_messages(to_settings(messages))
end
+ def stub_external_diffs_setting(messages)
+ allow(Gitlab.config.external_diffs).to receive_messages(to_settings(messages))
+ end
+
def stub_artifacts_setting(messages)
allow(Gitlab.config.artifacts).to receive_messages(to_settings(messages))
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 58b5c6a6435..e0c50e533a6 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -42,6 +42,13 @@ module StubObjectStorage
**params)
end
+ def stub_external_diffs_object_storage(uploader = described_class, **params)
+ stub_object_storage_uploader(config: Gitlab.config.external_diffs.object_store,
+ uploader: uploader,
+ remote_directory: 'external_diffs',
+ **params)
+ end
+
def stub_lfs_object_storage(**params)
stub_object_storage_uploader(config: Gitlab.config.lfs.object_store,
uploader: LfsObjectUploader,
diff --git a/spec/support/shared_examples/models/cluster_application_initial_status.rb b/spec/support/shared_examples/models/cluster_application_initial_status.rb
new file mode 100644
index 00000000000..9775d87953c
--- /dev/null
+++ b/spec/support/shared_examples/models/cluster_application_initial_status.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+shared_examples 'cluster application initial status specs' do
+ describe '#status' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ subject { described_class.new(cluster: cluster) }
+
+ context 'when application helm is scheduled' do
+ before do
+ create(:clusters_applications_helm, :scheduled, cluster: cluster)
+ end
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+ end
+
+ context 'when application is scheduled' do
+ before do
+ create(:clusters_applications_helm, :installed, cluster: cluster)
+ end
+
+ it 'sets a default status' do
+ expect(subject.status_name).to be(:installable)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
index c391cc48f4e..554f2e747bc 100644
--- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb
@@ -7,26 +7,6 @@ shared_examples 'cluster application status specs' do |application_name|
it 'sets a default status' do
expect(subject.status_name).to be(:not_installable)
end
-
- context 'when application helm is scheduled' do
- before do
- create(:clusters_applications_helm, :scheduled, cluster: cluster)
- end
-
- it 'defaults to :not_installable' do
- expect(subject.status_name).to be(:not_installable)
- end
- end
-
- context 'when application is scheduled' do
- before do
- create(:clusters_applications_helm, :installed, cluster: cluster)
- end
-
- it 'sets a default status' do
- expect(subject.status_name).to be(:installable)
- end
- end
end
describe 'status state machine' do
@@ -58,6 +38,16 @@ shared_examples 'cluster application status specs' do |application_name|
expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
end
+
+ it 'sets the correct version of the application' do
+ subject.update!(version: '0.0.0')
+
+ subject.make_installed!
+
+ subject.reload
+
+ expect(subject.version).to eq(subject.class.const_get(:VERSION))
+ end
end
describe '#make_updated' do
@@ -78,6 +68,16 @@ shared_examples 'cluster application status specs' do |application_name|
expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
end
+
+ it 'updates the version for the application' do
+ subject.update!(version: '0.0.0')
+
+ subject.make_updated!
+
+ subject.reload
+
+ expect(subject.version).to eq(subject.class.const_get(:VERSION))
+ end
end
describe '#make_errored' do
diff --git a/spec/uploaders/external_diff_uploader_spec.rb b/spec/uploaders/external_diff_uploader_spec.rb
new file mode 100644
index 00000000000..1c959770dc4
--- /dev/null
+++ b/spec/uploaders/external_diff_uploader_spec.rb
@@ -0,0 +1,67 @@
+require 'spec_helper'
+
+describe ExternalDiffUploader do
+ let(:diff) { create(:merge_request).merge_request_diff }
+ let(:path) { Gitlab.config.external_diffs.storage_path }
+
+ subject(:uploader) { described_class.new(diff, :external_diff) }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+],
+ cache_dir: %r[/external-diffs/tmp/cache],
+ work_dir: %r[/external-diffs/tmp/work]
+
+ context "object store is REMOTE" do
+ before do
+ stub_external_diffs_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[merge_request_diffs/mr-\d+]
+ end
+
+ describe 'migration to object storage' do
+ context 'with object storage disabled' do
+ it "is skipped" do
+ expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async)
+
+ diff
+ end
+ end
+
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage(background_upload: true)
+ end
+
+ it 'is scheduled to run after creation' do
+ expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with(described_class.name, 'MergeRequestDiff', :external_diff, kind_of(Numeric))
+
+ diff
+ end
+ end
+ end
+
+ describe 'remote file' do
+ context 'with object storage enabled' do
+ before do
+ stub_external_diffs_setting(enabled: true)
+ stub_external_diffs_object_storage
+
+ diff.update!(external_diff_store: described_class::Store::REMOTE)
+ end
+
+ it 'can store file remotely' do
+ allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async)
+
+ diff
+
+ expect(diff.external_diff_store).to eq(described_class::Store::REMOTE)
+ expect(diff.external_diff.path).not_to be_blank
+ end
+ end
+ end
+end