diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-05 18:08:48 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-05 18:08:48 +0300 |
commit | eabf8fd774fef6a54903e5141138f47bdafeb331 (patch) | |
tree | c74ff99fa6765cbe091767e9827374ce44b0df50 | |
parent | 20d564f1064622ef0623434372ac3ceb03173331 (diff) |
Add latest changes from gitlab-org/gitlab@master
52 files changed, 582 insertions, 863 deletions
@@ -84,7 +84,7 @@ gem 'net-ldap' # API gem 'grape', '~> 1.1.0' gem 'grape-entity', '~> 0.7.1' -gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' +gem 'rack-cors', '~> 1.0.6', require: 'rack/cors' # GraphQL API gem 'graphql', '~> 1.9.11' diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index a26acbbe301..aefbef5467d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -28,8 +28,8 @@ export default function setupVueRepositoryList() { }, }); - router.afterEach(({ params: { pathMatch } }) => { - setTitle(pathMatch, ref, fullName); + router.afterEach(({ params: { path } }) => { + setTitle(path, ref, fullName); }); const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); @@ -48,9 +48,9 @@ export default function setupVueRepositoryList() { newDirPath, } = breadcrumbEl.dataset; - router.afterEach(({ params: { pathMatch = '/' } }) => { - updateFormAction('.js-upload-blob-form', uploadPath, pathMatch); - updateFormAction('.js-create-dir-form', newDirPath, pathMatch); + router.afterEach(({ params: { path = '/' } }) => { + updateFormAction('.js-upload-blob-form', uploadPath, path); + updateFormAction('.js-create-dir-form', newDirPath, path); }); // eslint-disable-next-line no-new @@ -61,7 +61,7 @@ export default function setupVueRepositoryList() { render(h) { return h(Breadcrumbs, { props: { - currentPath: this.$route.params.pathMatch, + currentPath: this.$route.params.path, canCollaborate: parseBoolean(canCollaborate), canEditTree: parseBoolean(canEditTree), newBranchPath, @@ -84,7 +84,7 @@ export default function setupVueRepositoryList() { render(h) { return h(LastCommit, { props: { - currentPath: this.$route.params.pathMatch, + currentPath: this.$route.params.path, }, }); }, @@ -100,7 +100,7 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: historyLink + (this.$route.params.pathMatch || '/'), + path: `${historyLink}/${this.$route.params.path || ''}`, text: __('History'), }, }); @@ -117,7 +117,7 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`), + path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`), text: __('Web IDE'), cssClass: 'qa-web-ide-button', }, @@ -134,7 +134,7 @@ export default function setupVueRepositoryList() { el: directoryDownloadLinks, router, render(h) { - const currentPath = this.$route.params.pathMatch || '/'; + const currentPath = this.$route.params.path || '/'; if (currentPath !== '/') { return h(DirectoryDownloadLinks, { diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index e68996245a8..cb6c2294679 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -13,10 +13,10 @@ export default { return { projectPath: '', loadingPath: null }; }, beforeRouteUpdate(to, from, next) { - this.preload(to.params.pathMatch, next); + this.preload(to.params.path, next); }, methods: { - preload(path, next) { + preload(path = '/', next) { this.loadingPath = path.replace(/^\//, ''); return this.$apollo diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 6c31c9bae82..2386773699c 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,11 +12,11 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `/-/tree/${escape(baseRef)}(/.*)?`, + path: `(/-)?/tree/${escape(baseRef)}/:path*`, name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'), + path: route.params.path?.replace(/^\//, '') || '/', }), }, { diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue deleted file mode 100644 index 25d7bfe515c..00000000000 --- a/app/assets/javascripts/vue_shared/components/bar_chart.vue +++ /dev/null @@ -1,351 +0,0 @@ -<script> -import * as d3 from 'd3'; -import tooltip from '../directives/tooltip'; -import Icon from './icon.vue'; -import SvgGradient from './svg_gradient.vue'; -import { - GRADIENT_COLORS, - GRADIENT_OPACITY, - INVERSE_GRADIENT_COLORS, - INVERSE_GRADIENT_OPACITY, -} from './bar_chart_constants'; - -/** - * Renders a bar chart that can be dragged(scrolled) when the number - * of elements to renders surpasses that of the available viewport space - * while keeping even padding and a width of 24px (customizable) - * - * It can render data with the following format: - * graphData: [{ - * name: 'element' // x domain data - * value: 1 // y domain data - * }] - * - * Used in: - * - Contribution analytics - all of the rows describing pushes, merge requests and issues - */ - -export default { - directives: { - tooltip, - }, - components: { - Icon, - SvgGradient, - }, - props: { - graphData: { - type: Array, - required: true, - }, - barWidth: { - type: Number, - required: false, - default: 24, - }, - yAxisLabel: { - type: String, - required: true, - }, - }, - data() { - return { - minX: -40, - minY: 0, - vbWidth: 0, - vbHeight: 0, - vpWidth: 0, - vpHeight: 200, - preserveAspectRatioType: 'xMidYMin meet', - containerMargin: { - leftRight: 30, - }, - viewBoxMargin: { - topBottom: 100, - }, - panX: 0, - xScale: {}, - yScale: {}, - zoom: {}, - bars: {}, - xGraphRange: 0, - isLoading: true, - paddingThreshold: 50, - showScrollIndicator: false, - showLeftScrollIndicator: false, - isGrabbed: false, - isPanAvailable: false, - gradientColors: GRADIENT_COLORS, - gradientOpacity: GRADIENT_OPACITY, - inverseGradientColors: INVERSE_GRADIENT_COLORS, - inverseGradientOpacity: INVERSE_GRADIENT_OPACITY, - maxTextWidth: 72, - rectYAxisLabelDims: {}, - xAxisTextElements: {}, - yAxisRectTransformPadding: 20, - yAxisTextTransformPadding: 10, - yAxisTextRotation: 90, - }; - }, - computed: { - svgViewBox() { - return `${this.minX} ${this.minY} ${this.vbWidth} ${this.vbHeight}`; - }, - xAxisLocation() { - return `translate(${this.panX}, ${this.vbHeight})`; - }, - barTranslationTransform() { - return `translate(${this.panX}, 0)`; - }, - scrollIndicatorTransform() { - return `translate(${this.vbWidth - 80}, 0)`; - }, - activateGrabCursor() { - return { - 'svg-graph-container-with-grab': this.isPanAvailable, - 'svg-graph-container-grabbed': this.isPanAvailable && this.isGrabbed, - }; - }, - yAxisLabelRectTransform() { - const rectWidth = - this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0; - const yCoord = this.vbHeight / 2 - rectWidth; - - return `translate(${this.minX - this.yAxisRectTransformPadding}, ${yCoord})`; - }, - yAxisLabelTextTransform() { - const rectWidth = - this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0; - const yCoord = this.vbHeight / 2 + rectWidth - 5; - - return `translate(${this.minX + this.yAxisTextTransformPadding}, ${yCoord}) rotate(-${ - this.yAxisTextRotation - })`; - }, - }, - mounted() { - if (!this.allValuesEmpty) { - this.draw(); - } - }, - methods: { - draw() { - // update viewport - this.vpWidth = this.$refs.svgContainer.clientWidth - this.containerMargin.leftRight; - // update viewbox - this.vbWidth = this.vpWidth; - this.vbHeight = this.vpHeight - this.viewBoxMargin.topBottom; - let padding = 0; - if (this.graphData.length * this.barWidth > this.vbWidth) { - this.xGraphRange = this.graphData.length * this.barWidth; - padding = this.calculatePadding(this.barWidth); - this.showScrollIndicator = true; - this.isPanAvailable = true; - } else { - this.xGraphRange = this.vbWidth - Math.abs(this.minX); - } - - this.xScale = d3 - .scaleBand() - .range([0, this.xGraphRange]) - .round(true) - .paddingInner(padding); - this.yScale = d3.scaleLinear().rangeRound([this.vbHeight, 0]); - - this.xScale.domain(this.graphData.map(d => d.name)); - this.yScale.domain([0, d3.max(this.graphData.map(d => d.value))]); - - // Zoom/Panning Function - this.zoom = d3 - .zoom() - .translateExtent([[0, 0], [this.xGraphRange, this.vbHeight]]) - .on('zoom', this.panGraph) - .on('end', this.removeGrabStyling); - - const xAxis = d3.axisBottom().scale(this.xScale); - - const yAxis = d3 - .axisLeft() - .scale(this.yScale) - .ticks(4); - - const renderedXAxis = d3 - .select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - this.xAxisTextElements = this.$refs.xAxis.querySelectorAll('text'); - - renderedXAxis.select('.domain').remove(); - - renderedXAxis - .selectAll('text') - .style('text-anchor', 'end') - .attr('dx', '-.3em') - .attr('dy', '-.95em') - .attr('class', 'tick-text') - .attr('transform', 'rotate(-90)'); - - renderedXAxis.selectAll('line').remove(); - - const { maxTextWidth } = this; - renderedXAxis.selectAll('text').each(function formatText() { - const axisText = d3.select(this); - let textLength = axisText.node().getComputedTextLength(); - let textContent = axisText.text(); - while (textLength > maxTextWidth && textContent.length > 0) { - textContent = textContent.slice(0, -1); - axisText.text(`${textContent}...`); - textLength = axisText.node().getComputedTextLength(); - } - }); - - const width = this.vbWidth; - - const renderedYAxis = d3 - .select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis); - - renderedYAxis.selectAll('.tick').each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } - }); - - // Add the panning capabilities - if (this.isPanAvailable) { - d3.select(this.$refs.baseSvg) - .call(this.zoom) - .on('wheel.zoom', null); // This disables the pan of the graph with the scroll of the mouse wheel - } - - this.isLoading = false; - // Update the yAxisLabel coordinates - const labelDims = this.$refs.yAxisLabel.getBBox(); - this.rectYAxisLabelDims = { - height: labelDims.width + 10, - }; - }, - panGraph() { - const allowedRightScroll = this.xGraphRange - this.vbWidth - this.paddingThreshold; - const graphMaxPan = Math.abs(d3.event.transform.x) < allowedRightScroll; - this.isGrabbed = true; - this.panX = d3.event.transform.x; - - if (d3.event.transform.x === 0) { - this.showLeftScrollIndicator = false; - } else { - this.showLeftScrollIndicator = true; - this.showScrollIndicator = true; - } - - if (!graphMaxPan) { - this.panX = -1 * (this.xGraphRange - this.vbWidth + this.paddingThreshold); - this.showScrollIndicator = false; - } - }, - setTooltipTitle(data) { - return data !== null ? `${data.name}: ${data.value}` : ''; - }, - calculatePadding(desiredBarWidth) { - const widthWithMargin = this.vbWidth - Math.abs(this.minX); - const dividend = widthWithMargin - this.graphData.length * desiredBarWidth; - const divisor = widthWithMargin - desiredBarWidth; - - return dividend / divisor; - }, - removeGrabStyling() { - this.isGrabbed = false; - }, - barHoveredIn(index) { - this.xAxisTextElements[index].classList.add('x-axis-text'); - }, - barHoveredOut(index) { - this.xAxisTextElements[index].classList.remove('x-axis-text'); - }, - }, -}; -</script> -<template> - <div ref="svgContainer" :class="activateGrabCursor" class="svg-graph-container"> - <svg - ref="baseSvg" - class="svg-graph overflow-visible pt-5" - :width="vpWidth" - :height="vpHeight" - :viewBox="svgViewBox" - :preserveAspectRatio="preserveAspectRatioType" - > - <g ref="xAxis" :transform="xAxisLocation" class="x-axis" /> - <g v-if="!isLoading"> - <template v-for="(data, index) in graphData"> - <rect - :key="index" - v-tooltip - :width="xScale.bandwidth()" - :x="xScale(data.name)" - :y="yScale(data.value)" - :height="vbHeight - yScale(data.value)" - :transform="barTranslationTransform" - :title="setTooltipTitle(data)" - class="bar-rect" - data-placement="top" - @mouseover="barHoveredIn(index)" - @mouseout="barHoveredOut(index)" - /> - </template> - </g> - <rect :height="vbHeight + 100" transform="translate(-100, -5)" width="100" fill="#fff" /> - <g class="y-axis-label"> - <line :x1="0" :x2="0" :y1="0" :y2="vbHeight" transform="translate(-35, 0)" stroke="black" /> - <!-- Get text length and change the height of this rect accordingly --> - <rect - :height="rectYAxisLabelDims.height" - :transform="yAxisLabelRectTransform" - :width="30" - fill="#fff" - /> - <text ref="yAxisLabel" :transform="yAxisLabelTextTransform">{{ yAxisLabel }}</text> - </g> - <g class="y-axis" /> - <g v-if="showScrollIndicator"> - <rect - :height="vbHeight + 100" - :transform="`translate(${vpWidth - 60}, -5)`" - width="40" - fill="#fff" - /> - <icon - :x="vpWidth - 50" - :y="vbHeight / 2" - :width="14" - :height="14" - name="chevron-right" - class="animate-flicker" - /> - </g> - <!-- The line that shows up when the data elements surpass the available width --> - <g v-if="showScrollIndicator" :transform="scrollIndicatorTransform"> - <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#shadow-gradient)" /> - </g> - <!-- Left scroll indicator --> - <g v-if="showLeftScrollIndicator" transform="translate(0, 0)"> - <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#left-shadow-gradient)" /> - </g> - <svg-gradient - :colors="gradientColors" - :opacity="gradientOpacity" - identifier-name="shadow-gradient" - /> - <svg-gradient - :colors="inverseGradientColors" - :opacity="inverseGradientOpacity" - identifier-name="left-shadow-gradient" - /> - </svg> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js b/app/assets/javascripts/vue_shared/components/bar_chart_constants.js deleted file mode 100644 index 6957b112da6..00000000000 --- a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const GRADIENT_COLORS = ['#000', '#a7a7a7']; -export const GRADIENT_OPACITY = ['0', '0.4']; -export const INVERSE_GRADIENT_COLORS = ['#a7a7a7', '#000']; -export const INVERSE_GRADIENT_OPACITY = ['0.4', '0']; diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb index 1325bce6c43..30f4026e633 100644 --- a/app/models/sentry_issue.rb +++ b/app/models/sentry_issue.rb @@ -5,13 +5,22 @@ class SentryIssue < ApplicationRecord validates :issue, uniqueness: true, presence: true validates :sentry_issue_identifier, presence: true + validate :ensure_sentry_issue_identifier_is_unique_per_project after_create_commit :enqueue_sentry_sync_job def self.for_project_and_identifier(project, identifier) joins(:issue) .where(issues: { project_id: project.id }) - .find_by_sentry_issue_identifier(identifier) + .where(sentry_issue_identifier: identifier) + .order('issues.created_at').last + end + + def ensure_sentry_issue_identifier_is_unique_per_project + if issue && self.class.for_project_and_identifier(issue.project, sentry_issue_identifier).present? + # Custom message because field is hidden + errors.add(:_, _('is already associated to a GitLab Issue. New issue will not be associated.')) + end end def enqueue_sentry_sync_job diff --git a/changelogs/unreleased/37174-move-contribution-analytics-chart-to-echarts.yml b/changelogs/unreleased/37174-move-contribution-analytics-chart-to-echarts.yml new file mode 100644 index 00000000000..fc7e2cf588d --- /dev/null +++ b/changelogs/unreleased/37174-move-contribution-analytics-chart-to-echarts.yml @@ -0,0 +1,5 @@ +--- +title: Move contribution analytics chart to echarts +merge_request: 24272 +author: +type: other diff --git a/changelogs/unreleased/geo-spike-self-service-framework.yml b/changelogs/unreleased/geo-spike-self-service-framework.yml deleted file mode 100644 index 409bc8a9298..00000000000 --- a/changelogs/unreleased/geo-spike-self-service-framework.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Geo: Add tables to prepare to replicate package files' -merge_request: 23447 -author: -type: added diff --git a/changelogs/unreleased/mc-bug-fail-upstream-on-invalid-yaml.yml b/changelogs/unreleased/mc-bug-fail-upstream-on-invalid-yaml.yml new file mode 100644 index 00000000000..910f2851400 --- /dev/null +++ b/changelogs/unreleased/mc-bug-fail-upstream-on-invalid-yaml.yml @@ -0,0 +1,5 @@ +--- +title: Fail upstream bridge on downstream pipeline creation failure. +merge_request: 24092 +author: +type: fixed diff --git a/changelogs/unreleased/refactoring-entities-file-11.yml b/changelogs/unreleased/refactoring-entities-file-11.yml new file mode 100644 index 00000000000..f6002642df5 --- /dev/null +++ b/changelogs/unreleased/refactoring-entities-file-11.yml @@ -0,0 +1,5 @@ +--- +title: Separate merge request entities into own class files +merge_request: 24373 +author: Rajendra Kadam +type: added diff --git a/config/application.rb b/config/application.rb index 5241a195e53..e8cc35aed2a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -55,8 +55,6 @@ module Gitlab memo << ee_path.to_s end - ee_paths << "ee/app/replicators" - # Eager load should load CE first config.eager_load_paths.push(*ee_paths) config.helpers_paths.push "#{config.root}/ee/app/helpers" diff --git a/config/initializers/0_inflections.rb b/config/initializers/0_inflections.rb index 5c38859a667..7690eafdc6b 100644 --- a/config/initializers/0_inflections.rb +++ b/config/initializers/0_inflections.rb @@ -19,7 +19,6 @@ ActiveSupport::Inflector.inflections do |inflect| group_view job_artifact_registry lfs_object_registry - package_file_registry project_auto_devops project_registry project_statistics diff --git a/db/migrate/20200121192942_create_geo_events.rb b/db/migrate/20200121192942_create_geo_events.rb deleted file mode 100644 index 6dbe131051f..00000000000 --- a/db/migrate/20200121192942_create_geo_events.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class CreateGeoEvents < ActiveRecord::Migration[5.2] - DOWNTIME = false - - def change - create_table :geo_events do |t| - t.string :replicable_name, limit: 255, null: false - t.string :event_name, limit: 255, null: false - t.jsonb :payload, default: {}, null: false - t.datetime_with_timezone :created_at, null: false - end - end -end diff --git a/db/migrate/20200121194000_add_geo_event_id_to_geo_event_log.rb b/db/migrate/20200121194000_add_geo_event_id_to_geo_event_log.rb deleted file mode 100644 index 720995164b2..00000000000 --- a/db/migrate/20200121194000_add_geo_event_id_to_geo_event_log.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddGeoEventIdToGeoEventLog < ActiveRecord::Migration[5.2] - DOWNTIME = false - - def change - add_column :geo_event_log, :geo_event_id, :integer - end -end diff --git a/db/migrate/20200121194048_add_geo_event_id_index_to_geo_event_log.rb b/db/migrate/20200121194048_add_geo_event_id_index_to_geo_event_log.rb deleted file mode 100644 index 9b0ec12c959..00000000000 --- a/db/migrate/20200121194048_add_geo_event_id_index_to_geo_event_log.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class AddGeoEventIdIndexToGeoEventLog < ActiveRecord::Migration[5.2] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - disable_ddl_transaction! - - def up - add_concurrent_index :geo_event_log, :geo_event_id, - where: "(geo_event_id IS NOT NULL)", - using: :btree, - name: 'index_geo_event_log_on_geo_event_id' - end - - def down - remove_concurrent_index :geo_event_log, :geo_event_id, name: 'index_geo_event_log_on_geo_event_id' - end -end diff --git a/db/migrate/20200121194154_add_geo_events_foreign_key.rb b/db/migrate/20200121194154_add_geo_events_foreign_key.rb deleted file mode 100644 index b5e16e22989..00000000000 --- a/db/migrate/20200121194154_add_geo_events_foreign_key.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class AddGeoEventsForeignKey < ActiveRecord::Migration[5.2] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - disable_ddl_transaction! - - def up - add_concurrent_foreign_key :geo_event_log, :geo_events, - column: :geo_event_id, - name: 'fk_geo_event_log_on_geo_event_id', - on_delete: :cascade - end - - def down - remove_foreign_key_without_error :geo_event_log, column: :geo_event_id, name: 'fk_geo_event_log_on_geo_event_id' - end -end diff --git a/db/schema.rb b/db/schema.rb index 9617c595a65..258530ac3b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1665,10 +1665,8 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.bigint "reset_checksum_event_id" t.bigint "cache_invalidation_event_id" t.bigint "container_repository_updated_event_id" - t.integer "geo_event_id" t.index ["cache_invalidation_event_id"], name: "index_geo_event_log_on_cache_invalidation_event_id", where: "(cache_invalidation_event_id IS NOT NULL)" t.index ["container_repository_updated_event_id"], name: "index_geo_event_log_on_container_repository_updated_event_id" - t.index ["geo_event_id"], name: "index_geo_event_log_on_geo_event_id", where: "(geo_event_id IS NOT NULL)" t.index ["hashed_storage_attachments_event_id"], name: "index_geo_event_log_on_hashed_storage_attachments_event_id", where: "(hashed_storage_attachments_event_id IS NOT NULL)" t.index ["hashed_storage_migrated_event_id"], name: "index_geo_event_log_on_hashed_storage_migrated_event_id", where: "(hashed_storage_migrated_event_id IS NOT NULL)" t.index ["job_artifact_deleted_event_id"], name: "index_geo_event_log_on_job_artifact_deleted_event_id", where: "(job_artifact_deleted_event_id IS NOT NULL)" @@ -1682,13 +1680,6 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.index ["upload_deleted_event_id"], name: "index_geo_event_log_on_upload_deleted_event_id", where: "(upload_deleted_event_id IS NOT NULL)" end - create_table "geo_events", force: :cascade do |t| - t.string "replicable_name", limit: 255, null: false - t.string "event_name", limit: 255, null: false - t.jsonb "payload", default: {}, null: false - t.datetime_with_timezone "created_at", null: false - end - create_table "geo_hashed_storage_attachments_events", force: :cascade do |t| t.integer "project_id", null: false t.text "old_attachments_path", null: false @@ -4649,7 +4640,6 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do add_foreign_key "geo_container_repository_updated_events", "container_repositories", name: "fk_212c89c706", on_delete: :cascade add_foreign_key "geo_event_log", "geo_cache_invalidation_events", column: "cache_invalidation_event_id", name: "fk_42c3b54bed", on_delete: :cascade add_foreign_key "geo_event_log", "geo_container_repository_updated_events", column: "container_repository_updated_event_id", name: "fk_6ada82d42a", on_delete: :cascade - add_foreign_key "geo_event_log", "geo_events", name: "fk_geo_event_log_on_geo_event_id", on_delete: :cascade add_foreign_key "geo_event_log", "geo_hashed_storage_migrated_events", column: "hashed_storage_migrated_event_id", name: "fk_27548c6db3", on_delete: :cascade add_foreign_key "geo_event_log", "geo_job_artifact_deleted_events", column: "job_artifact_deleted_event_id", name: "fk_176d3fbb5d", on_delete: :cascade add_foreign_key "geo_event_log", "geo_lfs_object_deleted_events", column: "lfs_object_deleted_event_id", name: "fk_d5af95fcd9", on_delete: :cascade diff --git a/doc/administration/monitoring/gitlab_self_monitoring_project/index.md b/doc/administration/monitoring/gitlab_self_monitoring_project/index.md index 82944d10d52..df109b94d2d 100644 --- a/doc/administration/monitoring/gitlab_self_monitoring_project/index.md +++ b/doc/administration/monitoring/gitlab_self_monitoring_project/index.md @@ -17,7 +17,18 @@ All administrators at the time of creation of the project and group will be adde as maintainers of the group and project, and as an admin, you'll be able to add new members to the group in order to give them maintainer access to the project. -This project will be used for self-monitoring your GitLab instance. +This project will be used for self monitoring your GitLab instance. + +## Activating or deactivating the self monitoring project + +1. Navigate to **Admin Area > Settings > Metrics and profiling** and expand the **Self monitoring** section. +1. Toggle on or off the **Create Project** button to create or remove the "GitLab self monitoring" project. +1. Click **Save changes** for the changes to take effect. + +If you activated the monitoring project, it should now be visible in **Projects > Your projects**. + +CAUTION: **Warning:** +If you deactivate the self monitoring project, it will be permanently deleted. ## Connection to Prometheus diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 6eba9bf23bf..2adc0087474 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -56,6 +56,7 @@ The following API resources are available in the project context: | [Project milestones](milestones.md) | `/projects/:id/milestones` | | [Project snippets](project_snippets.md) | `/projects/:id/snippets` | | [Project templates](project_templates.md) | `/projects/:id/templates` | +| [Protected_environments](protected_environments.md) | `/projects/:id/protected_environments` | | [Protected branches](protected_branches.md) | `/projects/:id/protected_branches` | | [Protected tags](protected_tags.md) | `/projects/:id/protected_tags` | | [Releases](releases/index.md) | `/projects/:id/releases` | diff --git a/doc/api/protected_environments.md b/doc/api/protected_environments.md new file mode 100644 index 00000000000..7d4e62a8ff5 --- /dev/null +++ b/doc/api/protected_environments.md @@ -0,0 +1,136 @@ +# Protected environments API **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/30595) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.8. + +## Valid access levels + +The access levels are defined in the `ProtectedEnvironment::DeployAccessLevel::ALLOWED_ACCESS_LEVELS` method. +Currently, these levels are recognized: + +``` +30 => Developer access +40 => Maintainer access +60 => Admin access +``` + +## List protected environments + +Gets a list of protected environments from a project: + +```bash +GET /projects/:id/protected_environments +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/projects/5/protected_environments/' +``` + +Example response: + +```json +[ + { + "name":"production", + "deploy_access_levels":[ + { + "access_level":40, + "access_level_description":"Maintainers", + "user_id":null, + "group_id":null + } + ] + } +] +``` + +## Get a single protected environment + +Gets a single protected environment: + +```bash +GET /projects/:id/protected_environments/:name +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the protected environment | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/projects/5/protected_environments/production' +``` + +Example response: + +```json +{ + "name":"production", + "deploy_access_levels":[ + { + "access_level":40, + "access_level_description":"Maintainers", + "user_id":null, + "group_id":null + } + ] +} +``` + +## Protect repository environments + +Protects a single environment: + +```bash +POST /projects/:id/protected_environments +``` + +```bash +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/projects/5/protected_environments?name=staging&deploy_access_levels%5B%5D%5Buser_id%5D=1' +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | +| `name` | string | yes | The name of the environment. | +| `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. | + +Elements in the `deploy_access_levels` array should take the +form `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}`. +Each user must have access to the project and each group must [have this project shared](../user/project/members/share_project_with_groups.md). + +Example response: + +```json +{ + "name":"staging", + "deploy_access_levels":[ + { + "access_level":null, + "access_level_description":"Administrator", + "user_id":1, + "group_id":null + } + ] +} +``` + +## Unprotect environment + +Unprotects the given protected environment: + +```bash +DELETE /projects/:id/protected_environments/:name +``` + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/projects/5/protected_environments/staging' +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | +| `name` | string | yes | The name of the protected environment. | diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index 25a6c21ac39..b5d11f36080 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -85,7 +85,7 @@ Add the following to your `.gitlab-ci.yml` file: ```yaml include: - template: Container-Scanning.gitlab-ci.yml + - template: Container-Scanning.gitlab-ci.yml ``` The included template will: diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 7e318a47f4d..a96a17aa45a 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -71,7 +71,7 @@ Add the following to your `.gitlab-ci.yml` file: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml variables: DAST_WEBSITE: https://example.com @@ -111,7 +111,7 @@ It's also possible to authenticate the user before performing the DAST checks: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml variables: DAST_WEBSITE: https://example.com @@ -135,7 +135,7 @@ includes both passive and active scanning against the same target website: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml variables: DAST_FULL_SCAN_ENABLED: "true" @@ -151,7 +151,7 @@ Domain validation is not required by default. It can be required by setting the ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml variables: DAST_FULL_SCAN_ENABLED: "true" @@ -260,7 +260,7 @@ For example: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml variables: DAST_WEBSITE: https://example.com @@ -278,7 +278,7 @@ template inclusion and specify any additional keys under it. For example: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml dast: stage: dast # IMPORTANT: don't forget to add this @@ -447,7 +447,7 @@ for DAST by overwriting the `script` key in the DAST template: ```yaml include: - template: DAST.gitlab-ci.yml + - template: DAST.gitlab-ci.yml dast: script: diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 3f725089932..24cf7906848 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -79,7 +79,7 @@ Add the following to your `.gitlab-ci.yml` file: ```yaml include: - template: Dependency-Scanning.gitlab-ci.yml + - template: Dependency-Scanning.gitlab-ci.yml ``` The included template will create a `dependency_scanning` job in your CI/CD @@ -99,7 +99,7 @@ For example: ```yaml include: - template: Dependency-Scanning.gitlab-ci.yml + - template: Dependency-Scanning.gitlab-ci.yml variables: DS_PYTHON_VERSION: 2 @@ -116,7 +116,7 @@ after the template inclusion and specify any additional keys under it. For examp ```yaml include: - template: Dependency-Scanning.gitlab-ci.yml + - template: Dependency-Scanning.gitlab-ci.yml dependency_scanning: variables: @@ -187,7 +187,7 @@ This does not require running the executor in privileged mode. For example: ```yaml include: - template: Dependency-Scanning.gitlab-ci.yml + - template: Dependency-Scanning.gitlab-ci.yml variables: DS_DISABLE_DIND: "true" diff --git a/doc/user/application_security/license_compliance/index.md b/doc/user/application_security/license_compliance/index.md index 95eec0db7fa..f70c654413e 100644 --- a/doc/user/application_security/license_compliance/index.md +++ b/doc/user/application_security/license_compliance/index.md @@ -88,7 +88,7 @@ Add the following to your `.gitlab-ci.yml` file: ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml ``` The included template will create a `license_scanning` job in your CI/CD pipeline @@ -141,7 +141,7 @@ For example: ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml variables: LICENSE_MANAGEMENT_SETUP_CMD: sh my-custom-install-script.sh @@ -158,7 +158,7 @@ after the template inclusion and specify any additional keys under it. For examp ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml license_scanning: variables: @@ -173,7 +173,7 @@ Feel free to use it for the customization of Maven execution. For example: ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml license_scanning: variables: @@ -201,7 +201,7 @@ by setting the `LM_PYTHON_VERSION` environment variable to `2`. ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml license_scanning: variables: @@ -223,7 +223,7 @@ For example, the following `.gitlab-ci.yml`: ```yaml include: - template: License-Management.gitlab-ci.yml + - template: License-Management.gitlab-ci.yml license_management: artifacts: @@ -235,7 +235,7 @@ Should be changed to: ```yaml include: - template: License-Scanning.gitlab-ci.yml + - template: License-Scanning.gitlab-ci.yml license_scanning: artifacts: diff --git a/doc/user/application_security/sast/analyzers.md b/doc/user/application_security/sast/analyzers.md index a42cf7f09ff..c27c61a52e2 100644 --- a/doc/user/application_security/sast/analyzers.md +++ b/doc/user/application_security/sast/analyzers.md @@ -49,7 +49,7 @@ In `.gitlab-ci.yml` define: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_ANALYZER_IMAGE_PREFIX: my-docker-registry/gl-images @@ -66,7 +66,7 @@ In `.gitlab-ci.yml` define: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_DEFAULT_ANALYZERS: "bandit,flawfinder" @@ -82,7 +82,7 @@ default analyzers. In `.gitlab-ci.yml` define: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_DEFAULT_ANALYZERS: "" @@ -98,7 +98,7 @@ In `.gitlab-ci.yml` define: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_ANALYZER_IMAGES: "my-docker-registry/analyzers/csharp,amy-docker-registry/analyzers/perl" diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md index 24f28e547b3..ea9c0b85bea 100644 --- a/doc/user/application_security/sast/index.md +++ b/doc/user/application_security/sast/index.md @@ -225,7 +225,7 @@ stages: - test include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_DISABLE_DIND: "true" diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 2768dc103c4..98ae4af2a19 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -129,178 +129,6 @@ module API end end - class PipelineBasic < Grape::Entity - expose :id, :sha, :ref, :status - expose :created_at, :updated_at - - expose :web_url do |pipeline, _options| - Gitlab::Routing.url_helpers.project_pipeline_url(pipeline.project, pipeline) - end - end - - class MergeRequestSimple < IssuableEntity - expose :title - expose :web_url do |merge_request, options| - Gitlab::UrlBuilder.build(merge_request) - end - end - - class MergeRequestBasic < IssuableEntity - expose :merged_by, using: Entities::UserBasic do |merge_request, _options| - merge_request.metrics&.merged_by - end - - expose :merged_at do |merge_request, _options| - merge_request.metrics&.merged_at - end - - expose :closed_by, using: Entities::UserBasic do |merge_request, _options| - merge_request.metrics&.latest_closed_by - end - - expose :closed_at do |merge_request, _options| - merge_request.metrics&.latest_closed_at - end - - expose :title_html, if: -> (_, options) { options[:render_html] } do |entity| - MarkupHelper.markdown_field(entity, :title) - end - expose :description_html, if: -> (_, options) { options[:render_html] } do |entity| - MarkupHelper.markdown_field(entity, :description) - end - expose :target_branch, :source_branch - expose(:user_notes_count) { |merge_request, options| issuable_metadata(merge_request, options, :user_notes_count) } - expose(:upvotes) { |merge_request, options| issuable_metadata(merge_request, options, :upvotes) } - expose(:downvotes) { |merge_request, options| issuable_metadata(merge_request, options, :downvotes) } - expose :assignee, using: ::API::Entities::UserBasic do |merge_request| - merge_request.assignee - end - expose :author, :assignees, using: Entities::UserBasic - - expose :source_project_id, :target_project_id - expose :labels do |merge_request, options| - if options[:with_labels_details] - ::API::Entities::LabelBasic.represent(merge_request.labels.sort_by(&:title)) - else - merge_request.labels.map(&:title).sort - end - end - expose :work_in_progress?, as: :work_in_progress - expose :milestone, using: Entities::Milestone - expose :merge_when_pipeline_succeeds - - # Ideally we should deprecate `MergeRequest#merge_status` exposure and - # use `MergeRequest#mergeable?` instead (boolean). - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/42344 for more - # information. - expose :merge_status do |merge_request| - merge_request.check_mergeability(async: true) - merge_request.merge_status - end - expose :diff_head_sha, as: :sha - expose :merge_commit_sha - expose :squash_commit_sha - expose :discussion_locked - expose :should_remove_source_branch?, as: :should_remove_source_branch - expose :force_remove_source_branch?, as: :force_remove_source_branch - expose :allow_collaboration, if: -> (merge_request, _) { merge_request.for_fork? } - # Deprecated - expose :allow_collaboration, as: :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? } - - # reference is deprecated in favour of references - # Introduced [Gitlab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) - expose :reference do |merge_request, options| - merge_request.to_reference(options[:project]) - end - - expose :references, with: IssuableReferences do |merge_request| - merge_request - end - - expose :web_url do |merge_request| - Gitlab::UrlBuilder.build(merge_request) - end - - expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |merge_request| - merge_request - end - - expose :squash - - expose :task_completion_status - - expose :cannot_be_merged?, as: :has_conflicts - - expose :mergeable_discussions_state?, as: :blocking_discussions_resolved - end - - class MergeRequest < MergeRequestBasic - expose :subscribed, if: -> (_, options) { options.fetch(:include_subscribed, true) } do |merge_request, options| - merge_request.subscribed?(options[:current_user], options[:project]) - end - - expose :changes_count do |merge_request, _options| - merge_request.merge_request_diff.real_size - end - - expose :latest_build_started_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| - merge_request.metrics&.latest_build_started_at - end - - expose :latest_build_finished_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| - merge_request.metrics&.latest_build_finished_at - end - - expose :first_deployed_to_production_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| - merge_request.metrics&.first_deployed_to_production_at - end - - expose :pipeline, using: Entities::PipelineBasic, if: -> (_, options) { build_available?(options) } do |merge_request, _options| - merge_request.metrics&.pipeline - end - - expose :head_pipeline, using: 'API::Entities::Pipeline', if: -> (_, options) do - Ability.allowed?(options[:current_user], :read_pipeline, options[:project]) - end - - expose :diff_refs, using: Entities::DiffRefs - - # Allow the status of a rebase to be determined - expose :merge_error - expose :rebase_in_progress?, as: :rebase_in_progress, if: -> (_, options) { options[:include_rebase_in_progress] } - - expose :diverged_commits_count, as: :diverged_commits_count, if: -> (_, options) { options[:include_diverged_commits_count] } - - def build_available?(options) - options[:project]&.feature_available?(:builds, options[:current_user]) - end - - expose :user do - expose :can_merge do |merge_request, options| - merge_request.can_be_merged_by?(options[:current_user]) - end - end - end - - class MergeRequestChanges < MergeRequest - expose :diffs, as: :changes, using: Entities::Diff do |compare, _| - compare.raw_diffs(limits: false).to_a - end - end - - class MergeRequestDiff < Grape::Entity - expose :id, :head_commit_sha, :base_commit_sha, :start_commit_sha, - :created_at, :merge_request_id, :state, :real_size - end - - class MergeRequestDiffFull < MergeRequestDiff - expose :commits, using: Entities::Commit - - expose :diffs, using: Entities::Diff do |compare, _| - compare.raw_diffs(limits: false).to_a - end - end - class SSHKey < Grape::Entity expose :id, :title, :key, :created_at end diff --git a/lib/api/entities/merge_request.rb b/lib/api/entities/merge_request.rb new file mode 100644 index 00000000000..9ff8e20ced1 --- /dev/null +++ b/lib/api/entities/merge_request.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequest < MergeRequestBasic + expose :subscribed, if: -> (_, options) { options.fetch(:include_subscribed, true) } do |merge_request, options| + merge_request.subscribed?(options[:current_user], options[:project]) + end + + expose :changes_count do |merge_request, _options| + merge_request.merge_request_diff.real_size + end + + expose :latest_build_started_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| + merge_request.metrics&.latest_build_started_at + end + + expose :latest_build_finished_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| + merge_request.metrics&.latest_build_finished_at + end + + expose :first_deployed_to_production_at, if: -> (_, options) { build_available?(options) } do |merge_request, _options| + merge_request.metrics&.first_deployed_to_production_at + end + + expose :pipeline, using: Entities::PipelineBasic, if: -> (_, options) { build_available?(options) } do |merge_request, _options| + merge_request.metrics&.pipeline + end + + expose :head_pipeline, using: 'API::Entities::Pipeline', if: -> (_, options) do + Ability.allowed?(options[:current_user], :read_pipeline, options[:project]) + end + + expose :diff_refs, using: Entities::DiffRefs + + # Allow the status of a rebase to be determined + expose :merge_error + expose :rebase_in_progress?, as: :rebase_in_progress, if: -> (_, options) { options[:include_rebase_in_progress] } + + expose :diverged_commits_count, as: :diverged_commits_count, if: -> (_, options) { options[:include_diverged_commits_count] } + + def build_available?(options) + options[:project]&.feature_available?(:builds, options[:current_user]) + end + + expose :user do + expose :can_merge do |merge_request, options| + merge_request.can_be_merged_by?(options[:current_user]) + end + end + end + end +end diff --git a/lib/api/entities/merge_request_basic.rb b/lib/api/entities/merge_request_basic.rb new file mode 100644 index 00000000000..ce8bfa9e670 --- /dev/null +++ b/lib/api/entities/merge_request_basic.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequestBasic < IssuableEntity + expose :merged_by, using: Entities::UserBasic do |merge_request, _options| + merge_request.metrics&.merged_by + end + + expose :merged_at do |merge_request, _options| + merge_request.metrics&.merged_at + end + + expose :closed_by, using: Entities::UserBasic do |merge_request, _options| + merge_request.metrics&.latest_closed_by + end + + expose :closed_at do |merge_request, _options| + merge_request.metrics&.latest_closed_at + end + + expose :title_html, if: -> (_, options) { options[:render_html] } do |entity| + MarkupHelper.markdown_field(entity, :title) + end + expose :description_html, if: -> (_, options) { options[:render_html] } do |entity| + MarkupHelper.markdown_field(entity, :description) + end + expose :target_branch, :source_branch + expose(:user_notes_count) { |merge_request, options| issuable_metadata(merge_request, options, :user_notes_count) } + expose(:upvotes) { |merge_request, options| issuable_metadata(merge_request, options, :upvotes) } + expose(:downvotes) { |merge_request, options| issuable_metadata(merge_request, options, :downvotes) } + expose :assignee, using: ::API::Entities::UserBasic do |merge_request| + merge_request.assignee + end + expose :author, :assignees, using: Entities::UserBasic + + expose :source_project_id, :target_project_id + expose :labels do |merge_request, options| + if options[:with_labels_details] + ::API::Entities::LabelBasic.represent(merge_request.labels.sort_by(&:title)) + else + merge_request.labels.map(&:title).sort + end + end + expose :work_in_progress?, as: :work_in_progress + expose :milestone, using: Entities::Milestone + expose :merge_when_pipeline_succeeds + + # Ideally we should deprecate `MergeRequest#merge_status` exposure and + # use `MergeRequest#mergeable?` instead (boolean). + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/42344 for more + # information. + expose :merge_status do |merge_request| + merge_request.check_mergeability(async: true) + merge_request.merge_status + end + expose :diff_head_sha, as: :sha + expose :merge_commit_sha + expose :squash_commit_sha + expose :discussion_locked + expose :should_remove_source_branch?, as: :should_remove_source_branch + expose :force_remove_source_branch?, as: :force_remove_source_branch + expose :allow_collaboration, if: -> (merge_request, _) { merge_request.for_fork? } + # Deprecated + expose :allow_collaboration, as: :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? } + + # reference is deprecated in favour of references + # Introduced [Gitlab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) + expose :reference do |merge_request, options| + merge_request.to_reference(options[:project]) + end + + expose :references, with: IssuableReferences do |merge_request| + merge_request + end + + expose :web_url do |merge_request| + Gitlab::UrlBuilder.build(merge_request) + end + + expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |merge_request| + merge_request + end + + expose :squash + + expose :task_completion_status + + expose :cannot_be_merged?, as: :has_conflicts + + expose :mergeable_discussions_state?, as: :blocking_discussions_resolved + end + end +end diff --git a/lib/api/entities/merge_request_changes.rb b/lib/api/entities/merge_request_changes.rb new file mode 100644 index 00000000000..a835d119736 --- /dev/null +++ b/lib/api/entities/merge_request_changes.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequestChanges < MergeRequest + expose :diffs, as: :changes, using: Entities::Diff do |compare, _| + compare.raw_diffs(limits: false).to_a + end + end + end +end diff --git a/lib/api/entities/merge_request_diff.rb b/lib/api/entities/merge_request_diff.rb new file mode 100644 index 00000000000..3eda1400855 --- /dev/null +++ b/lib/api/entities/merge_request_diff.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequestDiff < Grape::Entity + expose :id, :head_commit_sha, :base_commit_sha, :start_commit_sha, + :created_at, :merge_request_id, :state, :real_size + end + end +end diff --git a/lib/api/entities/merge_request_diff_full.rb b/lib/api/entities/merge_request_diff_full.rb new file mode 100644 index 00000000000..772b9b6822c --- /dev/null +++ b/lib/api/entities/merge_request_diff_full.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequestDiffFull < MergeRequestDiff + expose :commits, using: Entities::Commit + + expose :diffs, using: Entities::Diff do |compare, _| + compare.raw_diffs(limits: false).to_a + end + end + end +end diff --git a/lib/api/entities/merge_request_simple.rb b/lib/api/entities/merge_request_simple.rb new file mode 100644 index 00000000000..f3ff4cc18a8 --- /dev/null +++ b/lib/api/entities/merge_request_simple.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module API + module Entities + class MergeRequestSimple < IssuableEntity + expose :title + expose :web_url do |merge_request, options| + Gitlab::UrlBuilder.build(merge_request) + end + end + end +end diff --git a/lib/api/entities/pipeline_basic.rb b/lib/api/entities/pipeline_basic.rb new file mode 100644 index 00000000000..359f6a447ab --- /dev/null +++ b/lib/api/entities/pipeline_basic.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + class PipelineBasic < Grape::Entity + expose :id, :sha, :ref, :status + expose :created_at, :updated_at + + expose :web_url do |pipeline, _options| + Gitlab::Routing.url_helpers.project_pipeline_url(pipeline.project, pipeline) + end + end + end +end diff --git a/lib/quality/test_level.rb b/lib/quality/test_level.rb index 022b42e489d..84470a73b1b 100644 --- a/lib/quality/test_level.rb +++ b/lib/quality/test_level.rb @@ -27,7 +27,6 @@ module Quality policies presenters rack_servers - replicators routing rubocop serializers diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 773b4ff88a6..4dd70043fd0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22692,6 +22692,9 @@ msgstr "" msgid "is" msgstr "" +msgid "is already associated to a GitLab Issue. New issue will not be associated." +msgstr "" + msgid "is an invalid IP address range" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb index eecf485a518..bbe0770444a 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan', :orchestrated, :smtp do + context 'Plan', :orchestrated, :smtp, :reliable do describe 'Email Notification' do let(:user) do Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb index aa88937504e..381606e1648 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'check xss occurence in @mentions in issues', :requires_admin do it 'user mentions a user in comment' do QA::Runtime::Env.personal_access_token = QA::Runtime::Env.admin_personal_access_token diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb index 2543c0091fb..fd00e010b85 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'Close issue' do let(:issue) do Resource::Issue.fabricate_via_api! diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb index e505c0991a6..a2b7904a84d 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'collapse comments in issue discussions' do let(:my_first_reply) { 'My first reply' } diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb index 6c37e3ecbb9..c4a8d2c4e20 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'Issue comments' do before do Flow::Login.sign_in diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb index 3b231b9930e..0ea81483a66 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan', :smoke do + context 'Plan', :smoke, :reliable do describe 'Issue creation' do before do Flow::Login.sign_in diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb index 4156ba54785..f1013d38a1f 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'filter issue comments activities' do before do Flow::Login.sign_in diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb index 9a1d13cf677..6c851fd8141 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/issue_suggestions_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan' do + context 'Plan', :reliable do describe 'issue suggestions' do let(:issue_title) { 'Issue Lists are awesome' } diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb index a0647df4097..8760f5cac04 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Plan', :smoke do + context 'Plan', :smoke, :reliable do describe 'mention' do before do Flow::Login.sign_in diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js new file mode 100644 index 00000000000..4c06e19cec0 --- /dev/null +++ b/spec/frontend/environments/environment_actions_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import eventHub from '~/environments/event_hub'; +import EnvironmentActions from '~/environments/components/environment_actions.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; + +describe('EnvironmentActions Component', () => { + let vm; + + beforeEach(() => { + vm = shallowMount(EnvironmentActions, { propsData: { actions: [] } }); + }); + + afterEach(() => { + vm.destroy(); + }); + + it('should render a dropdown button with 2 icons', () => { + expect(vm.find('.dropdown-new').findAll(Icon).length).toBe(2); + }); + + it('should render a dropdown button with aria-label description', () => { + expect(vm.find('.dropdown-new').attributes('aria-label')).toEqual('Deploy to...'); + }); + + describe('is loading', () => { + beforeEach(() => { + vm.setData({ isLoading: true }); + }); + + it('should render a dropdown button with a loading icon', () => { + expect(vm.findAll(GlLoadingIcon).length).toBe(1); + }); + }); + + describe('manual actions', () => { + const actions = [ + { + name: 'bar', + play_path: 'https://gitlab.com/play', + }, + { + name: 'foo', + play_path: '#', + }, + { + name: 'foo bar', + play_path: 'url', + playable: false, + }, + ]; + + beforeEach(() => { + vm.setProps({ actions }); + }); + + it('should render a dropdown with the provided list of actions', () => { + expect(vm.findAll('.dropdown-menu li').length).toEqual(actions.length); + }); + + it("should render a disabled action when it's not playable", () => { + expect(vm.find('.dropdown-menu li:last-child button').attributes('disabled')).toEqual( + 'disabled', + ); + + expect(vm.find('.dropdown-menu li:last-child button').classes('disabled')).toBe(true); + }); + }); + + describe('scheduled jobs', () => { + const scheduledJobAction = { + name: 'scheduled action', + playPath: `${TEST_HOST}/scheduled/job/action`, + playable: true, + scheduledAt: '2063-04-05T00:42:00Z', + }; + const expiredJobAction = { + name: 'expired action', + playPath: `${TEST_HOST}/expired/job/action`, + playable: true, + scheduledAt: '2018-10-05T08:23:00Z', + }; + const findDropdownItem = action => { + const buttons = vm.findAll('.dropdown-menu li button'); + return buttons.filter(button => button.text().startsWith(action.name)).at(0); + }; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + vm.setProps({ actions: [scheduledJobAction, expiredJobAction] }); + }); + + it('emits postAction event after confirming', () => { + const emitSpy = jest.fn(); + eventHub.$on('postAction', emitSpy); + jest.spyOn(window, 'confirm').mockImplementation(() => true); + + findDropdownItem(scheduledJobAction).trigger('click'); + + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); + }); + + it('does not emit postAction event if confirmation is cancelled', () => { + const emitSpy = jest.fn(); + eventHub.$on('postAction', emitSpy); + jest.spyOn(window, 'confirm').mockImplementation(() => false); + + findDropdownItem(scheduledJobAction).trigger('click'); + + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findDropdownItem(scheduledJobAction).text()).toContain('24:00:00'); + }); + + it('displays 00:00:00 for expired jobs in the dropdown', () => { + expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); + }); + }); +}); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index 1efd74a30c2..8f3ac53c37a 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -6,6 +6,7 @@ describe('Repository router spec', () => { it.each` path | component | componentName ${'/'} | ${IndexPage} | ${'IndexPage'} + ${'/tree/master'} | ${TreePage} | ${'TreePage'} ${'/-/tree/master'} | ${TreePage} | ${'TreePage'} ${'/-/tree/master/app/assets'} | ${TreePage} | ${'TreePage'} ${'/-/tree/123/app/assets'} | ${null} | ${'null'} diff --git a/spec/javascripts/environments/environment_actions_spec.js b/spec/javascripts/environments/environment_actions_spec.js deleted file mode 100644 index a844660f7bf..00000000000 --- a/spec/javascripts/environments/environment_actions_spec.js +++ /dev/null @@ -1,117 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import eventHub from '~/environments/event_hub'; -import EnvironmentActions from '~/environments/components/environment_actions.vue'; - -describe('EnvironmentActions Component', () => { - const Component = Vue.extend(EnvironmentActions); - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('manual actions', () => { - const actions = [ - { - name: 'bar', - play_path: 'https://gitlab.com/play', - }, - { - name: 'foo', - play_path: '#', - }, - { - name: 'foo bar', - play_path: 'url', - playable: false, - }, - ]; - - beforeEach(() => { - vm = mountComponent(Component, { actions }); - }); - - it('should render a dropdown button with icon and title attribute', () => { - expect(vm.$el.querySelector('.fa-caret-down')).toBeDefined(); - expect(vm.$el.querySelector('.dropdown-new').getAttribute('data-original-title')).toEqual( - 'Deploy to...', - ); - - expect(vm.$el.querySelector('.dropdown-new').getAttribute('aria-label')).toEqual( - 'Deploy to...', - ); - }); - - it('should render a dropdown with the provided list of actions', () => { - expect(vm.$el.querySelectorAll('.dropdown-menu li').length).toEqual(actions.length); - }); - - it("should render a disabled action when it's not playable", () => { - expect( - vm.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), - ).toEqual('disabled'); - - expect( - vm.$el.querySelector('.dropdown-menu li:last-child button').classList.contains('disabled'), - ).toEqual(true); - }); - }); - - describe('scheduled jobs', () => { - const scheduledJobAction = { - name: 'scheduled action', - playPath: `${TEST_HOST}/scheduled/job/action`, - playable: true, - scheduledAt: '2063-04-05T00:42:00Z', - }; - const expiredJobAction = { - name: 'expired action', - playPath: `${TEST_HOST}/expired/job/action`, - playable: true, - scheduledAt: '2018-10-05T08:23:00Z', - }; - const findDropdownItem = action => { - const buttons = vm.$el.querySelectorAll('.dropdown-menu li button'); - return Array.prototype.find.call(buttons, element => - element.innerText.trim().startsWith(action.name), - ); - }; - - beforeEach(() => { - spyOn(Date, 'now').and.callFake(() => new Date('2063-04-04T00:42:00Z').getTime()); - vm = mountComponent(Component, { actions: [scheduledJobAction, expiredJobAction] }); - }); - - it('emits postAction event after confirming', () => { - const emitSpy = jasmine.createSpy('emit'); - eventHub.$on('postAction', emitSpy); - spyOn(window, 'confirm').and.callFake(() => true); - - findDropdownItem(scheduledJobAction).click(); - - expect(window.confirm).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); - }); - - it('does not emit postAction event if confirmation is cancelled', () => { - const emitSpy = jasmine.createSpy('emit'); - eventHub.$on('postAction', emitSpy); - spyOn(window, 'confirm').and.callFake(() => false); - - findDropdownItem(scheduledJobAction).click(); - - expect(window.confirm).toHaveBeenCalled(); - expect(emitSpy).not.toHaveBeenCalled(); - }); - - it('displays the remaining time in the dropdown', () => { - expect(findDropdownItem(scheduledJobAction)).toContainText('24:00:00'); - }); - - it('displays 00:00:00 for expired jobs in the dropdown', () => { - expect(findDropdownItem(expiredJobAction)).toContainText('00:00:00'); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/bar_chart_spec.js b/spec/javascripts/vue_shared/components/bar_chart_spec.js deleted file mode 100644 index 8f673c146ec..00000000000 --- a/spec/javascripts/vue_shared/components/bar_chart_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import BarChart from '~/vue_shared/components/bar_chart.vue'; - -function getRandomArbitrary(min, max) { - return Math.random() * (max - min) + min; -} - -function generateRandomData(dataNumber) { - const randomGraphData = []; - - for (let i = 1; i <= dataNumber; i += 1) { - randomGraphData.push({ - name: `random ${i}`, - value: parseInt(getRandomArbitrary(1, 8), 10), - }); - } - - return randomGraphData; -} - -describe('Bar chart component', () => { - let barChart; - const graphData = generateRandomData(10); - - beforeEach(() => { - const BarChartComponent = Vue.extend(BarChart); - - barChart = mountComponent(BarChartComponent, { - graphData, - yAxisLabel: 'data', - }); - }); - - afterEach(() => { - barChart.$destroy(); - }); - - it('calculates the padding for even distribution across bars', () => { - barChart.vbWidth = 1000; - const result = barChart.calculatePadding(30); - - // since padding can't be higher than 1 and lower than 0 - // for more info: https://github.com/d3/d3-scale#band-scales - expect(result).not.toBeLessThan(0); - expect(result).not.toBeGreaterThan(1); - }); - - it('formats the tooltip title', () => { - const tooltipTitle = barChart.setTooltipTitle(barChart.graphData[0]); - - expect(tooltipTitle).toContain('random 1:'); - }); - - it('has a translates the bar graphs on across the X axis', () => { - barChart.panX = 100; - - expect(barChart.barTranslationTransform).toEqual('translate(100, 0)'); - }); - - it('translates the scroll indicator to the far right side', () => { - barChart.vbWidth = 500; - - expect(barChart.scrollIndicatorTransform).toEqual('translate(420, 0)'); - }); - - it('translates the x-axis to the bottom of the viewbox and pan coordinates', () => { - barChart.panX = 100; - barChart.vbHeight = 250; - - expect(barChart.xAxisLocation).toEqual('translate(100, 250)'); - }); - - it('rotates the x axis labels a total of 90 degress (CCW)', () => { - const xAxisLabel = barChart.$el.querySelector('.x-axis').querySelectorAll('text')[0]; - - expect(xAxisLabel.getAttribute('transform')).toEqual('rotate(-90)'); - }); -}); diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb index 621a426a18d..13817bdcc72 100644 --- a/spec/lib/quality/test_level_spec.rb +++ b/spec/lib/quality/test_level_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a pattern' do expect(subject.pattern(:unit)) - .to eq("spec/{bin,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb") + .to eq("spec/{bin,config,db,dependencies,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,routing,rubocop,serializers,services,sidekiq,tasks,uploaders,validators,views,workers,elastic_integration}{,/**/}*_spec.rb") end end @@ -82,7 +82,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a regexp' do expect(subject.regexp(:unit)) - .to eq(%r{spec/(bin|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|tasks|uploaders|validators|views|workers|elastic_integration)}) + .to eq(%r{spec/(bin|config|db|dependencies|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|routing|rubocop|serializers|services|sidekiq|tasks|uploaders|validators|views|workers|elastic_integration)}) end end diff --git a/spec/models/sentry_issue_spec.rb b/spec/models/sentry_issue_spec.rb index 022cfd4734e..b4c1cf57761 100644 --- a/spec/models/sentry_issue_spec.rb +++ b/spec/models/sentry_issue_spec.rb @@ -13,6 +13,20 @@ describe SentryIssue do it { is_expected.to validate_presence_of(:issue) } it { is_expected.to validate_uniqueness_of(:issue) } it { is_expected.to validate_presence_of(:sentry_issue_identifier) } + + it 'allows duplicated sentry_issue_identifier' do + duplicate_sentry_issue = build(:sentry_issue, sentry_issue_identifier: sentry_issue.sentry_issue_identifier) + + expect(duplicate_sentry_issue).to be_valid + end + + it 'validates uniqueness of sentry_issue_identifier per project' do + second_issue = create(:issue, project: sentry_issue.issue.project) + duplicate_sentry_issue = build(:sentry_issue, issue: second_issue, sentry_issue_identifier: sentry_issue.sentry_issue_identifier) + + expect(duplicate_sentry_issue).to be_invalid + expect(duplicate_sentry_issue.errors.full_messages.first).to include('is already associated') + end end describe 'callbacks' do @@ -28,13 +42,16 @@ describe SentryIssue do end describe '.for_project_and_identifier' do - let!(:sentry_issue) { create(:sentry_issue) } - let(:project) { sentry_issue.issue.project } - let(:identifier) { sentry_issue.sentry_issue_identifier } - let!(:second_sentry_issue) { create(:sentry_issue) } + it 'finds the most recent per project and sentry_issue_identifier' do + sentry_issue = create(:sentry_issue) + create(:sentry_issue) + project = sentry_issue.issue.project + sentry_issue_3 = build(:sentry_issue, issue: create(:issue, project: project), sentry_issue_identifier: sentry_issue.sentry_issue_identifier) + sentry_issue_3.save(validate: false) - subject { described_class.for_project_and_identifier(project, identifier) } + result = described_class.for_project_and_identifier(project, sentry_issue.sentry_issue_identifier) - it { is_expected.to eq(sentry_issue) } + expect(result).to eq(sentry_issue_3) + end end end |