diff options
217 files changed, 4682 insertions, 1010 deletions
diff --git a/.gitignore b/.gitignore index dfc99a4ee48..bb818213de1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ eslint-report.html /public/uploads.* /public/uploads/ /shared/artifacts/ +/spec/javascripts/fixtures/blob/pdf/ /rails_best_practices_output.html /tags /tmp/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ddc2c5f2542..aa62a86d31d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -253,38 +253,46 @@ spinach mysql 9 10: *spinach-knapsack-mysql SETUP_DB: "false" USE_BUNDLE_INSTALL: "true" -.exec: &exec +.rake-exec: &rake-exec <<: *ruby-static-analysis <<: *dedicated-runner <<: *except-docs stage: test script: - - bundle exec $CI_JOB_NAME + - bundle exec rake $CI_JOB_NAME -rubocop: +static-analysis: <<: *ruby-static-analysis <<: *dedicated-runner - <<: *except-docs stage: test script: - - bundle exec "rubocop --require rubocop-rspec" - -rake haml_lint: *exec -rake scss_lint: *exec -rake config_lint: *exec -rake brakeman: *exec -rake flay: *exec -license_finder: *exec -rake downtime_check: - <<: *exec + - scripts/static-analysis + +docs:check:links: + image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine" + stage: test + <<: *dedicated-runner + cache: {} + dependencies: [] + before_script: [] + script: + - mv doc/ /nanoc/content/ + - cd /nanoc + # Build HTML from Markdown + - bundle exec nanoc + # Check the internal links + - bundle exec nanoc check internal_links + +downtime_check: + <<: *rake-exec except: - master - tags - /^[\d-]+-stable(-ee)?$/ - /^docs\/*/ -rake ee_compat_check: - <<: *exec +ee_compat_check: + <<: *rake-exec only: - branches@gitlab-org/gitlab-ce except: @@ -306,22 +314,22 @@ rake ee_compat_check: .db-migrate-reset: &db-migrate-reset stage: test <<: *dedicated-runner + <<: *except-docs script: - bundle exec rake db:migrate:reset rake pg db:migrate:reset: <<: *db-migrate-reset <<: *use-pg - <<: *except-docs rake mysql db:migrate:reset: <<: *db-migrate-reset <<: *use-mysql - <<: *except-docs .db-rollback: &db-rollback stage: test <<: *dedicated-runner + <<: *except-docs script: - bundle exec rake db:rollback STEP=120 - bundle exec rake db:migrate @@ -329,16 +337,15 @@ rake mysql db:migrate:reset: rake pg db:rollback: <<: *db-rollback <<: *use-pg - <<: *except-docs rake mysql db:rollback: <<: *db-rollback <<: *use-mysql - <<: *except-docs .db-seed_fu: &db-seed_fu stage: test <<: *dedicated-runner + <<: *except-docs variables: SIZE: "1" SETUP_DB: "false" @@ -356,12 +363,10 @@ rake mysql db:rollback: rake pg db:seed_fu: <<: *db-seed_fu <<: *use-pg - <<: *except-docs rake mysql db:seed_fu: <<: *db-seed_fu <<: *use-mysql - <<: *except-docs rake gitlab:assets:compile: stage: test @@ -402,31 +407,6 @@ rake karma: paths: - coverage-javascript/ -docs:check:apilint: - image: "phusion/baseimage" - stage: test - <<: *dedicated-runner - cache: {} - dependencies: [] - before_script: [] - script: - - scripts/lint-doc.sh - -docs:check:links: - image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine" - stage: test - <<: *dedicated-runner - cache: {} - dependencies: [] - before_script: [] - script: - - mv doc/ /nanoc/content/ - - cd /nanoc - # Build HTML from Markdown - - bundle exec nanoc - # Check the internal links - - bundle exec nanoc check internal_links - bundler:audit: stage: test <<: *ruby-static-analysis @@ -485,14 +465,6 @@ coverage: - coverage/index.html - coverage/assets/ -lint:javascript: - <<: *dedicated-runner - <<: *except-docs - stage: test - before_script: [] - script: - - yarn run eslint - lint:javascript:report: <<: *dedicated-runner <<: *except-docs @@ -526,22 +498,6 @@ trigger_docs: - master@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ee -# Notify slack in the end -notify:slack: - stage: post-test - <<: *dedicated-runner - variables: - SETUP_DB: "false" - USE_BUNDLE_INSTALL: "false" - script: - - ./scripts/notify_slack.sh "#development" "Build on \`$CI_COMMIT_REF_NAME\` failed! Commit \`$(git log -1 --oneline)\` See <https://gitlab.com/gitlab-org/$(basename "$PWD")/commit/"$CI_COMMIT_SHA"/pipelines>" - when: on_failure - only: - - master@gitlab-org/gitlab-ce - - tags@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - tags@gitlab-org/gitlab-ee - pages: before_script: [] stage: pages @@ -85,14 +85,14 @@ gem 'kaminari', '~> 0.17.0' gem 'hamlit', '~> 2.6.1' # Files attachments -gem 'carrierwave', '~> 0.11.0' +gem 'carrierwave', '~> 1.0' # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' # for backups gem 'fog-aws', '~> 0.9' -gem 'fog-core', '~> 1.40' +gem 'fog-core', '~> 1.44' gem 'fog-google', '~> 0.5' gem 'fog-local', '~> 0.3' gem 'fog-openstack', '~> 0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 038d1f746b3..b822a325861 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,12 +105,10 @@ GEM capybara-screenshot (1.0.14) capybara (>= 1.0, < 3) launchy - carrierwave (0.11.2) - activemodel (>= 3.2.0) - activesupport (>= 3.2.0) - json (>= 1.7) + carrierwave (1.0.0) + activemodel (>= 4.0.0) + activesupport (>= 4.0.0) mime-types (>= 1.16) - mimemagic (>= 0.3.0) cause (0.1) charlock_holmes (0.7.3) chronic (0.10.2) @@ -184,7 +182,7 @@ GEM erubis (2.7.0) escape_utils (1.1.1) eventmachine (1.0.8) - excon (0.52.0) + excon (0.55.0) execjs (2.6.0) expression_parser (0.9.0) extlib (0.9.16) @@ -210,12 +208,12 @@ GEM flowdock (0.7.1) httparty (~> 0.7) multi_json - fog-aws (0.11.0) + fog-aws (0.13.0) fog-core (~> 1.38) fog-json (~> 1.0) fog-xml (~> 0.1) ipaddress (~> 0.8) - fog-core (1.42.0) + fog-core (1.44.1) builder excon (~> 0.49) formatador (~> 0.2) @@ -237,9 +235,9 @@ GEM fog-json (>= 1.0) fog-xml (>= 0.1) ipaddress (>= 0.8) - fog-xml (0.1.2) + fog-xml (0.1.3) fog-core - nokogiri (~> 1.5, >= 1.5.11) + nokogiri (>= 1.5.11, < 2.0.0) font-awesome-rails (4.7.0.1) railties (>= 3.2, < 5.1) foreman (0.78.0) @@ -871,7 +869,7 @@ DEPENDENCIES bundler-audit (~> 0.5.0) capybara (~> 2.6.2) capybara-screenshot (~> 1.0.0) - carrierwave (~> 0.11.0) + carrierwave (~> 1.0) charlock_holmes (~> 0.7.3) chronic (~> 0.10.2) chronic_duration (~> 0.10.6) @@ -896,7 +894,7 @@ DEPENDENCIES ffaker (~> 2.4) flay (~> 2.8.0) fog-aws (~> 0.9) - fog-core (~> 1.40) + fog-core (~> 1.44) fog-google (~> 0.5) fog-local (~> 0.3) fog-openstack (~> 0.1) diff --git a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico Binary files differnew file mode 100644 index 00000000000..4af3582b60d --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_created.ico b/app/assets/images/ci_favicons/dev/favicon_status_created.ico Binary files differnew file mode 100644 index 00000000000..13639da2e8a --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico Binary files differnew file mode 100644 index 00000000000..5f0e711b104 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico Binary files differnew file mode 100644 index 00000000000..8b1168a1267 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico Binary files differnew file mode 100644 index 00000000000..ed19b69e1c5 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico Binary files differnew file mode 100644 index 00000000000..5dfefd4cc5a --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_running.ico b/app/assets/images/ci_favicons/dev/favicon_status_running.ico Binary files differnew file mode 100644 index 00000000000..a41539c0e3e --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico Binary files differnew file mode 100644 index 00000000000..2c1ae552b93 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_success.ico b/app/assets/images/ci_favicons/dev/favicon_status_success.ico Binary files differnew file mode 100644 index 00000000000..70f0ca61eca --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico Binary files differnew file mode 100644 index 00000000000..db289e03eb1 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.ico b/app/assets/images/ci_favicons/favicon_status_canceled.ico Binary files differindex 5a19458f2a2..23adcffff50 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_canceled.ico +++ b/app/assets/images/ci_favicons/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/favicon_status_created.ico b/app/assets/images/ci_favicons/favicon_status_created.ico Binary files differindex 4dca9640cb3..f9d93b390d8 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_created.ico +++ b/app/assets/images/ci_favicons/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/favicon_status_failed.ico b/app/assets/images/ci_favicons/favicon_status_failed.ico Binary files differindex c961ff9a69b..28a22ebf724 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_failed.ico +++ b/app/assets/images/ci_favicons/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/favicon_status_manual.ico b/app/assets/images/ci_favicons/favicon_status_manual.ico Binary files differindex 5fbbc99ea7c..dbbf1abf30c 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_manual.ico +++ b/app/assets/images/ci_favicons/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.ico b/app/assets/images/ci_favicons/favicon_status_not_found.ico Binary files differindex 21afa9c72e6..49b9b232dd1 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_not_found.ico +++ b/app/assets/images/ci_favicons/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/favicon_status_pending.ico b/app/assets/images/ci_favicons/favicon_status_pending.ico Binary files differindex 8be32dab85a..05962f3f148 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_pending.ico +++ b/app/assets/images/ci_favicons/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/favicon_status_running.ico b/app/assets/images/ci_favicons/favicon_status_running.ico Binary files differindex f328ff1a5ed..7fa3d4d48d4 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_running.ico +++ b/app/assets/images/ci_favicons/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.ico b/app/assets/images/ci_favicons/favicon_status_skipped.ico Binary files differindex b4394e1b4af..b0c26b62068 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_skipped.ico +++ b/app/assets/images/ci_favicons/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/favicon_status_success.ico b/app/assets/images/ci_favicons/favicon_status_success.ico Binary files differindex 4f436c95242..b150960b5be 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_success.ico +++ b/app/assets/images/ci_favicons/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/favicon_status_warning.ico b/app/assets/images/ci_favicons/favicon_status_warning.ico Binary files differindex 805cc20cdec..7e71d71684d 100755..100644 --- a/app/assets/images/ci_favicons/favicon_status_warning.ico +++ b/app/assets/images/ci_favicons/favicon_status_warning.ico diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index 9161be98853..0ed915c1ac9 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -1,11 +1,6 @@ /* eslint-disable no-new */ import Vue from 'vue'; -import PDFLab from 'vendor/pdflab'; -import workerSrc from 'vendor/pdf.worker'; - -Vue.use(PDFLab, { - workerSrc, -}); +import pdfLab from '../../pdf/index.vue'; export default () => { const el = document.getElementById('js-pdf-viewer'); @@ -20,6 +15,9 @@ export default () => { pdf: el.dataset.endpoint, }; }, + components: { + pdfLab, + }, methods: { onLoad() { this.loading = false; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 15fe87f21ea..0bdce52cc89 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -44,6 +44,7 @@ import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; +import Landing from './landing'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; @@ -148,8 +149,19 @@ const ShortcutsBlob = require('./shortcuts_blob'); new ProjectsList(); break; case 'dashboard:groups:index': + new GroupsList(); + break; case 'explore:groups:index': new GroupsList(); + + const landingElement = document.querySelector('.js-explore-groups-landing'); + if (!landingElement) break; + const exploreGroupsLanding = new Landing( + landingElement, + landingElement.querySelector('.dismiss-button'), + 'explore_groups_landing_dismissed', + ); + exploreGroupsLanding.toggle(); break; case 'projects:milestones:new': case 'projects:milestones:edit': diff --git a/app/assets/javascripts/landing.js b/app/assets/javascripts/landing.js new file mode 100644 index 00000000000..8c0950ad5d5 --- /dev/null +++ b/app/assets/javascripts/landing.js @@ -0,0 +1,37 @@ +import Cookies from 'js-cookie'; + +class Landing { + constructor(landingElement, dismissButton, cookieName) { + this.landingElement = landingElement; + this.cookieName = cookieName; + this.dismissButton = dismissButton; + this.eventWrapper = {}; + } + + toggle() { + const isDismissed = this.isDismissed(); + + this.landingElement.classList.toggle('hidden', isDismissed); + if (!isDismissed) this.addEvents(); + } + + addEvents() { + this.eventWrapper.dismissLanding = this.dismissLanding.bind(this); + this.dismissButton.addEventListener('click', this.eventWrapper.dismissLanding); + } + + removeEvents() { + this.dismissButton.removeEventListener('click', this.eventWrapper.dismissLanding); + } + + dismissLanding() { + this.landingElement.classList.add('hidden'); + Cookies.set(this.cookieName, 'true', { expires: 365 }); + } + + isDismissed() { + return Cookies.get(this.cookieName) === 'true'; + } +} + +export default Landing; diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js new file mode 100644 index 00000000000..c3a8da52404 --- /dev/null +++ b/app/assets/javascripts/monitoring/constants.js @@ -0,0 +1,4 @@ +import d3 from 'd3'; + +export const dateFormat = d3.time.format('%b %d, %Y'); +export const timeFormat = d3.time.format('%H:%M%p'); diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js new file mode 100644 index 00000000000..fc92ab61b31 --- /dev/null +++ b/app/assets/javascripts/monitoring/deployments.js @@ -0,0 +1,211 @@ +/* global Flash */ +import d3 from 'd3'; +import { + dateFormat, + timeFormat, +} from './constants'; + +export default class Deployments { + constructor(width, height) { + this.width = width; + this.height = height; + + this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint; + + this.createGradientDef(); + } + + init(chartData) { + this.chartData = chartData; + + this.x = d3.time.scale().range([0, this.width]); + this.x.domain(d3.extent(this.chartData, d => d.time)); + + this.charts = d3.selectAll('.prometheus-graph'); + + this.getData(); + } + + getData() { + $.ajax({ + url: this.endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error getting deployment information.')) + .done((data) => { + this.data = data.deployments.reduce((deploymentDataArray, deployment) => { + const time = new Date(deployment.created_at); + const xPos = Math.floor(this.x(time)); + + time.setSeconds(this.chartData[0].time.getSeconds()); + + if (xPos >= 0) { + deploymentDataArray.push({ + id: deployment.id, + time, + sha: deployment.sha, + tag: deployment.tag, + ref: deployment.ref.name, + xPos, + }); + } + + return deploymentDataArray; + }, []); + + this.plotData(); + }); + } + + plotData() { + this.charts.each((d, i) => { + const svg = d3.select(this.charts[0][i]); + const chart = svg.select('.graph-container'); + const key = svg.node().getAttribute('graph-type'); + + this.createLine(chart, key); + this.createDeployInfoBox(chart, key); + }); + } + + createGradientDef() { + const defs = d3.select('body') + .append('svg') + .attr({ + height: 0, + width: 0, + }) + .append('defs'); + + defs.append('linearGradient') + .attr({ + id: 'shadow-gradient', + }) + .append('stop') + .attr({ + offset: '0%', + 'stop-color': '#000', + 'stop-opacity': 0.4, + }) + .select(this.selectParentNode) + .append('stop') + .attr({ + offset: '100%', + 'stop-color': '#000', + 'stop-opacity': 0, + }); + } + + createLine(chart, key) { + chart.append('g') + .attr({ + class: 'deploy-info', + }) + .selectAll('.deploy-info') + .data(this.data) + .enter() + .append('g') + .attr({ + class: d => `deploy-info-${d.id}-${key}`, + transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`, + }) + .append('rect') + .attr({ + x: 1, + y: 0, + height: this.height + 1, + width: 3, + fill: 'url(#shadow-gradient)', + }) + .select(this.selectParentNode) + .append('line') + .attr({ + class: 'deployment-line', + x1: 0, + x2: 0, + y1: 0, + y2: this.height + 1, + }); + } + + createDeployInfoBox(chart, key) { + chart.selectAll('.deploy-info') + .selectAll('.js-deploy-info-box') + .data(this.data) + .enter() + .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`)) + .append('svg') + .attr({ + class: 'js-deploy-info-box hidden', + x: 3, + y: 0, + width: 92, + height: 60, + }) + .append('rect') + .attr({ + class: 'rect-text-metric deploy-info-rect rect-metric', + x: 1, + y: 1, + rx: 2, + width: 90, + height: 58, + }) + .select(this.selectParentNode) + .append('g') + .attr({ + transform: 'translate(5, 2)', + }) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + }) + .text(Deployments.refText) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text', + y: 18, + }) + .text(d => dateFormat(d.time)) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + y: 38, + }) + .text(d => timeFormat(d.time)); + } + + static toggleDeployTextbox(deploy, key, showInfoBox) { + d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`) + .classed('hidden', !showInfoBox); + } + + mouseOverDeployInfo(mouseXPos, key) { + if (!this.data) return false; + + let dataFound = false; + + this.data.forEach((d) => { + if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { + dataFound = d.xPos + 1; + + Deployments.toggleDeployTextbox(d, key, true); + } else { + Deployments.toggleDeployTextbox(d, key, false); + } + }); + + return dataFound; + } + + /* `this` is bound to the D3 node */ + selectParentNode() { + return this.parentNode; + } + + static refText(d) { + return d.tag ? d.ref : d.sha.slice(0, 6); + } +} diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 78bb0e6fb47..6af88769129 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -3,16 +3,20 @@ import d3 from 'd3'; import statusCodes from '~/lib/utils/http_status'; -import { formatRelevantDigits } from '~/lib/utils/number_utils'; +import Deployments from './deployments'; +import '../lib/utils/common_utils'; +import { formatRelevantDigits } from '../lib/utils/number_utils'; import '../flash'; +import { + dateFormat, + timeFormat, +} from './constants'; const prometheusContainer = '.prometheus-container'; const prometheusParentGraphContainer = '.prometheus-graphs'; const prometheusGraphsContainer = '.prometheus-graph'; const prometheusStatesContainer = '.prometheus-state'; const metricsEndpoint = 'metrics.json'; -const timeFormat = d3.time.format('%H:%M'); -const dayFormat = d3.time.format('%b %e, %a'); const bisectDate = d3.bisector(d => d.time).left; const extraAddedWidthParent = 100; @@ -36,6 +40,7 @@ class PrometheusGraph { this.width = parentContainerWidth - this.margin.left - this.margin.right; this.height = this.originalHeight - this.margin.top - this.margin.bottom; this.backOffRequestCounter = 0; + this.deployments = new Deployments(this.width, this.height); this.configureGraph(); this.init(); } else { @@ -74,6 +79,12 @@ class PrometheusGraph { $(prometheusParentGraphContainer).show(); this.transformData(metricsResponse); this.createGraph(); + + const firstMetricData = this.graphSpecificProperties[ + Object.keys(this.graphSpecificProperties)[0] + ].data; + + this.deployments.init(firstMetricData); } }); } @@ -96,6 +107,7 @@ class PrometheusGraph { .attr('width', this.width + this.margin.left + this.margin.right) .attr('height', this.height + this.margin.bottom + this.margin.top) .append('g') + .attr('class', 'graph-container') .attr('transform', `translate(${this.margin.left},${this.margin.top})`); const axisLabelContainer = d3.select(prometheusGraphContainer) @@ -116,6 +128,7 @@ class PrometheusGraph { .scale(y) .ticks(this.commonGraphProperties.axis_no_ticks) .tickSize(-this.width) + .outerTickSize(0) .orient('left'); this.createAxisLabelContainers(axisLabelContainer, key); @@ -248,7 +261,8 @@ class PrometheusGraph { const d1 = currentGraphProps.data[overlayIndex]; const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay; const currentData = evalTime ? d1 : d0; - const currentTimeCoordinate = currentGraphProps.xScale(currentData.time); + const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time)); + const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key); const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value)); const maxMetricValue = currentGraphProps.yScale(maxValueFromData); @@ -256,13 +270,12 @@ class PrometheusGraph { // Clear up all the pieces of the flag d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove(); d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .text-metric`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove(); const currentChart = d3.select(currentPrometheusGraphContainer).select('g'); currentChart.append('line') - .attr('class', 'selected-metric-line') .attr({ + class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`, x1: currentTimeCoordinate, y1: currentGraphProps.yScale(0), x2: currentTimeCoordinate, @@ -272,33 +285,45 @@ class PrometheusGraph { currentChart.append('circle') .attr('class', 'circle-metric') .attr('fill', currentGraphProps.line_color) - .attr('cx', currentTimeCoordinate) + .attr('cx', currentDeployXPos || currentTimeCoordinate) .attr('cy', currentGraphProps.yScale(currentData.value)) .attr('r', this.commonGraphProperties.circle_radius_metric); + if (currentDeployXPos) return; + // The little box with text - const rectTextMetric = currentChart.append('g') - .attr('class', 'rect-text-metric') - .attr('translate', `(${currentTimeCoordinate}, ${currentGraphProps.yScale(currentData.value)})`); + const rectTextMetric = currentChart.append('svg') + .attr({ + class: 'rect-text-metric', + x: currentTimeCoordinate, + y: 0, + }); rectTextMetric.append('rect') - .attr('class', 'rect-metric') - .attr('x', currentTimeCoordinate + 10) - .attr('y', maxMetricValue) - .attr('width', this.commonGraphProperties.rect_text_width) - .attr('height', this.commonGraphProperties.rect_text_height); + .attr({ + class: 'rect-metric', + x: 4, + y: 1, + rx: 2, + width: this.commonGraphProperties.rect_text_width, + height: this.commonGraphProperties.rect_text_height, + }); rectTextMetric.append('text') - .attr('class', 'text-metric') - .attr('x', currentTimeCoordinate + 35) - .attr('y', maxMetricValue + 35) + .attr({ + class: 'text-metric text-metric-bold', + x: 8, + y: 35, + }) .text(timeFormat(currentData.time)); rectTextMetric.append('text') - .attr('class', 'text-metric-date') - .attr('x', currentTimeCoordinate + 15) - .attr('y', maxMetricValue + 15) - .text(dayFormat(currentData.time)); + .attr({ + class: 'text-metric-date', + x: 8, + y: 15, + }) + .text(dateFormat(currentData.time)); let currentMetricValue = formatRelevantDigits(currentData.value); if (key === 'cpu_values') { diff --git a/app/assets/javascripts/pdf/assets/img/bg.gif b/app/assets/javascripts/pdf/assets/img/bg.gif Binary files differnew file mode 100644 index 00000000000..c7e98e044f5 --- /dev/null +++ b/app/assets/javascripts/pdf/assets/img/bg.gif diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue new file mode 100644 index 00000000000..4603859d7b0 --- /dev/null +++ b/app/assets/javascripts/pdf/index.vue @@ -0,0 +1,73 @@ +<template> + <div class="pdf-viewer" v-if="hasPDF"> + <page v-for="(page, index) in pages" + :key="index" + :v-if="!loading" + :page="page" + :number="index + 1" /> + </div> +</template> + +<script> + import pdfjsLib from 'pdfjs-dist'; + import workerSrc from 'vendor/pdf.worker'; + + import page from './page/index.vue'; + + export default { + props: { + pdf: { + type: [String, Uint8Array], + required: true, + }, + }, + data() { + return { + loading: false, + pages: [], + }; + }, + components: { page }, + watch: { pdf: 'load' }, + computed: { + document() { + return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf }; + }, + hasPDF() { + return this.pdf && this.pdf.length > 0; + }, + }, + methods: { + load() { + this.pages = []; + return pdfjsLib.getDocument(this.document) + .then(this.renderPages) + .then(() => this.$emit('pdflabload')) + .catch(error => this.$emit('pdflaberror', error)) + .then(() => { this.loading = false; }); + }, + renderPages(pdf) { + const pagePromises = []; + this.loading = true; + for (let num = 1; num <= pdf.numPages; num += 1) { + pagePromises.push( + pdf.getPage(num).then(p => this.pages.push(p)), + ); + } + return Promise.all(pagePromises); + }, + }, + mounted() { + pdfjsLib.PDFJS.workerSrc = workerSrc; + if (this.hasPDF) this.load(); + }, + }; +</script> + +<style> + .pdf-viewer { + background: url('./assets/img/bg.gif'); + display: flex; + flex-flow: column nowrap; + } +</style> diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue new file mode 100644 index 00000000000..7b74ee4eb2e --- /dev/null +++ b/app/assets/javascripts/pdf/page/index.vue @@ -0,0 +1,68 @@ +<template> + <canvas + class="pdf-page" + ref="canvas" + :data-page="number" /> +</template> + +<script> + export default { + props: { + page: { + type: Object, + required: true, + }, + number: { + type: Number, + required: true, + }, + }, + data() { + return { + scale: 4, + rendering: false, + }; + }, + computed: { + viewport() { + return this.page.getViewport(this.scale); + }, + context() { + return this.$refs.canvas.getContext('2d'); + }, + renderContext() { + return { + canvasContext: this.context, + viewport: this.viewport, + }; + }, + }, + mounted() { + this.$refs.canvas.height = this.viewport.height; + this.$refs.canvas.width = this.viewport.width; + this.rendering = true; + this.page.render(this.renderContext) + .then(() => { this.rendering = false; }) + .catch(error => this.$emit('pdflaberror', error)); + }, + }; +</script> + +<style> +.pdf-page { + margin: 8px auto 0 auto; + border-top: 1px #ddd solid; + border-bottom: 1px #ddd solid; + width: 100%; +} + +.pdf-page:first-child { + margin-top: 0px; + border-top: 0px; +} + +.pdf-page:last-child { + margin-bottom: 0px; + border-bottom: 0px; +} +</style> diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index f3e2a5db0a6..ac1fc0eb8ae 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -254,6 +254,63 @@ padding: 10px 0; } +.landing { + margin-bottom: $gl-padding; + overflow: hidden; + display: flex; + position: relative; + border: 1px solid $blue-300; + border-radius: $border-radius-default; + background-color: $blue-25; + justify-content: center; + + .dismiss-button { + position: absolute; + right: 6px; + top: 6px; + cursor: pointer; + color: $blue-300; + z-index: 1; + border: none; + background-color: transparent; + + &:hover, + &:focus { + border: none; + color: $blue-400; + } + } + + .svg-container { + align-self: center; + } + + .inner-content { + text-align: left; + white-space: nowrap; + + h4 { + color: $gl-text-color; + font-size: 17px; + } + + p { + color: $gl-text-color; + margin-bottom: $gl-padding; + } + } + + @media (max-width: $screen-sm-min) { + flex-direction: column; + + .inner-content { + white-space: normal; + padding: 0 28px; + text-align: center; + } + } +} + .empty-state { margin: 100px 0 0; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 638c1403b25..1a6f36d032d 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -424,6 +424,11 @@ table { } } +.bordered-box { + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + .str-truncated { &-60 { @include str-truncated(60%); diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index ad3dbc7ac48..403724cd68a 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -93,11 +93,6 @@ top: $gl-padding-top; } - .bordered-box { - border: 1px solid $border-color; - border-radius: $border-radius-default; - } - .content-list { li { padding: 18px $gl-padding $gl-padding; @@ -139,42 +134,9 @@ } } - .landing { - margin-bottom: $gl-padding; - overflow: hidden; - - .dismiss-icon { - position: absolute; - right: $cycle-analytics-box-padding; - cursor: pointer; - color: $cycle-analytics-dismiss-icon-color; - } - - .svg-container { - text-align: center; - - svg { - width: 136px; - height: 136px; - } - } - - .inner-content { - @media (max-width: $screen-xs-max) { - padding: 0 28px; - text-align: center; - } - - h4 { - color: $gl-text-color; - font-size: 17px; - } - - p { - color: $cycle-analytics-box-text-color; - margin-bottom: $gl-padding; - } - } + .landing svg { + width: 136px; + height: 136px; } .fa-spinner { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 72e7d42858d..026d35295d7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -157,7 +157,8 @@ .prometheus-graph { text { - fill: $stat-graph-axis-fill; + fill: $gl-text-color; + stroke-width: 0; } .label-axis-text, @@ -210,27 +211,33 @@ .rect-text-metric { fill: $white-light; stroke-width: 1; - stroke: $black; + stroke: $gray-darkest; } .rect-axis-text { fill: $white-light; } -.text-metric, -.text-median-metric, -.text-metric-usage, -.text-metric-date { - fill: $black; +.text-metric { + font-weight: 600; } -.text-metric-date { - font-weight: 200; +.selected-metric-line { + stroke: $gl-gray-dark; + stroke-width: 1; } -.selected-metric-line { +.deployment-line { stroke: $black; - stroke-width: 1; + stroke-width: 2; +} + +.deploy-info-text { + dominant-baseline: text-before-edge; +} + +.text-metric-bold { + font-weight: 600; } .prometheus-state { diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 73a5889867a..72d73b89a2a 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -88,3 +88,26 @@ color: $gl-text-color-secondary; margin-top: 10px; } + +.explore-groups.landing { + margin-top: 10px; + + .inner-content { + padding: 0; + + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + + svg { + width: 62px; + height: 50px; + } +} diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index cbfc4581411..a119934febc 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,4 +1,6 @@ class Admin::HooksController < Admin::ApplicationController + before_action :hook, only: :edit + def index @hooks = SystemHook.all @hook = SystemHook.new @@ -15,15 +17,25 @@ class Admin::HooksController < Admin::ApplicationController end end + def edit + end + + def update + if hook.update_attributes(hook_params) + flash[:notice] = 'System hook was successfully updated.' + redirect_to admin_hooks_path + else + render 'edit' + end + end + def destroy - @hook = SystemHook.find(params[:id]) - @hook.destroy + hook.destroy redirect_to admin_hooks_path end def test - @hook = SystemHook.find(params[:hook_id]) data = { event_name: "project_create", name: "Ruby", @@ -32,11 +44,17 @@ class Admin::HooksController < Admin::ApplicationController owner_name: "Someone", owner_email: "example@gitlabhq.com" } - @hook.execute(data, 'system_hooks') + hook.execute(data, 'system_hooks') redirect_back_or_default end + private + + def hook + @hook ||= SystemHook.find(params[:id]) + end + def hook_params params.require(:hook).permit( :enable_ssl_verification, diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb new file mode 100644 index 00000000000..c32038d07bf --- /dev/null +++ b/app/controllers/concerns/notes_actions.rb @@ -0,0 +1,136 @@ +module NotesActions + include RendersNotes + extend ActiveSupport::Concern + + included do + before_action :authorize_admin_note!, only: [:update, :destroy] + end + + def index + current_fetched_at = Time.now.to_i + + notes_json = { notes: [], last_fetched_at: current_fetched_at } + + @notes = notes_finder.execute.inc_relations_for_view + @notes = prepare_notes_for_rendering(@notes) + + @notes.each do |note| + next if note.cross_reference_not_visible_for?(current_user) + + notes_json[:notes] << note_json(note) + end + + render json: notes_json + end + + def create + create_params = note_params.merge( + merge_request_diff_head_sha: params[:merge_request_diff_head_sha], + in_reply_to_discussion_id: params[:in_reply_to_discussion_id] + ) + @note = Notes::CreateService.new(project, current_user, create_params).execute + + if @note.is_a?(Note) + Banzai::NoteRenderer.render([@note], @project, current_user) + end + + respond_to do |format| + format.json { render json: note_json(@note) } + format.html { redirect_back_or_default } + end + end + + def update + @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) + + if @note.is_a?(Note) + Banzai::NoteRenderer.render([@note], @project, current_user) + end + + respond_to do |format| + format.json { render json: note_json(@note) } + format.html { redirect_back_or_default } + end + end + + def destroy + if note.editable? + Notes::DestroyService.new(project, current_user).execute(note) + end + + respond_to do |format| + format.js { head :ok } + end + end + + private + + def note_json(note) + attrs = { + commands_changes: note.commands_changes + } + + if note.persisted? + attrs.merge!( + valid: true, + id: note.id, + discussion_id: note.discussion_id(noteable), + html: note_html(note), + note: note.note + ) + + discussion = note.to_discussion(noteable) + unless discussion.individual_note? + attrs.merge!( + discussion_resolvable: discussion.resolvable?, + + diff_discussion_html: diff_discussion_html(discussion), + discussion_html: discussion_html(discussion) + ) + end + else + attrs.merge!( + valid: false, + errors: note.errors + ) + end + + attrs + end + + def authorize_admin_note! + return access_denied! unless can?(current_user, :admin_note, note) + end + + def note_params + params.require(:note).permit( + :project_id, + :noteable_type, + :noteable_id, + :commit_id, + :noteable, + :type, + + :note, + :attachment, + + # LegacyDiffNote + :line_code, + + # DiffNote + :position + ) + end + + def noteable + @noteable ||= notes_finder.target + end + + def last_fetched_at + request.headers['X-Last-Fetched-At'] + end + + def notes_finder + @notes_finder ||= NotesFinder.new(project, current_user, finder_params) + end +end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index dd21066ac13..41c3114ad1e 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -10,6 +10,8 @@ module RendersNotes private def preload_max_access_for_authors(notes, project) + return nil unless project + user_ids = notes.map(&:author_id) project.team.max_member_access_for_user_ids(user_ids) end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index ca6dffe1cc5..ffea712a833 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -5,10 +5,12 @@ module SnippetsActions end def raw + disposition = params[:inline] == 'false' ? 'attachment' : 'inline' + send_data( convert_line_endings(@snippet.content), type: 'text/plain; charset=utf-8', - disposition: 'inline', + disposition: disposition, filename: @snippet.sanitized_file_name ) end diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb index fbf9a026b10..ba5b7d33f87 100644 --- a/app/controllers/concerns/toggle_award_emoji.rb +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -22,7 +22,8 @@ module ToggleAwardEmoji def to_todoable(awardable) case awardable when Note - awardable.noteable + # we don't create todos for personal snippet comments for now + awardable.for_personal_snippet? ? nil : awardable.noteable when MergeRequest, Issue awardable when Snippet diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb new file mode 100644 index 00000000000..c319671456d --- /dev/null +++ b/app/controllers/projects/deployments_controller.rb @@ -0,0 +1,18 @@ +class Projects::DeploymentsController < Projects::ApplicationController + before_action :authorize_read_environment! + before_action :authorize_read_deployment! + + def index + deployments = environment.deployments.reorder(created_at: :desc) + deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time + + render json: { deployments: DeploymentSerializer.new(user: @current_user, project: project) + .represent_concise(deployments) } + end + + private + + def environment + @environment ||= project.environments.find(params[:environment_id]) + end +end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 1e41f980f31..86d13a0d222 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,6 +1,7 @@ class Projects::HooksController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! + before_action :hook, only: :edit respond_to :html @@ -17,6 +18,18 @@ class Projects::HooksController < Projects::ApplicationController redirect_to namespace_project_settings_integrations_path(@project.namespace, @project) end + def edit + end + + def update + if hook.update_attributes(hook_params) + flash[:notice] = 'Hook was successfully updated.' + redirect_to namespace_project_settings_integrations_path(@project.namespace, @project) + else + render 'edit' + end + end + def test if !@project.empty_repo? status, message = TestHookService.new.execute(hook, current_user) diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 405ea3c0a4f..37f51b2ebe3 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,68 +1,22 @@ class Projects::NotesController < Projects::ApplicationController - include RendersNotes + include NotesActions include ToggleAwardEmoji - # Authorize before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] - before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - def index - current_fetched_at = Time.now.to_i - - notes_json = { notes: [], last_fetched_at: current_fetched_at } - - @notes = notes_finder.execute.inc_relations_for_view - @notes = prepare_notes_for_rendering(@notes) - - @notes.each do |note| - next if note.cross_reference_not_visible_for?(current_user) - - notes_json[:notes] << note_json(note) - end - - render json: notes_json - end - + # + # This is a fix to make spinach feature tests passing: + # Controller actions are returned from AbstractController::Base and methods of parent classes are + # excluded in order to return only specific controller related methods. + # That is ok for the app (no :create method in ancestors) + # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors) + # + # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78 + # def create - create_params = note_params.merge( - merge_request_diff_head_sha: params[:merge_request_diff_head_sha], - in_reply_to_discussion_id: params[:in_reply_to_discussion_id] - ) - @note = Notes::CreateService.new(project, current_user, create_params).execute - - if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) - end - - respond_to do |format| - format.json { render json: note_json(@note) } - format.html { redirect_back_or_default } - end - end - - def update - @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) - - if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) - end - - respond_to do |format| - format.json { render json: note_json(@note) } - format.html { redirect_back_or_default } - end - end - - def destroy - if note.editable? - Notes::DestroyService.new(project, current_user).execute(note) - end - - respond_to do |format| - format.js { head :ok } - end + super end def delete_attachment @@ -110,7 +64,7 @@ class Projects::NotesController < Projects::ApplicationController def note_html(note) render_to_string( - "projects/notes/_note", + "shared/notes/_note", layout: false, formats: [:html], locals: { note: note } @@ -152,76 +106,11 @@ class Projects::NotesController < Projects::ApplicationController ) end - def note_json(note) - attrs = { - commands_changes: note.commands_changes - } - - if note.persisted? - attrs.merge!( - valid: true, - id: note.id, - discussion_id: note.discussion_id(noteable), - html: note_html(note), - note: note.note - ) - - discussion = note.to_discussion(noteable) - unless discussion.individual_note? - attrs.merge!( - discussion_resolvable: discussion.resolvable?, - - diff_discussion_html: diff_discussion_html(discussion), - discussion_html: discussion_html(discussion) - ) - end - else - attrs.merge!( - valid: false, - errors: note.errors - ) - end - - attrs - end - - def authorize_admin_note! - return access_denied! unless can?(current_user, :admin_note, note) + def finder_params + params.merge(last_fetched_at: last_fetched_at) end def authorize_resolve_note! return access_denied! unless can?(current_user, :resolve_note, note) end - - def note_params - params.require(:note).permit( - :project_id, - :noteable_type, - :noteable_id, - :commit_id, - :noteable, - :type, - - :note, - :attachment, - - # LegacyDiffNote - :line_code, - - # DiffNote - :position - ) - end - - def notes_finder - @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) - end - - def noteable - @noteable ||= notes_finder.target - end - - def last_fetched_at - request.headers['X-Last-Fetched-At'] - end end diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb new file mode 100644 index 00000000000..3c4ddc1680d --- /dev/null +++ b/app/controllers/snippets/notes_controller.rb @@ -0,0 +1,44 @@ +class Snippets::NotesController < ApplicationController + include NotesActions + include ToggleAwardEmoji + + skip_before_action :authenticate_user!, only: [:index] + before_action :snippet + before_action :authorize_read_snippet!, only: [:show, :index, :create] + + private + + def note + @note ||= snippet.notes.find(params[:id]) + end + alias_method :awardable, :note + + def note_html(note) + render_to_string( + "shared/notes/_note", + layout: false, + formats: [:html], + locals: { note: note } + ) + end + + def project + nil + end + + def snippet + PersonalSnippet.find_by(id: params[:snippet_id]) + end + + def note_params + super.merge(noteable_id: params[:snippet_id]) + end + + def finder_params + params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet') + end + + def authorize_read_snippet! + return render_404 unless can?(current_user, :read_personal_snippet, snippet) + end +end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 906833505d1..da1ae9a34d9 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -1,14 +1,15 @@ class SnippetsController < ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions include MarkdownPreview include RendersBlob - before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :download] + before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] # Allow read snippet - before_action :authorize_read_snippet!, only: [:show, :raw, :download] + before_action :authorize_read_snippet!, only: [:show, :raw] # Allow modify snippet before_action :authorize_update_snippet!, only: [:edit, :update] @@ -16,7 +17,7 @@ class SnippetsController < ApplicationController # Allow destroy snippet before_action :authorize_admin_snippet!, only: [:destroy] - skip_before_action :authenticate_user!, only: [:index, :show, :raw, :download] + skip_before_action :authenticate_user!, only: [:index, :show, :raw] layout 'snippets' respond_to :html @@ -64,6 +65,11 @@ class SnippetsController < ApplicationController blob = @snippet.blob override_max_blob_size(blob) + @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) + respond_to do |format| format.html do render 'show' @@ -83,14 +89,6 @@ class SnippetsController < ApplicationController redirect_to snippets_path end - def download - send_data( - convert_line_endings(@snippet.content), - type: 'text/plain; charset=utf-8', - filename: @snippet.sanitized_file_name - ) - end - def preview_markdown render_markdown_preview(params[:text], skip_project_check: true) end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 3c499184b41..dc6a8ad1f66 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -68,6 +68,8 @@ class NotesFinder MergeRequestsFinder.new(@current_user, project_id: @project.id).execute when "snippet", "project_snippet" SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project) + when "personal_snippet" + PersonalSnippet.all else raise 'invalid target_type' end diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index 167b09e678f..024cf38469e 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -1,10 +1,14 @@ module AwardEmojiHelper def toggle_award_url(awardable) - return url_for([:toggle_award_emoji, awardable]) unless @project + return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note) if awardable.is_a?(Note) # We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x) - toggle_award_emoji_namespace_project_note_url(@project.namespace, @project, awardable.id) + if awardable.for_personal_snippet? + toggle_award_emoji_snippet_note_path(awardable.noteable, awardable) + else + toggle_award_emoji_namespace_project_note_path(@project.namespace, @project, awardable.id) + end else url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 0b80747f001..377b080b3c6 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -217,11 +217,21 @@ module BlobHelper end def copy_blob_source_button(blob) + return unless blob.rendered_as_text?(ignore_errors: false) + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard") end - def open_raw_blob_button - link_to icon('file-code-o'), blob_raw_url, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } + def open_raw_blob_button(blob) + if blob.raw_binary? + icon = icon('download') + title = 'Download' + else + icon = icon('file-code-o') + title = 'Open raw' + end + + link_to icon, blob_raw_url, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end def blob_render_error_reason(viewer) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index e347f61fb8d..2614cdfe90e 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -1,6 +1,6 @@ module MergeRequestsHelper def new_mr_path_from_push_event(event) - target_project = event.project.forked_from_project || event.project + target_project = event.project.default_merge_request_target new_namespace_project_merge_request_path( event.project.namespace, event.project, @@ -127,6 +127,10 @@ module MergeRequestsHelper end end + def target_projects(project) + [project, project.default_merge_request_target].uniq + end + def merge_request_button_visibility(merge_request, closed) return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 979264c9421..2fd64b3441e 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -8,6 +8,14 @@ module SnippetsHelper end end + def download_snippet_path(snippet) + if snippet.project_id + raw_namespace_project_snippet_path(@project.namespace, @project, snippet, inline: false) + else + raw_snippet_path(snippet, inline: false) + end + end + # Return the path of a snippets index for a user or for a project # # @returns String, path to snippet index diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index f033028c4e5..eb32bf3d32a 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -78,6 +78,9 @@ module CacheMarkdownField def cached_html_up_to_date?(markdown_field) html_field = cached_markdown_fields.html_field(markdown_field) + cached = !cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? + return false unless cached + markdown_changed = attribute_changed?(markdown_field) || false html_changed = attribute_changed?(html_field) || false diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 9d2288c311e..365fa4f1e70 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -100,6 +100,7 @@ class MergeRequest < ActiveRecord::Base validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? + validate :validate_target_project, on: :create scope :by_source_or_target_branch, ->(branch_name) do where("source_branch = :branch OR target_branch = :branch", branch: branch_name) @@ -330,6 +331,12 @@ class MergeRequest < ActiveRecord::Base end end + def validate_target_project + return true if target_project.merge_requests_enabled? + + errors.add :base, 'Target project has disabled merge requests' + end + def validate_fork return true unless target_project && source_project return true if target_project == source_project diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 9bfa731785f..397dc7a25ab 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -33,7 +33,7 @@ class Namespace < ActiveRecord::Base validates :path, presence: true, length: { maximum: 255 }, - namespace: true + dynamic_path: true validate :nesting_level_allowed @@ -220,6 +220,10 @@ class Namespace < ActiveRecord::Base Project.inside_path(full_path) end + def has_parent? + parent.present? + end + private def repository_storage_paths diff --git a/app/models/project.rb b/app/models/project.rb index c7dc562c238..6a8f8c3500f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -196,13 +196,14 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_name_regex_message } validates :path, presence: true, - project_path: true, + dynamic_path: true, length: { maximum: 255 }, format: { with: Gitlab::Regex.project_path_regex, - message: Gitlab::Regex.project_path_regex_message } + message: Gitlab::Regex.project_path_regex_message }, + uniqueness: { scope: :namespace_id } + validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :path, uniqueness: { scope: :namespace_id } validates :import_url, addressable_url: true, if: :external_import? validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } @@ -1314,6 +1315,14 @@ class Project < ActiveRecord::Base namespace_id_changed? end + def default_merge_request_target + if forked_from_project&.merge_requests_enabled? + forked_from_project + else + self + end + end + alias_method :name_with_namespace, :full_name alias_method :human_name, :full_name alias_method :path_with_namespace, :full_path diff --git a/app/models/repository.rb b/app/models/repository.rb index feabfa111fb..ba34d570dbd 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -505,14 +505,8 @@ class Repository delegate :tag_names, to: :raw_repository cache_method :tag_names, fallback: [] - def branch_count - branches.size - end + delegate :branch_count, :tag_count, to: :raw_repository cache_method :branch_count, fallback: 0 - - def tag_count - raw_repository.rugged.tags.count - end cache_method :tag_count, fallback: 0 def avatar diff --git a/app/models/user.rb b/app/models/user.rb index bd9c9f99663..2b7ebe6c1a7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -118,7 +118,7 @@ class User < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, - namespace: true, + dynamic_path: true, presence: true, uniqueness: { case_sensitive: false } diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index d610fbe0c8a..8b3de1bed0f 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -18,8 +18,10 @@ class DeploymentEntity < Grape::Entity end end + expose :created_at expose :tag expose :last? + expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: BuildEntity diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb new file mode 100644 index 00000000000..cba5c3f311f --- /dev/null +++ b/app/serializers/deployment_serializer.rb @@ -0,0 +1,8 @@ +class DeploymentSerializer < BaseSerializer + entity DeploymentEntity + + def represent_concise(resource, opts = {}) + opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]] + represent(resource, opts) + end +end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 944472f3e51..188c3747f18 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -7,6 +7,9 @@ class StatusEntity < Grape::Entity expose :details_path expose :favicon do |status| - ActionController::Base.helpers.image_path(File.join('ci_favicons', "#{status.favicon}.ico")) + dir = 'ci_favicons' + dir = File.join(dir, 'dev') if Rails.env.development? + + ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index d45da5180e1..bc0e7ad4e39 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -28,7 +28,7 @@ module MergeRequests def find_target_project return target_project if target_project.present? && can?(current_user, :read_project, target_project) - project.forked_from_project || project + project.default_merge_request_target end def find_target_branch diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index b6e88b0280f..8ae61694b50 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -281,7 +281,7 @@ class TodoService def attributes_for_target(target) attributes = { - project_id: target.project.id, + project_id: target&.project&.id, target_id: target.id, target_type: target.class.name, commit_id: nil diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb new file mode 100644 index 00000000000..226eb6b313c --- /dev/null +++ b/app/validators/dynamic_path_validator.rb @@ -0,0 +1,208 @@ +# DynamicPathValidator +# +# Custom validator for GitLab path values. +# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` +# +# Values are checked for formatting and exclusion from a list of reserved path +# names. +class DynamicPathValidator < ActiveModel::EachValidator + # All routes that appear on the top level must be listed here. + # This will make sure that groups cannot be created with these names + # as these routes would be masked by the paths already in place. + # + # Example: + # /api/api-project + # + # the path `api` shouldn't be allowed because it would be masked by `api/*` + # + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + # This list should contain all words following `/*namespace_id/:project_id` in + # routes that contain a second wildcard. + # + # Example: + # /*namespace_id/:project_id/badges/*ref/build + # + # If `badges` was allowed as a project/group name, we would not be able to access the + # `badges` route for those projects: + # + # Consider a namespace with path `foo/bar` and a project called `badges`. + # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` + # + # When accessing this path the route would be matched to the `badges` path + # with the following params: + # - namespace_id: `foo` + # - project_id: `bar` + # - ref: `badges/master` + # + # Failing to find the project, this would result in a 404. + # + # By rejecting `badges` the router can _count_ on the fact that `badges` will + # be preceded by the `namespace/project`. + WILDCARD_ROUTES = %w[ + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` + # We need to reject these because we have a `/groups/*id` page that is the same + # as the `/*id`. + # + # If we would allow a subgroup to be created with the name `activity` then + # this group would not be accessible through `/groups/parent/activity` since + # this would map to the activity-page of it's parent. + GROUP_ROUTES = %w[ + activity + avatar + edit + group_members + issues + labels + merge_requests + milestones + projects + subgroups + ].freeze + + CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze + + def self.without_reserved_wildcard_paths_regex + @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES) + end + + def self.without_reserved_child_paths_regex + @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES) + end + + # This is used to validate a full path. + # It doesn't match paths + # - Starting with one of the top level words + # - Containing one of the child level words in the middle of a path + def self.regex_excluding_child_paths(child_routes) + reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES) + not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))} + + reserved_child_level_words = Regexp.union(child_routes) + not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))} + + %r{#{not_starting_in_reserved_word} + #{not_containing_reserved_child} + #{Gitlab::Regex.full_namespace_regex}}x + end + + def self.valid?(path) + path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path) + end + + def self.full_path_reserved?(path) + path = path.to_s.downcase + _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse) + + wildcard_reserved?(path) || child_reserved?(namespace_parts) + end + + def self.child_reserved?(path) + return false unless path + + path !~ without_reserved_child_paths_regex + end + + def self.wildcard_reserved?(path) + return false unless path + + path !~ without_reserved_wildcard_paths_regex + end + + delegate :full_path_reserved?, + :child_reserved?, + to: :class + + def path_reserved_for_record?(record, value) + full_path = record.respond_to?(:full_path) ? record.full_path : value + + # For group paths the entire path cannot contain a reserved child word + # The path doesn't contain the last `_project_part` so we need to validate + # if the entire path. + # Example: + # A *group* with full path `parent/activity` is reserved. + # A *project* with full path `parent/activity` is allowed. + if record.is_a? Group + child_reserved?(full_path) + else + full_path_reserved?(full_path) + end + end + + def validate_each(record, attribute, value) + unless value =~ Gitlab::Regex.namespace_regex + record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) + return + end + + if path_reserved_for_record?(record, value) + record.errors.add(attribute, "#{value} is a reserved name") + end + end +end diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb deleted file mode 100644 index 77ca033e97f..00000000000 --- a/app/validators/namespace_validator.rb +++ /dev/null @@ -1,73 +0,0 @@ -# NamespaceValidator -# -# Custom validator for GitLab namespace values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class NamespaceValidator < ActiveModel::EachValidator - RESERVED = %w[ - .well-known - admin - all - assets - ci - dashboard - files - groups - help - hooks - issues - merge_requests - new - notes - profile - projects - public - repository - robots.txt - s - search - services - snippets - teams - u - unsubscribes - users - ].freeze - - WILDCARD_ROUTES = %w[tree commits wikis new edit create update logs_tree - preview blob blame raw files create_dir find_file - artifacts graphs refs badges].freeze - - STRICT_RESERVED = (RESERVED + WILDCARD_ROUTES).freeze - - def self.valid?(value) - !reserved?(value) && follow_format?(value) - end - - def self.reserved?(value, strict: false) - if strict - STRICT_RESERVED.include?(value) - else - RESERVED.include?(value) - end - end - - def self.follow_format?(value) - value =~ Gitlab::Regex.namespace_regex - end - - delegate :reserved?, :follow_format?, to: :class - - def validate_each(record, attribute, value) - unless follow_format?(value) - record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) - end - - strict = record.is_a?(Group) && record.parent_id - - if reserved?(value, strict: strict) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb deleted file mode 100644 index ee2ae65be7b..00000000000 --- a/app/validators/project_path_validator.rb +++ /dev/null @@ -1,35 +0,0 @@ -# ProjectPathValidator -# -# Custom validator for GitLab project path values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class ProjectPathValidator < ActiveModel::EachValidator - # All project routes with wildcard argument must be listed here. - # Otherwise it can lead to routing issues when route considered as project name. - # - # Example: - # /group/project/tree/deploy_keys - # - # without tree as reserved name routing can match 'group/project' as group name, - # 'tree' as project name and 'deploy_keys' as route. - # - RESERVED = (NamespaceValidator::STRICT_RESERVED - - %w[dashboard help ci admin search notes services assets profile public]).freeze - - def self.valid?(value) - !reserved?(value) - end - - def self.reserved?(value) - RESERVED.include?(value) - end - - delegate :reserved?, to: :class - - def validate_each(record, attribute, value) - if reserved?(value) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 8c9fdc9ae42..53f0a1e7fde 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -73,6 +73,12 @@ = container_reg %span.light.pull-right = boolean_to_icon Gitlab.config.registry.enabled + - gitlab_pages = 'GitLab Pages' + - gitlab_pages_enabled = Gitlab.config.pages.enabled + %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") } + = gitlab_pages + %span.light.pull-right + = boolean_to_icon gitlab_pages_enabled .col-md-4 %h4 diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml new file mode 100644 index 00000000000..6217d5fb135 --- /dev/null +++ b/app/views/admin/hooks/_form.html.haml @@ -0,0 +1,40 @@ += form_errors(hook) + +.form-group + = form.label :url, 'URL', class: 'control-label' + .col-sm-10 + = form.text_field :url, class: 'form-control' +.form-group + = form.label :token, 'Secret Token', class: 'control-label' + .col-sm-10 + = form.text_field :token, class: 'form-control' + %p.help-block + Use this token to validate received payloads +.form-group + = form.label :url, 'Trigger', class: 'control-label' + .col-sm-10.prepend-top-10 + %div + System hook will be triggered on set of events like creating project + or adding ssh key. But you can also enable extra triggers like Push events. + + .prepend-top-default + = form.check_box :push_events, class: 'pull-left' + .prepend-left-20 + = form.label :push_events, class: 'list-label' do + %strong Push events + %p.light + This url will be triggered by a push to the repository + %div + = form.check_box :tag_push_events, class: 'pull-left' + .prepend-left-20 + = form.label :tag_push_events, class: 'list-label' do + %strong Tag push events + %p.light + This url will be triggered when a new tag is pushed to the repository +.form-group + = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' + .col-sm-10 + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml new file mode 100644 index 00000000000..0777f5e2629 --- /dev/null +++ b/app/views/admin/hooks/edit.html.haml @@ -0,0 +1,14 @@ +- page_title 'Edit System Hook' +%h3.page-title + Edit System Hook + +%p.light + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be + used for binding events when GitLab creates a User or Project. + +%hr + += form_for @hook, as: :hook, url: admin_hook_path, html: { class: 'form-horizontal' } do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + .form-actions + = f.submit 'Save changes', class: 'btn btn-create' diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index d9c7948763a..71117758921 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,57 +1,17 @@ -- page_title "System Hooks" +- page_title 'System Hooks' %h3.page-title System hooks %p.light - #{link_to "System hooks ", help_page_path("system_hooks/system_hooks"), class: "vlink"} can be + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be used for binding events when GitLab creates a User or Project. %hr - = form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f| - = form_errors(@hook) - - .form-group - = f.label :url, 'URL', class: 'control-label' - .col-sm-10 - = f.text_field :url, class: 'form-control' - .form-group - = f.label :token, 'Secret Token', class: 'control-label' - .col-sm-10 - = f.text_field :token, class: 'form-control' - %p.help-block - Use this token to validate received payloads - .form-group - = f.label :url, "Trigger", class: 'control-label' - .col-sm-10.prepend-top-10 - %div - System hook will be triggered on set of events like creating project - or adding ssh key. But you can also enable extra triggers like Push events. - - .prepend-top-default - = f.check_box :push_events, class: 'pull-left' - .prepend-left-20 - = f.label :push_events, class: 'list-label' do - %strong Push events - %p.light - This url will be triggered by a push to the repository - %div - = f.check_box :tag_push_events, class: 'pull-left' - .prepend-left-20 - = f.label :tag_push_events, class: 'list-label' do - %strong Tag push events - %p.light - This url will be triggered when a new tag is pushed to the repository - .form-group - = f.label :enable_ssl_verification, "SSL verification", class: 'control-label checkbox' - .col-sm-10 - .checkbox - = f.label :enable_ssl_verification do - = f.check_box :enable_ssl_verification - %strong Enable SSL verification + = render partial: 'form', locals: { form: f, hook: @hook } .form-actions - = f.submit "Add system hook", class: "btn btn-create" + = f.submit 'Add system hook', class: 'btn btn-create' %hr - if @hooks.any? @@ -62,11 +22,12 @@ - @hooks.each do |hook| %li .controls - = link_to 'Test hook', admin_hook_test_path(hook), class: "btn btn-sm" - = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-remove btn-sm" + = link_to 'Test hook', test_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' .monospace= hook.url %div - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray= trigger.titleize - %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"} + %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 0e848386ebb..4594c52b34b 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -2,10 +2,10 @@ %ul.nav-links = nav_link(page: dashboard_groups_path) do = link_to dashboard_groups_path, title: 'Your groups' do - Your Groups + Your groups = nav_link(page: explore_groups_path) do - = link_to explore_groups_path, title: 'Explore groups' do - Explore Groups + = link_to explore_groups_path, title: 'Explore public groups' do + Explore public groups .nav-controls = render 'shared/groups/search_form' = render 'shared/groups/dropdown' diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 34789808f10..964473ee3e0 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,6 +1,6 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note + = render partial: "shared/notes/note", collection: discussion.notes, as: :note - if current_user .discussion-reply-holder diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index bb2cd0d44c8..ffe07b217a7 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -7,6 +7,15 @@ = render 'explore/head' = render 'nav' +- if cookies[:explore_groups_landing_dismissed] != 'true' + .explore-groups.landing.content-block.js-explore-groups-landing.hidden + %button.dismiss-button{ type: 'button', 'aria-label' => 'Dismiss' }= icon('times') + .svg-container + = custom_icon('icon_explore_groups_splash') + .inner-content + %p Below you will find all the groups that are public. + %p You can easily contribute to them by requesting to join these groups. + - if @groups.present? = render 'groups' - else diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 638f8cef3bd..219dc14645b 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -15,8 +15,8 @@ = render 'projects/blob/viewer_switcher', blob: blob unless blame .btn-group{ role: "group" }< - = copy_blob_source_button(blob) if !blame && blob.rendered_as_text?(ignore_errors: false) - = open_raw_blob_button + = copy_blob_source_button(blob) unless blame + = open_raw_blob_button(blob) = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 85e442e115c..50e0bad3ccf 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -60,7 +60,7 @@ git init git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')} git add . - git commit + git commit -m "Initial commit" git push -u origin master %fieldset diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 766f119116f..e8f8fbbcf09 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -5,7 +5,7 @@ = page_specific_javascript_bundle_tag('monitoring') = render "projects/pipelines/head" -.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" } +#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } } .top-area .row .col-sm-6 diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml index 8faad351463..676b7c345bc 100644 --- a/app/views/projects/hooks/_index.html.haml +++ b/app/views/projects/hooks/_index.html.haml @@ -1 +1,23 @@ -= render 'shared/web_hooks/form', hook: @hook, hooks: @hooks, url_components: [@project.namespace.becomes(Namespace), @project] +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be + used for binding events when something is happening within the project. + + .col-lg-9.append-bottom-default + = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| + = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Add webhook', class: 'btn btn-create' + + %hr + %h5.prepend-top-default + Webhooks (#{@hooks.count}) + - if @hooks.any? + %ul.well-list + - @hooks.each do |hook| + = render 'project_hook', hook: hook + - else + %p.settings-message.text-center.append-bottom-0 + No webhooks found, add one in the form above. diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml new file mode 100644 index 00000000000..7998713be1f --- /dev/null +++ b/app/views/projects/hooks/edit.html.haml @@ -0,0 +1,14 @@ += render 'projects/settings/head' + +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be + used for binding events when something is happening within the project. + .col-lg-9.append-bottom-default + = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f| + = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Save changes', class: 'btn btn-create' + diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index 8d134aaac67..9cf24e10842 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -38,7 +38,7 @@ .panel-heading Target branch .panel-body.clearfix - - projects = @project.forked_from_project.nil? ? [@project] : [@project, @project.forked_from_project] + - projects = target_projects(@project) .merge-request-select.dropdown = f.hidden_field :target_project_id = dropdown_toggle f.object.target_project.path_with_namespace, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" } diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml new file mode 100644 index 00000000000..718b52dd82e --- /dev/null +++ b/app/views/projects/notes/_actions.html.haml @@ -0,0 +1,44 @@ +- access = note_max_access_for_user(note) +- if access + %span.note-role= access + +- if note.resolvable? + - can_resolve = can?(current_user, :resolve_note, note) + %resolve-btn{ "project-path" => project_path(note.project), + "discussion-id" => note.discussion_id(@noteable), + ":note-id" => note.id, + ":resolved" => note.resolved?, + ":can-resolve" => can_resolve, + ":author-name" => "'#{j(note.author.name)}'", + "author-avatar" => note.author.avatar_url, + ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'", + ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'", + "v-show" => "#{can_resolve || note.resolved?}", + "inline-template" => true, + "ref" => "note_#{note.id}" } + + %button.note-action-button.line-resolve-btn{ type: "button", + class: ("is-disabled" unless can_resolve), + ":class" => "{ 'is-active': isResolved }", + ":aria-label" => "buttonText", + "@click" => "resolve", + ":title" => "buttonText", + ":ref" => "'button'" } + + = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading') + %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg' + +- if current_user + - if note.emoji_awardable? + - user_authored = note.user_authored?(current_user) + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = icon('spinner spin') + %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') + %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = icon('pencil', class: 'link-highlight') + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/projects/notes/_edit.html.haml b/app/views/projects/notes/_edit.html.haml new file mode 100644 index 00000000000..f1e251d65b7 --- /dev/null +++ b/app/views/projects/notes/_edit.html.haml @@ -0,0 +1,3 @@ +.original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + #{note.note} +%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml deleted file mode 100644 index 7afccb3900a..00000000000 --- a/app/views/projects/notes/_note.html.haml +++ /dev/null @@ -1,101 +0,0 @@ -- return unless note.author -- return if note.cross_reference_not_visible_for?(current_user) - -- note_editable = note_editable?(note) -%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } - .timeline-entry-inner - .timeline-icon - - if note.system - = icon_for_system_note(note) - - else - %a{ href: user_path(note.author) } - = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' - .timeline-content - .note-header - .note-header-info - %a{ href: user_path(note.author) } - %span.hidden-xs - = sanitize(note.author.name) - %span.note-headline-light - = note.author.to_reference - %span.note-headline-light - %span.note-headline-meta - - unless note.system - commented - - if note.system - %span.system-note-message - = note.redacted_note_html - %a{ href: "##{dom_id(note)}" } - = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - - unless note.system? - .note-actions - - access = note_max_access_for_user(note) - - if access - %span.note-role= access - - - if note.resolvable? - - can_resolve = can?(current_user, :resolve_note, note) - %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id(@noteable), - ":note-id" => note.id, - ":resolved" => note.resolved?, - ":can-resolve" => can_resolve, - ":author-name" => "'#{j(note.author.name)}'", - "author-avatar" => note.author.avatar_url, - ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'", - ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'", - "v-show" => "#{can_resolve || note.resolved?}", - "inline-template" => true, - "ref" => "note_#{note.id}" } - - %button.note-action-button.line-resolve-btn{ type: "button", - class: ("is-disabled" unless can_resolve), - ":class" => "{ 'is-active': isResolved }", - ":aria-label" => "buttonText", - "@click" => "resolve", - ":title" => "buttonText", - ":ref" => "'button'" } - - = icon("spin spinner", "v-show" => "loading", class: 'loading') - %div{ 'v-show' => '!loading' }= render "shared/icons/icon_status_success.svg" - - - if current_user - - if note.emoji_awardable? - - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do - = icon('spinner spin') - %span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') - %span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley') - %span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile') - - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do - = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do - = icon('trash-o', class: 'danger-highlight') - .note-body{ class: note_editable ? 'js-task-list-container' : '' } - .note-text.md - = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - - if note_editable - .original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } - #{note.note} - %textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note - .note-awards - = render 'award_emoji/awards_block', awardable: note, inline: false - - if note.system - .system-note-commit-list-toggler - Toggle commit list - %i.fa.fa-angle-down - - if note.attachment.url - .note-attachment - - if note.attachment.image? - = link_to note.attachment.url, target: '_blank' do - = image_tag note.attachment.url, class: 'note-image-attach' - .attachment - = link_to note.attachment.url, target: '_blank' do - = icon('paperclip') - = note.attachment_identifier - = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), - title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do - = icon('trash-o', class: 'cred') diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml index 90a150aa74c..555228623cc 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/projects/notes/_notes_with_form.html.haml @@ -1,5 +1,5 @@ %ul#notes-list.notes.main-notes-list.timeline - = render "projects/notes/notes" + = render "shared/notes/notes" = render 'projects/notes/edit_form' diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index e50a543ffa8..5a5ade03624 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -14,7 +14,7 @@ %span Members - if can_edit - = nav_link(controller: [:integrations, :services]) do + = nav_link(controller: [:integrations, :services, :hooks]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span Integrations diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index ceabe2eab3d..8dc276a3bec 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_project_hook.html.haml @@ -9,6 +9,7 @@ .col-md-4.col-lg-5.text-right-lg.prepend-top-5 %span.append-right-10.inline SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"} + = link_to "Edit", edit_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm" = link_to "Test", test_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm" = link_to namespace_project_hook_path(@project.namespace, @project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent" do %span.sr-only Remove diff --git a/app/views/shared/icons/_icon_explore_groups_splash.svg b/app/views/shared/icons/_icon_explore_groups_splash.svg new file mode 100644 index 00000000000..79f17872739 --- /dev/null +++ b/app/views/shared/icons/_icon_explore_groups_splash.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="62" height="50" viewBox="260 141 62 50" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M24.6 7.7H56c3.3 0 6 2.7 6 6V44c0 3.3-2.7 6-6 6H6c-3.3 0-6-2.7-6-6V4.8C0 2 2.2 0 4.8 0h12c1.5 0 3 1 4 2l3.8 5.7z"/><mask id="e" width="62" height="50" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="f" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#b"/></mask><path id="c" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="g" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#c"/></mask><path id="d" d="M5.4 16c4.7 0 5.3-2.3 5.3-6 0-3.5-1.7-4.6-5.3-4.6C1.7 5.4 0 6.4 0 10s.6 6 5.4 6z"/><mask id="h" width="13.1" height="13.1" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 4.2h13v13H-1z"/><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(260 141)"><use fill="#FFF" stroke="#EEE" stroke-width="4.8" mask="url(#e)" xlink:href="#a"/><g transform="translate(33.98 22.62)"><use fill="#B5A7DD" xlink:href="#b"/><use stroke="#FFF" stroke-width="2.4" mask="url(#f)" xlink:href="#b"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(19.673 22.62)"><use fill="#B5A7DD" xlink:href="#c"/><use stroke="#FFF" stroke-width="2.4" mask="url(#g)" xlink:href="#c"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(25.635 21.43)"><use fill="#B5A7DD" xlink:href="#d"/><use stroke="#FFF" stroke-width="2.4" mask="url(#h)" xlink:href="#d"/><ellipse cx="5.4" cy="3.6" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3.6" ry="3.6"/></g></g></svg> diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml new file mode 100644 index 00000000000..731270d4127 --- /dev/null +++ b/app/views/shared/notes/_note.html.haml @@ -0,0 +1,62 @@ +- return unless note.author +- return if note.cross_reference_not_visible_for?(current_user) + +- note_editable = note_editable?(note) +%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } + .timeline-entry-inner + .timeline-icon + - if note.system + = icon_for_system_note(note) + - else + %a{ href: user_path(note.author) } + = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' + .timeline-content + .note-header + .note-header-info + %a{ href: user_path(note.author) } + %span.hidden-xs + = sanitize(note.author.name) + %span.note-headline-light + = note.author.to_reference + %span.note-headline-light + %span.note-headline-meta + - unless note.system + commented + - if note.system + %span.system-note-message + = note.redacted_note_html + %a{ href: "##{dom_id(note)}" } + = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') + - unless note.system? + .note-actions + - if note.for_personal_snippet? + = render 'snippets/notes/actions', note: note, note_editable: note_editable + - else + = render 'projects/notes/actions', note: note, note_editable: note_editable + .note-body{ class: note_editable ? 'js-task-list-container' : '' } + .note-text.md + = note.redacted_note_html + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + - if note_editable + - if note.for_personal_snippet? + = render 'snippets/notes/edit', note: note + - else + = render 'projects/notes/edit', note: note + .note-awards + = render 'award_emoji/awards_block', awardable: note, inline: false + - if note.system + .system-note-commit-list-toggler + Toggle commit list + %i.fa.fa-angle-down + - if note.attachment.url + .note-attachment + - if note.attachment.image? + = link_to note.attachment.url, target: '_blank' do + = image_tag note.attachment.url, class: 'note-image-attach' + .attachment + = link_to note.attachment.url, target: '_blank' do + = icon('paperclip') + = note.attachment_identifier + = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), + title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do + = icon('trash-o', class: 'cred') diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/shared/notes/_notes.html.haml index 2b2bab09c74..cfdfeeb9e97 100644 --- a/app/views/projects/notes/_notes.html.haml +++ b/app/views/shared/notes/_notes.html.haml @@ -1,8 +1,8 @@ - if defined?(@discussions) - @discussions.each do |discussion| - if discussion.individual_note? - = render partial: "projects/notes/note", collection: discussion.notes, as: :note + = render partial: "shared/notes/note", collection: discussion.notes, as: :note - else = render 'discussions/discussion', discussion: discussion - else - = render partial: "projects/notes/note", collection: @notes, as: :note + = render partial: "shared/notes/note", collection: @notes, as: :note diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 0c481120969..9bcb4544b97 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -16,9 +16,8 @@ .btn-group{ role: "group" }< = copy_blob_source_button(blob) - = open_raw_blob_button + = open_raw_blob_button(blob) - - if defined?(download_path) && download_path - = link_to icon('download'), download_path, class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } + = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } = render 'projects/blob/content', blob: blob diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ee3be3c789a..37c3e61912c 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,102 +1,82 @@ -.row.prepend-top-default - .col-lg-3 - %h4.prepend-top-0 - = page_title - %p - #{link_to "Webhooks", help_page_path("user/project/integrations/webhooks")} can be - used for binding events when something is happening within the project. - .col-lg-9.append-bottom-default - = form_for hook, as: :hook, url: polymorphic_path(url_components + [:hooks]) do |f| - = form_errors(hook) += form_errors(hook) - .form-group - = f.label :url, "URL", class: 'label-light' - = f.text_field :url, class: "form-control", placeholder: 'http://example.com/trigger-ci.json' - .form-group - = f.label :token, "Secret Token", class: 'label-light' - = f.text_field :token, class: "form-control", placeholder: '' - %p.help-block - Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. - .form-group - = f.label :url, "Trigger", class: 'label-light' - %ul.list-unstyled - %li - = f.check_box :push_events, class: 'pull-left' - .prepend-left-20 - = f.label :push_events, class: 'list-label' do - %strong Push events - %p.light - This URL will be triggered by a push to the repository - %li - = f.check_box :tag_push_events, class: 'pull-left' - .prepend-left-20 - = f.label :tag_push_events, class: 'list-label' do - %strong Tag push events - %p.light - This URL will be triggered when a new tag is pushed to the repository - %li - = f.check_box :note_events, class: 'pull-left' - .prepend-left-20 - = f.label :note_events, class: 'list-label' do - %strong Comments - %p.light - This URL will be triggered when someone adds a comment - %li - = f.check_box :issues_events, class: 'pull-left' - .prepend-left-20 - = f.label :issues_events, class: 'list-label' do - %strong Issues events - %p.light - This URL will be triggered when an issue is created/updated/merged - %li - = f.check_box :confidential_issues_events, class: 'pull-left' - .prepend-left-20 - = f.label :confidential_issues_events, class: 'list-label' do - %strong Confidential Issues events - %p.light - This URL will be triggered when a confidential issue is created/updated/merged - %li - = f.check_box :merge_requests_events, class: 'pull-left' - .prepend-left-20 - = f.label :merge_requests_events, class: 'list-label' do - %strong Merge Request events - %p.light - This URL will be triggered when a merge request is created/updated/merged - %li - = f.check_box :build_events, class: 'pull-left' - .prepend-left-20 - = f.label :build_events, class: 'list-label' do - %strong Jobs events - %p.light - This URL will be triggered when the job status changes - %li - = f.check_box :pipeline_events, class: 'pull-left' - .prepend-left-20 - = f.label :pipeline_events, class: 'list-label' do - %strong Pipeline events - %p.light - This URL will be triggered when the pipeline status changes - %li - = f.check_box :wiki_page_events, class: 'pull-left' - .prepend-left-20 - = f.label :wiki_page_events, class: 'list-label' do - %strong Wiki Page events - %p.light - This URL will be triggered when a wiki page is created/updated - .form-group - = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' - .checkbox - = f.label :enable_ssl_verification do - = f.check_box :enable_ssl_verification - %strong Enable SSL verification - = f.submit "Add webhook", class: "btn btn-create" - %hr - %h5.prepend-top-default - Webhooks (#{hooks.count}) - - if hooks.any? - %ul.well-list - - hooks.each do |hook| - = render "project_hook", hook: hook - - else - %p.settings-message.text-center.append-bottom-0 - No webhooks found, add one in the form above. +.form-group + = form.label :url, 'URL', class: 'label-light' + = form.text_field :url, class: 'form-control', placeholder: 'http://example.com/trigger-ci.json' +.form-group + = form.label :token, 'Secret Token', class: 'label-light' + = form.text_field :token, class: 'form-control', placeholder: '' + %p.help-block + Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. +.form-group + = form.label :url, 'Trigger', class: 'label-light' + %ul.list-unstyled + %li + = form.check_box :push_events, class: 'pull-left' + .prepend-left-20 + = form.label :push_events, class: 'list-label' do + %strong Push events + %p.light + This URL will be triggered by a push to the repository + %li + = form.check_box :tag_push_events, class: 'pull-left' + .prepend-left-20 + = form.label :tag_push_events, class: 'list-label' do + %strong Tag push events + %p.light + This URL will be triggered when a new tag is pushed to the repository + %li + = form.check_box :note_events, class: 'pull-left' + .prepend-left-20 + = form.label :note_events, class: 'list-label' do + %strong Comments + %p.light + This URL will be triggered when someone adds a comment + %li + = form.check_box :issues_events, class: 'pull-left' + .prepend-left-20 + = form.label :issues_events, class: 'list-label' do + %strong Issues events + %p.light + This URL will be triggered when an issue is created/updated/merged + %li + = form.check_box :confidential_issues_events, class: 'pull-left' + .prepend-left-20 + = form.label :confidential_issues_events, class: 'list-label' do + %strong Confidential Issues events + %p.light + This URL will be triggered when a confidential issue is created/updated/merged + %li + = form.check_box :merge_requests_events, class: 'pull-left' + .prepend-left-20 + = form.label :merge_requests_events, class: 'list-label' do + %strong Merge Request events + %p.light + This URL will be triggered when a merge request is created/updated/merged + %li + = form.check_box :build_events, class: 'pull-left' + .prepend-left-20 + = form.label :build_events, class: 'list-label' do + %strong Jobs events + %p.light + This URL will be triggered when the job status changes + %li + = form.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = form.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This URL will be triggered when the pipeline status changes + %li + = form.check_box :wiki_page_events, class: 'pull-left' + .prepend-left-20 + = form.label :wiki_page_events, class: 'list-label' do + %strong Wiki Page events + %p.light + This URL will be triggered when a wiki page is created/updated +.form-group + = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox' + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml new file mode 100644 index 00000000000..dace11e5474 --- /dev/null +++ b/app/views/snippets/notes/_actions.html.haml @@ -0,0 +1,13 @@ +- if current_user + - if note.emoji_awardable? + - user_authored = note.user_authored?(current_user) + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = icon('spinner spin') + %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') + %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = icon('pencil', class: 'link-highlight') + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/snippets/notes/_edit.html.haml b/app/views/snippets/notes/_edit.html.haml new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/app/views/snippets/notes/_edit.html.haml diff --git a/app/views/snippets/notes/_notes.html.haml b/app/views/snippets/notes/_notes.html.haml new file mode 100644 index 00000000000..f07d6b8c126 --- /dev/null +++ b/app/views/snippets/notes/_notes.html.haml @@ -0,0 +1,2 @@ +%ul#notes-list.notes.main-notes-list.timeline + = render "projects/notes/notes" diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 8a80013bbfd..98287cba5b4 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -3,7 +3,10 @@ = render 'shared/snippets/header' %article.file-holder.snippet-file-content - = render 'shared/snippets/blob', download_path: download_snippet_path(@snippet) + = render 'shared/snippets/blob' .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true + +%ul#notes-list.notes.main-notes-list.timeline + #notes= render 'shared/notes/notes' diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index eb403c134d1..7b59e976492 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -8,7 +8,7 @@ class ExpireBuildInstanceArtifactsWorker .reorder(nil) .find_by(id: build_id) - return unless build.try(:project) + return unless build&.project && !build.project.pending_delete Rails.logger.info "Removing artifacts for build #{build.id}..." build.erase_artifacts! diff --git a/changelogs/unreleased/12910-personal-snippets-notes-show.yml b/changelogs/unreleased/12910-personal-snippets-notes-show.yml new file mode 100644 index 00000000000..15c6f3c5e6a --- /dev/null +++ b/changelogs/unreleased/12910-personal-snippets-notes-show.yml @@ -0,0 +1,4 @@ +--- +title: Display comments for personal snippets +merge_request: +author: diff --git a/changelogs/unreleased/19364-webhook-edit.yml b/changelogs/unreleased/19364-webhook-edit.yml new file mode 100644 index 00000000000..60e154b8b83 --- /dev/null +++ b/changelogs/unreleased/19364-webhook-edit.yml @@ -0,0 +1,4 @@ +--- +title: Implement ability to edit hooks +merge_request: 10816 +author: Alexander Randa diff --git a/changelogs/unreleased/26488-target-disabled-mr.yml b/changelogs/unreleased/26488-target-disabled-mr.yml new file mode 100644 index 00000000000..02058481ccf --- /dev/null +++ b/changelogs/unreleased/26488-target-disabled-mr.yml @@ -0,0 +1,4 @@ +--- +title: Disallow merge requests from fork when source project have disabled merge requests +merge_request: +author: mhasbini diff --git a/changelogs/unreleased/28968-prevent-people-from-creating-branches-if-they-don-have-permission-to-push.yml b/changelogs/unreleased/28968-prevent-people-from-creating-branches-if-they-don-have-permission-to-push.yml new file mode 100644 index 00000000000..6612cfd8866 --- /dev/null +++ b/changelogs/unreleased/28968-prevent-people-from-creating-branches-if-they-don-have-permission-to-push.yml @@ -0,0 +1,4 @@ +--- +title: Prevent people from creating branches if they don't have persmission to push +merge_request: +author: diff --git a/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml b/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml new file mode 100644 index 00000000000..56bce084546 --- /dev/null +++ b/changelogs/unreleased/30272-bvl-reject-more-namespaces.yml @@ -0,0 +1,4 @@ +--- +title: Improve validation of namespace & project paths +merge_request: 10413 +author: diff --git a/changelogs/unreleased/30535-display-whether-pages-is-enabled-in-the-admin-dashboard.yml b/changelogs/unreleased/30535-display-whether-pages-is-enabled-in-the-admin-dashboard.yml new file mode 100644 index 00000000000..4452b13037b --- /dev/null +++ b/changelogs/unreleased/30535-display-whether-pages-is-enabled-in-the-admin-dashboard.yml @@ -0,0 +1,4 @@ +--- +title: Display GitLab Pages status in Admin Dashboard +merge_request: +author: diff --git a/changelogs/unreleased/31254-change-git-commit-command-in-existing-folder.yml b/changelogs/unreleased/31254-change-git-commit-command-in-existing-folder.yml new file mode 100644 index 00000000000..950336ea932 --- /dev/null +++ b/changelogs/unreleased/31254-change-git-commit-command-in-existing-folder.yml @@ -0,0 +1,4 @@ +--- +title: Change Git commit command in Existing folder to git commit -m +merge_request: 10900 +author: TM Lee diff --git a/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml b/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml new file mode 100644 index 00000000000..02c048cb3b4 --- /dev/null +++ b/changelogs/unreleased/31560-workhose-gitaly-from-mirror.yml @@ -0,0 +1,4 @@ +--- +title: rickettm Add repo parameter to gitaly:install and workhorse:install rake tasks +merge_request: 10979 +author: M. Ricketts diff --git a/changelogs/unreleased/add-tanuki-ci-status-favicons.yml b/changelogs/unreleased/add-tanuki-ci-status-favicons.yml new file mode 100644 index 00000000000..b60ad81947a --- /dev/null +++ b/changelogs/unreleased/add-tanuki-ci-status-favicons.yml @@ -0,0 +1,4 @@ +--- +title: Updated CI status favicons to include the tanuki +merge_request: 10923 +author: diff --git a/changelogs/unreleased/dm-blob-download-button.yml b/changelogs/unreleased/dm-blob-download-button.yml new file mode 100644 index 00000000000..bd31137b670 --- /dev/null +++ b/changelogs/unreleased/dm-blob-download-button.yml @@ -0,0 +1,4 @@ +--- +title: Show Raw button as Download for binary files +merge_request: +author: diff --git a/changelogs/unreleased/dm-snippet-download-button.yml b/changelogs/unreleased/dm-snippet-download-button.yml new file mode 100644 index 00000000000..09ece1e7f98 --- /dev/null +++ b/changelogs/unreleased/dm-snippet-download-button.yml @@ -0,0 +1,4 @@ +--- +title: Add download button to project snippets +merge_request: +author: diff --git a/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml b/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml new file mode 100644 index 00000000000..a4345b70744 --- /dev/null +++ b/changelogs/unreleased/dont-blow-up-when-email-has-no-references-header.yml @@ -0,0 +1,5 @@ +--- +title: Gracefully handle failures for incoming emails which do not match on the To + header, and have no References header +merge_request: +author: diff --git a/config/initializers/active_record_query_trace.rb b/config/initializers/active_record_query_trace.rb deleted file mode 100644 index 4b3c2803b3b..00000000000 --- a/config/initializers/active_record_query_trace.rb +++ /dev/null @@ -1,5 +0,0 @@ -if ENV['ENABLE_QUERY_TRACE'] - require 'active_record_query_trace' - - ActiveRecordQueryTrace.enabled = 'true' -end diff --git a/config/initializers/carrierwave.rb b/config/initializers/carrierwave.rb index 1933afcbfb1..cd7df44351a 100644 --- a/config/initializers/carrierwave.rb +++ b/config/initializers/carrierwave.rb @@ -6,6 +6,8 @@ if File.exist?(aws_file) AWS_CONFIG = YAML.load(File.read(aws_file))[Rails.env] CarrierWave.configure do |config| + config.fog_provider = 'fog/aws' + config.fog_credentials = { provider: 'AWS', # required aws_access_key_id: AWS_CONFIG['access_key_id'], # required diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 52ba10604d4..48993420ed9 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -50,8 +50,10 @@ namespace :admin do resources :deploy_keys, only: [:index, :new, :create, :destroy] - resources :hooks, only: [:index, :create, :destroy] do - get :test + resources :hooks, only: [:index, :create, :edit, :update, :destroy] do + member do + get :test + end end resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do diff --git a/config/routes/project.rb b/config/routes/project.rb index 115ae2324b3..894faeb6188 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -44,7 +44,7 @@ constraints(ProjectUrlConstrainer.new) do resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do member do - get 'raw' + get :raw post :mark_as_spam end end @@ -138,6 +138,8 @@ constraints(ProjectUrlConstrainer.new) do collection do get :folder, path: 'folders/*id', constraints: { format: /(html|json)/ } end + + resources :deployments, only: [:index] end resource :cycle_analytics, only: [:show] @@ -185,7 +187,7 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :hooks, only: [:index, :create, :destroy], constraints: { id: /\d+/ } do + resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do member do get :test end diff --git a/config/routes/snippets.rb b/config/routes/snippets.rb index 56534f677be..dae83734fe6 100644 --- a/config/routes/snippets.rb +++ b/config/routes/snippets.rb @@ -1,10 +1,17 @@ resources :snippets, concerns: :awardable do member do - get 'raw' - get 'download' + get :raw post :mark_as_spam post :preview_markdown end + + scope module: :snippets do + resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do + member do + delete :delete_attachment + end + end + end end get '/s/:username', to: redirect('/u/%{username}/snippets'), diff --git a/config/webpack.config.js b/config/webpack.config.js index cb0a57a3a41..0ec9e48845e 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -78,6 +78,11 @@ var config = { loader: 'raw-loader', }, { + test: /\.gif$/, + loader: 'url-loader', + query: { mimetype: 'image/gif' }, + }, + { test: /\.(worker\.js|pdf)$/, exclude: /node_modules/, loader: 'file-loader', diff --git a/db/migrate/20170327091750_add_created_at_index_to_deployments.rb b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb new file mode 100644 index 00000000000..fd6ed499b80 --- /dev/null +++ b/db/migrate/20170327091750_add_created_at_index_to_deployments.rb @@ -0,0 +1,15 @@ +class AddCreatedAtIndexToDeployments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :deployments, :created_at + end + + def down + remove_concurrent_index :deployments, :created_at + end +end diff --git a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb new file mode 100644 index 00000000000..a23f83205f1 --- /dev/null +++ b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb @@ -0,0 +1,55 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RenameReservedDynamicPaths < ActiveRecord::Migration + include Gitlab::Database::RenameReservedPathsMigration::V1 + + DOWNTIME = false + + disable_ddl_transaction! + + DISALLOWED_ROOT_PATHS = %w[ + - + abuse_reports + api + autocomplete + explore + health_check + import + invites + jwt + koding + member + notification_settings + oauth + sent_notifications + unicorn_test + uploads + users + ] + + DISALLOWED_WILDCARD_PATHS = %w[ + environments/folders + gitlab-lfs/objects + info/lfs/objects + ] + + DISSALLOWED_GROUP_PATHS = %w[ + activity + avatar + group_members + labels + milestones + subgroups + ] + + def up + rename_root_paths(DISALLOWED_ROOT_PATHS) + rename_wildcard_paths(DISALLOWED_WILDCARD_PATHS) + rename_child_paths(DISSALLOWED_GROUP_PATHS) + end + + def down + # nothing to do + end +end diff --git a/db/schema.rb b/db/schema.rb index b938657a186..be6684f3a6b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -386,6 +386,7 @@ ActiveRecord::Schema.define(version: 20170426181740) do t.string "on_stop" end + add_index "deployments", ["created_at"], name: "index_deployments_on_created_at", using: :btree add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true, using: :btree diff --git a/doc/administration/high_availability/load_balancer.md b/doc/administration/high_availability/load_balancer.md index d9ca74ca1a3..359de0efadb 100644 --- a/doc/administration/high_availability/load_balancer.md +++ b/doc/administration/high_availability/load_balancer.md @@ -13,7 +13,7 @@ you need to use with GitLab. | LB Port | Backend Port | Protocol | | ------- | ------------ | --------------- | | 80 | 80 | HTTP [^1] | -| 443 | 443 | HTTPS [^1] [^2] | +| 443 | 443 | TCP or HTTPS [^1] [^2] | | 22 | 22 | TCP | ## GitLab Pages Ports diff --git a/doc/administration/integration/terminal.md b/doc/administration/integration/terminal.md index 3b5ee86b68b..91e844c7b42 100644 --- a/doc/administration/integration/terminal.md +++ b/doc/administration/integration/terminal.md @@ -32,7 +32,7 @@ In brief: As web terminals use WebSockets, every HTTP/HTTPS reverse proxy in front of Workhorse needs to be configured to pass the `Connection` and `Upgrade` headers -through to the next one in the chain. If you installed Gitlab using Omnibus, or +through to the next one in the chain. If you installed GitLab using Omnibus, or from source, starting with GitLab 8.15, this should be done by the default configuration, so there's no need for you to do anything. @@ -58,7 +58,7 @@ document for more details. If you'd like to disable web terminal support in GitLab, just stop passing the `Connection` and `Upgrade` hop-by-hop headers in the *first* HTTP reverse proxy in the chain. For most users, this will be the NGINX server bundled with -Omnibus Gitlab, in which case, you need to: +Omnibus GitLab, in which case, you need to: * Find the `nginx['proxy_set_headers']` section of your `gitlab.rb` file * Ensure the whole block is uncommented, and then comment out or remove the diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 3e8b709c18f..77ba2a5fd87 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -270,3 +270,28 @@ end When doing so be sure to explicitly set the model's table name so it's not derived from the class name or namespace. + +### Renaming reserved paths + +When a new route for projects is introduced that could conflict with any +existing records. The path for this records should be renamed, and the +related data should be moved on disk. + +Since we had to do this a few times already, there are now some helpers to help +with this. + +To use this you can include `Gitlab::Database::RenameReservedPathsMigration::V1` +in your migration. This will provide 3 methods which you can pass one or more +paths that need to be rejected. + +**`rename_root_paths`**: This will rename the path of all _namespaces_ with the +given name that don't have a `parent_id`. + +**`rename_child_paths`**: This will rename the path of all _namespaces_ with the +given name that have a `parent_id`. + +**`rename_wildcard_paths`**: This will rename the path of all _projects_, and all +_namespaces_ that have a `project_id`. + +The `path` column for these rows will be renamed to their previous value followed +by an integer. For example: `users` would turn into `users0` diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md index 1c549844ee1..2513f4b420a 100644 --- a/doc/gitlab-basics/create-project.md +++ b/doc/gitlab-basics/create-project.md @@ -1,24 +1,28 @@ # How to create a project in GitLab -There are two ways to create a new project in GitLab. - -1. While in your dashboard, you can create a new project using the **New project** - green button or you can use the cross icon in the upper right corner next to - your avatar which is always visible. +1. In your dashboard, click the green **New project** button or use the plus + icon in the upper right corner of the navigation bar. ![Create a project](img/create_new_project_button.png) -1. From there you can see several options. +1. This opens the **New project** page. ![Project information](img/create_new_project_info.png) -1. Fill out the information: - - 1. "Project name" is the name of your project (you can't use special characters, - but you can use spaces, hyphens, underscores or even emojis). - 1. The "Project description" is optional and will be shown in your project's - dashboard so others can briefly understand what your project is about. - 1. Select a [visibility level](../public_access/public_access.md). - 1. You can also [import your existing projects](../workflow/importing/README.md). - -1. Finally, click **Create project**. +1. Provide the following information: + - Enter the name of your project in the **Project name** field. You can't use + special characters, but you can use spaces, hyphens, underscores or even + emoji. + - If you have a project in a different repository, you can [import it] by + clicking an **Import project from** button provided this is enabled in + your GitLab instance. Ask your administrator if not. + - The **Project description (optional)** field enables you to enter a + description for your project's dashboard, which will help others + understand what your project is about. Though it's not required, it's a good + idea to fill this in. + - Changing the **Visibility Level** modifies the project's + [viewing and access rights](../public_access/public_access.md) for users. + +1. Click **Create project**. + +[import it]: ../workflow/importing/README.md diff --git a/doc/gitlab-basics/img/create_new_project_button.png b/doc/gitlab-basics/img/create_new_project_button.png Binary files differindex 8d7a69e55ed..567f104880f 100644 --- a/doc/gitlab-basics/img/create_new_project_button.png +++ b/doc/gitlab-basics/img/create_new_project_button.png diff --git a/doc/install/installation.md b/doc/install/installation.md index b6bbc2a0af6..dc807d93bbb 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -423,6 +423,11 @@ which is the recommended location. sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]" RAILS_ENV=production +You can specify a different Git repository by providing it as an extra paramter: + + sudo -u git -H bundle exec rake "gitlab:workhorse:install[/home/git/gitlab-workhorse,https://example.com/gitlab-workhorse.git]" RAILS_ENV=production + + ### Initialize Database and Activate Advanced Features sudo -u git -H bundle exec rake gitlab:setup RAILS_ENV=production @@ -466,6 +471,12 @@ with setting up Gitaly until you upgrade to GitLab 9.2 or later. # Fetch Gitaly source with Git and compile with Go sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production +You can specify a different Git repository by providing it as an extra paramter: + + sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly,https://example.com/gitaly.git]" RAILS_ENV=production + +Next, make sure gitaly configured: + # Restrict Gitaly socket access sudo chmod 0700 /home/git/gitlab/tmp/sockets/private sudo chown git /home/git/gitlab/tmp/sockets/private diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md index e5e3cd395df..e538983e603 100644 --- a/doc/update/8.10-to-8.11.md +++ b/doc/update/8.10-to-8.11.md @@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Get latest code ```bash +cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically ``` diff --git a/doc/update/8.11-to-8.12.md b/doc/update/8.11-to-8.12.md index d6b3b0ffa5a..604166beb56 100644 --- a/doc/update/8.11-to-8.12.md +++ b/doc/update/8.11-to-8.12.md @@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Get latest code ```bash +cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically ``` diff --git a/doc/update/8.12-to-8.13.md b/doc/update/8.12-to-8.13.md index ed0e668d854..d83965131f5 100644 --- a/doc/update/8.12-to-8.13.md +++ b/doc/update/8.12-to-8.13.md @@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Get latest code ```bash +cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically ``` diff --git a/doc/update/8.13-to-8.14.md b/doc/update/8.13-to-8.14.md index aa1c659717e..aaadcec8ac0 100644 --- a/doc/update/8.13-to-8.14.md +++ b/doc/update/8.13-to-8.14.md @@ -49,6 +49,8 @@ sudo gem install bundler --no-ri --no-rdoc ### 4. Get latest code ```bash +cd /home/git/gitlab + sudo -u git -H git fetch --all sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically ``` diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 36fe21a047c..ef09bddddd8 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -367,7 +367,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I should see buttons for allowed commands' do page.within '.content' do - expect(page).to have_link 'Open raw' + expect(page).to have_link 'Download' expect(page).to have_content 'History' expect(page).to have_content 'Permalink' expect(page).not_to have_content 'Edit' diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index e5793fbc5cb..710deba5ae3 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -20,6 +20,8 @@ module API error!(errors[:validate_fork], 422) elsif errors[:validate_branches].any? conflict!(errors[:validate_branches]) + elsif errors[:base].any? + error!(errors[:base], 422) end render_api_error!(errors, 400) diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb index 3077240e650..1616142a619 100644 --- a/lib/api/v3/merge_requests.rb +++ b/lib/api/v3/merge_requests.rb @@ -23,6 +23,8 @@ module API error!(errors[:validate_fork], 422) elsif errors[:validate_branches].any? conflict!(errors[:validate_branches]) + elsif errors[:base].any? + error!(errors[:base], 422) end render_api_error!(errors, 400) diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index bae4db1ca4d..1501f64d537 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -2,16 +2,8 @@ class GroupUrlConstrainer def matches?(request) id = request.params[:id] - return false unless valid?(id) + return false unless DynamicPathValidator.valid?(id) Group.find_by_full_path(id).present? end - - private - - def valid?(id) - id.split('/').all? do |namespace| - NamespaceValidator.valid?(namespace) - end - end end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index a10b4657d7d..d0ce2caffff 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -4,9 +4,7 @@ class ProjectUrlConstrainer project_path = request.params[:project_id] || request.params[:id] full_path = namespace_path + '/' + project_path - unless ProjectPathValidator.valid?(project_path) - return false - end + return false unless DynamicPathValidator.valid?(full_path) Project.find_by_full_path(full_path).present? end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 6dabbe0264c..298b1a1f4e6 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -498,6 +498,29 @@ module Gitlab columns(table).find { |column| column.name == name } end + + # This will replace the first occurance of a string in a column with + # the replacement + # On postgresql we can use `regexp_replace` for that. + # On mysql we find the location of the pattern, and overwrite it + # with the replacement + def replace_sql(column, pattern, replacement) + quoted_pattern = Arel::Nodes::Quoted.new(pattern.to_s) + quoted_replacement = Arel::Nodes::Quoted.new(replacement.to_s) + + if Database.mysql? + locate = Arel::Nodes::NamedFunction. + new('locate', [quoted_pattern, column]) + insert_in_place = Arel::Nodes::NamedFunction. + new('insert', [column, locate, pattern.size, quoted_replacement]) + + Arel::Nodes::SqlLiteral.new(insert_in_place.to_sql) + else + replace = Arel::Nodes::NamedFunction. + new("regexp_replace", [column, quoted_pattern, quoted_replacement]) + Arel::Nodes::SqlLiteral.new(replace.to_sql) + end + end end end end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb new file mode 100644 index 00000000000..89530082cd2 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb @@ -0,0 +1,35 @@ +# This module can be included in migrations to make it easier to rename paths +# of `Namespace` & `Project` models certain paths would become `reserved`. +# +# If the way things are stored on the filesystem related to namespaces and +# projects ever changes. Don't update this module, or anything nested in `V1`, +# since it needs to keep functioning for all migrations using it using the state +# that the data is in at the time. Instead, create a `V2` module that implements +# the new way of reserving paths. +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + def self.included(kls) + kls.include(MigrationHelpers) + end + + def rename_wildcard_paths(one_or_more_paths) + rename_child_paths(one_or_more_paths) + paths = Array(one_or_more_paths) + RenameProjects.new(paths, self).rename_projects + end + + def rename_child_paths(one_or_more_paths) + paths = Array(one_or_more_paths) + RenameNamespaces.new(paths, self).rename_namespaces(type: :child) + end + + def rename_root_paths(paths) + paths = Array(paths) + RenameNamespaces.new(paths, self).rename_namespaces(type: :top_level) + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb new file mode 100644 index 00000000000..4fdcb682c2f --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/migration_classes.rb @@ -0,0 +1,76 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + module MigrationClasses + module Routable + def full_path + if route && route.path.present? + @full_path ||= route.path + else + update_route if persisted? + + build_full_path + end + end + + def build_full_path + if parent && path + parent.full_path + '/' + path + else + path + end + end + + def update_route + prepare_route + route.save + end + + def prepare_route + route || build_route(source: self) + route.path = build_full_path + @full_path = nil + end + end + + class Namespace < ActiveRecord::Base + include MigrationClasses::Routable + self.table_name = 'namespaces' + belongs_to :parent, + class_name: "#{MigrationClasses.name}::Namespace" + has_one :route, as: :source + has_many :children, + class_name: "#{MigrationClasses.name}::Namespace", + foreign_key: :parent_id + + # Overridden to have the correct `source_type` for the `route` relation + def self.name + 'Namespace' + end + end + + class Route < ActiveRecord::Base + self.table_name = 'routes' + belongs_to :source, polymorphic: true + end + + class Project < ActiveRecord::Base + include MigrationClasses::Routable + has_one :route, as: :source + self.table_name = 'projects' + + def repository_storage_path + Gitlab.config.repositories.storages[repository_storage]['path'] + end + + # Overridden to have the correct `source_type` for the `route` relation + def self.name + 'Project' + end + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb new file mode 100644 index 00000000000..de4e6e7c404 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb @@ -0,0 +1,131 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameBase + attr_reader :paths, :migration + + delegate :update_column_in_batches, + :replace_sql, + to: :migration + + def initialize(paths, migration) + @paths = paths + @migration = migration + end + + def path_patterns + @path_patterns ||= paths.map { |path| "%#{path}" } + end + + def rename_path_for_routable(routable) + old_path = routable.path + old_full_path = routable.full_path + # Only remove the last occurrence of the path name to get the parent namespace path + namespace_path = remove_last_occurrence(old_full_path, old_path) + new_path = rename_path(namespace_path, old_path) + new_full_path = join_routable_path(namespace_path, new_path) + + # skips callbacks & validations + routable.class.where(id: routable). + update_all(path: new_path) + + rename_routes(old_full_path, new_full_path) + + [old_full_path, new_full_path] + end + + def rename_routes(old_full_path, new_full_path) + replace_statement = replace_sql(Route.arel_table[:path], + old_full_path, + new_full_path) + + update_column_in_batches(:routes, :path, replace_statement) do |table, query| + query.where(MigrationClasses::Route.arel_table[:path].matches("#{old_full_path}%")) + end + end + + def rename_path(namespace_path, path_was) + counter = 0 + path = "#{path_was}#{counter}" + + while route_exists?(join_routable_path(namespace_path, path)) + counter += 1 + path = "#{path_was}#{counter}" + end + + path + end + + def remove_last_occurrence(string, pattern) + string.reverse.sub(pattern.reverse, "").reverse + end + + def join_routable_path(namespace_path, top_level) + if namespace_path.present? + File.join(namespace_path, top_level) + else + top_level + end + end + + def route_exists?(full_path) + MigrationClasses::Route.where(Route.arel_table[:path].matches(full_path)).any? + end + + def move_pages(old_path, new_path) + move_folders(pages_dir, old_path, new_path) + end + + def move_uploads(old_path, new_path) + return unless file_storage? + + move_folders(uploads_dir, old_path, new_path) + end + + def move_folders(directory, old_relative_path, new_relative_path) + old_path = File.join(directory, old_relative_path) + return unless File.directory?(old_path) + + new_path = File.join(directory, new_relative_path) + FileUtils.mv(old_path, new_path) + end + + def remove_cached_html_for_projects(project_ids) + update_column_in_batches(:projects, :description_html, nil) do |table, query| + query.where(table[:id].in(project_ids)) + end + + update_column_in_batches(:issues, :description_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + + update_column_in_batches(:merge_requests, :description_html, nil) do |table, query| + query.where(table[:target_project_id].in(project_ids)) + end + + update_column_in_batches(:notes, :note_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + + update_column_in_batches(:milestones, :description_html, nil) do |table, query| + query.where(table[:project_id].in(project_ids)) + end + end + + def file_storage? + CarrierWave::Uploader::Base.storage == CarrierWave::Storage::File + end + + def uploads_dir + File.join(CarrierWave.root, "uploads") + end + + def pages_dir + Settings.pages.path + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb new file mode 100644 index 00000000000..b9f4f3cff3c --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb @@ -0,0 +1,72 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameNamespaces < RenameBase + include Gitlab::ShellAdapter + + def rename_namespaces(type:) + namespaces_for_paths(type: type).each do |namespace| + rename_namespace(namespace) + end + end + + def namespaces_for_paths(type:) + namespaces = case type + when :child + MigrationClasses::Namespace.where.not(parent_id: nil) + when :top_level + MigrationClasses::Namespace.where(parent_id: nil) + end + with_paths = MigrationClasses::Route.arel_table[:path]. + matches_any(path_patterns) + namespaces.joins(:route).where(with_paths) + end + + def rename_namespace(namespace) + old_full_path, new_full_path = rename_path_for_routable(namespace) + + move_repositories(namespace, old_full_path, new_full_path) + move_uploads(old_full_path, new_full_path) + move_pages(old_full_path, new_full_path) + remove_cached_html_for_projects(projects_for_namespace(namespace).map(&:id)) + end + + def move_repositories(namespace, old_full_path, new_full_path) + repo_paths_for_namespace(namespace).each do |repository_storage_path| + # Ensure old directory exists before moving it + gitlab_shell.add_namespace(repository_storage_path, old_full_path) + + unless gitlab_shell.mv_namespace(repository_storage_path, old_full_path, new_full_path) + message = "Exception moving path #{repository_storage_path} \ + from #{old_full_path} to #{new_full_path}" + Rails.logger.error message + end + end + end + + def repo_paths_for_namespace(namespace) + projects_for_namespace(namespace).distinct.select(:repository_storage). + map(&:repository_storage_path) + end + + def projects_for_namespace(namespace) + namespace_ids = child_ids_for_parent(namespace, ids: [namespace.id]) + namespace_or_children = MigrationClasses::Project. + arel_table[:namespace_id]. + in(namespace_ids) + MigrationClasses::Project.where(namespace_or_children) + end + + def child_ids_for_parent(namespace, ids: []) + namespace.children.each do |child| + ids << child.id + child_ids_for_parent(child, ids: ids) if child.children.any? + end + ids + end + end + end + end + end +end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb new file mode 100644 index 00000000000..448717eb744 --- /dev/null +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb @@ -0,0 +1,45 @@ +module Gitlab + module Database + module RenameReservedPathsMigration + module V1 + class RenameProjects < RenameBase + include Gitlab::ShellAdapter + + def rename_projects + projects_for_paths.each do |project| + rename_project(project) + end + + remove_cached_html_for_projects(projects_for_paths.map(&:id)) + end + + def rename_project(project) + old_full_path, new_full_path = rename_path_for_routable(project) + + move_repository(project, old_full_path, new_full_path) + move_repository(project, "#{old_full_path}.wiki", "#{new_full_path}.wiki") + move_uploads(old_full_path, new_full_path) + move_pages(old_full_path, new_full_path) + end + + def move_repository(project, old_path, new_path) + unless gitlab_shell.mv_repository(project.repository_storage_path, + old_path, + new_path) + Rails.logger.error "Error moving #{old_path} to #{new_path}" + end + end + + def projects_for_paths + return @projects_for_paths if @projects_for_paths + + with_paths = MigrationClasses::Route.arel_table[:path] + .matches_any(path_patterns) + + @projects_for_paths = MigrationClasses::Project.joins(:route).where(with_paths) + end + end + end + end + end +end diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index 419d56a51e0..c270c0ea9ff 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -70,6 +70,8 @@ module Gitlab # Handle emails from clients which append with commas, # example clients are Microsoft exchange and iOS app Gitlab::IncomingEmail.scan_fallback_references(references) + when nil + [] end end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index f6e4f279c06..aac210f19e8 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -2,31 +2,39 @@ module Gitlab module EtagCaching class Router Route = Struct.new(:regexp, :name) - - RESERVED_WORDS = NamespaceValidator::WILDCARD_ROUTES.map { |word| "/#{word}/" }.join('|') + # We enable an ETag for every request matching the regex. + # To match a regex the path needs to match the following: + # - Don't contain a reserved word (expect for the words used in the + # regex itself) + # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route + # - Ending in `issues/id`/rendered_title` for the `issue_title` route + USED_IN_ROUTES = %w[noteable issue notes issues rendered_title + commit pipelines merge_requests new].freeze + RESERVED_WORDS = DynamicPathValidator::WILDCARD_ROUTES - USED_IN_ROUTES + RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) ROUTES = [ Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), 'issue_notes' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/issues/\d+/rendered_title\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/rendered_title\z), 'issue_title' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/commit/\S+/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/commit/\S+/pipelines\.json\z), 'commit_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/new\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/new\.json\z), 'new_merge_request_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/merge_requests/\d+/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/\d+/pipelines\.json\z), 'merge_request_pipelines' ), Gitlab::EtagCaching::Router::Route.new( - %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines\.json\z), + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z), 'project_pipelines' ) ].freeze diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 18eda0279f7..c3f0de76d01 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -122,13 +122,30 @@ module Gitlab # Returns the number of valid branches def branch_count - rugged.branches.count do |ref| - begin - ref.name && ref.target # ensures the branch is valid + Gitlab::GitalyClient.migrate(:branch_names) do |is_enabled| + if is_enabled + gitaly_ref_client.count_branch_names + else + rugged.branches.count do |ref| + begin + ref.name && ref.target # ensures the branch is valid - true - rescue Rugged::ReferenceError - false + true + rescue Rugged::ReferenceError + false + end + end + end + end + end + + # Returns the number of valid tags + def tag_count + Gitlab::GitalyClient.migrate(:tag_names) do |is_enabled| + if is_enabled + gitaly_ref_client.count_tag_names + else + rugged.tags.count end end end diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index d3c0743db4e..2a5e8f73e55 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -34,6 +34,14 @@ module Gitlab stub.find_ref_name(request).name end + def count_tag_names + tag_names.count + end + + def count_branch_names + branch_names.count + end + private def consume_refs_response(response, prefix:) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 08b061d5e31..b7fef5dd068 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -22,6 +22,10 @@ module Gitlab @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze end + def full_namespace_regex + @full_namespace_regex ||= %r{\A#{FULL_NAMESPACE_REGEX_STR}\z} + end + def namespace_route_regex @namespace_route_regex ||= /#{NAMESPACE_REGEX_STR}/.freeze end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index 54728e5ff0e..e46ff313654 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -44,9 +44,7 @@ module Gitlab if ProtectedBranch.protected?(project, ref) return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) - has_access = project.protected_branches.protected_ref_accessible_to?(ref, user, action: :push) - - has_access || !project.repository.branch_exists?(ref) && can_merge_to_branch?(ref) + project.protected_branches.protected_ref_accessible_to?(ref, user, action: :push) else user.can?(:push_code, project) end diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake index 046780481ba..3c5bc0146a1 100644 --- a/lib/tasks/gitlab/gitaly.rake +++ b/lib/tasks/gitlab/gitaly.rake @@ -1,18 +1,18 @@ namespace :gitlab do namespace :gitaly do desc "GitLab | Install or upgrade gitaly" - task :install, [:dir] => :environment do |t, args| + task :install, [:dir, :repo] => :environment do |t, args| require 'toml' warn_user_is_not_gitlab unless args.dir.present? abort %(Please specify the directory where you want to install gitaly:\n rake "gitlab:gitaly:install[/home/git/gitaly]") end + args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitaly.git') version = Gitlab::GitalyClient.expected_server_version - repo = 'https://gitlab.com/gitlab-org/gitaly.git' - checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/lib/tasks/gitlab/workhorse.rake b/lib/tasks/gitlab/workhorse.rake index a00b02188cf..e7ac0b5859f 100644 --- a/lib/tasks/gitlab/workhorse.rake +++ b/lib/tasks/gitlab/workhorse.rake @@ -1,16 +1,16 @@ namespace :gitlab do namespace :workhorse do desc "GitLab | Install or upgrade gitlab-workhorse" - task :install, [:dir] => :environment do |t, args| + task :install, [:dir, :repo] => :environment do |t, args| warn_user_is_not_gitlab unless args.dir.present? abort %(Please specify the directory where you want to install gitlab-workhorse:\n rake "gitlab:workhorse:install[/home/git/gitlab-workhorse]") end + args.with_defaults(repo: 'https://gitlab.com/gitlab-org/gitlab-workhorse.git') version = Gitlab::Workhorse.version - repo = 'https://gitlab.com/gitlab-org/gitlab-workhorse.git' - checkout_or_clone_version(version: version, repo: repo, target_dir: args.dir) + checkout_or_clone_version(version: version, repo: args.repo, target_dir: args.dir) _, status = Gitlab::Popen.popen(%w[which gmake]) command = status.zero? ? 'gmake' : 'make' diff --git a/package.json b/package.json index f8c151ebd81..9ed5e1a7475 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "jszip-utils": "^0.0.2", "marked": "^0.3.6", "mousetrap": "^1.4.6", + "pdfjs-dist": "^1.8.252", "pikaday": "^1.5.1", "prismjs": "^1.6.0", "raphael": "^2.2.7", @@ -46,6 +47,7 @@ "three-stl-loader": "^1.0.4", "timeago.js": "^2.0.5", "underscore": "^1.8.3", + "url-loader": "^0.5.8", "visibilityjs": "^1.2.4", "vue": "^2.2.6", "vue-loader": "^11.3.4", diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh index 62236ed539a..54c1ef3dfdd 100755 --- a/scripts/lint-doc.sh +++ b/scripts/lint-doc.sh @@ -21,4 +21,3 @@ fi echo "✔ Linting passed" exit 0 - diff --git a/scripts/notify_slack.sh b/scripts/notify_slack.sh deleted file mode 100755 index 6b3bc563c7a..00000000000 --- a/scripts/notify_slack.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# Sends Slack notification ERROR_MSG to CHANNEL -# An env. variable CI_SLACK_WEBHOOK_URL needs to be set. - -CHANNEL=$1 -ERROR_MSG=$2 - -if [ -z "$CHANNEL" ] || [ -z "$ERROR_MSG" ] || [ -z "$CI_SLACK_WEBHOOK_URL" ]; then - echo "Missing argument(s) - Use: $0 channel message" - echo "and set CI_SLACK_WEBHOOK_URL environment variable." -else - curl -X POST --data-urlencode 'payload={"channel": "'"$CHANNEL"'", "username": "gitlab-ci", "text": "'"$ERROR_MSG"'", "icon_emoji": ":gitlab:"}' "$CI_SLACK_WEBHOOK_URL" -fi
\ No newline at end of file diff --git a/scripts/static-analysis b/scripts/static-analysis new file mode 100755 index 00000000000..192d9d4c3ba --- /dev/null +++ b/scripts/static-analysis @@ -0,0 +1,40 @@ +#!/usr/bin/env ruby + +require ::File.expand_path('../lib/gitlab/popen', __dir__) + +tasks = [ + %w[bundle exec rake config_lint], + %w[bundle exec rake flay], + %w[bundle exec rake haml_lint], + %w[bundle exec rake scss_lint], + %w[bundle exec rake brakeman], + %w[bundle exec license_finder], + %w[scripts/lint-doc.sh], + %w[yarn run eslint], + %w[bundle exec rubocop --require rubocop-rspec] +] + +failed_tasks = tasks.reduce({}) do |failures, task| + output, status = Gitlab::Popen.popen(task) + + puts "Running: #{task.join(' ')}" + puts output + + failures[task.join(' ')] = output unless status.zero? + + failures +end + +if failed_tasks.empty? + puts 'All static analyses passed successfully.' +else + puts "\n===================================================\n\n" + puts "Some static analyses failed:" + + failed_tasks.each do |failed_task, output| + puts "\n**** #{failed_task} failed with the following error:\n\n" + puts output + end + + exit 1 +end diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb new file mode 100644 index 00000000000..89692b601b2 --- /dev/null +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Projects::DeploymentsController do + include ApiHelpers + + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:environment) { create(:environment, name: 'production', project: project) } + + before do + project.add_master(user) + + sign_in(user) + end + + describe 'GET #index' do + it 'returns list of deployments from last 8 hours' do + create(:deployment, environment: environment, created_at: 9.hours.ago) + create(:deployment, environment: environment, created_at: 7.hours.ago) + create(:deployment, environment: environment) + + get :index, environment_params(after: 8.hours.ago) + + expect(response).to be_ok + + expect(json_response['deployments'].count).to eq(2) + end + + it 'returns a list with deployments information' do + create(:deployment, environment: environment) + + get :index, environment_params + + expect(response).to be_ok + expect(response).to match_response_schema('deployments') + end + end + + def environment_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, project_id: project, environment_id: environment.id) + end +end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index f140eaef5d5..45f4cf9180d 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -167,6 +167,47 @@ describe Projects::NotesController do end end + describe 'DELETE destroy' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + id: note, + format: :js + } + end + + context 'user is the author of a note' do + before do + sign_in(note.author) + project.team << [note.author, :developer] + end + + it "returns status 200 for html" do + delete :destroy, request_params + + expect(response).to have_http_status(200) + end + + it "deletes the note" do + expect { delete :destroy, request_params }.to change { Note.count }.from(1).to(0) + end + end + + context 'user is not the author of a note' do + before do + sign_in(user) + project.team << [user, :developer] + end + + it "returns status 404" do + delete :destroy, request_params + + expect(response).to have_http_status(404) + end + end + end + describe 'POST toggle_award_emoji' do before do sign_in(user) diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb new file mode 100644 index 00000000000..1c494b8c7ab --- /dev/null +++ b/spec/controllers/snippets/notes_controller_spec.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +describe Snippets::NotesController do + let(:user) { create(:user) } + + let(:private_snippet) { create(:personal_snippet, :private) } + let(:internal_snippet) { create(:personal_snippet, :internal) } + let(:public_snippet) { create(:personal_snippet, :public) } + + let(:note_on_private) { create(:note_on_personal_snippet, noteable: private_snippet) } + let(:note_on_internal) { create(:note_on_personal_snippet, noteable: internal_snippet) } + let(:note_on_public) { create(:note_on_personal_snippet, noteable: public_snippet) } + + describe 'GET index' do + context 'when a snippet is public' do + before do + note_on_public + + get :index, { snippet_id: public_snippet } + end + + it "returns status 200" do + expect(response).to have_http_status(200) + end + + it "returns not empty array of notes" do + expect(JSON.parse(response.body)["notes"].empty?).to be_falsey + end + end + + context 'when a snippet is internal' do + before do + note_on_internal + end + + context 'when user not logged in' do + it "returns status 404" do + get :index, { snippet_id: internal_snippet } + + expect(response).to have_http_status(404) + end + end + + context 'when user logged in' do + before do + sign_in(user) + end + + it "returns status 200" do + get :index, { snippet_id: internal_snippet } + + expect(response).to have_http_status(200) + end + end + end + + context 'when a snippet is private' do + before do + note_on_private + end + + context 'when user not logged in' do + it "returns status 404" do + get :index, { snippet_id: private_snippet } + + expect(response).to have_http_status(404) + end + end + + context 'when user other than author logged in' do + before do + sign_in(user) + end + + it "returns status 404" do + get :index, { snippet_id: private_snippet } + + expect(response).to have_http_status(404) + end + end + + context 'when author logged in' do + before do + note_on_private + + sign_in(private_snippet.author) + end + + it "returns status 200" do + get :index, { snippet_id: private_snippet } + + expect(response).to have_http_status(200) + end + + it "returns 1 note" do + get :index, { snippet_id: private_snippet } + + expect(JSON.parse(response.body)['notes'].count).to eq(1) + end + end + end + + context 'dont show non visible notes' do + before do + note_on_public + + sign_in(user) + + expect_any_instance_of(Note).to receive(:cross_reference_not_visible_for?).and_return(true) + end + + it "does not return any note" do + get :index, { snippet_id: public_snippet } + + expect(JSON.parse(response.body)['notes'].count).to eq(0) + end + end + end + + describe 'DELETE destroy' do + let(:request_params) do + { + snippet_id: public_snippet, + id: note_on_public, + format: :js + } + end + + context 'when user is the author of a note' do + before do + sign_in(note_on_public.author) + end + + it "returns status 200" do + delete :destroy, request_params + + expect(response).to have_http_status(200) + end + + it "deletes the note" do + expect{ delete :destroy, request_params }.to change{ Note.count }.from(1).to(0) + end + + context 'system note' do + before do + expect_any_instance_of(Note).to receive(:system?).and_return(true) + end + + it "does not delete the note" do + expect{ delete :destroy, request_params }.not_to change{ Note.count } + end + end + end + + context 'when user is not the author of a note' do + before do + sign_in(user) + + note_on_public + end + + it "returns status 404" do + delete :destroy, request_params + + expect(response).to have_http_status(404) + end + + it "does not update the note" do + expect{ delete :destroy, request_params }.not_to change{ Note.count } + end + end + end + + describe 'POST toggle_award_emoji' do + let(:note) { create(:note_on_personal_snippet, noteable: public_snippet) } + before do + sign_in(user) + end + + subject { post(:toggle_award_emoji, snippet_id: public_snippet, id: note.id, name: "thumbsup") } + + it "toggles the award emoji" do + expect { subject }.to change { note.award_emoji.count }.by(1) + + expect(response).to have_http_status(200) + end + + it "removes the already awarded emoji when it exists" do + note.toggle_award_emoji('thumbsup', user) # create award emoji before + + expect { subject }.to change { AwardEmoji.count }.by(-1) + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 234f3edd3d8..41cd5bdcdd8 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -350,144 +350,138 @@ describe SnippetsController do end end - %w(raw download).each do |action| - describe "GET #{action}" do - context 'when the personal snippet is private' do - let(:personal_snippet) { create(:personal_snippet, :private, author: user) } + describe "GET #raw" do + context 'when the personal snippet is private' do + let(:personal_snippet) { create(:personal_snippet, :private, author: user) } - context 'when signed in' do - before do - sign_in(user) - end + context 'when signed in' do + before do + sign_in(user) + end - context 'when signed in user is not the author' do - let(:other_author) { create(:author) } - let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) } + context 'when signed in user is not the author' do + let(:other_author) { create(:author) } + let(:other_personal_snippet) { create(:personal_snippet, :private, author: other_author) } - it 'responds with status 404' do - get action, id: other_personal_snippet.to_param + it 'responds with status 404' do + get :raw, id: other_personal_snippet.to_param - expect(response).to have_http_status(404) - end + expect(response).to have_http_status(404) end + end - context 'when signed in user is the author' do - before { get action, id: personal_snippet.to_param } + context 'when signed in user is the author' do + before { get :raw, id: personal_snippet.to_param } - it 'responds with status 200' do - expect(assigns(:snippet)).to eq(personal_snippet) - expect(response).to have_http_status(200) - end + it 'responds with status 200' do + expect(assigns(:snippet)).to eq(personal_snippet) + expect(response).to have_http_status(200) + end - it 'has expected headers' do - expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + it 'has expected headers' do + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') - if action == :download - expect(response.header['Content-Disposition']).to match(/attachment/) - elsif action == :raw - expect(response.header['Content-Disposition']).to match(/inline/) - end - end + expect(response.header['Content-Disposition']).to match(/inline/) end end + end - context 'when not signed in' do - it 'redirects to the sign in page' do - get action, id: personal_snippet.to_param + context 'when not signed in' do + it 'redirects to the sign in page' do + get :raw, id: personal_snippet.to_param - expect(response).to redirect_to(new_user_session_path) - end + expect(response).to redirect_to(new_user_session_path) end end + end - context 'when the personal snippet is internal' do - let(:personal_snippet) { create(:personal_snippet, :internal, author: user) } + context 'when the personal snippet is internal' do + let(:personal_snippet) { create(:personal_snippet, :internal, author: user) } - context 'when signed in' do - before do - sign_in(user) - end + context 'when signed in' do + before do + sign_in(user) + end - it 'responds with status 200' do - get action, id: personal_snippet.to_param + it 'responds with status 200' do + get :raw, id: personal_snippet.to_param - expect(assigns(:snippet)).to eq(personal_snippet) - expect(response).to have_http_status(200) - end + expect(assigns(:snippet)).to eq(personal_snippet) + expect(response).to have_http_status(200) end + end - context 'when not signed in' do - it 'redirects to the sign in page' do - get action, id: personal_snippet.to_param + context 'when not signed in' do + it 'redirects to the sign in page' do + get :raw, id: personal_snippet.to_param - expect(response).to redirect_to(new_user_session_path) - end + expect(response).to redirect_to(new_user_session_path) end end + end - context 'when the personal snippet is public' do - let(:personal_snippet) { create(:personal_snippet, :public, author: user) } + context 'when the personal snippet is public' do + let(:personal_snippet) { create(:personal_snippet, :public, author: user) } - context 'when signed in' do - before do - sign_in(user) - end + context 'when signed in' do + before do + sign_in(user) + end - it 'responds with status 200' do - get action, id: personal_snippet.to_param + it 'responds with status 200' do + get :raw, id: personal_snippet.to_param - expect(assigns(:snippet)).to eq(personal_snippet) - expect(response).to have_http_status(200) - end + expect(assigns(:snippet)).to eq(personal_snippet) + expect(response).to have_http_status(200) + end - context 'CRLF line ending' do - let(:personal_snippet) do - create(:personal_snippet, :public, author: user, content: "first line\r\nsecond line\r\nthird line") - end + context 'CRLF line ending' do + let(:personal_snippet) do + create(:personal_snippet, :public, author: user, content: "first line\r\nsecond line\r\nthird line") + end - it 'returns LF line endings by default' do - get action, id: personal_snippet.to_param + it 'returns LF line endings by default' do + get :raw, id: personal_snippet.to_param - expect(response.body).to eq("first line\nsecond line\nthird line") - end + expect(response.body).to eq("first line\nsecond line\nthird line") + end - it 'does not convert line endings when parameter present' do - get action, id: personal_snippet.to_param, line_ending: :raw + it 'does not convert line endings when parameter present' do + get :raw, id: personal_snippet.to_param, line_ending: :raw - expect(response.body).to eq("first line\r\nsecond line\r\nthird line") - end + expect(response.body).to eq("first line\r\nsecond line\r\nthird line") end end + end - context 'when not signed in' do - it 'responds with status 200' do - get action, id: personal_snippet.to_param + context 'when not signed in' do + it 'responds with status 200' do + get :raw, id: personal_snippet.to_param - expect(assigns(:snippet)).to eq(personal_snippet) - expect(response).to have_http_status(200) - end + expect(assigns(:snippet)).to eq(personal_snippet) + expect(response).to have_http_status(200) end end + end - context 'when the personal snippet does not exist' do - context 'when signed in' do - before do - sign_in(user) - end + context 'when the personal snippet does not exist' do + context 'when signed in' do + before do + sign_in(user) + end - it 'responds with status 404' do - get action, id: 'doesntexist' + it 'responds with status 404' do + get :raw, id: 'doesntexist' - expect(response).to have_http_status(404) - end + expect(response).to have_http_status(404) end + end - context 'when not signed in' do - it 'responds with status 404' do - get action, id: 'doesntexist' + context 'when not signed in' do + it 'responds with status 404' do + get :raw, id: 'doesntexist' - expect(response).to have_http_status(404) - end + expect(response).to have_http_status(404) end end end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 93f4903119c..44c3186d813 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -5,7 +5,7 @@ include ActionDispatch::TestProcess FactoryGirl.define do factory :note do project factory: :empty_project - note "Note" + note { generate(:title) } author on_issue diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 39c2a9dd1fb..0210e871a63 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -1,6 +1,7 @@ FactoryGirl.define do factory :project_hook do url { generate(:url) } + enable_ssl_verification false trait :token do token { SecureRandom.hex(10) } @@ -11,6 +12,7 @@ FactoryGirl.define do merge_requests_events true tag_push_events true issues_events true + confidential_issues_events true note_events true build_events true pipeline_events true diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 0db2fe04edd..3580752a805 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -32,6 +32,10 @@ FactoryGirl.define do request_access_enabled true end + trait :with_avatar do + avatar { File.open(Rails.root.join('spec/fixtures/dk.png')) } + end + trait :repository do # no-op... for now! end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index fb519a9bf12..c5f24d412d7 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe "Admin::Hooks", feature: true do +describe 'Admin::Hooks', feature: true do before do @project = create(:project) login_as :admin @@ -8,24 +8,24 @@ describe "Admin::Hooks", feature: true do @system_hook = create(:system_hook) end - describe "GET /admin/hooks" do - it "is ok" do + describe 'GET /admin/hooks' do + it 'is ok' do visit admin_root_path - page.within ".layout-nav" do - click_on "Hooks" + page.within '.layout-nav' do + click_on 'Hooks' end expect(current_path).to eq(admin_hooks_path) end - it "has hooks list" do + it 'has hooks list' do visit admin_hooks_path expect(page).to have_content(@system_hook.url) end end - describe "New Hook" do + describe 'New Hook' do let(:url) { generate(:url) } it 'adds new hook' do @@ -40,11 +40,36 @@ describe "Admin::Hooks", feature: true do end end - describe "Test" do + describe 'Update existing hook' do + let(:new_url) { generate(:url) } + + it 'updates existing hook' do + visit admin_hooks_path + + click_link 'Edit' + fill_in 'hook_url', with: new_url + check 'Enable SSL verification' + click_button 'Save changes' + + expect(page).to have_content 'SSL Verification: enabled' + expect(current_path).to eq(admin_hooks_path) + expect(page).to have_content(new_url) + end + end + + describe 'Remove existing hook' do + it 'remove existing hook' do + visit admin_hooks_path + + expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + end + end + + describe 'Test' do before do WebMock.stub_request(:post, @system_hook.url) visit admin_hooks_path - click_link "Test hook" + click_link 'Test hook' end it { expect(current_path).to eq(admin_hooks_path) } diff --git a/spec/features/admin/admin_requests_profiles_spec.rb b/spec/features/admin/admin_requests_profiles_spec.rb new file mode 100644 index 00000000000..e8ecb70306b --- /dev/null +++ b/spec/features/admin/admin_requests_profiles_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe 'Admin::RequestsProfilesController', feature: true do + before do + FileUtils.mkdir_p(Gitlab::RequestProfiler::PROFILES_DIR) + login_as(:admin) + end + + after do + Gitlab::RequestProfiler.remove_all_profiles + end + + describe 'GET /admin/requests_profiles' do + it 'shows the current profile token' do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + + visit admin_requests_profiles_path + + expect(page).to have_content("X-Profile-Token: #{Gitlab::RequestProfiler.profile_token}") + end + + it 'lists all available profiles' do + time1 = 1.hour.ago + time2 = 2.hours.ago + time3 = 3.hours.ago + profile1 = "|gitlab-org|gitlab-ce_#{time1.to_i}.html" + profile2 = "|gitlab-org|gitlab-ce_#{time2.to_i}.html" + profile3 = "|gitlab-com|infrastructure_#{time3.to_i}.html" + + FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile1}") + FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile2}") + FileUtils.touch("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile3}") + + visit admin_requests_profiles_path + + within('.panel', text: '/gitlab-org/gitlab-ce') do + expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile1)}']", text: time1.to_s(:long)) + expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile2)}']", text: time2.to_s(:long)) + end + + within('.panel', text: '/gitlab-com/infrastructure') do + expect(page).to have_selector("a[href='#{admin_requests_profile_path(profile3)}']", text: time3.to_s(:long)) + end + end + end + + describe 'GET /admin/requests_profiles/:profile' do + context 'when a profile exists' do + it 'displays the content of the profile' do + content = 'This is a request profile' + profile = "|gitlab-org|gitlab-ce_#{Time.now.to_i}.html" + + File.write("#{Gitlab::RequestProfiler::PROFILES_DIR}/#{profile}", content) + + visit admin_requests_profile_path(profile) + + expect(page).to have_content(content) + end + end + + context 'when a profile does not exist' do + it 'shows an error message' do + visit admin_requests_profile_path('|non|existent_12345.html') + + expect(page).to have_content('Profile not found') + end + end + end +end diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index 8e5421a984b..9828cb179a7 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Explore Groups page', js: true, feature: true do +describe 'Explore Groups page', :js, :feature do let!(:user) { create :user } let!(:group) { create(:group) } let!(:public_group) { create(:group, :public) } @@ -46,19 +46,39 @@ describe 'Explore Groups page', js: true, feature: true do it 'shows non-archived projects count' do # Initially project is not archived expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") - + # Archive project empty_project.archive! visit explore_groups_path # Check project count expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0") - + # Unarchive project empty_project.unarchive! visit explore_groups_path # Check project count - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + end + + describe 'landing component' do + it 'should show a landing component' do + expect(page).to have_content('Below you will find all the groups that are public.') + end + + it 'should be dismissable' do + find('.dismiss-button').click + + expect(page).not_to have_content('Below you will find all the groups that are public.') + end + + it 'should persistently not show once dismissed' do + find('.dismiss-button').click + + visit explore_groups_path + + expect(page).not_to have_content('Below you will find all the groups that are public.') + end end end diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index e1353ec71ed..f0fec625108 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -35,6 +35,7 @@ feature 'Create New Merge Request', feature: true, js: true do expect(page).to have_content('Target branch') first('.js-target-branch').click + first('.dropdown-target-branch .dropdown-content') first('.dropdown-target-branch .dropdown-content a', text: 'v1.1.0').click expect(page).to have_content "b83d6e3" diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 8613b850203..8dba2ccbafa 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -24,6 +24,9 @@ feature 'File blob', :js, feature: true do # shows an enabled copy button expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + + # shows a raw button + expect(page).to have_link('Open raw') end end end @@ -50,6 +53,9 @@ feature 'File blob', :js, feature: true do # shows a disabled copy button expect(page).to have_selector('.js-copy-blob-source-btn.disabled') + + # shows a raw button + expect(page).to have_link('Open raw') end end @@ -160,6 +166,9 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a raw button + expect(page).to have_link('Open raw') end end @@ -203,6 +212,9 @@ feature 'File blob', :js, feature: true do # shows an enabled copy button expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + + # shows a raw button + expect(page).to have_link('Open raw') end end end @@ -219,7 +231,7 @@ feature 'File blob', :js, feature: true do branch_name: 'master', commit_message: "Add PDF", file_path: 'files/test.pdf', - file_content: File.read(Rails.root.join('spec/javascripts/blob/pdf/test.pdf')) + file_content: project.repository.blob_at('add-pdf-file', 'files/pdf/test.pdf').data ).execute visit_blob('files/test.pdf') @@ -237,6 +249,9 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') end end end @@ -262,6 +277,9 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') end end end @@ -283,6 +301,9 @@ feature 'File blob', :js, feature: true do # shows an enabled copy button expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + + # shows a raw button + expect(page).to have_link('Open raw') end end end @@ -305,6 +326,9 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') end end end diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index 5d64d42fd61..fa67d390c47 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -74,8 +74,10 @@ describe 'Cherry-pick Commits' do wait_for_ajax - page.within('#modal-cherry-pick-commit .dropdown-menu .dropdown-content') do - click_link 'feature' + page.within('#modal-cherry-pick-commit .dropdown-menu') do + find('.dropdown-input input').set('feature') + wait_for_ajax + click_link "feature" end page.within('#modal-cherry-pick-commit') do diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index acc3efe04e6..1e12f8542e2 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -200,7 +200,7 @@ feature 'Environment', :feature do end scenario 'user deletes the branch with running environment' do - visit namespace_project_branches_path(project.namespace, project) + visit namespace_project_branches_path(project.namespace, project, search: 'feature') remove_branch_with_hooks(project, user, 'feature') do page.within('.js-branch-feature') { find('a.btn-remove').click } diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb index 05f3162f13c..1370ab1c521 100644 --- a/spec/features/projects/merge_request_button_spec.rb +++ b/spec/features/projects/merge_request_button_spec.rb @@ -85,8 +85,8 @@ feature 'Merge Request button', feature: true do context 'on branches page' do it_behaves_like 'Merge request button only shown when allowed' do let(:label) { 'Merge request' } - let(:url) { namespace_project_branches_path(project.namespace, project) } - let(:fork_url) { namespace_project_branches_path(forked_project.namespace, forked_project) } + let(:url) { namespace_project_branches_path(project.namespace, project, search: 'feature') } + let(:fork_url) { namespace_project_branches_path(forked_project.namespace, forked_project, search: 'feature') } end end diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb new file mode 100644 index 00000000000..7909234556e --- /dev/null +++ b/spec/features/projects/settings/integration_settings_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +feature 'Integration settings', feature: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:role) { :developer } + let(:integrations_path) { namespace_project_settings_integrations_path(project.namespace, project) } + + background do + login_as(user) + project.team << [user, role] + end + + context 'for developer' do + given(:role) { :developer } + + scenario 'to be disallowed to view' do + visit integrations_path + + expect(page.status_code).to eq(404) + end + end + + context 'for master' do + given(:role) { :master } + + context 'Webhooks' do + let(:hook) { create(:project_hook, :all_events_enabled, enable_ssl_verification: true, project: project) } + let(:url) { generate(:url) } + + scenario 'show list of webhooks' do + hook + + visit integrations_path + + expect(page.status_code).to eq(200) + expect(page).to have_content(hook.url) + expect(page).to have_content('SSL Verification: enabled') + expect(page).to have_content('Push Events') + expect(page).to have_content('Tag Push Events') + expect(page).to have_content('Issues Events') + expect(page).to have_content('Confidential Issues Events') + expect(page).to have_content('Note Events') + expect(page).to have_content('Merge Requests Events') + expect(page).to have_content('Pipeline Events') + expect(page).to have_content('Wiki Page Events') + end + + scenario 'create webhook' do + visit integrations_path + + fill_in 'hook_url', with: url + check 'Tag push events' + check 'Enable SSL verification' + + click_button 'Add webhook' + + expect(page).to have_content(url) + expect(page).to have_content('SSL Verification: enabled') + expect(page).to have_content('Push Events') + expect(page).to have_content('Tag Push Events') + end + + scenario 'edit existing webhook' do + hook + visit integrations_path + + click_link 'Edit' + fill_in 'hook_url', with: url + check 'Enable SSL verification' + click_button 'Save changes' + + expect(page).to have_content 'SSL Verification: enabled' + expect(page).to have_content(url) + end + + scenario 'test existing webhook' do + WebMock.stub_request(:post, hook.url) + visit integrations_path + + click_link 'Test' + + expect(current_path).to eq(integrations_path) + end + + scenario 'remove existing webhook' do + hook + visit integrations_path + + expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + end + end + end +end diff --git a/spec/features/projects/snippets/show_spec.rb b/spec/features/projects/snippets/show_spec.rb index 7eb1210e307..cedf3778c7e 100644 --- a/spec/features/projects/snippets/show_spec.rb +++ b/spec/features/projects/snippets/show_spec.rb @@ -30,6 +30,12 @@ feature 'Project snippet', :js, feature: true do # shows an enabled copy button expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + + # shows a raw button + expect(page).to have_link('Open raw') + + # shows a download button + expect(page).to have_link('Download') end end end @@ -59,6 +65,12 @@ feature 'Project snippet', :js, feature: true do # shows a disabled copy button expect(page).to have_selector('.js-copy-blob-source-btn.disabled') + + # shows a raw button + expect(page).to have_link('Open raw') + + # shows a download button + expect(page).to have_link('Download') end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index a1a36931824..26879a77c48 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -466,6 +466,21 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index 5d58494a22a..699ca4f724c 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -449,6 +449,21 @@ describe "Private Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 5df5b710dc4..624f0d0f485 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -286,6 +286,21 @@ describe "Public Project Access", feature: true do it { is_expected.to be_denied_for(:visitor) } end + describe "GET /:project_path/environments/:id/deployments" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environment_deployments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/environments/new" do subject { new_namespace_project_environment_path(project.namespace, project) } diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb new file mode 100644 index 00000000000..c646039e0b1 --- /dev/null +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe 'Comments on personal snippets', feature: true do + let!(:user) { create(:user) } + let!(:snippet) { create(:personal_snippet, :public) } + let!(:snippet_notes) do + [ + create(:note_on_personal_snippet, noteable: snippet, author: user), + create(:note_on_personal_snippet, noteable: snippet) + ] + end + let!(:other_note) { create(:note_on_personal_snippet) } + + before do + login_as user + visit snippet_path(snippet) + end + + subject { page } + + context 'viewing the snippet detail page' do + it 'contains notes for a snippet with correct action icons' do + expect(page).to have_selector('#notes-list li', count: 2) + + # comment authored by current user + page.within("#notes-list li#note_#{snippet_notes[0].id}") do + expect(page).to have_content(snippet_notes[0].note) + expect(page).to have_selector('.js-note-delete') + expect(page).to have_selector('.note-emoji-button') + end + + page.within("#notes-list li#note_#{snippet_notes[1].id}") do + expect(page).to have_content(snippet_notes[1].note) + expect(page).not_to have_selector('.js-note-delete') + expect(page).to have_selector('.note-emoji-button') + end + end + end +end diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index cebcba6a230..e36cf547f80 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -24,6 +24,12 @@ feature 'Snippet', :js, feature: true do # shows an enabled copy button expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + + # shows a raw button + expect(page).to have_link('Open raw') + + # shows a download button + expect(page).to have_link('Download') end end end @@ -53,6 +59,12 @@ feature 'Snippet', :js, feature: true do # shows a disabled copy button expect(page).to have_selector('.js-copy-blob-source-btn.disabled') + + # shows a raw button + expect(page).to have_link('Open raw') + + # shows a download button + expect(page).to have_link('Download') end end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 765bf44d863..ba6bbb3bce0 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -110,6 +110,15 @@ describe NotesFinder do expect(notes.count).to eq(1) end + it 'finds notes on personal snippets' do + note = create(:note_on_personal_snippet) + params = { target_type: 'personal_snippet', target_id: note.noteable_id } + + notes = described_class.new(project, user, params).execute + + expect(notes.count).to eq(1) + end + it 'raises an exception for an invalid target_type' do params[:target_type] = 'invalid' expect { described_class.new(project, user, params).execute }.to raise_error('invalid target_type') diff --git a/spec/fixtures/api/schemas/deployments.json b/spec/fixtures/api/schemas/deployments.json new file mode 100644 index 00000000000..1112f23aab2 --- /dev/null +++ b/spec/fixtures/api/schemas/deployments.json @@ -0,0 +1,58 @@ +{ + "additionalProperties": false, + "properties": { + "deployments": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "iid": { + "type": "integer" + }, + "last?": { + "type": "boolean" + }, + "ref": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "sha": { + "type": "string" + }, + "tag": { + "type": "boolean" + } + }, + "required": [ + "sha", + "created_at", + "iid", + "tag", + "last?", + "ref", + "id" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "deployments" + ], + "type": "object" +} diff --git a/spec/helpers/award_emoji_helper_spec.rb b/spec/helpers/award_emoji_helper_spec.rb new file mode 100644 index 00000000000..7dfd6a3f6b4 --- /dev/null +++ b/spec/helpers/award_emoji_helper_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe AwardEmojiHelper do + describe '.toggle_award_url' do + context 'note on personal snippet' do + let(:note) { create(:note_on_personal_snippet) } + + it 'returns correct url' do + expected_url = "/snippets/#{note.noteable.id}/notes/#{note.id}/toggle_award_emoji" + + expect(helper.toggle_award_url(note)).to eq(expected_url) + end + end + + context 'note on project item' do + let(:note) { create(:note_on_project_snippet) } + + it 'returns correct url' do + @project = note.noteable.project + + expected_url = "/#{@project.namespace.path}/#{@project.path}/notes/#{note.id}/toggle_award_emoji" + + expect(helper.toggle_award_url(note)).to eq(expected_url) + end + end + + context 'personal snippet' do + let(:snippet) { create(:personal_snippet) } + + it 'returns correct url' do + expected_url = "/snippets/#{snippet.id}/toggle_award_emoji" + + expect(helper.toggle_award_url(snippet)).to eq(expected_url) + end + end + + context 'merge request' do + let(:merge_request) { create(:merge_request) } + + it 'returns correct url' do + @project = merge_request.project + + expected_url = "/#{@project.namespace.path}/#{@project.path}/merge_requests/#{merge_request.id}/toggle_award_emoji" + + expect(helper.toggle_award_url(merge_request)).to eq(expected_url) + end + end + + context 'issue' do + let(:issue) { create(:issue) } + + it 'returns correct url' do + @project = issue.project + + expected_url = "/#{@project.namespace.path}/#{@project.path}/issues/#{issue.id}/toggle_award_emoji" + + expect(helper.toggle_award_url(issue)).to eq(expected_url) + end + end + end +end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index e9037749ef2..10681af5f7e 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -64,7 +64,7 @@ describe MergeRequestsHelper do it do @project = project - + is_expected.to eq("#1, #2, and #{other_project.namespace.path}/#{other_project.path}#3") end end @@ -149,6 +149,50 @@ describe MergeRequestsHelper do end end + describe '#target_projects' do + let(:project) { create(:empty_project) } + let(:fork_project) { create(:empty_project, forked_from_project: project) } + + context 'when target project has enabled merge requests' do + it 'returns the forked_from project' do + expect(target_projects(fork_project)).to contain_exactly(project, fork_project) + end + end + + context 'when target project has disabled merge requests' do + it 'returns the forked project' do + project.project_feature.update(merge_requests_access_level: 0) + + expect(target_projects(fork_project)).to contain_exactly(fork_project) + end + end + end + + describe '#new_mr_path_from_push_event' do + subject(:url_params) { URI.decode_www_form(new_mr_path_from_push_event(event)).to_h } + let(:user) { create(:user) } + let(:project) { create(:empty_project, creator: user) } + let(:fork_project) { create(:project, forked_from_project: project, creator: user) } + let(:event) do + push_data = Gitlab::DataBuilder::Push.build_sample(fork_project, user) + create(:event, :pushed, project: fork_project, target: fork_project, author: user, data: push_data) + end + + context 'when target project has enabled merge requests' do + it 'returns link to create merge request on source project' do + expect(url_params['merge_request[target_project_id]'].to_i).to eq(project.id) + end + end + + context 'when target project has disabled merge requests' do + it 'returns link to create merge request on forked project' do + project.project_feature.update(merge_requests_access_level: 0) + + expect(url_params['merge_request[target_project_id]'].to_i).to eq(fork_project.id) + end + end + end + describe '#mr_issues_mentioned_but_not_closing' do let(:user_1) { create(:user) } let(:user_2) { create(:user) } diff --git a/spec/javascripts/blob/pdf/index_spec.js b/spec/javascripts/blob/pdf/index_spec.js index d3a4d04345b..bbeaf95e68d 100644 --- a/spec/javascripts/blob/pdf/index_spec.js +++ b/spec/javascripts/blob/pdf/index_spec.js @@ -1,5 +1,7 @@ +/* eslint-disable import/no-unresolved */ + import renderPDF from '~/blob/pdf'; -import testPDF from './test.pdf'; +import testPDF from '../../fixtures/blob/pdf/test.pdf'; describe('PDF renderer', () => { let viewer; @@ -59,7 +61,7 @@ describe('PDF renderer', () => { describe('error getting file', () => { beforeEach((done) => { - viewer.dataset.endpoint = 'invalid/endpoint'; + viewer.dataset.endpoint = 'invalid/path/to/file.pdf'; app = renderPDF(); checkLoaded(done); diff --git a/spec/javascripts/blob/pdf/test.pdf b/spec/javascripts/blob/pdf/test.pdf Binary files differdeleted file mode 100644 index eb3d147fde3..00000000000 --- a/spec/javascripts/blob/pdf/test.pdf +++ /dev/null diff --git a/spec/javascripts/fixtures/environments.rb b/spec/javascripts/fixtures/environments.rb new file mode 100644 index 00000000000..3474f4696ef --- /dev/null +++ b/spec/javascripts/fixtures/environments.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Projects::EnvironmentsController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'environments-project') } + let(:environment) { create(:environment, name: 'production', project: project) } + + render_views + + before(:all) do + clean_frontend_fixtures('environments/metrics') + end + + before(:each) do + sign_in(admin) + end + + it 'environments/metrics/metrics.html.raw' do |example| + get :metrics, + namespace_id: project.namespace, + project_id: project, + id: environment.id + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml deleted file mode 100644 index e2dd9519898..00000000000 --- a/spec/javascripts/fixtures/environments/metrics.html.haml +++ /dev/null @@ -1,62 +0,0 @@ -.prometheus-container{ 'data-has-metrics': "false", 'data-doc-link': '/help/administration/monitoring/prometheus/index.md', 'data-prometheus-integration': '/root/hello-prometheus/services/prometheus/edit' } - .top-area - .row - .col-sm-6 - %h3.page-title - Metrics for environment - .prometheus-state - .js-getting-started.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Get started with performance monitoring - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. Learn more about performance monitoring - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - Configure Prometheus - .js-loading.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Waiting for performance data - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - View documentation - .js-unable-to-connect.hidden - .row - .col-md-4.col-md-offset-4.state-svg - %svg - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Unable to connect to Prometheus server - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Ensure connectivity is available from the GitLab server to the Prometheus server - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - %a.btn.btn-success - View documentation - .prometheus-graphs - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/spec/javascripts/fixtures/pdf.rb b/spec/javascripts/fixtures/pdf.rb new file mode 100644 index 00000000000..6b2422a7986 --- /dev/null +++ b/spec/javascripts/fixtures/pdf.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'PDF file', '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, namespace: namespace, path: 'pdf-project') } + + before(:all) do + clean_frontend_fixtures('blob/pdf/') + end + + it 'blob/pdf/test.pdf' do |example| + blob = project.repository.blob_at('e774ebd33', 'files/pdf/test.pdf') + + store_frontend_fixture(blob.data.force_encoding("utf-8"), example.description) + end +end diff --git a/spec/javascripts/landing_spec.js b/spec/javascripts/landing_spec.js new file mode 100644 index 00000000000..7916073190a --- /dev/null +++ b/spec/javascripts/landing_spec.js @@ -0,0 +1,160 @@ +import Landing from '~/landing'; +import Cookies from 'js-cookie'; + +describe('Landing', function () { + describe('class constructor', function () { + beforeEach(function () { + this.landingElement = {}; + this.dismissButton = {}; + this.cookieName = 'cookie_name'; + + this.landing = new Landing(this.landingElement, this.dismissButton, this.cookieName); + }); + + it('should set .landing', function () { + expect(this.landing.landingElement).toBe(this.landingElement); + }); + + it('should set .cookieName', function () { + expect(this.landing.cookieName).toBe(this.cookieName); + }); + + it('should set .dismissButton', function () { + expect(this.landing.dismissButton).toBe(this.dismissButton); + }); + + it('should set .eventWrapper', function () { + expect(this.landing.eventWrapper).toEqual({}); + }); + }); + + describe('toggle', function () { + beforeEach(function () { + this.isDismissed = false; + this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) }; + this.landing = { + isDismissed: () => {}, + addEvents: () => {}, + landingElement: this.landingElement, + }; + + spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed); + spyOn(this.landing, 'addEvents'); + + Landing.prototype.toggle.call(this.landing); + }); + + it('should call .isDismissed', function () { + expect(this.landing.isDismissed).toHaveBeenCalled(); + }); + + it('should call .classList.toggle', function () { + expect(this.landingElement.classList.toggle).toHaveBeenCalledWith('hidden', this.isDismissed); + }); + + it('should call .addEvents', function () { + expect(this.landing.addEvents).toHaveBeenCalled(); + }); + + describe('if isDismissed is true', function () { + beforeEach(function () { + this.isDismissed = true; + this.landingElement = { classList: jasmine.createSpyObj('classList', ['toggle']) }; + this.landing = { + isDismissed: () => {}, + addEvents: () => {}, + landingElement: this.landingElement, + }; + + spyOn(this.landing, 'isDismissed').and.returnValue(this.isDismissed); + spyOn(this.landing, 'addEvents'); + + this.landing.isDismissed.calls.reset(); + + Landing.prototype.toggle.call(this.landing); + }); + + it('should not call .addEvents', function () { + expect(this.landing.addEvents).not.toHaveBeenCalled(); + }); + }); + }); + + describe('addEvents', function () { + beforeEach(function () { + this.dismissButton = jasmine.createSpyObj('dismissButton', ['addEventListener']); + this.eventWrapper = {}; + this.landing = { + eventWrapper: this.eventWrapper, + dismissButton: this.dismissButton, + dismissLanding: () => {}, + }; + + Landing.prototype.addEvents.call(this.landing); + }); + + it('should set .eventWrapper.dismissLanding', function () { + expect(this.eventWrapper.dismissLanding).toEqual(jasmine.any(Function)); + }); + + it('should call .addEventListener', function () { + expect(this.dismissButton.addEventListener).toHaveBeenCalledWith('click', this.eventWrapper.dismissLanding); + }); + }); + + describe('removeEvents', function () { + beforeEach(function () { + this.dismissButton = jasmine.createSpyObj('dismissButton', ['removeEventListener']); + this.eventWrapper = { dismissLanding: () => {} }; + this.landing = { + eventWrapper: this.eventWrapper, + dismissButton: this.dismissButton, + }; + + Landing.prototype.removeEvents.call(this.landing); + }); + + it('should call .removeEventListener', function () { + expect(this.dismissButton.removeEventListener).toHaveBeenCalledWith('click', this.eventWrapper.dismissLanding); + }); + }); + + describe('dismissLanding', function () { + beforeEach(function () { + this.landingElement = { classList: jasmine.createSpyObj('classList', ['add']) }; + this.cookieName = 'cookie_name'; + this.landing = { landingElement: this.landingElement, cookieName: this.cookieName }; + + spyOn(Cookies, 'set'); + + Landing.prototype.dismissLanding.call(this.landing); + }); + + it('should call .classList.add', function () { + expect(this.landingElement.classList.add).toHaveBeenCalledWith('hidden'); + }); + + it('should call Cookies.set', function () { + expect(Cookies.set).toHaveBeenCalledWith(this.cookieName, 'true', { expires: 365 }); + }); + }); + + describe('isDismissed', function () { + beforeEach(function () { + this.cookieName = 'cookie_name'; + this.landing = { cookieName: this.cookieName }; + + spyOn(Cookies, 'get').and.returnValue('true'); + + this.isDismissed = Landing.prototype.isDismissed.call(this.landing); + }); + + it('should call Cookies.get', function () { + expect(Cookies.get).toHaveBeenCalledWith(this.cookieName); + }); + + it('should return a boolean', function () { + expect(typeof this.isDismissed).toEqual('boolean'); + }); + }); +}); diff --git a/spec/javascripts/monitoring/deployments_spec.js b/spec/javascripts/monitoring/deployments_spec.js new file mode 100644 index 00000000000..19bc11d0f24 --- /dev/null +++ b/spec/javascripts/monitoring/deployments_spec.js @@ -0,0 +1,133 @@ +import d3 from 'd3'; +import PrometheusGraph from '~/monitoring/prometheus_graph'; +import Deployments from '~/monitoring/deployments'; +import { prometheusMockData } from './prometheus_mock_data'; + +describe('Metrics deployments', () => { + const fixtureName = 'environments/metrics/metrics.html.raw'; + let deployment; + let prometheusGraph; + + const graphElement = () => document.querySelector('.prometheus-graph'); + + preloadFixtures(fixtureName); + + beforeEach((done) => { + // Setup the view + loadFixtures(fixtureName); + + d3.selectAll('.prometheus-graph') + .append('g') + .attr('class', 'graph-container'); + + prometheusGraph = new PrometheusGraph(); + deployment = new Deployments(1000, 500); + + spyOn(prometheusGraph, 'init'); + spyOn($, 'ajax').and.callFake(() => { + const d = $.Deferred(); + d.resolve({ + deployments: [{ + id: 1, + created_at: deployment.chartData[10].time, + sha: 'testing', + tag: false, + ref: { + name: 'testing', + }, + }, { + id: 2, + created_at: deployment.chartData[15].time, + sha: '', + tag: true, + ref: { + name: 'tag', + }, + }], + }); + + setTimeout(done); + + return d.promise(); + }); + + prometheusGraph.configureGraph(); + prometheusGraph.transformData(prometheusMockData.metrics); + + deployment.init(prometheusGraph.graphSpecificProperties.memory_values.data); + }); + + it('creates line on graph for deploment', () => { + expect( + graphElement().querySelectorAll('.deployment-line').length, + ).toBe(2); + }); + + it('creates hidden deploy boxes', () => { + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box').length, + ).toBe(2); + }); + + it('hides the info boxes by default', () => { + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(2); + }); + + it('shows sha short code when tag is false', () => { + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box').textContent.trim(), + ).toContain('testin'); + }); + + it('shows ref name when tag is true', () => { + expect( + graphElement().querySelector('.deploy-info-2-cpu_values .js-deploy-info-box').textContent.trim(), + ).toContain('tag'); + }); + + it('shows info box when moving mouse over line', () => { + deployment.mouseOverDeployInfo(deployment.data[0].xPos, 'cpu_values'); + + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(1); + + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), + ).toBeNull(); + }); + + it('hides previously visible info box when moving mouse away', () => { + deployment.mouseOverDeployInfo(500, 'cpu_values'); + + expect( + graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, + ).toBe(2); + + expect( + graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), + ).not.toBeNull(); + }); + + describe('refText', () => { + it('returns shortened SHA', () => { + expect( + Deployments.refText({ + tag: false, + sha: '123456789', + }), + ).toBe('123456'); + }); + + it('returns tag name', () => { + expect( + Deployments.refText({ + tag: true, + ref: 'v1.0', + }), + ).toBe('v1.0'); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js index 4b904fc2960..25578bf1c6e 100644 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -3,7 +3,7 @@ import PrometheusGraph from '~/monitoring/prometheus_graph'; import { prometheusMockData } from './prometheus_mock_data'; describe('PrometheusGraph', () => { - const fixtureName = 'static/environments/metrics.html.raw'; + const fixtureName = 'environments/metrics/metrics.html.raw'; const prometheusGraphContainer = '.prometheus-graph'; const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; @@ -77,7 +77,7 @@ describe('PrometheusGraph', () => { }); describe('PrometheusGraphs UX states', () => { - const fixtureName = 'static/environments/metrics.html.raw'; + const fixtureName = 'environments/metrics/metrics.html.raw'; preloadFixtures(fixtureName); beforeEach(() => { diff --git a/spec/javascripts/pdf/index_spec.js b/spec/javascripts/pdf/index_spec.js new file mode 100644 index 00000000000..f661fae5fe2 --- /dev/null +++ b/spec/javascripts/pdf/index_spec.js @@ -0,0 +1,61 @@ +/* eslint-disable import/no-unresolved */ + +import Vue from 'vue'; +import { PDFJS } from 'pdfjs-dist'; +import workerSrc from 'vendor/pdf.worker'; + +import PDFLab from '~/pdf/index.vue'; +import pdf from '../fixtures/blob/pdf/test.pdf'; + +PDFJS.workerSrc = workerSrc; +const Component = Vue.extend(PDFLab); + +describe('PDF component', () => { + let vm; + + const checkLoaded = (done) => { + if (vm.loading) { + setTimeout(() => { + checkLoaded(done); + }, 100); + } else { + done(); + } + }; + + describe('without PDF data', () => { + beforeEach((done) => { + vm = new Component({ + propsData: { + pdf: '', + }, + }); + + vm.$mount(); + + checkLoaded(done); + }); + + it('does not render', () => { + expect(vm.$el.tagName).toBeUndefined(); + }); + }); + + describe('with PDF data', () => { + beforeEach((done) => { + vm = new Component({ + propsData: { + pdf, + }, + }); + + vm.$mount(); + + checkLoaded(done); + }); + + it('renders pdf component', () => { + expect(vm.$el.tagName).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/pdf/page_spec.js b/spec/javascripts/pdf/page_spec.js new file mode 100644 index 00000000000..ac76ebbfbe6 --- /dev/null +++ b/spec/javascripts/pdf/page_spec.js @@ -0,0 +1,57 @@ +/* eslint-disable import/no-unresolved */ + +import Vue from 'vue'; +import pdfjsLib from 'pdfjs-dist'; +import workerSrc from 'vendor/pdf.worker'; + +import PageComponent from '~/pdf/page/index.vue'; +import testPDF from '../fixtures/blob/pdf/test.pdf'; + +const Component = Vue.extend(PageComponent); + +describe('Page component', () => { + let vm; + let testPage; + pdfjsLib.PDFJS.workerSrc = workerSrc; + + const checkRendered = (done) => { + if (vm.rendering) { + setTimeout(() => { + checkRendered(done); + }, 100); + } else { + done(); + } + }; + + beforeEach((done) => { + pdfjsLib.getDocument(testPDF) + .then(pdf => pdf.getPage(1)) + .then((page) => { + testPage = page; + done(); + }) + .catch((error) => { + console.error(error); + }); + }); + + describe('render', () => { + beforeEach((done) => { + vm = new Component({ + propsData: { + page: testPage, + number: 1, + }, + }); + + vm.$mount(); + + checkRendered(done); + }); + + it('renders first page', () => { + expect(vm.$el.tagName).toBeDefined(); + }); + }); +}); diff --git a/spec/lib/constraints/group_url_constrainer_spec.rb b/spec/lib/constraints/group_url_constrainer_spec.rb index 96dacdc5cd2..f95adf3a84b 100644 --- a/spec/lib/constraints/group_url_constrainer_spec.rb +++ b/spec/lib/constraints/group_url_constrainer_spec.rb @@ -17,6 +17,13 @@ describe GroupUrlConstrainer, lib: true do it { expect(subject.matches?(request)).to be_truthy } end + context 'valid request for nested group with reserved top level name' do + let!(:nested_group) { create(:group, path: 'api', parent: group) } + let!(:request) { build_request('gitlab/api') } + + it { expect(subject.matches?(request)).to be_truthy } + end + context 'invalid request' do let(:request) { build_request('foo') } diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index a044b871730..737fac14f92 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -726,4 +726,37 @@ describe Gitlab::Database::MigrationHelpers, lib: true do expect(model.column_for(:users, :kittens)).to be_nil end end + + describe '#replace_sql' do + context 'using postgres' do + before do + allow(Gitlab::Database).to receive(:mysql?).and_return(false) + end + + it 'builds the sql with correct functions' do + expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s). + to include('regexp_replace') + end + end + + context 'using mysql' do + before do + allow(Gitlab::Database).to receive(:mysql?).and_return(true) + end + + it 'builds the sql with the correct functions' do + expect(model.replace_sql(Arel::Table.new(:users)[:first_name], "Alice", "Eve").to_s). + to include('locate', 'insert') + end + end + + describe 'results' do + let!(:user) { create(:user, name: 'Kathy Alice Aliceson') } + + it 'replaces the correct part of the string' do + model.update_column_in_batches(:users, :name, model.replace_sql(Arel::Table.new(:users)[:name], 'Alice', 'Eve')) + expect(user.reload.name).to eq('Kathy Eve Aliceson') + end + end + end end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb new file mode 100644 index 00000000000..64bc5fc0429 --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb @@ -0,0 +1,197 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + def migration_namespace(namespace) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Namespace.find(namespace.id) + end + + def migration_project(project) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Project.find(project.id) + end + + describe "#remove_last_ocurrence" do + it "removes only the last occurance of a string" do + input = "this/is/a-word-to-replace/namespace/with/a-word-to-replace" + + expect(subject.remove_last_occurrence(input, "a-word-to-replace")) + .to eq("this/is/a-word-to-replace/namespace/with/") + end + end + + describe '#remove_cached_html_for_projects' do + let(:project) { create(:empty_project, description_html: 'Project description') } + + it 'removes description_html from projects' do + subject.remove_cached_html_for_projects([project.id]) + + expect(project.reload.description_html).to be_nil + end + + it 'removes issue descriptions' do + issue = create(:issue, project: project, description_html: 'Issue description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(issue.reload.description_html).to be_nil + end + + it 'removes merge request descriptions' do + merge_request = create(:merge_request, + source_project: project, + target_project: project, + description_html: 'MergeRequest description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(merge_request.reload.description_html).to be_nil + end + + it 'removes note html' do + note = create(:note, + project: project, + noteable: create(:issue, project: project), + note_html: 'note description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(note.reload.note_html).to be_nil + end + + it 'removes milestone description' do + milestone = create(:milestone, + project: project, + description_html: 'milestone description') + + subject.remove_cached_html_for_projects([project.id]) + + expect(milestone.reload.description_html).to be_nil + end + end + + describe '#rename_path_for_routable' do + context 'for namespaces' do + let(:namespace) { create(:namespace, path: 'the-path') } + it "renames namespaces called the-path" do + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(namespace.reload.path).to eq("the-path0") + end + + it "renames the route to the namespace" do + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(Namespace.find(namespace.id).full_path).to eq("the-path0") + end + + it "renames the route for projects of the namespace" do + project = create(:project, path: "project-path", namespace: namespace) + + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(project.route.reload.path).to eq("the-path0/project-path") + end + + it 'returns the old & the new path' do + old_path, new_path = subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(old_path).to eq('the-path') + expect(new_path).to eq('the-path0') + end + + context "the-path namespace -> subgroup -> the-path0 project" do + it "updates the route of the project correctly" do + subgroup = create(:group, path: "subgroup", parent: namespace) + project = create(:project, path: "the-path0", namespace: subgroup) + + subject.rename_path_for_routable(migration_namespace(namespace)) + + expect(project.route.reload.path).to eq("the-path0/subgroup/the-path0") + end + end + end + + context 'for projects' do + let(:parent) { create(:namespace, path: 'the-parent') } + let(:project) { create(:empty_project, path: 'the-path', namespace: parent) } + + it 'renames the project called `the-path`' do + subject.rename_path_for_routable(migration_project(project)) + + expect(project.reload.path).to eq('the-path0') + end + + it 'renames the route for the project' do + subject.rename_path_for_routable(project) + + expect(project.reload.route.path).to eq('the-parent/the-path0') + end + + it 'returns the old & new path' do + old_path, new_path = subject.rename_path_for_routable(migration_project(project)) + + expect(old_path).to eq('the-parent/the-path') + expect(new_path).to eq('the-parent/the-path0') + end + end + end + + describe '#move_pages' do + it 'moves the pages directory' do + expect(subject).to receive(:move_folders) + .with(TestEnv.pages_path, 'old-path', 'new-path') + + subject.move_pages('old-path', 'new-path') + end + end + + describe "#move_uploads" do + let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') } + let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') } + + it 'moves subdirectories in the uploads folder' do + expect(subject).to receive(:uploads_dir).and_return(uploads_dir) + expect(subject).to receive(:move_folders).with(uploads_dir, 'old_path', 'new_path') + + subject.move_uploads('old_path', 'new_path') + end + + it "doesn't move uploads when they are stored in object storage" do + expect(subject).to receive(:file_storage?).and_return(false) + expect(subject).not_to receive(:move_folders) + + subject.move_uploads('old_path', 'new_path') + end + end + + describe '#move_folders' do + let(:test_dir) { File.join(Rails.root, 'tmp', 'tests', 'rename_reserved_paths') } + let(:uploads_dir) { File.join(test_dir, 'public', 'uploads') } + + before do + FileUtils.remove_dir(test_dir) if File.directory?(test_dir) + FileUtils.mkdir_p(uploads_dir) + allow(subject).to receive(:uploads_dir).and_return(uploads_dir) + end + + it 'moves a folder with files' do + source = File.join(uploads_dir, 'parent-group', 'sub-group') + FileUtils.mkdir_p(source) + destination = File.join(uploads_dir, 'parent-group', 'moved-group') + FileUtils.touch(File.join(source, 'test.txt')) + expected_file = File.join(destination, 'test.txt') + + subject.move_folders(uploads_dir, File.join('parent-group', 'sub-group'), File.join('parent-group', 'moved-group')) + + expect(File.exist?(expected_file)).to be(true) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb new file mode 100644 index 00000000000..a25c5da488a --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + def migration_namespace(namespace) + Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses:: + Namespace.find(namespace.id) + end + + describe '#namespaces_for_paths' do + context 'nested namespaces' do + let(:subject) { described_class.new(['parent/the-Path'], migration) } + + it 'includes the namespace' do + parent = create(:namespace, path: 'parent') + child = create(:namespace, path: 'the-path', parent: parent) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + expect(found_ids).to contain_exactly(child.id) + end + end + + context 'for child namespaces' do + it 'only returns child namespaces with the correct path' do + _root_namespace = create(:namespace, path: 'THE-path') + _other_path = create(:namespace, + path: 'other', + parent: create(:namespace)) + namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :child). + map(&:id) + expect(found_ids).to contain_exactly(namespace.id) + end + end + + context 'for top levelnamespaces' do + it 'only returns child namespaces with the correct path' do + root_namespace = create(:namespace, path: 'the-path') + _other_path = create(:namespace, path: 'other') + _child_namespace = create(:namespace, + path: 'the-path', + parent: create(:namespace)) + + found_ids = subject.namespaces_for_paths(type: :top_level). + map(&:id) + expect(found_ids).to contain_exactly(root_namespace.id) + end + end + end + + describe '#move_repositories' do + let(:namespace) { create(:group, name: 'hello-group') } + it 'moves a project for a namespace' do + create(:project, namespace: namespace, path: 'hello-project') + expected_path = File.join(TestEnv.repos_path, 'bye-group', 'hello-project.git') + + subject.move_repositories(namespace, 'hello-group', 'bye-group') + + expect(File.directory?(expected_path)).to be(true) + end + + it 'moves a namespace in a subdirectory correctly' do + child_namespace = create(:group, name: 'sub-group', parent: namespace) + create(:project, namespace: child_namespace, path: 'hello-project') + + expected_path = File.join(TestEnv.repos_path, 'hello-group', 'renamed-sub-group', 'hello-project.git') + + subject.move_repositories(child_namespace, 'hello-group/sub-group', 'hello-group/renamed-sub-group') + + expect(File.directory?(expected_path)).to be(true) + end + + it 'moves a parent namespace with subdirectories' do + child_namespace = create(:group, name: 'sub-group', parent: namespace) + create(:project, namespace: child_namespace, path: 'hello-project') + expected_path = File.join(TestEnv.repos_path, 'renamed-group', 'sub-group', 'hello-project.git') + + subject.move_repositories(child_namespace, 'hello-group', 'renamed-group') + + expect(File.directory?(expected_path)).to be(true) + end + end + + describe "#child_ids_for_parent" do + it "collects child ids for all levels" do + parent = create(:namespace) + first_child = create(:namespace, parent: parent) + second_child = create(:namespace, parent: parent) + third_child = create(:namespace, parent: second_child) + all_ids = [parent.id, first_child.id, second_child.id, third_child.id] + + collected_ids = subject.child_ids_for_parent(parent, ids: [parent.id]) + + expect(collected_ids).to contain_exactly(*all_ids) + end + end + + describe "#rename_namespace" do + let(:namespace) { create(:namespace, path: 'the-path') } + + it 'renames paths & routes for the namespace' do + expect(subject).to receive(:rename_path_for_routable). + with(namespace). + and_call_original + + subject.rename_namespace(namespace) + + expect(namespace.reload.path).to eq('the-path0') + end + + it "moves the the repository for a project in the namespace" do + create(:project, namespace: namespace, path: "the-path-project") + expected_repo = File.join(TestEnv.repos_path, "the-path0", "the-path-project.git") + + subject.rename_namespace(namespace) + + expect(File.directory?(expected_repo)).to be(true) + end + + it "moves the uploads for the namespace" do + expect(subject).to receive(:move_uploads).with("the-path", "the-path0") + + subject.rename_namespace(namespace) + end + + it "moves the pages for the namespace" do + expect(subject).to receive(:move_pages).with("the-path", "the-path0") + + subject.rename_namespace(namespace) + end + + it 'invalidates the markdown cache of related projects' do + project = create(:empty_project, namespace: namespace, path: "the-path-project") + + expect(subject).to receive(:remove_cached_html_for_projects).with([project.id]) + + subject.rename_namespace(namespace) + end + end + + describe '#rename_namespaces' do + let!(:top_level_namespace) { create(:namespace, path: 'the-path') } + let!(:child_namespace) do + create(:namespace, path: 'the-path', parent: create(:namespace)) + end + + it 'renames top level namespaces the namespace' do + expect(subject).to receive(:rename_namespace). + with(migration_namespace(top_level_namespace)) + + subject.rename_namespaces(type: :top_level) + end + + it 'renames child namespaces' do + expect(subject).to receive(:rename_namespace). + with(migration_namespace(child_namespace)) + + subject.rename_namespaces(type: :child) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb new file mode 100644 index 00000000000..59e8de2712d --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects do + let(:migration) { FakeRenameReservedPathMigrationV1.new } + let(:subject) { described_class.new(['the-path'], migration) } + + before do + allow(migration).to receive(:say) + end + + describe '#projects_for_paths' do + it 'searches using nested paths' do + namespace = create(:namespace, path: 'hello') + project = create(:empty_project, path: 'THE-path', namespace: namespace) + + result_ids = described_class.new(['Hello/the-path'], migration). + projects_for_paths.map(&:id) + + expect(result_ids).to contain_exactly(project.id) + end + + it 'includes the correct projects' do + project = create(:empty_project, path: 'THE-path') + _other_project = create(:empty_project) + + result_ids = subject.projects_for_paths.map(&:id) + + expect(result_ids).to contain_exactly(project.id) + end + end + + describe '#rename_projects' do + let!(:projects) { create_list(:empty_project, 2, path: 'the-path') } + + it 'renames each project' do + expect(subject).to receive(:rename_project).twice + + subject.rename_projects + end + + it 'invalidates the markdown cache of related projects' do + expect(subject).to receive(:remove_cached_html_for_projects). + with(projects.map(&:id)) + + subject.rename_projects + end + end + + describe '#rename_project' do + let(:project) do + create(:empty_project, + path: 'the-path', + namespace: create(:namespace, path: 'known-parent' )) + end + + it 'renames path & route for the project' do + expect(subject).to receive(:rename_path_for_routable). + with(project). + and_call_original + + subject.rename_project(project) + + expect(project.reload.path).to eq('the-path0') + end + + it 'moves the wiki & the repo' do + expect(subject).to receive(:move_repository). + with(project, 'known-parent/the-path.wiki', 'known-parent/the-path0.wiki') + expect(subject).to receive(:move_repository). + with(project, 'known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + + it 'moves uploads' do + expect(subject).to receive(:move_uploads). + with('known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + + it 'moves pages' do + expect(subject).to receive(:move_pages). + with('known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + end + + describe '#move_repository' do + let(:known_parent) { create(:namespace, path: 'known-parent') } + let(:project) { create(:project, path: 'the-path', namespace: known_parent) } + + it 'moves the repository for a project' do + expected_path = File.join(TestEnv.repos_path, 'known-parent', 'new-repo.git') + + subject.move_repository(project, 'known-parent/the-path', 'known-parent/new-repo') + + expect(File.directory?(expected_path)).to be(true) + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb new file mode 100644 index 00000000000..f8cc1eb91ec --- /dev/null +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +shared_examples 'renames child namespaces' do |type| + it 'renames namespaces' do + rename_namespaces = double + expect(described_class::RenameNamespaces). + to receive(:new).with(['first-path', 'second-path'], subject). + and_return(rename_namespaces) + expect(rename_namespaces).to receive(:rename_namespaces). + with(type: :child) + + subject.rename_wildcard_paths(['first-path', 'second-path']) + end +end + +describe Gitlab::Database::RenameReservedPathsMigration::V1 do + let(:subject) { FakeRenameReservedPathMigrationV1.new } + + before do + allow(subject).to receive(:say) + end + + describe '#rename_child_paths' do + it_behaves_like 'renames child namespaces' + end + + describe '#rename_wildcard_paths' do + it_behaves_like 'renames child namespaces' + + it 'should rename projects' do + rename_projects = double + expect(described_class::RenameProjects). + to receive(:new).with(['the-path'], subject). + and_return(rename_projects) + + expect(rename_projects).to receive(:rename_projects) + + subject.rename_wildcard_paths(['the-path']) + end + end + + describe '#rename_root_paths' do + it 'should rename namespaces' do + rename_namespaces = double + expect(described_class::RenameNamespaces). + to receive(:new).with(['the-path'], subject). + and_return(rename_namespaces) + expect(rename_namespaces).to receive(:rename_namespaces). + with(type: :top_level) + + subject.rename_root_paths('the-path') + end + end +end diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index 2a86b427806..f127e45ae6a 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -7,9 +7,17 @@ describe Gitlab::Email::Receiver, lib: true do context "when we cannot find a capable handler" do let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "!!!") } - it "raises a UnknownIncomingEmail" do + it "raises an UnknownIncomingEmail error" do expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail) end + + context "and the email contains no references header" do + let(:email_raw) { fixture_file("emails/auto_reply.eml").gsub(mail_key, "!!!") } + + it "raises an UnknownIncomingEmail error" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail) + end + end end context "when the email is blank" do diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index f88653cb1fe..1b78910fa3c 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1074,20 +1074,8 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe '#branch_count' do - before(:each) do - valid_ref = double(:ref) - invalid_ref = double(:ref) - - allow(valid_ref).to receive_messages(name: 'master', target: double(:target)) - - allow(invalid_ref).to receive_messages(name: 'bad-branch') - allow(invalid_ref).to receive(:target) { raise Rugged::ReferenceError } - - allow(repository.rugged).to receive_messages(branches: [valid_ref, invalid_ref]) - end - it 'returns the number of branches' do - expect(repository.branch_count).to eq(1) + expect(repository.branch_count).to eq(9) end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 127cd8c78d8..72e947f2cc2 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -45,8 +45,8 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('foo-') } end - describe 'FULL_NAMESPACE_REGEX_STR' do - subject { %r{\A#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}\z} } + describe '.full_namespace_regex' do + subject { described_class.full_namespace_regex } it { is_expected.to match('gitlab.org') } it { is_expected.to match('gitlab.org/gitlab-git') } diff --git a/spec/lib/gitlab/request_profiler_spec.rb b/spec/lib/gitlab/request_profiler_spec.rb new file mode 100644 index 00000000000..ae9c06ebb7d --- /dev/null +++ b/spec/lib/gitlab/request_profiler_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Gitlab::RequestProfiler, lib: true do + describe '.profile_token' do + it 'returns a token' do + expect(described_class.profile_token).to be_present + end + + it 'caches the token' do + expect(Rails.cache).to receive(:fetch).with('profile-token') + + described_class.profile_token + end + end + + describe '.remove_all_profiles' do + it 'removes Gitlab::RequestProfiler::PROFILES_DIR directory' do + dir = described_class::PROFILES_DIR + FileUtils.mkdir_p(dir) + + expect(Dir.exist?(dir)).to be true + + described_class.remove_all_profiles + expect(Dir.exist?(dir)).to be false + end + end +end diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index 611cdbbc865..2b27ff66c09 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -87,10 +87,10 @@ describe Gitlab::UserAccess, lib: true do expect(access.can_push_to_branch?(branch.name)).to be_falsey end - it 'returns true if branch does not exist and user has permission to merge' do + it 'returns false if branch does not exist' do project.team << [user, :developer] - expect(access.can_push_to_branch?(not_existing_branch.name)).to be_truthy + expect(access.can_push_to_branch?(not_existing_branch.name)).to be_falsey end end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 4edafbc4e32..40bbb10eaac 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -170,6 +170,12 @@ describe CacheMarkdownField do is_expected.to be_truthy end + + it 'returns false if the markdown field is set but the html is not' do + thing.foo_html = nil + + is_expected.to be_falsy + end end describe '#refresh_markdown_cache!' do diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 8ffde6f7fbb..a11805926cc 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -57,6 +57,32 @@ describe Group, models: true do it { is_expected.not_to validate_presence_of :owner } it { is_expected.to validate_presence_of :two_factor_grace_period } it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) } + + describe 'path validation' do + it 'rejects paths reserved on the root namespace when the group has no parent' do + group = build(:group, path: 'api') + + expect(group).not_to be_valid + end + + it 'allows root paths when the group has a parent' do + group = build(:group, path: 'api', parent: create(:group)) + + expect(group).to be_valid + end + + it 'rejects any wildcard paths when not a top level group' do + group = build(:group, path: 'tree', parent: create(:group)) + + expect(group).not_to be_valid + end + + it 'rejects reserved group paths' do + group = build(:group, path: 'activity', parent: create(:group)) + + expect(group).not_to be_valid + end + end end describe '.visible_to_user' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e406d0a16bd..8624616316c 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -34,6 +34,13 @@ describe Namespace, models: true do let(:group) { build(:group, :nested, path: 'tree') } it { expect(group).not_to be_valid } + + it 'rejects nested paths' do + parent = create(:group, :nested, path: 'environments') + namespace = build(:project, path: 'folders', namespace: parent) + + expect(namespace).not_to be_valid + end end context 'top-level group' do @@ -47,6 +54,7 @@ describe Namespace, models: true do describe "Respond to" do it { is_expected.to respond_to(:human_name) } it { is_expected.to respond_to(:to_param) } + it { is_expected.to respond_to(:has_parent?) } end describe '#to_param' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 92d420337f9..49455303096 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -253,6 +253,34 @@ describe Project, models: true do expect(new_project.errors.full_messages.first).to eq('The project is still being deleted. Please try again later.') end end + + describe 'path validation' do + it 'allows paths reserved on the root namespace' do + project = build(:project, path: 'api') + + expect(project).to be_valid + end + + it 'rejects paths reserved on another level' do + project = build(:project, path: 'tree') + + expect(project).not_to be_valid + end + + it 'rejects nested paths' do + parent = create(:group, :nested, path: 'environments') + project = build(:project, path: 'folders', namespace: parent) + + expect(project).not_to be_valid + end + + it 'allows a reserved group name' do + parent = create(:group) + project = build(:project, path: 'avatar', namespace: parent) + + expect(project).to be_valid + end + end end describe 'default_scope' do @@ -781,17 +809,14 @@ describe Project, models: true do let(:project) { create(:empty_project) } - context 'When avatar file is uploaded' do - before do - project.update_columns(avatar: 'uploads/avatar.png') - allow(project.avatar).to receive(:present?) { true } - end + context 'when avatar file is uploaded' do + let(:project) { create(:empty_project, :with_avatar) } - let(:avatar_path) do - "/uploads/project/avatar/#{project.id}/uploads/avatar.png" - end + it 'creates a correct avatar path' do + avatar_path = "/uploads/project/avatar/#{project.id}/dk.png" - it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } + expect(project.avatar_url).to eq("http://#{Gitlab.config.gitlab.host}#{avatar_path}") + end end context 'When avatar file in git' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index f6846cc1b2f..5216764a82d 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1379,12 +1379,22 @@ describe Repository, models: true do describe '#branch_count' do it 'returns the number of branches' do expect(repository.branch_count).to be_an(Integer) + + # NOTE: Until rugged goes away, make sure rugged and gitaly are in sync + rugged_count = repository.raw_repository.rugged.branches.count + + expect(repository.branch_count).to eq(rugged_count) end end describe '#tag_count' do it 'returns the number of tags' do expect(repository.tag_count).to be_an(Integer) + + # NOTE: Until rugged goes away, make sure rugged and gitaly are in sync + rugged_count = repository.raw_repository.rugged.tags.count + + expect(repository.tag_count).to eq(rugged_count) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0bcebc27598..1c2df4c9d97 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -97,6 +97,18 @@ describe User, models: true do expect(user.errors.values).to eq [['dashboard is a reserved name']] end + it 'allows child names' do + user = build(:user, username: 'avatar') + + expect(user).to be_valid + end + + it 'allows wildcard names' do + user = build(:user, username: 'blob') + + expect(user).to be_valid + end + it 'validates uniqueness' do expect(subject).to validate_uniqueness_of(:username).case_insensitive end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index c4bff1647b5..16e5efb2f5b 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -434,6 +434,19 @@ describe API::MergeRequests do expect(json_response['title']).to eq('Test merge_request') end + it 'returns 422 when target project has disabled merge requests' do + project.project_feature.update(merge_requests_access_level: 0) + + post api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test', + target_branch: 'master', + source_branch: 'markdown', + author: user2, + target_project_id: project.id + + expect(response).to have_http_status(422) + end + it "returns 400 when source_branch is missing" do post api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb index 6c2950a6e6f..f6ff96be566 100644 --- a/spec/requests/api/v3/merge_requests_spec.rb +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -338,6 +338,19 @@ describe API::MergeRequests do expect(json_response['title']).to eq('Test merge_request') end + it "returns 422 when target project has disabled merge requests" do + project.project_feature.update(merge_requests_access_level: 0) + + post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + title: 'Test', + target_branch: "master", + source_branch: 'markdown', + author: user2, + target_project_id: project.id + + expect(response).to have_http_status(422) + end + it "returns 400 when source_branch is missing" do post v3_api("/projects/#{fork_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id diff --git a/spec/requests/request_profiler_spec.rb b/spec/requests/request_profiler_spec.rb new file mode 100644 index 00000000000..51fbfecec4b --- /dev/null +++ b/spec/requests/request_profiler_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe 'Request Profiler' do + let(:user) { create(:user) } + + shared_examples 'profiling a request' do + before do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + allow(RubyProf::Profile).to receive(:profile) do |&blk| + blk.call + RubyProf::Profile.new + end + end + + it 'creates a profile of the request' do + project = create(:project, namespace: user.namespace) + time = Time.now + path = "/#{project.path_with_namespace}" + + Timecop.freeze(time) do + get path, nil, 'X-Profile-Token' => Gitlab::RequestProfiler.profile_token + end + + profile_path = "#{Gitlab.config.shared.path}/tmp/requests_profiles/#{path.tr('/', '|')}_#{time.to_i}.html" + expect(File.exist?(profile_path)).to be true + end + + after do + Gitlab::RequestProfiler.remove_all_profiles + end + end + + context "when user is logged-in" do + before do + login_as(user) + end + + include_examples 'profiling a request' + end + + context "when user is not logged-in" do + include_examples 'profiling a request' + end +end diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 99c44bde151..e5fc0b676af 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -71,13 +71,15 @@ describe Admin::ProjectsController, "routing" do end end -# admin_hook_test GET /admin/hooks/:hook_id/test(.:format) admin/hooks#test +# admin_hook_test GET /admin/hooks/:id/test(.:format) admin/hooks#test # admin_hooks GET /admin/hooks(.:format) admin/hooks#index # POST /admin/hooks(.:format) admin/hooks#create # admin_hook DELETE /admin/hooks/:id(.:format) admin/hooks#destroy +# PUT /admin/hooks/:id(.:format) admin/hooks#update +# edit_admin_hook GET /admin/hooks/:id(.:format) admin/hooks#edit describe Admin::HooksController, "routing" do it "to #test" do - expect(get("/admin/hooks/1/test")).to route_to('admin/hooks#test', hook_id: '1') + expect(get("/admin/hooks/1/test")).to route_to('admin/hooks#test', id: '1') end it "to #index" do @@ -88,6 +90,14 @@ describe Admin::HooksController, "routing" do expect(post("/admin/hooks")).to route_to('admin/hooks#create') end + it "to #edit" do + expect(get("/admin/hooks/1/edit")).to route_to('admin/hooks#edit', id: '1') + end + + it "to #update" do + expect(put("/admin/hooks/1")).to route_to('admin/hooks#update', id: '1') + end + it "to #destroy" do expect(delete("/admin/hooks/1")).to route_to('admin/hooks#destroy', id: '1') end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a3de022d242..163df072cf6 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -340,14 +340,16 @@ describe 'project routing' do # test_project_hook GET /:project_id/hooks/:id/test(.:format) hooks#test # project_hooks GET /:project_id/hooks(.:format) hooks#index # POST /:project_id/hooks(.:format) hooks#create - # project_hook DELETE /:project_id/hooks/:id(.:format) hooks#destroy + # edit_project_hook GET /:project_id/hooks/:id/edit(.:format) hooks#edit + # project_hook PUT /:project_id/hooks/:id(.:format) hooks#update + # DELETE /:project_id/hooks/:id(.:format) hooks#destroy describe Projects::HooksController, 'routing' do it 'to #test' do expect(get('/gitlab/gitlabhq/hooks/1/test')).to route_to('projects/hooks#test', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') end it_behaves_like 'RESTful project resources' do - let(:actions) { [:index, :create, :destroy] } + let(:actions) { [:index, :create, :destroy, :edit, :update] } let(:controller) { 'hooks' } end end diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 95eca5463eb..69355bcde42 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -3,25 +3,23 @@ require 'spec_helper' describe DeploymentEntity do let(:user) { create(:user) } let(:request) { double('request') } + let(:deployment) { create(:deployment) } + let(:entity) { described_class.new(deployment, request: request) } + subject { entity.as_json } before do allow(request).to receive(:user).and_return(user) end - let(:entity) do - described_class.new(deployment, request: request) - end - - let(:deployment) { create(:deployment) } - - subject { entity.as_json } - it 'exposes internal deployment id' do expect(subject).to include(:iid) end it 'exposes nested information about branch' do expect(subject[:ref][:name]).to eq 'master' - expect(subject[:ref][:ref_path]).not_to be_empty + end + + it 'exposes creation date' do + expect(subject).to include(:created_at) end end diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb index c94902dbab8..3964b998084 100644 --- a/spec/serializers/status_entity_spec.rb +++ b/spec/serializers/status_entity_spec.rb @@ -18,6 +18,12 @@ describe StatusEntity do it 'contains status details' do expect(subject).to include :text, :icon, :favicon, :label, :group expect(subject).to include :has_details, :details_path + expect(subject[:favicon]).to eq('/assets/ci_favicons/favicon_status_success.ico') + end + + it 'contains a dev namespaced favicon if dev env' do + allow(Rails.env).to receive(:development?) { true } + expect(entity.as_json[:favicon]).to eq('/assets/ci_favicons/dev/favicon_status_success.ico') end end end diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index be9f9ea2dec..6f9d1208b1d 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -261,6 +261,16 @@ describe MergeRequests::BuildService, services: true do end end + context 'upstream project has disabled merge requests' do + let(:upstream_project) { create(:empty_project, :merge_requests_disabled) } + let(:project) { create(:empty_project, forked_from_project: upstream_project) } + let(:commits) { Commit.decorate([commit_1], project) } + + it 'sets target project correctly' do + expect(merge_request.target_project).to eq(project) + end + end + context 'target_project is set and accessible by current_user' do let(:target_project) { create(:project, :public, :repository)} let(:commits) { Commit.decorate([commit_1], project) } diff --git a/spec/support/fake_migration_classes.rb b/spec/support/fake_migration_classes.rb new file mode 100644 index 00000000000..3de0460c3ca --- /dev/null +++ b/spec/support/fake_migration_classes.rb @@ -0,0 +1,3 @@ +class FakeRenameReservedPathMigrationV1 < ActiveRecord::Migration + include Gitlab::Database::RenameReservedPathsMigration::V1 +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 5c8ee8d62f5..0b3c6169c9b 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -39,7 +39,8 @@ module TestEnv 'wip' => 'b9238ee', 'csv' => '3dd0896', 'v1.1.0' => 'b83d6e3', - 'add-ipython-files' => '6d85bb69' + 'add-ipython-files' => '6d85bb69', + 'add-pdf-file' => 'e774ebd3' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 0a4a6ed8145..df2f2ce95e6 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -230,11 +230,13 @@ describe 'gitlab:app namespace rake task' do before do FileUtils.mkdir('tmp/tests/default_storage') FileUtils.mkdir('tmp/tests/custom_storage') + gitaly_address = Gitlab.config.repositories.storages.default.gitaly_address storages = { - 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage') }, - 'custom' => { 'path' => Settings.absolute('tmp/tests/custom_storage') } + 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address }, + 'custom' => { 'path' => Settings.absolute('tmp/tests/custom_storage'), 'gitaly_address' => gitaly_address } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) + Gitlab::GitalyClient.configure_channels # Create the projects now, after mocking the settings but before doing the backup project_a diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb new file mode 100644 index 00000000000..b114bfc1bca --- /dev/null +++ b/spec/validators/dynamic_path_validator_spec.rb @@ -0,0 +1,266 @@ +require 'spec_helper' + +describe DynamicPathValidator do + let(:validator) { described_class.new(attributes: [:path]) } + + # Pass in a full path to remove the format segment: + # `/ci/lint(.:format)` -> `/ci/lint` + def without_format(path) + path.split('(', 2)[0] + end + + # Pass in a full path and get the last segment before a wildcard + # That's not a parameter + # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path` + # -> 'builds/artifacts' + def path_before_wildcard(path) + path = path.gsub(STARTING_WITH_NAMESPACE, "") + path_segments = path.split('/').reject(&:empty?) + wildcard_index = path_segments.index { |segment| parameter?(segment) } + + segments_before_wildcard = path_segments[0..wildcard_index - 1] + + segments_before_wildcard.join('/') + end + + def parameter?(segment) + segment =~ /[*:]/ + end + + # If the path is reserved. Then no conflicting paths can# be created for any + # route using this reserved word. + # + # Both `builds/artifacts` & `build` are covered by reserving the word + # `build` + def wildcards_include?(path) + described_class::WILDCARD_ROUTES.include?(path) || + described_class::WILDCARD_ROUTES.include?(path.split('/').first) + end + + def failure_message(missing_words, constant_name, migration_helper) + missing_words = Array(missing_words) + <<-MSG + Found new routes that could cause conflicts with existing namespaced routes + for groups or projects. + + Add <#{missing_words.join(', ')}> to `DynamicPathValidator::#{constant_name} + to make sure no projects or namespaces can be created with those paths. + + To rename any existing records with those paths you can use the + `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}` + migration helper. + + Make sure to make a note of the renamed records in the release blog post. + + MSG + end + + let(:all_routes) do + Rails.application.routes.routes.routes. + map { |r| r.path.spec.to_s } + end + + let(:routes_without_format) { all_routes.map { |path| without_format(path) } } + + # Routes not starting with `/:` or `/*` + # all routes not starting with a param + let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } + + let(:top_level_words) do + routes_not_starting_in_wildcard.map do |route| + route.split('/')[1] + end.compact.uniq + end + + # All routes that start with a namespaced path, that have 1 or more + # path-segments before having another wildcard parameter. + # - Starting with paths: + # - `/*namespace_id/:project_id/` + # - `/*namespace_id/:id/` + # - Followed by one or more path-parts not starting with `:` or `*` + # - Followed by a path-part that includes a wildcard parameter `*` + # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw + STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id} + NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*} + ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*} + WILDCARD_SEGMENT = %r{\*} + let(:namespaced_wildcard_routes) do + routes_without_format.select do |p| + p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}} + end + end + + # This will return all paths that are used in a namespaced route + # before another wildcard path: + # + # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path + # /*namespace_id/:project_id/info/lfs/objects/*oid + # /*namespace_id/:project_id/commits/*id + # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path + # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file'] + let(:all_wildcard_paths) do + namespaced_wildcard_routes.map do |route| + path_before_wildcard(route) + end.uniq + end + + STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/} + let(:group_routes) do + routes_without_format.select do |path| + path =~ STARTING_WITH_GROUP + end + end + + let(:paths_after_group_id) do + group_routes.map do |route| + route.gsub(STARTING_WITH_GROUP, '').split('/').first + end.uniq + end + + describe 'TOP_LEVEL_ROUTES' do + it 'includes all the top level namespaces' do + failure_block = lambda do + missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES + failure_message(missing_words, 'TOP_LEVEL_ROUTES', 'rename_root_paths') + end + + expect(described_class::TOP_LEVEL_ROUTES) + .to include(*top_level_words), failure_block + end + end + + describe 'GROUP_ROUTES' do + it "don't contain a second wildcard" do + failure_block = lambda do + missing_words = paths_after_group_id - described_class::GROUP_ROUTES + failure_message(missing_words, 'GROUP_ROUTES', 'rename_child_paths') + end + + expect(described_class::GROUP_ROUTES) + .to include(*paths_after_group_id), failure_block + end + end + + describe 'WILDCARD_ROUTES' do + it 'includes all paths that can be used after a namespace/project path' do + aggregate_failures do + all_wildcard_paths.each do |path| + expect(wildcards_include?(path)) + .to be(true), failure_message(path, 'WILDCARD_ROUTES', 'rename_wildcard_paths') + end + end + end + end + + describe '.without_reserved_wildcard_paths_regex' do + subject { described_class.without_reserved_wildcard_paths_regex } + + it 'rejects paths starting with a reserved top level' do + expect(subject).not_to match('dashboard/hello/world') + expect(subject).not_to match('dashboard') + end + + it 'matches valid paths with a toplevel word in a different place' do + expect(subject).to match('parent/dashboard/project-path') + end + + it 'rejects paths containing a wildcard reserved word' do + expect(subject).not_to match('hello/edit') + expect(subject).not_to match('hello/edit/in-the-middle') + expect(subject).not_to match('foo/bar1/refs/master/logs_tree') + end + + it 'matches valid paths' do + expect(subject).to match('parent/child/project-path') + end + end + + describe '.regex_excluding_child_paths' do + let(:subject) { described_class.without_reserved_child_paths_regex } + + it 'rejects paths containing a child reserved word' do + expect(subject).not_to match('hello/group_members') + expect(subject).not_to match('hello/activity/in-the-middle') + expect(subject).not_to match('foo/bar1/refs/master/logs_tree') + end + + it 'allows a child path on the top level' do + expect(subject).to match('activity/foo') + expect(subject).to match('avatar') + end + end + + describe ".valid?" do + it 'is not case sensitive' do + expect(described_class.valid?("Users")).to be_falsey + end + + it "isn't valid when the top level is reserved" do + test_path = 'u/should-be-a/reserved-word' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it "isn't valid if any of the path segments is reserved" do + test_path = 'the-wildcard/wikis/is-not-allowed' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it "is valid if the path doesn't contain reserved words" do + test_path = 'there-are/no-wildcards/in-this-path' + + expect(described_class.valid?(test_path)).to be_truthy + end + + it 'allows allows a child path on the last spot' do + test_path = 'there/can-be-a/project-called/labels' + + expect(described_class.valid?(test_path)).to be_truthy + end + + it 'rejects a child path somewhere else' do + test_path = 'there/can-be-no/labels/group' + + expect(described_class.valid?(test_path)).to be_falsey + end + + it 'rejects paths that are in an incorrect format' do + test_path = 'incorrect/format.git' + + expect(described_class.valid?(test_path)).to be_falsey + end + end + + describe '#path_reserved_for_record?' do + it 'reserves a sub-group named activity' do + group = build(:group, :nested, path: 'activity') + + expect(validator.path_reserved_for_record?(group, 'activity')).to be_truthy + end + + it "doesn't reserve a project called activity" do + project = build(:project, path: 'activity') + + expect(validator.path_reserved_for_record?(project, 'activity')).to be_falsey + end + end + + describe '#validates_each' do + it 'adds a message when the path is not in the correct format' do + group = build(:group) + + validator.validate_each(group, :path, "Path with spaces, and comma's!") + + expect(group.errors[:path]).to include(Gitlab::Regex.namespace_regex_message) + end + + it 'adds a message when the path is not in the correct format' do + group = build(:group, path: 'users') + + validator.validate_each(group, :path, 'users') + + expect(group.errors[:path]).to include('users is a reserved name') + end + end +end diff --git a/spec/workers/expire_build_instance_artifacts_worker_spec.rb b/spec/workers/expire_build_instance_artifacts_worker_spec.rb index d202b3de77e..1d8da68883b 100644 --- a/spec/workers/expire_build_instance_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_instance_artifacts_worker_spec.rb @@ -34,12 +34,14 @@ describe ExpireBuildInstanceArtifactsWorker do context 'when associated project was removed' do let(:build) do create(:ci_build, :artifacts, artifacts_expiry) do |build| - build.project.delete + build.project.pending_delete = true end end it 'does not remove artifacts' do - expect(build.reload.artifacts_file.exists?).to be_truthy + expect do + build.reload.artifacts_file + end.not_to raise_error end end end diff --git a/yarn.lock b/yarn.lock index 8f38fb4a9a4..fdef0665d15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3638,7 +3638,7 @@ mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: dependencies: mime-db "~1.26.0" -mime@1.3.4, mime@^1.3.4: +mime@1.3.4, mime@1.3.x, mime@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" @@ -3710,6 +3710,10 @@ nested-error-stacks@^1.0.0: dependencies: inherits "~2.0.1" +node-ensure@^0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" + node-libs-browser@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-1.1.1.tgz#2a38243abedd7dffcd07a97c9aca5668975a6fea" @@ -4102,6 +4106,13 @@ pbkdf2@^3.0.3: dependencies: create-hmac "^1.1.2" +pdfjs-dist@^1.8.252: + version "1.8.252" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-1.8.252.tgz#2477245695341f7fe096824dacf327bc324c0f52" + dependencies: + node-ensure "^0.0.0" + worker-loader "^0.8.0" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -5538,6 +5549,13 @@ update-notifier@0.5.0: semver-diff "^2.0.0" string-length "^1.0.0" +url-loader@^0.5.8: + version "0.5.8" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.8.tgz#b9183b1801e0f847718673673040bc9dc1c715c5" + dependencies: + loader-utils "^1.0.2" + mime "1.3.x" + url-parse@1.0.x: version "1.0.5" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" @@ -5821,6 +5839,12 @@ wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" +worker-loader@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-0.8.0.tgz#13582960dcd7d700dc829d3fd252a7561696167e" + dependencies: + loader-utils "^1.0.2" + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" |