From 8e6b6528543818a779e1df2b38150eb422145fb0 Mon Sep 17 00:00:00 2001 From: Simon Knox Date: Tue, 2 Apr 2019 19:27:38 +1100 Subject: [PATCH 1/9] Fix webpack dev-server crash due to memory limit Remove unneeded var for webpack-prod call in gitlab-ci --- .gitlab-ci.yml | 3 +-- package.json | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ab38c87039e..7e38952358b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -852,8 +852,6 @@ qa:selectors: .qa-frontend-node: &qa-frontend-node <<: *dedicated-no-docs-no-db-pull-cache-job stage: test - variables: - NODE_OPTIONS: --max_old_space_size=3584 cache: key: "$CI_JOB_NAME" paths: @@ -1145,3 +1143,4 @@ schedule:review-performance: <<: *review-schedules-only script: - wait_for_job_to_be_done "schedule:review-deploy" + diff --git a/package.json b/package.json index 90fcfe01438..a83d5e233bf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "scripts": { "clean": "rm -rf public/assets tmp/cache/*-loader", - "dev-server": "nodemon -w 'config/webpack.config.js' --exec 'webpack-dev-server --config config/webpack.config.js'", + "dev-server": "NODE_OPTIONS=\"--max-old-space-size=3584\" nodemon -w 'config/webpack.config.js' --exec 'webpack-dev-server --config config/webpack.config.js'", "eslint": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue .", "eslint-fix": "eslint --max-warnings 0 --report-unused-disable-directives --ext .js,.vue --fix .", "eslint-report": "eslint --max-warnings 0 --ext .js,.vue --format html --output-file ./eslint-report.html --no-inline-config .", @@ -20,8 +20,8 @@ "stylelint-file": "node node_modules/stylelint/bin/stylelint.js", "stylelint-create-utility-map": "node scripts/frontend/stylelint/stylelint-utility-map.js", "test": "yarn jest && yarn karma", - "webpack": "webpack --config config/webpack.config.js", - "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" + "webpack": "NODE_OPTIONS=\"--max-old-space-size=3584\" webpack --config config/webpack.config.js", + "webpack-prod": "NODE_OPTIONS=\"--max-old-space-size=3584\" NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { "@babel/core": "^7.2.2", -- GitLab From 1c8e99be48551b8b04791166886d2b2ac86d83d3 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 28 Mar 2019 18:07:27 +0700 Subject: [PATCH 2/9] Ignore merge if the status of the merge request pipeline is stale Merge request pipeline is meant for ensuring target branch's pipeline green. We should not let maintainers merge a merge request if the head pipeline of the merge request doesn't fulfill the criteria. --- spec/requests/api/merge_requests_spec.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 4259fda7f04..73d4072b9d1 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1353,7 +1353,12 @@ describe API::MergeRequests do end it 'returns 405 if the build failed for a merge request that requires success' do - allow_any_instance_of(MergeRequest).to receive(:mergeable_ci_state?).and_return(false) + project.update!(only_allow_merge_if_pipeline_succeeds: true) + + create(:ci_pipeline, + :failed, + sha: merge_request.diff_head_sha, + merge_requests_as_head_pipeline: [merge_request]) put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) -- GitLab From e1850718034bceaf9ef521d21592cb1d8d84cb6a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 4 Apr 2019 08:07:36 +0000 Subject: [PATCH 3/9] Revert "Merge branch 'fix/missing-border' into 'master'" This reverts merge request !26242 --- app/assets/stylesheets/pages/notes.scss | 1 - changelogs/unreleased/fix-missing-border.yml | 5 ----- 2 files changed, 6 deletions(-) delete mode 100644 changelogs/unreleased/fix-missing-border.yml diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 0c334e919de..fd07415a52f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -44,7 +44,6 @@ $note-form-margin-left: 72px; border: 1px solid $border-color; border-radius: $border-radius-default; margin: $gl-padding 0; - overflow: auto; &.system-note, &.note-form { diff --git a/changelogs/unreleased/fix-missing-border.yml b/changelogs/unreleased/fix-missing-border.yml deleted file mode 100644 index 21728223cb8..00000000000 --- a/changelogs/unreleased/fix-missing-border.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fixes missing border color in discussion card component -merge_request: 26242 -author: Farhad Yasir -type: fixed -- GitLab From ec85debaf51067cc78d54188ec1eef94342d5a8b Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Tue, 2 Apr 2019 12:52:28 +0100 Subject: [PATCH 4/9] Speed up avatar URLs with object storage With object storage enabled, calling `#filename` on an upload does this: 1. Call the `#filename` method on the CarrierWave object. 2. Generate the URL for that object. 3. If the uploader isn't public, do so by generating an authenticated URL, including signing that request. That's all correct behaviour, but for the case where we use `#filename`, it's typically to generate a GitLab URL. That URL doesn't need to be signed because we do our own auth. Signing the URLs can be very expensive, especially in batch (say, we need to get the avatar URLs for 150 users in one request). It's all unnecessary work. If we used the `RecordsUploads` concern, we have already recorded a `path` in the database. That `path` is actually generated from CarrierWave's `#filename` at upload time, so we don't need to recompute it - we can just use it and strip off the prefix if it's available. On a sample users autocomplete URL, at least 10% of the time before this change went to signing URLs. After this change, we spend no time in URL signing, and still get the correct results. --- app/uploaders/records_uploads.rb | 4 ++++ changelogs/unreleased/stop-signing-avatar-paths.yml | 5 +++++ spec/uploaders/records_uploads_spec.rb | 9 +++++++++ 3 files changed, 18 insertions(+) create mode 100644 changelogs/unreleased/stop-signing-avatar-paths.yml diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 9a243e07936..00b51f92b12 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -46,6 +46,10 @@ module RecordsUploads File.join(store_dir, filename.to_s) end + def filename + upload&.path ? File.basename(upload.path) : super + end + private # rubocop: disable CodeReuse/ActiveRecord diff --git a/changelogs/unreleased/stop-signing-avatar-paths.yml b/changelogs/unreleased/stop-signing-avatar-paths.yml new file mode 100644 index 00000000000..2c2493f0f21 --- /dev/null +++ b/changelogs/unreleased/stop-signing-avatar-paths.yml @@ -0,0 +1,5 @@ +--- +title: Speed up generation of avatar URLs when using object storage +merge_request: +author: +type: performance diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb index 3592a11360d..ab98976ec27 100644 --- a/spec/uploaders/records_uploads_spec.rb +++ b/spec/uploaders/records_uploads_spec.rb @@ -94,4 +94,13 @@ describe RecordsUploads do expect { uploader.remove! }.to change { Upload.count }.from(1).to(0) end end + + describe '#filename' do + it 'gets the filename from the path recorded in the database, not CarrierWave' do + uploader.store!(upload_fixture('rails_sample.jpg')) + expect_any_instance_of(GitlabUploader).not_to receive(:filename) + + expect(uploader.filename).to eq('rails_sample.jpg') + end + end end -- GitLab From fee7036394c881aa5a1f9851b3531267104e8c1d Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 4 Apr 2019 13:26:01 +0200 Subject: [PATCH 5/9] Exempt release automation MRs from Danger rules --- Dangerfile | 33 ++++++++++++++++++--------------- lib/gitlab/danger/helper.rb | 5 +++++ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Dangerfile b/Dangerfile index 95dd48aae9e..3e8cb456003 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,16 +1,19 @@ danger.import_plugin('danger/plugins/helper.rb') -danger.import_dangerfile(path: 'danger/metadata') -danger.import_dangerfile(path: 'danger/changes_size') -danger.import_dangerfile(path: 'danger/changelog') -danger.import_dangerfile(path: 'danger/specs') -danger.import_dangerfile(path: 'danger/gemfile') -danger.import_dangerfile(path: 'danger/database') -danger.import_dangerfile(path: 'danger/documentation') -danger.import_dangerfile(path: 'danger/frozen_string') -danger.import_dangerfile(path: 'danger/commit_messages') -danger.import_dangerfile(path: 'danger/duplicate_yarn_dependencies') -danger.import_dangerfile(path: 'danger/prettier') -danger.import_dangerfile(path: 'danger/eslint') -danger.import_dangerfile(path: 'danger/roulette') -danger.import_dangerfile(path: 'danger/single_codebase') -danger.import_dangerfile(path: 'danger/gitlab_ui_wg') + +unless helper.release_automation? + danger.import_dangerfile(path: 'danger/metadata') + danger.import_dangerfile(path: 'danger/changes_size') + danger.import_dangerfile(path: 'danger/changelog') + danger.import_dangerfile(path: 'danger/specs') + danger.import_dangerfile(path: 'danger/gemfile') + danger.import_dangerfile(path: 'danger/database') + danger.import_dangerfile(path: 'danger/documentation') + danger.import_dangerfile(path: 'danger/frozen_string') + danger.import_dangerfile(path: 'danger/commit_messages') + danger.import_dangerfile(path: 'danger/duplicate_yarn_dependencies') + danger.import_dangerfile(path: 'danger/prettier') + danger.import_dangerfile(path: 'danger/eslint') + danger.import_dangerfile(path: 'danger/roulette') + danger.import_dangerfile(path: 'danger/single_codebase') + danger.import_dangerfile(path: 'danger/gitlab_ui_wg') +end diff --git a/lib/gitlab/danger/helper.rb b/lib/gitlab/danger/helper.rb index ac65cf74808..d347f3c13a4 100644 --- a/lib/gitlab/danger/helper.rb +++ b/lib/gitlab/danger/helper.rb @@ -7,6 +7,7 @@ require_relative 'teammate' module Gitlab module Danger module Helper + RELEASE_TOOLS_BOT = 'gitlab-release-tools-bot' ROULETTE_DATA_URL = URI.parse('https://about.gitlab.com/roulette.json').freeze # Returns a list of all files that have been added, modified or renamed. @@ -40,6 +41,10 @@ module Gitlab ENV['CI_PROJECT_NAME'] == 'gitlab-ee' || File.exist?('../../CHANGELOG-EE.md') end + def release_automation? + gitlab.mr_author == RELEASE_TOOLS_BOT + end + def project_name ee? ? 'gitlab-ee' : 'gitlab-ce' end -- GitLab From e540c0d71e00c4ce031b94cf11ec3de905e87da7 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Thu, 4 Apr 2019 13:08:34 +0000 Subject: [PATCH 6/9] Fixed test specs - added suggestions to mock data - fixed props to be not required --- .../diffs/components/diff_line_note_form.vue | 6 +- .../notes/components/note_form.vue | 44 ++++++- .../components/lib/utils/diff_utils.js | 20 ++++ .../vue_shared/components/markdown/field.vue | 5 +- .../vue_shared/components/markdown/header.vue | 2 +- .../components/markdown/suggestion_diff.vue | 42 ++----- .../markdown/suggestion_diff_row.vue | 32 +++++ .../components/markdown/suggestions.vue | 38 +----- app/controllers/concerns/preview_markdown.rb | 2 +- app/serializers/issue_entity.rb | 2 +- .../merge_request_widget_entity.rb | 2 +- app/serializers/suggestion_entity.rb | 2 + app/serializers/suggestion_serializer.rb | 9 ++ app/services/concerns/suggestible.rb | 7 ++ app/services/preview_markdown_service.rb | 28 +++-- .../form_elements/_description.html.haml | 2 +- app/views/shared/notes/_form.html.haml | 2 +- .../osw-support-multi-line-suggestions.yml | 5 + .../img/multi-line-suggestion-preview.png | Bin 0 -> 61692 bytes .../img/multi-line-suggestion-syntax.png | Bin 0 -> 29753 bytes doc/user/discussions/index.md | 18 +++ lib/banzai/suggestions_parser.rb | 16 --- spec/controllers/projects_controller_spec.rb | 10 ++ .../user_suggests_changes_on_diff_spec.rb | 111 ++++++++++++++++-- .../markdown/suggestion_diff_row_spec.js | 98 ++++++++++++++++ spec/javascripts/notes/mock_data.js | 6 +- .../components/markdown/header_spec.js | 2 +- .../markdown/suggestion_diff_spec.js | 66 +++++++---- .../components/markdown/suggestions_spec.js | 109 +++++++---------- spec/lib/banzai/suggestions_parser_spec.rb | 32 ----- spec/lib/gitlab/diff/suggestion_spec.rb | 87 ++++++++++++-- .../gitlab/diff/suggestions_parser_spec.rb | 61 ++++++++++ spec/models/suggestion_spec.rb | 16 +++ spec/serializers/suggestion_entity_spec.rb | 3 +- .../services/preview_markdown_service_spec.rb | 73 ++++++++++-- .../suggestions/apply_service_spec.rb | 64 ++++++++-- 36 files changed, 755 insertions(+), 267 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js create mode 100644 app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue create mode 100644 app/serializers/suggestion_serializer.rb create mode 100644 changelogs/unreleased/osw-support-multi-line-suggestions.yml create mode 100644 doc/user/discussions/img/multi-line-suggestion-preview.png create mode 100644 doc/user/discussions/img/multi-line-suggestion-syntax.png delete mode 100644 lib/banzai/suggestions_parser.rb create mode 100644 spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js delete mode 100644 spec/lib/banzai/suggestions_parser_spec.rb diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index bb66ab36283..41670b45798 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -48,10 +48,13 @@ export default { noteableType: this.noteableType, noteTargetLine: this.noteTargetLine, diffViewType: this.diffViewType, - diffFile: this.getDiffFileByHash(this.diffFileHash), + diffFile: this.diffFile, linePosition: this.linePosition, }; }, + diffFile() { + return this.getDiffFileByHash(this.diffFileHash); + }, }, mounted() { if (this.isLoggedIn) { @@ -102,6 +105,7 @@ export default { :line-code="line.line_code" :line="line" :help-page-path="helpPagePath" + :diff-file="diffFile" save-button-title="Comment" class="diff-comment-form" @handleFormUpdateAddToReview="addToReview" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 57d6b181bd7..471323bfc83 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -61,6 +61,11 @@ export default { required: false, default: null, }, + diffFile: { + type: Object, + required: false, + default: null, + }, helpPagePath: { type: String, required: false, @@ -102,9 +107,42 @@ export default { } return '#'; }, + diffParams() { + if (this.diffFile) { + return { + filePath: this.diffFile.file_path, + refs: this.diffFile.diff_refs, + }; + } else if (this.note && this.note.position) { + return { + filePath: this.note.position.new_path, + refs: this.note.position, + }; + } else if (this.discussion && this.discussion.diff_file) { + return { + filePath: this.discussion.diff_file.file_path, + refs: this.discussion.diff_file.diff_refs, + }; + } + + return null; + }, markdownPreviewPath() { const notable = this.getNoteableDataByProp('preview_note_path'); - return mergeUrlParams({ preview_suggestions: true }, notable); + + const previewSuggestions = this.line && this.diffParams; + const params = previewSuggestions + ? { + preview_suggestions: previewSuggestions, + line: this.line.new_line, + file_path: this.diffParams.filePath, + base_sha: this.diffParams.refs.base_sha, + start_sha: this.diffParams.refs.start_sha, + head_sha: this.diffParams.refs.head_sha, + } + : {}; + + return mergeUrlParams(params, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -234,8 +272,8 @@ export default { placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" - @keydown.up="editMyLastNote()" - @keydown.esc="cancelHandler(true)" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" @input="onInput" > diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js new file mode 100644 index 00000000000..d1aba99ac22 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -0,0 +1,20 @@ +/* eslint-disable import/prefer-default-export */ + +function trimFirstCharOfLineContent(text) { + if (!text) { + return text; + } + + return text.replace(/^( |\+|-)/, ''); +} + +function cleanSuggestionLine(line = {}) { + return { + ...line, + text: trimFirstCharOfLineContent(line.text), + }; +} + +export function selectDiffLines(lines) { + return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line)); +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index eccf73e227c..0f3b3568414 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -76,6 +76,7 @@ export default { hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, + suggestions: this.note.suggestions || [], }; }, computed: { @@ -109,9 +110,6 @@ export default { } return lineNumber; }, - suggestions() { - return this.note.suggestions || []; - }, lineType() { return this.line ? this.line.type : ''; }, @@ -175,6 +173,7 @@ export default { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; + this.suggestions = data.references.suggestions; } this.$nextTick() diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index cc6ecdb0395..a5a5b2ef415 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -38,7 +38,7 @@ export default { ].join('\n'); }, mdSuggestion() { - return ['```suggestion', `{text}`, '```'].join('\n'); + return ['```suggestion:-0+0', `{text}`, '```'].join('\n'); }, }, mounted() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index a351ca62c94..2eb4ec12a4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -1,24 +1,14 @@ + + diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 177d78cb904..8d3705e1e4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -6,16 +6,6 @@ import Flash from '~/flash'; export default { components: { SuggestionDiff }, props: { - fromLine: { - type: Number, - required: false, - default: 0, - }, - fromContent: { - type: String, - required: false, - default: '', - }, lineType: { type: String, required: false, @@ -71,41 +61,19 @@ export default { suggestionElements.forEach((suggestionEl, i) => { const suggestionParentEl = suggestionEl.parentElement; - const newLines = this.extractNewLines(suggestionParentEl); - const diffComponent = this.generateDiff(newLines, i); + const diffComponent = this.generateDiff(i); diffComponent.$mount(suggestionParentEl); }); this.isRendered = true; }, - extractNewLines(suggestionEl) { - // extracts the suggested lines from the markdown - // calculates a line number for each line - - const newLines = suggestionEl.querySelectorAll('.line'); - const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; - const lines = []; - - newLines.forEach((line, i) => { - const content = `${line.innerText}\n`; - const lineNumber = fromLine + i; - lines.push({ content, lineNumber }); - }); - - return lines; - }, - generateDiff(newLines, suggestionIndex) { - // generates the diff component - // all `suggestion` markdown will be swapped out by this component - + generateDiff(suggestionIndex) { const { suggestions, disabled, helpPagePath } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; - const fromContent = suggestion.from_content || this.fromContent; - const fromLine = suggestion.from_line || this.fromLine; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + propsData: { disabled, suggestion, helpPagePath }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index f72d25fc54c..2a9729b6ffd 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -20,7 +20,7 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], - suggestions: result[:suggestions], + suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]), commands: view_context.markdown(result[:commands]) } } diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index c3f7d4651fb..914ad628a99 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -42,6 +42,6 @@ class IssueEntity < IssuableEntity end expose :preview_note_path do |issue| - preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid) + preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid) end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index d673f8ae896..4831eb32c96 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -235,7 +235,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :preview_note_path do |merge_request| - preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid) + preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid) end expose :merge_commit_path do |merge_request| diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 4d0d4da10be..2dd62e19e29 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -3,6 +3,8 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity + unexpose :from_line, :to_line, :from_content, :to_content + expose :diff_lines, using: DiffLineEntity expose :current_user do expose :can_apply do |suggestion| Ability.allowed?(current_user, :apply_suggestion, suggestion) diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb new file mode 100644 index 00000000000..010344f9fcd --- /dev/null +++ b/app/serializers/suggestion_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SuggestionSerializer < BaseSerializer + entity SuggestionEntity + + def represent_diff(resource) + represent(resource, { only: [:diff_lines] }) + end +end diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb index 0b9822b1909..0cba9bf1b8a 100644 --- a/app/services/concerns/suggestible.rb +++ b/app/services/concerns/suggestible.rb @@ -2,10 +2,17 @@ module Suggestible extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize # This translates into limiting suggestion changes to `suggestion:-100+100`. MAX_LINES_CONTEXT = 100.freeze + def diff_lines + strong_memoize(:diff_lines) do + Gitlab::Diff::SuggestionDiff.new(self).diff_lines + end + end + def fetch_from_content diff_file.new_blob_lines_between(from_line, to_line).join end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index c1655c38095..7386530f45f 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService private def explain_quick_actions(text) - return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type) + return text, [] unless %w(Issue MergeRequest Commit).include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) quick_actions_service.explain(text, find_commands_target) @@ -30,22 +30,34 @@ class PreviewMarkdownService < BaseService end def find_suggestions(text) - return [] unless params[:preview_suggestions] + return [] unless preview_sugestions? - Banzai::SuggestionsParser.parse(text) + position = Gitlab::Diff::Position.new(new_path: params[:file_path], + new_line: params[:line].to_i, + base_sha: params[:base_sha], + head_sha: params[:head_sha], + start_sha: params[:start_sha]) + + Gitlab::Diff::SuggestionsParser.parse(text, position: position, project: project) + end + + def preview_sugestions? + params[:preview_suggestions] && + target_type == 'MergeRequest' && + Ability.allowed?(current_user, :download_code, project) end def find_commands_target QuickActions::TargetService .new(project, current_user) - .execute(commands_target_type, commands_target_id) + .execute(target_type, target_id) end - def commands_target_type - params[:quick_actions_target_type] + def target_type + params[:target_type] end - def commands_target_id - params[:quick_actions_target_id] + def target_id + params[:target_id] end end diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 25df2fe5cd6..b11cb8a3076 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -5,7 +5,7 @@ - supports_quick_actions = model.new_record? - if supports_quick_actions - - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name) + - preview_url = preview_markdown_path(project, target_type: model.class.name) - else - preview_url = preview_markdown_path(project) diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 6a1eea85fde..d91bc6e57c9 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,7 +1,7 @@ - supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true) - supports_quick_actions = note_supports_quick_actions?(@note) - if supports_quick_actions - - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id) + - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id) - else - preview_url = preview_markdown_path(@project) diff --git a/changelogs/unreleased/osw-support-multi-line-suggestions.yml b/changelogs/unreleased/osw-support-multi-line-suggestions.yml new file mode 100644 index 00000000000..8c8206c3822 --- /dev/null +++ b/changelogs/unreleased/osw-support-multi-line-suggestions.yml @@ -0,0 +1,5 @@ +--- +title: Support multi-line suggestions +merge_request: 25211 +author: +type: added diff --git a/doc/user/discussions/img/multi-line-suggestion-preview.png b/doc/user/discussions/img/multi-line-suggestion-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..4288d0ba034b29fec5c2ebb03f905aa7cede7bd2 GIT binary patch literal 61692 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 literal 0 HcmV?d00001 diff --git a/doc/user/discussions/img/multi-line-suggestion-syntax.png b/doc/user/discussions/img/multi-line-suggestion-syntax.png new file mode 100644 index 0000000000000000000000000000000000000000..df0c99b84ef48aaa60b1f8dbdaa7afb574116efc GIT binary patch literal 29753 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 [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/53310) in GitLab 11.10. + +Reviewers can also suggest changes to +multiple lines with a single suggestion within Merge Request diff discussions. + +![Multi-line suggestion syntax](img/multi-line-suggestion-syntax.png) + +In the example above, the suggestion covers three lines above and four lines below the commented diff line. +It'd change from 3 lines _above_ to 4 lines _below_ the commented Diff line. + +![Multi-line suggestion preview](img/multi-line-suggestion-preview.png) + +NOTE: **Note:** +Suggestions covering multiple lines are limited to 100 lines _above_ and 100 lines _below_ +the commented diff line, allowing up to 200 changed lines per suggestion. + ## Start a discussion by replying to a standard comment > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/30299) in GitLab 11.9 diff --git a/lib/banzai/suggestions_parser.rb b/lib/banzai/suggestions_parser.rb deleted file mode 100644 index 0d7f751bfc1..00000000000 --- a/lib/banzai/suggestions_parser.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# TODO: Delete when https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/26107 -# exchange this parser by `Gitlab::Diff::SuggestionsParser`. -module Banzai - module SuggestionsParser - # Returns the content of each suggestion code block. - # - def self.parse(text) - html = Banzai.render(text, project: nil, no_original_data: true) - doc = Nokogiri::HTML(html) - - doc.search('pre.suggestion').map { |node| node.text } - end - end -end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 356d606d5c5..56d38b9475e 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -703,6 +703,16 @@ describe ProjectsController do expect(JSON.parse(response.body).keys).to match_array(%w(body references)) end + context 'when not authorized' do + let(:private_project) { create(:project, :private) } + + it 'returns 404' do + post :preview_markdown, params: { namespace_id: private_project.namespace, id: private_project, text: '*Markdown* text' } + + expect(response).to have_gitlab_http_status(404) + end + end + context 'state filter on references' do let(:issue) { create(:issue, :closed, project: public_project) } let(:merge_request) { create(:merge_request, :closed, target_project: public_project) } diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb index c19e299097e..1b5dd6945e0 100644 --- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb +++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb @@ -6,6 +6,14 @@ describe 'User comments on a diff', :js do include MergeRequestDiffHelpers include RepoHelpers + def expect_suggestion_has_content(element, expected_changing_content, expected_suggested_content) + changing_content = element.all(:css, '.line_holder.old').map(&:text) + suggested_content = element.all(:css, '.line_holder.new').map(&:text) + + expect(changing_content).to eq(expected_changing_content) + expect(suggested_content).to eq(expected_suggested_content) + end + let(:project) { create(:project, :repository) } let(:merge_request) do create(:merge_request_with_diffs, source_project: project, target_project: project, source_branch: 'merge-test') @@ -33,8 +41,18 @@ describe 'User comments on a diff', :js do page.within('.diff-discussions') do expect(page).to have_button('Apply suggestion') expect(page).to have_content('Suggested change') - expect(page).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git') - expect(page).to have_content('# change to a comment') + end + + page.within('.md-suggestion-diff') do + expected_changing_content = [ + "6 url = https://github.com/gitlabhq/gitlab-shell.git" + ] + + expected_suggested_content = [ + "6 # change to a comment" + ] + + expect_suggestion_has_content(page, expected_changing_content, expected_suggested_content) end end @@ -64,7 +82,7 @@ describe 'User comments on a diff', :js do click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) page.within('.js-discussion-note-form') do - fill_in('note_note', with: "```suggestion\n# change to a comment\n```\n```suggestion\n# or that\n```") + fill_in('note_note', with: "```suggestion\n# change to a comment\n```\n```suggestion:-2\n# or that\n# heh\n```") click_button('Comment') end @@ -74,11 +92,90 @@ describe 'User comments on a diff', :js do suggestion_1 = page.all(:css, '.md-suggestion-diff')[0] suggestion_2 = page.all(:css, '.md-suggestion-diff')[1] - expect(suggestion_1).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git') - expect(suggestion_1).to have_content('# change to a comment') + suggestion_1_expected_changing_content = [ + "6 url = https://github.com/gitlabhq/gitlab-shell.git" + ] + suggestion_1_expected_suggested_content = [ + "6 # change to a comment" + ] + + suggestion_2_expected_changing_content = [ + "4 [submodule \"gitlab-shell\"]", + "5 path = gitlab-shell", + "6 url = https://github.com/gitlabhq/gitlab-shell.git" + ] + suggestion_2_expected_suggested_content = [ + "4 # or that", + "5 # heh" + ] + + expect_suggestion_has_content(suggestion_1, + suggestion_1_expected_changing_content, + suggestion_1_expected_suggested_content) + + expect_suggestion_has_content(suggestion_2, + suggestion_2_expected_changing_content, + suggestion_2_expected_suggested_content) + end + end + end + + context 'multi-line suggestions' do + it 'suggestion is presented' do + click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) + + page.within('.js-discussion-note-form') do + fill_in('note_note', with: "```suggestion:-3+5\n# change to a\n# comment\n# with\n# broken\n# lines\n```") + click_button('Comment') + end - expect(suggestion_2).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git') - expect(suggestion_2).to have_content('# or that') + wait_for_requests + + page.within('.diff-discussions') do + expect(page).to have_button('Apply suggestion') + expect(page).to have_content('Suggested change') + end + + page.within('.md-suggestion-diff') do + expected_changing_content = [ + "3 url = git://github.com/randx/six.git", + "4 [submodule \"gitlab-shell\"]", + "5 path = gitlab-shell", + "6 url = https://github.com/gitlabhq/gitlab-shell.git", + "7 [submodule \"gitlab-grack\"]", + "8 path = gitlab-grack", + "9 url = https://gitlab.com/gitlab-org/gitlab-grack.git" + ] + + expected_suggested_content = [ + "3 # change to a", + "4 # comment", + "5 # with", + "6 # broken", + "7 # lines" + ] + + expect_suggestion_has_content(page, expected_changing_content, expected_suggested_content) + end + end + + it 'suggestion is appliable' do + click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) + + page.within('.js-discussion-note-form') do + fill_in('note_note', with: "```suggestion:-3+5\n# change to a\n# comment\n# with\n# broken\n# lines\n```") + click_button('Comment') + end + + wait_for_requests + + page.within('.diff-discussions') do + expect(page).not_to have_content('Applied') + + click_button('Apply suggestion') + wait_for_requests + + expect(page).to have_content('Applied') end end end diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js new file mode 100644 index 00000000000..866d6eb05c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js @@ -0,0 +1,98 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue'; + +const oldLine = { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-oldtext', + text: '-oldtext', + type: 'old', +}; + +const newLine = { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: 6, + old_line: null, + rich_text: '-newtext', + text: '-newtext', + type: 'new', +}; + +describe(SuggestionDiffRow.name, () => { + let wrapper; + + const factory = (options = {}) => { + const localVue = createLocalVue(); + + wrapper = shallowMount(SuggestionDiffRow, { + localVue, + ...options, + }); + }; + + const findOldLineWrapper = () => wrapper.find('.old_line'); + const findNewLineWrapper = () => wrapper.find('.new_line'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correctly', () => { + factory({ + propsData: { + line: oldLine, + }, + }); + + expect(wrapper.is('.line_holder')).toBe(true); + }); + + describe('when passed line has type old', () => { + beforeEach(() => { + factory({ + propsData: { + line: oldLine, + }, + }); + }); + + it('has old class when line has type old', () => { + expect(wrapper.find('td').classes()).toContain('old'); + }); + + it('has old line number rendered', () => { + expect(findOldLineWrapper().text()).toBe('5'); + }); + + it('has no new line number rendered', () => { + expect(findNewLineWrapper().text()).toBe(''); + }); + }); + + describe('when passed line has type new', () => { + beforeEach(() => { + factory({ + propsData: { + line: newLine, + }, + }); + }); + + it('has new class when line has type new', () => { + expect(wrapper.find('td').classes()).toContain('new'); + }); + + it('has no old line number rendered', () => { + expect(findOldLineWrapper().text()).toBe(''); + }); + + it('has no new line number rendered', () => { + expect(findNewLineWrapper().text()).toBe('6'); + }); + }); +}); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 348743081eb..1df5cf9ef68 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -44,8 +44,7 @@ export const noteableDataMock = { milestone: null, milestone_id: null, moved_to_id: null, - preview_note_path: - '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', + preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?target_id=98&target_type=Issue', project_id: 2, state: 'opened', time_estimate: 0, @@ -347,8 +346,7 @@ export const loggedOutnoteableData = { }, noteable_note_url: '/group/project/merge_requests/1#note_1', create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue', - preview_note_path: - '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', + preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?target_id=98&target_type=Issue', }; export const collapseNotesMock = [ diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js index e733a95288e..d4be2451f0b 100644 --- a/spec/javascripts/vue_shared/components/markdown/header_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -98,7 +98,7 @@ describe('Markdown field header component', () => { it('renders suggestion template', () => { vm.lineContent = 'Some content'; - expect(vm.mdSuggestion).toEqual('```suggestion\n{text}\n```'); + expect(vm.mdSuggestion).toEqual('```suggestion:-0+0\n{text}\n```'); }); it('does not render suggestion button if `canSuggest` is set to false', () => { diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js index f87c2a92f47..ea74cb9eb21 100644 --- a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js @@ -1,21 +1,50 @@ import Vue from 'vue'; import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue'; +import { selectDiffLines } from '~/vue_shared/components/lib/utils/diff_utils'; const MOCK_DATA = { canApply: true, - newLines: [ - { content: 'Line 1\n', lineNumber: 1 }, - { content: 'Line 2\n', lineNumber: 2 }, - { content: 'Line 3\n', lineNumber: 3 }, - ], - fromLine: 1, - fromContent: 'Old content', suggestion: { id: 1, + diff_lines: [ + { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-test', + text: '-test', + type: 'old', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test', + text: '+new test', + type: 'new', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test2', + text: '+new test2', + type: 'new', + }, + ], }, helpPagePath: 'path_to_docs', }; +const lines = selectDiffLines(MOCK_DATA.suggestion.diff_lines); +const newLines = lines.filter(line => line.type === 'new'); + describe('Suggestion Diff component', () => { let vm; @@ -39,30 +68,23 @@ describe('Suggestion Diff component', () => { }); it('renders the oldLineNumber', () => { - const fromLine = vm.$el.querySelector('.qa-old-diff-line-number').innerHTML; + const fromLine = vm.$el.querySelector('.old_line').innerHTML; - expect(parseInt(fromLine, 10)).toBe(vm.fromLine); + expect(parseInt(fromLine, 10)).toBe(lines[0].old_line); }); it('renders the oldLineContent', () => { const fromContent = vm.$el.querySelector('.line_content.old').innerHTML; - expect(fromContent.includes(vm.fromContent)).toBe(true); - }); - - it('renders the contents of newLines', () => { - const newLines = vm.$el.querySelectorAll('.line_holder.new'); - - newLines.forEach((line, i) => { - expect(newLines[i].innerHTML.includes(vm.newLines[i].content)).toBe(true); - }); + expect(fromContent.includes(lines[0].text)).toBe(true); }); - it('renders a line number for each line', () => { - const newLineNumbers = vm.$el.querySelectorAll('.qa-new-diff-line-number'); + it('renders new lines', () => { + const newLinesElements = vm.$el.querySelectorAll('.line_holder.new'); - newLineNumbers.forEach((line, i) => { - expect(newLineNumbers[i].innerHTML.includes(vm.newLines[i].lineNumber)).toBe(true); + newLinesElements.forEach((line, i) => { + expect(newLinesElements[i].innerHTML.includes(newLines[i].new_line)).toBe(true); + expect(newLinesElements[i].innerHTML.includes(newLines[i].text)).toBe(true); }); }); }); diff --git a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js index 33be63a3a1e..b7de40b4831 100644 --- a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js @@ -2,46 +2,52 @@ import Vue from 'vue'; import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue'; const MOCK_DATA = { - fromLine: 1, - fromContent: 'Old content', - suggestions: [], + suggestions: [ + { + id: 1, + appliable: true, + applied: false, + current_user: { + can_apply: true, + }, + diff_lines: [ + { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-test', + text: '-test', + type: 'old', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test', + text: '+new test', + type: 'new', + }, + ], + }, + ], noteHtml: ` +
+
-oldtest
+
-
Suggestion 1
+
+newtest
- -
-
Suggestion 2
-
`, isApplied: false, helpPagePath: 'path_to_docs', }; -const generateLine = content => { - const line = document.createElement('div'); - line.className = 'line'; - line.innerHTML = content; - - return line; -}; - -const generateMockLines = () => { - const line1 = generateLine('Line 1'); - const line2 = generateLine('Line 2'); - const line3 = generateLine('- Line 3'); - const container = document.createElement('div'); - - container.appendChild(line1); - container.appendChild(line2); - container.appendChild(line3); - - return container; -}; - describe('Suggestion component', () => { let vm; - let extractedLines; let diffTable; beforeEach(done => { @@ -51,8 +57,7 @@ describe('Suggestion component', () => { propsData: MOCK_DATA, }).$mount(); - extractedLines = vm.extractNewLines(generateMockLines()); - diffTable = vm.generateDiff(extractedLines).$mount().$el; + diffTable = vm.generateDiff(0).$mount().$el; spyOn(vm, 'renderSuggestions'); vm.renderSuggestions(); @@ -70,32 +75,8 @@ describe('Suggestion component', () => { it('renders suggestions', () => { expect(vm.renderSuggestions).toHaveBeenCalled(); - expect(vm.$el.innerHTML.includes('Suggestion 1')).toBe(true); - expect(vm.$el.innerHTML.includes('Suggestion 2')).toBe(true); - }); - }); - - describe('extractNewLines', () => { - it('extracts suggested lines', () => { - const expectedReturn = [ - { content: 'Line 1\n', lineNumber: 1 }, - { content: 'Line 2\n', lineNumber: 2 }, - { content: '- Line 3\n', lineNumber: 3 }, - ]; - - expect(vm.extractNewLines(generateMockLines())).toEqual(expectedReturn); - }); - - it('increments line number for each extracted line', () => { - expect(extractedLines[0].lineNumber).toEqual(1); - expect(extractedLines[1].lineNumber).toEqual(2); - expect(extractedLines[2].lineNumber).toEqual(3); - }); - - it('returns empty array if no lines are found', () => { - const el = document.createElement('div'); - - expect(vm.extractNewLines(el)).toEqual([]); + expect(vm.$el.innerHTML.includes('oldtest')).toBe(true); + expect(vm.$el.innerHTML.includes('newtest')).toBe(true); }); }); @@ -109,17 +90,17 @@ describe('Suggestion component', () => { }); it('generates a diff table that contains contents the suggested lines', () => { - extractedLines.forEach((line, i) => { - expect(diffTable.innerHTML.includes(extractedLines[i].content)).toBe(true); + MOCK_DATA.suggestions[0].diff_lines.forEach(line => { + const text = line.text.substring(1); + + expect(diffTable.innerHTML.includes(text)).toBe(true); }); }); it('generates a diff table with the correct line number for each suggested line', () => { - const lines = diffTable.getElementsByClassName('qa-new-diff-line-number'); + const lines = diffTable.querySelectorAll('.old_line'); - expect([...lines][0].innerHTML).toBe('1'); - expect([...lines][1].innerHTML).toBe('2'); - expect([...lines][2].innerHTML).toBe('3'); + expect(parseInt([...lines][0].innerHTML, 10)).toBe(5); }); }); }); diff --git a/spec/lib/banzai/suggestions_parser_spec.rb b/spec/lib/banzai/suggestions_parser_spec.rb deleted file mode 100644 index 79658d710ce..00000000000 --- a/spec/lib/banzai/suggestions_parser_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Banzai::SuggestionsParser do - describe '.parse' do - it 'returns a list of suggestion contents' do - markdown = <<-MARKDOWN.strip_heredoc - ```suggestion - foo - bar - ``` - - ``` - nothing - ``` - - ```suggestion - xpto - baz - ``` - - ```thing - this is not a suggestion, it's a thing - ``` - MARKDOWN - - expect(described_class.parse(markdown)).to eq([" foo\n bar", - " xpto\n baz"]) - end - end -end diff --git a/spec/lib/gitlab/diff/suggestion_spec.rb b/spec/lib/gitlab/diff/suggestion_spec.rb index 71fd25df698..d7ca0e0a522 100644 --- a/spec/lib/gitlab/diff/suggestion_spec.rb +++ b/spec/lib/gitlab/diff/suggestion_spec.rb @@ -10,6 +10,16 @@ describe Gitlab::Diff::Suggestion do lines_above: above, lines_below: below) end + + it 'returns diff lines with correct line numbers' do + diff_lines = suggestion.diff_lines + + expect(diff_lines).to all(be_a(Gitlab::Diff::Line)) + + expected_diff_lines.each_with_index do |expected_line, index| + expect(diff_lines[index].to_hash).to include(expected_line) + end + end end let(:merge_request) { create(:merge_request) } @@ -48,6 +58,18 @@ describe Gitlab::Diff::Suggestion do let(:expected_above) { line - 1 } let(:expected_below) { below } let(:expected_lines) { blob_lines_data(line - expected_above, line + expected_below) } + let(:expected_diff_lines) do + [ + { old_pos: 1, new_pos: 1, type: 'old', text: "-require 'fileutils'" }, + { old_pos: 2, new_pos: 1, type: 'old', text: "-require 'open3'" }, + { old_pos: 3, new_pos: 1, type: 'old', text: "-" }, + { old_pos: 4, new_pos: 1, type: 'old', text: "-module Popen" }, + { old_pos: 5, new_pos: 1, type: 'old', text: "- extend self" }, + { old_pos: 6, new_pos: 1, type: 'old', text: "-" }, + { old_pos: 7, new_pos: 1, type: 'new', text: "+# parsed suggestion content" }, + { old_pos: 7, new_pos: 2, type: 'new', text: "+# with comments" } + ] + end it_behaves_like 'correct suggestion raw content' end @@ -59,6 +81,47 @@ describe Gitlab::Diff::Suggestion do let(:expected_below) { below } let(:expected_above) { above } let(:expected_lines) { blob_lines_data(line - expected_above, line + expected_below) } + let(:expected_diff_lines) do + [ + { old_pos: 4, new_pos: 4, type: "match", text: "@@ -4 +4" }, + { old_pos: 4, new_pos: 4, type: "old", text: "-module Popen" }, + { old_pos: 5, new_pos: 4, type: "old", text: "- extend self" }, + { old_pos: 6, new_pos: 4, type: "old", text: "-" }, + { old_pos: 7, new_pos: 4, type: "old", text: "- def popen(cmd, path=nil)" }, + { old_pos: 8, new_pos: 4, type: "old", text: "- unless cmd.is_a?(Array)" }, + { old_pos: 9, new_pos: 4, type: "old", text: "- raise RuntimeError, \"System commands must be given as an array of strings\"" }, + { old_pos: 10, new_pos: 4, type: "old", text: "- end" }, + { old_pos: 11, new_pos: 4, type: "old", text: "-" }, + { old_pos: 12, new_pos: 4, type: "old", text: "- path ||= Dir.pwd" }, + { old_pos: 13, new_pos: 4, type: "old", text: "-" }, + { old_pos: 14, new_pos: 4, type: "old", text: "- vars = {" }, + { old_pos: 15, new_pos: 4, type: "old", text: "- \"PWD\" => path" }, + { old_pos: 16, new_pos: 4, type: "old", text: "- }" }, + { old_pos: 17, new_pos: 4, type: "old", text: "-" }, + { old_pos: 18, new_pos: 4, type: "old", text: "- options = {" }, + { old_pos: 19, new_pos: 4, type: "old", text: "- chdir: path" }, + { old_pos: 20, new_pos: 4, type: "old", text: "- }" }, + { old_pos: 21, new_pos: 4, type: "old", text: "-" }, + { old_pos: 22, new_pos: 4, type: "old", text: "- unless File.directory?(path)" }, + { old_pos: 23, new_pos: 4, type: "old", text: "- FileUtils.mkdir_p(path)" }, + { old_pos: 24, new_pos: 4, type: "old", text: "- end" }, + { old_pos: 25, new_pos: 4, type: "old", text: "-" }, + { old_pos: 26, new_pos: 4, type: "old", text: "- @cmd_output = \"\"" }, + { old_pos: 27, new_pos: 4, type: "old", text: "- @cmd_status = 0" }, + { old_pos: 28, new_pos: 4, type: "old", text: "-" }, + { old_pos: 29, new_pos: 4, type: "old", text: "- Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|" }, + { old_pos: 30, new_pos: 4, type: "old", text: "- @cmd_output << stdout.read" }, + { old_pos: 31, new_pos: 4, type: "old", text: "- @cmd_output << stderr.read" }, + { old_pos: 32, new_pos: 4, type: "old", text: "- @cmd_status = wait_thr.value.exitstatus" }, + { old_pos: 33, new_pos: 4, type: "old", text: "- end" }, + { old_pos: 34, new_pos: 4, type: "old", text: "-" }, + { old_pos: 35, new_pos: 4, type: "old", text: "- return @cmd_output, @cmd_status" }, + { old_pos: 36, new_pos: 4, type: "old", text: "- end" }, + { old_pos: 37, new_pos: 4, type: "old", text: "-end" }, + { old_pos: 38, new_pos: 4, type: "new", text: "+# parsed suggestion content" }, + { old_pos: 38, new_pos: 5, type: "new", text: "+# with comments" } + ] + end it_behaves_like 'correct suggestion raw content' end @@ -70,17 +133,19 @@ describe Gitlab::Diff::Suggestion do let(:expected_below) { below } let(:expected_above) { above } let(:expected_lines) { blob_lines_data(line - expected_above, line + expected_below) } - - it_behaves_like 'correct suggestion raw content' - end - - context 'when no extra lines (single-line suggestion)' do - let(:line) { 5 } - let(:above) { 0 } - let(:below) { 0 } - let(:expected_below) { below } - let(:expected_above) { above } - let(:expected_lines) { blob_lines_data(line - expected_above, line + expected_below) } + let(:expected_diff_lines) do + [ + { old_pos: 3, new_pos: 3, type: "match", text: "@@ -3 +3" }, + { old_pos: 3, new_pos: 3, type: "old", text: "-" }, + { old_pos: 4, new_pos: 3, type: "old", text: "-module Popen" }, + { old_pos: 5, new_pos: 3, type: "old", text: "- extend self" }, + { old_pos: 6, new_pos: 3, type: "old", text: "-" }, + { old_pos: 7, new_pos: 3, type: "old", text: "- def popen(cmd, path=nil)" }, + { old_pos: 8, new_pos: 3, type: "old", text: "- unless cmd.is_a?(Array)" }, + { old_pos: 9, new_pos: 3, type: "new", text: "+# parsed suggestion content" }, + { old_pos: 9, new_pos: 4, type: "new", text: "+# with comments" } + ] + end it_behaves_like 'correct suggestion raw content' end diff --git a/spec/lib/gitlab/diff/suggestions_parser_spec.rb b/spec/lib/gitlab/diff/suggestions_parser_spec.rb index 1119ea04995..1f2af42f6e7 100644 --- a/spec/lib/gitlab/diff/suggestions_parser_spec.rb +++ b/spec/lib/gitlab/diff/suggestions_parser_spec.rb @@ -69,5 +69,66 @@ describe Gitlab::Diff::SuggestionsParser do lines_below: 0) end end + + context 'multi-line suggestions' do + let(:markdown) do + <<-MARKDOWN.strip_heredoc + ```suggestion:-2+1 + # above and below + ``` + + ``` + nothing + ``` + + ```suggestion:-3 + # only above + ``` + + ```suggestion:+3 + # only below + ``` + + ```thing + this is not a suggestion, it's a thing + ``` + MARKDOWN + end + + it 'returns a list of Gitlab::Diff::Suggestion' do + expect(subject).to all(be_a(Gitlab::Diff::Suggestion)) + expect(subject.size).to eq(3) + end + + it 'suggestion with above and below param has correct data' do + from_line = position.new_line - 2 + to_line = position.new_line + 1 + + expect(subject.first.to_hash).to include(from_content: blob_lines_data(from_line, to_line), + to_content: " # above and below\n", + lines_above: 2, + lines_below: 1) + end + + it 'suggestion with above param has correct data' do + from_line = position.new_line - 3 + to_line = position.new_line + + expect(subject.second.to_hash).to eq(from_content: blob_lines_data(from_line, to_line), + to_content: " # only above\n", + lines_above: 3, + lines_below: 0) + end + + it 'suggestion with below param has correct data' do + from_line = position.new_line + to_line = position.new_line + 3 + + expect(subject.third.to_hash).to eq(from_content: blob_lines_data(from_line, to_line), + to_content: " # only below\n", + lines_above: 0, + lines_below: 3) + end + end end end diff --git a/spec/models/suggestion_spec.rb b/spec/models/suggestion_spec.rb index cafc725dddb..8d4e9070b19 100644 --- a/spec/models/suggestion_spec.rb +++ b/spec/models/suggestion_spec.rb @@ -21,6 +21,22 @@ describe Suggestion do end end + describe '#diff_lines' do + let(:suggestion) { create(:suggestion, :content_from_repo) } + + it 'returns parsed diff lines' do + expected_diff_lines = Gitlab::Diff::SuggestionDiff.new(suggestion).diff_lines + diff_lines = suggestion.diff_lines + + expect(diff_lines.size).to eq(expected_diff_lines.size) + expect(diff_lines).to all(be_a(Gitlab::Diff::Line)) + + expected_diff_lines.each_with_index do |expected_line, index| + expect(diff_lines[index].to_hash).to eq(expected_line.to_hash) + end + end + end + describe '#appliable?' do context 'when note does not support suggestions' do it 'returns false' do diff --git a/spec/serializers/suggestion_entity_spec.rb b/spec/serializers/suggestion_entity_spec.rb index d38fc2b132b..d282a7f9c7a 100644 --- a/spec/serializers/suggestion_entity_spec.rb +++ b/spec/serializers/suggestion_entity_spec.rb @@ -13,8 +13,7 @@ describe SuggestionEntity do subject { entity.as_json } it 'exposes correct attributes' do - expect(subject).to include(:id, :from_line, :to_line, :appliable, - :applied, :from_content, :to_content) + expect(subject.keys).to match_array([:id, :appliable, :applied, :diff_lines, :current_user]) end it 'exposes current user abilities' do diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb index 85515d548a7..a1d31464e07 100644 --- a/spec/services/preview_markdown_service_spec.rb +++ b/spec/services/preview_markdown_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe PreviewMarkdownService do let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } before do project.add_developer(user) @@ -20,23 +20,72 @@ describe PreviewMarkdownService do end describe 'suggestions' do - let(:params) { { text: "```suggestion\nfoo\n```", preview_suggestions: preview_suggestions } } + let(:merge_request) do + create(:merge_request, target_project: project, source_project: project) + end + let(:text) { "```suggestion\nfoo\n```" } + let(:params) do + suggestion_params.merge(text: text, + target_type: 'MergeRequest', + target_id: merge_request.iid) + end let(:service) { described_class.new(project, user, params) } context 'when preview markdown param is present' do - let(:preview_suggestions) { true } + let(:path) { "files/ruby/popen.rb" } + let(:line) { 10 } + let(:diff_refs) { merge_request.diff_refs } + + let(:suggestion_params) do + { + preview_suggestions: true, + file_path: path, + line: line, + base_sha: diff_refs.base_sha, + start_sha: diff_refs.start_sha, + head_sha: diff_refs.head_sha + } + end + + it 'returns suggestions referenced in text' do + position = Gitlab::Diff::Position.new(new_path: path, + new_line: line, + diff_refs: diff_refs) + + expect(Gitlab::Diff::SuggestionsParser) + .to receive(:parse) + .with(text, position: position, project: merge_request.project) + .and_call_original - it 'returns users referenced in text' do result = service.execute - expect(result[:suggestions]).to eq(['foo']) + expect(result[:suggestions]).to all(be_a(Gitlab::Diff::Suggestion)) + end + + context 'when user is not authorized' do + let(:another_user) { create(:user) } + let(:service) { described_class.new(project, another_user, params) } + + before do + project.add_guest(another_user) + end + + it 'returns no suggestions' do + result = service.execute + + expect(result[:suggestions]).to be_empty + end end end context 'when preview markdown param is not present' do - let(:preview_suggestions) { false } + let(:suggestion_params) do + { + preview_suggestions: false + } + end - it 'returns users referenced in text' do + it 'returns suggestions referenced in text' do result = service.execute expect(result[:suggestions]).to eq([]) @@ -49,8 +98,8 @@ describe PreviewMarkdownService do let(:params) do { text: "Please do it\n/assign #{user.to_reference}", - quick_actions_target_type: 'Issue', - quick_actions_target_id: issue.id + target_type: 'Issue', + target_id: issue.id } end let(:service) { described_class.new(project, user, params) } @@ -72,7 +121,7 @@ describe PreviewMarkdownService do let(:params) do { text: "My work\n/estimate 2y", - quick_actions_target_type: 'MergeRequest' + target_type: 'MergeRequest' } end let(:service) { described_class.new(project, user, params) } @@ -96,8 +145,8 @@ describe PreviewMarkdownService do let(:params) do { text: "My work\n/tag v1.2.3 Stable release", - quick_actions_target_type: 'Commit', - quick_actions_target_id: commit.id + target_type: 'Commit', + target_id: commit.id } end let(:service) { described_class.new(project, user, params) } diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb index 80b5dcac6c7..7732767137c 100644 --- a/spec/services/suggestions/apply_service_spec.rb +++ b/spec/services/suggestions/apply_service_spec.rb @@ -51,6 +51,10 @@ describe Suggestions::ApplyService do diff_refs: merge_request.diff_refs) end + let(:diff_note) do + create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project) + end + let(:suggestion) do create(:suggestion, :content_from_repo, note: diff_note, to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n") @@ -108,12 +112,6 @@ describe Suggestions::ApplyService do target_project: project) end - let!(:diff_note) do - create(:diff_note_on_merge_request, noteable: merge_request, - position: position, - project: project) - end - before do project.add_maintainer(user) end @@ -192,11 +190,6 @@ describe Suggestions::ApplyService do CONTENT end - let(:merge_request) do - create(:merge_request, source_project: project, - target_project: project) - end - def create_suggestion(diff, old_line: nil, new_line: nil, from_content:, to_content:, path:) position = Gitlab::Diff::Position.new(old_path: path, new_path: path, @@ -291,6 +284,55 @@ describe Suggestions::ApplyService do expect(suggestion_2_diff.strip).to eq(expected_suggestion_2_diff.strip) end end + + context 'multi-line suggestion' do + let(:expected_content) do + <<~CONTENT + require 'fileutils' + require 'open3' + + module Popen + extend self + + # multi + # line + + vars = { + "PWD" => path + } + + options = { + chdir: path + } + + unless File.directory?(path) + FileUtils.mkdir_p(path) + end + + @cmd_output = "" + @cmd_status = 0 + + Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr| + @cmd_output << stdout.read + @cmd_output << stderr.read + @cmd_status = wait_thr.value.exitstatus + end + + return @cmd_output, @cmd_status + end + end + CONTENT + end + + let(:suggestion) do + create(:suggestion, :content_from_repo, note: diff_note, + lines_above: 2, + lines_below: 3, + to_content: "# multi\n# line\n") + end + + it_behaves_like 'successfully creates commit and updates suggestion' + end end context 'fork-project' do -- GitLab From 7af20f8e510ed389ba39995038d10369a93afe77 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Thu, 4 Apr 2019 14:02:42 +0100 Subject: [PATCH 7/9] Fix an order-dependent spec failure in spec/migrations/schedule_sync_issuables_state_id_spec.rb --- spec/migrations/schedule_runners_token_encryption_spec.rb | 2 +- spec/migrations/schedule_sync_issuables_state_id_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/migrations/schedule_runners_token_encryption_spec.rb b/spec/migrations/schedule_runners_token_encryption_spec.rb index 376d2795277..97ff6c128f3 100644 --- a/spec/migrations/schedule_runners_token_encryption_spec.rb +++ b/spec/migrations/schedule_runners_token_encryption_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20181121111200_schedule_runners_token_encryption') -describe ScheduleRunnersTokenEncryption, :migration do +describe ScheduleRunnersTokenEncryption, :migration, :sidekiq do let(:settings) { table(:application_settings) } let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/migrations/schedule_sync_issuables_state_id_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_spec.rb index bf974d60b24..bc94f8820bd 100644 --- a/spec/migrations/schedule_sync_issuables_state_id_spec.rb +++ b/spec/migrations/schedule_sync_issuables_state_id_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20190214112022_schedule_sync_issuables_state_id.rb') -describe ScheduleSyncIssuablesStateId, :migration do +describe ScheduleSyncIssuablesStateId, :migration, :sidekiq do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:merge_requests) { table(:merge_requests) } -- GitLab From 435d98c9cee0d32305a6fd20995f41849749e8eb Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Thu, 4 Apr 2019 13:50:31 +0000 Subject: [PATCH 8/9] Monitor GraphQL with Prometheus Extends graphql's platform tracing class to observe duration of graphql methods. In graphql 1.8.11 is added prometheus class but it's not very useful for us because it uses prometheus_exporter to export results. --- app/graphql/gitlab_schema.rb | 1 + changelogs/unreleased/graphql-prometheus.yml | 5 +++ lib/gitlab/graphql/tracing.rb | 43 ++++++++++++++++++++ spec/lib/gitlab/graphql/tracing_spec.rb | 33 +++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 changelogs/unreleased/graphql-prometheus.yml create mode 100644 lib/gitlab/graphql/tracing.rb create mode 100644 spec/lib/gitlab/graphql/tracing_spec.rb diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 06d26309b5b..ecc34eacc7d 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -5,6 +5,7 @@ class GitlabSchema < GraphQL::Schema use Gitlab::Graphql::Authorize use Gitlab::Graphql::Present use Gitlab::Graphql::Connections + use Gitlab::Graphql::Tracing query(Types::QueryType) diff --git a/changelogs/unreleased/graphql-prometheus.yml b/changelogs/unreleased/graphql-prometheus.yml new file mode 100644 index 00000000000..180577f3aec --- /dev/null +++ b/changelogs/unreleased/graphql-prometheus.yml @@ -0,0 +1,5 @@ +--- +title: Added prometheus monitoring to GraphQL +merge_request: +author: +type: added diff --git a/lib/gitlab/graphql/tracing.rb b/lib/gitlab/graphql/tracing.rb new file mode 100644 index 00000000000..6b505e4262b --- /dev/null +++ b/lib/gitlab/graphql/tracing.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + class Tracing < GraphQL::Tracing::PlatformTracing + self.platform_keys = { + 'lex' => 'graphql.lex', + 'parse' => 'graphql.parse', + 'validate' => 'graphql.validate', + 'analyze_query' => 'graphql.analyze', + 'analyze_multiplex' => 'graphql.analyze', + 'execute_multiplex' => 'graphql.execute', + 'execute_query' => 'graphql.execute', + 'execute_query_lazy' => 'graphql.execute', + 'execute_field' => 'graphql.execute', + 'execute_field_lazy' => 'graphql.execute' + } + + def platform_field_key(type, field) + "#{type.name}.#{field.name}" + end + + def platform_trace(platform_key, key, data, &block) + start = Gitlab::Metrics::System.monotonic_time + + yield + ensure + duration = Gitlab::Metrics::System.monotonic_time - start + + graphql_duration_seconds.observe({ platform_key: platform_key, key: key }, duration) + end + + private + + def graphql_duration_seconds + @graphql_duration_seconds ||= Gitlab::Metrics.histogram( + :graphql_duration_seconds, + 'GraphQL execution time' + ) + end + end + end +end diff --git a/spec/lib/gitlab/graphql/tracing_spec.rb b/spec/lib/gitlab/graphql/tracing_spec.rb new file mode 100644 index 00000000000..7300a9a572e --- /dev/null +++ b/spec/lib/gitlab/graphql/tracing_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Tracing do + let(:graphql_duration_seconds_histogram) { double('Gitlab::Metrics::NullMetric') } + + it 'updates graphql histogram with expected labels' do + query = 'query { users { id } }' + tracer = described_class.new + + allow(tracer) + .to receive(:graphql_duration_seconds) + .and_return(graphql_duration_seconds_histogram) + + expect_metric('graphql.lex', 'lex') + expect_metric('graphql.parse', 'parse') + expect_metric('graphql.validate', 'validate') + expect_metric('graphql.analyze', 'analyze_multiplex') + expect_metric('graphql.execute', 'execute_query_lazy') + expect_metric('graphql.execute', 'execute_multiplex') + + GitlabSchema.execute(query, context: { tracers: [tracer] }) + end + + private + + def expect_metric(platform_key, key) + expect(graphql_duration_seconds_histogram) + .to receive(:observe) + .with({ platform_key: platform_key, key: key }, be > 0.0) + end +end -- GitLab From 17bee986bc971cc7d04c4b767cc026577eb56c6a Mon Sep 17 00:00:00 2001 From: Gosia Ksionek Date: Thu, 4 Apr 2019 14:19:57 +0000 Subject: [PATCH 9/9] Add cr remarks Chnage method used in model to make it more efficient database-wise Add additional spec --- app/models/group.rb | 7 +- .../unreleased/38564-cant-leave-subgroup.yml | 5 + spec/models/group_spec.rb | 26 +++++ spec/policies/group_member_policy_spec.rb | 105 ++++++++++++++++++ 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 changelogs/unreleased/38564-cant-leave-subgroup.yml create mode 100644 spec/policies/group_member_policy_spec.rb diff --git a/app/models/group.rb b/app/models/group.rb index c77586c4cdc..ac66815705c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -228,22 +228,21 @@ class Group < Namespace def has_owner?(user) return false unless user - members_with_parents.owners.where(user_id: user).any? + members_with_parents.owners.exists?(user_id: user) end def has_maintainer?(user) return false unless user - members_with_parents.maintainers.where(user_id: user).any? + members_with_parents.maintainers.exists?(user_id: user) end # @deprecated alias_method :has_master?, :has_maintainer? # Check if user is a last owner of the group. - # Parent owners are ignored for nested groups. def last_owner?(user) - owners.include?(user) && owners.size == 1 + has_owner?(user) && members_with_parents.owners.size == 1 end def ldap_synced? diff --git a/changelogs/unreleased/38564-cant-leave-subgroup.yml b/changelogs/unreleased/38564-cant-leave-subgroup.yml new file mode 100644 index 00000000000..a6397062343 --- /dev/null +++ b/changelogs/unreleased/38564-cant-leave-subgroup.yml @@ -0,0 +1,5 @@ +--- +title: Allow removing last owner from subgroup if parent group has owners +merge_request: 26718 +author: +type: changed diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 2c6abddca17..b2ffd5812ab 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -364,6 +364,32 @@ describe Group do it { expect(group.has_maintainer?(nil)).to be_falsey } end + describe '#last_owner?' do + before do + @members = setup_group_members(group) + end + + it { expect(group.last_owner?(@members[:owner])).to be_truthy } + + context 'with two owners' do + before do + create(:group_member, :owner, group: group) + end + + it { expect(group.last_owner?(@members[:owner])).to be_falsy } + end + + context 'with owners from a parent', :postgresql do + before do + parent_group = create(:group) + create(:group_member, :owner, group: parent_group) + group.update(parent: parent_group) + end + + it { expect(group.last_owner?(@members[:owner])).to be_falsy } + end + end + describe '#lfs_enabled?' do context 'LFS enabled globally' do before do diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb new file mode 100644 index 00000000000..7bd7184cffe --- /dev/null +++ b/spec/policies/group_member_policy_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GroupMemberPolicy do + let(:guest) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :private) } + + before do + group.add_guest(guest) + group.add_owner(owner) + end + + let(:member_related_permissions) do + [:update_group_member, :destroy_group_member] + end + + let(:membership) { current_user.members.first } + + subject { described_class.new(current_user, membership) } + + def expect_allowed(*permissions) + permissions.each { |p| is_expected.to be_allowed(p) } + end + + def expect_disallowed(*permissions) + permissions.each { |p| is_expected.not_to be_allowed(p) } + end + + context 'with guest user' do + let(:current_user) { guest } + + it do + expect_disallowed(:member_related_permissions) + end + end + + context 'with one owner' do + let(:current_user) { owner } + + it do + expect_disallowed(:destroy_group_member) + expect_disallowed(:update_group_member) + end + end + + context 'with more than one owner' do + let(:current_user) { owner } + + before do + group.add_owner(create(:user)) + end + + it do + expect_allowed(:destroy_group_member) + expect_allowed(:update_group_member) + end + end + + context 'with the group parent', :postgresql do + let(:current_user) { create :user } + let(:subgroup) { create(:group, :private, parent: group)} + + before do + group.add_owner(owner) + subgroup.add_owner(current_user) + end + + it do + expect_allowed(:destroy_group_member) + expect_allowed(:update_group_member) + end + end + + context 'without group parent' do + let(:current_user) { create :user } + let(:subgroup) { create(:group, :private)} + + before do + subgroup.add_owner(current_user) + end + + it do + expect_disallowed(:destroy_group_member) + expect_disallowed(:update_group_member) + end + end + + context 'without group parent with two owners' do + let(:current_user) { create :user } + let(:other_user) { create :user } + let(:subgroup) { create(:group, :private)} + + before do + subgroup.add_owner(current_user) + subgroup.add_owner(other_user) + end + + it do + expect_allowed(:destroy_group_member) + expect_allowed(:update_group_member) + end + end +end -- GitLab