diff options
285 files changed, 8127 insertions, 1625 deletions
diff --git a/app/assets/images/icon_image_comment.svg b/app/assets/images/icon_image_comment.svg new file mode 100644 index 00000000000..cf6cb972940 --- /dev/null +++ b/app/assets/images/icon_image_comment.svg @@ -0,0 +1 @@ +<svg width="24" height="30" viewBox="0 0 24 30" xmlns="http://www.w3.org/2000/svg"><title>cursor</title><g fill="none" fill-rule="evenodd"><path d="M24 12.105c0 6.686-5.74 11.58-12 17.895C5.74 23.684 0 18.79 0 12.105 0 5.42 5.373 0 12 0s12 5.42 12 12.105z" fill="#1F78D1" fill-rule="nonzero"/><path d="M15.28 25.249c1.458-1.475 2.539-2.635 3.474-3.747 2.851-3.394 4.203-6.265 4.203-9.397 0-6.111-4.908-11.062-10.957-11.062-6.05 0-10.957 4.951-10.957 11.062 0 3.132 1.352 6.003 4.203 9.397.935 1.112 2.016 2.272 3.474 3.747.511.517 2.216 2.213 3.28 3.275 1.064-1.062 2.769-2.758 3.28-3.275z" fill="#FFF"/><path d="M14.551 8.256A6.874 6.874 0 0 0 12 7.787c-.91 0-1.763.156-2.558.469-.79.308-1.42.725-1.888 1.252-.465.527-.697 1.096-.697 1.708 0 .5.159.977.476 1.433.321.45.772.841 1.352 1.172l.583.334-.181.643c-.107.407-.263.79-.469 1.152a6.604 6.604 0 0 0 1.842-1.145l.288-.254.381.04c.309.035.599.053.871.053.91 0 1.761-.154 2.551-.462.795-.312 1.424-.732 1.889-1.259.468-.526.703-1.096.703-1.707 0-.612-.235-1.181-.703-1.708-.465-.527-1.094-.944-1.889-1.252zm2.645.81c.536.656.804 1.373.804 2.15 0 .776-.268 1.495-.804 2.156-.535.656-1.263 1.176-2.183 1.56-.92.38-1.924.57-3.013.57a9.16 9.16 0 0 1-.971-.054 7.32 7.32 0 0 1-3.08 1.62 5.044 5.044 0 0 1-.764.148h-.033a.26.26 0 0 1-.181-.074.324.324 0 0 1-.107-.18v-.007c-.014-.018-.016-.045-.007-.08.014-.037.018-.059.014-.068 0-.009.01-.031.033-.067a.645.645 0 0 0 .04-.06 1.73 1.73 0 0 0 .047-.054l.054-.06a53.034 53.034 0 0 1 .435-.489c.049-.049.118-.136.207-.26.094-.126.168-.24.221-.342.054-.103.114-.235.181-.395.067-.161.125-.33.174-.51-.7-.397-1.254-.888-1.66-1.473A3.261 3.261 0 0 1 6 11.216c0-.777.268-1.494.804-2.15.535-.66 1.263-1.18 2.183-1.56.92-.384 1.924-.576 3.013-.576 1.09 0 2.094.192 3.013.576.92.38 1.648.9 2.183 1.56z" fill="#1F78D1" fill-rule="nonzero"/></g></svg> diff --git a/app/assets/images/icon_image_comment@2x.svg b/app/assets/images/icon_image_comment@2x.svg new file mode 100644 index 00000000000..83be91d3705 --- /dev/null +++ b/app/assets/images/icon_image_comment@2x.svg @@ -0,0 +1 @@ +<svg width="48" height="60" viewBox="0 0 48 60" xmlns="http://www.w3.org/2000/svg"><title>cursor_2x</title><g fill="none" fill-rule="evenodd"><path d="M48 24.21C48 37.583 36.522 47.369 24 60 11.478 47.368 0 37.582 0 24.21 0 10.84 10.745 0 24 0s24 10.84 24 24.21z" fill="#1F78D1" fill-rule="nonzero"/><path d="M30.56 50.497c2.915-2.95 5.078-5.268 6.947-7.493 5.703-6.788 8.406-12.53 8.406-18.793 0-12.223-9.815-22.124-21.913-22.124S2.087 11.988 2.087 24.211c0 6.263 2.703 12.005 8.406 18.793 1.87 2.225 4.032 4.544 6.947 7.493 1.022 1.035 4.432 4.426 6.56 6.55 2.128-2.124 5.538-5.515 6.56-6.55z" fill="#FFF"/><path d="M29.103 16.512c-1.58-.625-3.282-.938-5.103-.938-1.821 0-3.527.313-5.116.938-1.58.616-2.84 1.45-3.777 2.504-.928 1.054-1.393 2.192-1.393 3.415 0 1 .317 1.956.951 2.866.643.902 1.545 1.684 2.706 2.344l1.165.67-.362 1.286a9.603 9.603 0 0 1-.937 2.303 13.208 13.208 0 0 0 3.683-2.29l.576-.509.763.08c.616.072 1.196.108 1.741.108 1.821 0 3.522-.308 5.103-.925 1.589-.625 2.848-1.464 3.776-2.517.938-1.054 1.407-2.192 1.407-3.416 0-1.223-.469-2.361-1.407-3.415-.928-1.053-2.187-1.888-3.776-2.504zm5.29 1.62c1.071 1.313 1.607 2.746 1.607 4.3 0 1.553-.536 2.99-1.607 4.312-1.072 1.312-2.527 2.353-4.366 3.12-1.84.76-3.848 1.139-6.027 1.139a18.32 18.32 0 0 1-1.942-.107c-1.768 1.562-3.821 2.643-6.16 3.24-.438.126-.947.224-1.527.295h-.067a.521.521 0 0 1-.362-.147.649.649 0 0 1-.214-.362v-.013c-.027-.036-.032-.09-.014-.16.027-.072.036-.117.027-.135 0-.017.022-.062.067-.133a1.29 1.29 0 0 0 .08-.121c.01-.009.04-.045.094-.107a106.068 106.068 0 0 1 .522-.59c.215-.232.367-.401.456-.508.098-.099.236-.273.415-.523.188-.25.335-.477.442-.683.107-.205.228-.468.362-.79.134-.321.25-.66.348-1.018-1.402-.794-2.51-1.777-3.322-2.946C12.402 25.025 12 23.77 12 22.43c0-1.553.536-2.986 1.607-4.299 1.072-1.321 2.527-2.361 4.366-3.12 1.84-.768 3.848-1.152 6.027-1.152 2.179 0 4.188.384 6.027 1.152 1.84.759 3.294 1.799 4.366 3.12z" fill="#1F78D1" fill-rule="nonzero"/></g></svg> diff --git a/app/assets/images/new_nav.png b/app/assets/images/new_nav.png Binary files differdeleted file mode 100644 index f98ca15d787..00000000000 --- a/app/assets/images/new_nav.png +++ /dev/null diff --git a/app/assets/images/old_nav.png b/app/assets/images/old_nav.png Binary files differdeleted file mode 100644 index 23fae7aa19e..00000000000 --- a/app/assets/images/old_nav.png +++ /dev/null diff --git a/app/assets/javascripts/commit.js b/app/assets/javascripts/commit.js deleted file mode 100644 index 5f637524e30..00000000000 --- a/app/assets/javascripts/commit.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife */ -/* global CommitFile */ - -window.Commit = (function() { - function Commit() { - $('.files .diff-file').each(function() { - return new CommitFile(this); - }); - } - - return Commit; -})(); diff --git a/app/assets/javascripts/commit/file.js b/app/assets/javascripts/commit/file.js deleted file mode 100644 index ee087c978dd..00000000000 --- a/app/assets/javascripts/commit/file.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new */ -/* global ImageFile */ - -(function() { - this.CommitFile = (function() { - function CommitFile(file) { - if ($('.image', file).length) { - new gl.ImageFile(file); - } - } - - return CommitFile; - })(); -}).call(window); diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 4763985c802..e7adf8814b8 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-use-before-define, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, object-shorthand, max-len */ +import 'vendor/jquery.waitforimages'; + (function() { gl.ImageFile = (function() { var prepareFrames; @@ -17,15 +19,10 @@ // Load two-up view after images are loaded // so that we can display the correct width and height information - const images = $('.two-up.view img', _this.file); - let loadedCount = 0; - - images.on('load', () => { - loadedCount += 1; + const $images = $('.two-up.view img', _this.file); - if (loadedCount === images.length) { - _this.initView('two-up'); - } + $images.waitForImages(function() { + _this.initView('two-up'); }); }); }; diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue new file mode 100644 index 00000000000..732697c134e --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/banner.vue @@ -0,0 +1,55 @@ +<script> + import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg'; + + export default { + props: { + documentationLink: { + type: String, + required: true, + }, + }, + computed: { + iconCycleAnalyticsSplash() { + return iconCycleAnalyticsSplash; + }, + }, + methods: { + dismissOverviewDialog() { + this.$emit('dismiss-overview-dialog'); + }, + }, + }; +</script> +<template> + <div class="landing content-block"> + <button + class="js-ca-dismiss-button dismiss-button" + type="button" + :aria-label="__('Dismiss Cycle Analytics introduction box')" + @click="dismissOverviewDialog"> + <i + class="fa fa-times" + aria-hidden="true"> + </i> + </button> + <div class="svg-container" v-html="iconCycleAnalyticsSplash"> + </div> + <div class="inner-content"> + <h4> + {{__('Introducing Cycle Analytics')}} + </h4> + <p> + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} + </p> + <p> + <a + :href="documentationLink" + target="_blank" + rel="nofollow" + class="btn"> + {{__('Read more')}} + </a> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 991fcf114da..cdf5e3c0290 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import Translate from '../vue_shared/translate'; +import banner from './components/banner.vue'; import stageCodeComponent from './components/stage_code_component.vue'; import stagePlanComponent from './components/stage_plan_component.vue'; import stageComponent from './components/stage_component.vue'; @@ -44,6 +45,7 @@ $(() => { }, }, components: { + banner, 'stage-issue-component': stageComponent, 'stage-plan-component': stagePlanComponent, 'stage-code-component': stageCodeComponent, diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 9ddfdb6ae21..6c78662baa7 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -3,6 +3,7 @@ import './lib/utils/url_utility'; import FilesCommentButton from './files_comment_button'; import SingleFileDiff from './single_file_diff'; +import imageDiffHelper from './image_diff/helpers/index'; const UNFOLD_COUNT = 20; let isBound = false; @@ -20,7 +21,9 @@ class Diff { const tab = document.getElementById('diffs'); if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile); - $diffFile.each((index, file) => new gl.ImageFile(file)); + const firstFile = $('.files').first().get(0); + const canCreateNote = firstFile && firstFile.hasAttribute('data-can-create-note'); + $diffFile.each((index, file) => imageDiffHelper.initImageDiff(file, canCreateNote)); if (!isBound) { $(document) diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 497c23f014f..e77910a83d4 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -171,7 +171,14 @@ const JumpToDiscussion = Vue.extend({ // When jumping between unresolved discussions on the diffs tab, we show them. $target.closest(".content").show(); - $target = $target.closest("tr.notes_holder"); + const $notesHolder = $target.closest("tr.notes_holder"); + + // Image diff discussions does not use notes_holder + // so we should keep original $target value in those cases + if ($notesHolder.length > 0) { + $target = $notesHolder; + } + $target.show(); // If we are on the diffs tab, we don't scroll to the discussion itself, but to diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0ef67a9bc4a..3755fe368c5 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -7,7 +7,6 @@ /* global IssuableForm */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global Commit */ /* global NewBranchForm */ /* global NotificationsForm */ /* global NotificationsDropdown */ @@ -316,7 +315,6 @@ import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; new gl.Activities(); break; case 'projects:commit:show': - new Commit(); new gl.Diff(); new ZenMode(); shortcut_handler = new ShortcutsNavigation(); diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js new file mode 100644 index 00000000000..6a6a668308d --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -0,0 +1,38 @@ +export function createImageBadge(noteId, { x, y }, classNames = []) { + const buttonEl = document.createElement('button'); + const classList = classNames.concat(['js-image-badge']); + classList.forEach(className => buttonEl.classList.add(className)); + buttonEl.setAttribute('type', 'button'); + buttonEl.setAttribute('disabled', true); + buttonEl.dataset.noteId = noteId; + buttonEl.style.left = `${x}px`; + buttonEl.style.top = `${y}px`; + + return buttonEl; +} + +export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { + const buttonEl = createImageBadge(noteId, coordinate, ['badge']); + buttonEl.innerText = badgeText; + + containerEl.appendChild(buttonEl); +} + +export function addImageCommentBadge(containerEl, { coordinate, noteId }) { + const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge', 'inverted']); + const iconEl = document.createElement('i'); + iconEl.className = 'fa fa-comment-o'; + iconEl.setAttribute('aria-label', 'comment'); + + buttonEl.appendChild(iconEl); + containerEl.appendChild(buttonEl); +} + +export function addAvatarBadge(el, event) { + const { noteId, badgeNumber } = event.detail; + + // Add badge to new comment + const avatarBadgeEl = el.querySelector(`#${noteId} .badge`); + avatarBadgeEl.innerText = badgeNumber; + avatarBadgeEl.classList.remove('hidden'); +} diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js new file mode 100644 index 00000000000..05000c73052 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -0,0 +1,58 @@ +export function addCommentIndicator(containerEl, { x, y }) { + const buttonEl = document.createElement('button'); + buttonEl.classList.add('btn-transparent'); + buttonEl.classList.add('comment-indicator'); + buttonEl.setAttribute('type', 'button'); + buttonEl.style.left = `${x}px`; + buttonEl.style.top = `${y}px`; + + buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark'); + + containerEl.appendChild(buttonEl); +} + +export function removeCommentIndicator(imageFrameEl) { + const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); + const imageEl = imageFrameEl.querySelector('img'); + const willRemove = !!commentIndicatorEl; + let meta = {}; + + if (willRemove) { + meta = { + x: parseInt(commentIndicatorEl.style.left, 10), + y: parseInt(commentIndicatorEl.style.top, 10), + image: { + width: imageEl.width, + height: imageEl.height, + }, + }; + + commentIndicatorEl.remove(); + } + + return Object.assign({}, meta, { + removed: willRemove, + }); +} + +export function showCommentIndicator(imageFrameEl, coordinate) { + const { x, y } = coordinate; + const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); + + if (commentIndicatorEl) { + commentIndicatorEl.style.left = `${x}px`; + commentIndicatorEl.style.top = `${y}px`; + } else { + addCommentIndicator(imageFrameEl, coordinate); + } +} + +export function commentIndicatorOnClick(event) { + // Prevent from triggering onAddImageDiffNote in notes.js + event.stopPropagation(); + + const buttonEl = event.currentTarget; + const diffViewerEl = buttonEl.closest('.diff-viewer'); + const textareaEl = diffViewerEl.querySelector('.note-container .note-textarea'); + textareaEl.focus(); +} diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js new file mode 100644 index 00000000000..12d56714b34 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js @@ -0,0 +1,44 @@ +export function setPositionDataAttribute(el, options) { + // Update position data attribute so that the + // new comment form can use this data for ajax request + const { x, y, width, height } = options; + const position = el.dataset.position; + const positionObject = Object.assign({}, JSON.parse(position), { + x, + y, + width, + height, + }); + + el.setAttribute('data-position', JSON.stringify(positionObject)); +} + +export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) { + const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge'); + avatarBadgeEl.innerText = newBadgeNumber; +} + +export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) { + const discussionBadgeEl = discussionEl.querySelector('.badge'); + discussionBadgeEl.innerText = newBadgeNumber; +} + +export function toggleCollapsed(event) { + const toggleButtonEl = event.currentTarget; + const discussionNotesEl = toggleButtonEl.closest('.discussion-notes'); + const formEl = discussionNotesEl.querySelector('.discussion-form'); + const isCollapsed = discussionNotesEl.classList.contains('collapsed'); + + if (isCollapsed) { + discussionNotesEl.classList.remove('collapsed'); + } else { + discussionNotesEl.classList.add('collapsed'); + } + + // Override the inline display style set in notes.js + if (formEl && !isCollapsed) { + formEl.style.display = 'none'; + } else if (formEl && isCollapsed) { + formEl.style.display = 'block'; + } +} diff --git a/app/assets/javascripts/image_diff/helpers/index.js b/app/assets/javascripts/image_diff/helpers/index.js new file mode 100644 index 00000000000..4a100631003 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/index.js @@ -0,0 +1,25 @@ +import * as badgeHelper from './badge_helper'; +import * as commentIndicatorHelper from './comment_indicator_helper'; +import * as domHelper from './dom_helper'; +import * as utilsHelper from './utils_helper'; + +export default { + addCommentIndicator: commentIndicatorHelper.addCommentIndicator, + removeCommentIndicator: commentIndicatorHelper.removeCommentIndicator, + showCommentIndicator: commentIndicatorHelper.showCommentIndicator, + commentIndicatorOnClick: commentIndicatorHelper.commentIndicatorOnClick, + + addImageBadge: badgeHelper.addImageBadge, + addImageCommentBadge: badgeHelper.addImageCommentBadge, + addAvatarBadge: badgeHelper.addAvatarBadge, + + setPositionDataAttribute: domHelper.setPositionDataAttribute, + updateDiscussionAvatarBadgeNumber: domHelper.updateDiscussionAvatarBadgeNumber, + updateDiscussionBadgeNumber: domHelper.updateDiscussionBadgeNumber, + toggleCollapsed: domHelper.toggleCollapsed, + + resizeCoordinatesToImageElement: utilsHelper.resizeCoordinatesToImageElement, + generateBadgeFromDiscussionDOM: utilsHelper.generateBadgeFromDiscussionDOM, + getTargetSelection: utilsHelper.getTargetSelection, + initImageDiff: utilsHelper.initImageDiff, +}; diff --git a/app/assets/javascripts/image_diff/helpers/utils_helper.js b/app/assets/javascripts/image_diff/helpers/utils_helper.js new file mode 100644 index 00000000000..96fc735e629 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/utils_helper.js @@ -0,0 +1,95 @@ +import ImageBadge from '../image_badge'; +import ImageDiff from '../image_diff'; +import ReplacedImageDiff from '../replaced_image_diff'; +import '../../commit/image_file'; + +export function resizeCoordinatesToImageElement(imageEl, meta) { + const { x, y, width, height } = meta; + + const imageWidth = imageEl.width; + const imageHeight = imageEl.height; + + const widthRatio = imageWidth / width; + const heightRatio = imageHeight / height; + + return { + x: Math.round(x * widthRatio), + y: Math.round(y * heightRatio), + width: imageWidth, + height: imageHeight, + }; +} + +export function generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl) { + const position = JSON.parse(discussionEl.dataset.position); + const firstNoteEl = discussionEl.querySelector('.note'); + const badge = new ImageBadge({ + actual: position, + imageEl: imageFrameEl.querySelector('img'), + noteId: firstNoteEl.id, + discussionId: discussionEl.dataset.discussionId, + }); + + return badge; +} + +export function getTargetSelection(event) { + const containerEl = event.currentTarget; + const imageEl = containerEl.querySelector('img'); + + const x = event.offsetX; + const y = event.offsetY; + + const width = imageEl.width; + const height = imageEl.height; + + const actualWidth = imageEl.naturalWidth; + const actualHeight = imageEl.naturalHeight; + + const widthRatio = actualWidth / width; + const heightRatio = actualHeight / height; + + // Browser will include the frame as a clickable target, + // which would result in potential 1px out of bounds value + // This bound the coordinates to inside the frame + const normalizedX = Math.max(0, x) && Math.min(x, width); + const normalizedY = Math.max(0, y) && Math.min(y, height); + + return { + browser: { + x: normalizedX, + y: normalizedY, + width, + height, + }, + actual: { + // Round x, y so that we don't need to deal with decimals + x: Math.round(normalizedX * widthRatio), + y: Math.round(normalizedY * heightRatio), + width: actualWidth, + height: actualHeight, + }, + }; +} + +export function initImageDiff(fileEl, canCreateNote, renderCommentBadge) { + const options = { + canCreateNote, + renderCommentBadge, + }; + let diff; + + // ImageFile needs to be invoked before initImageDiff so that badges + // can mount to the correct location + new gl.ImageFile(fileEl); // eslint-disable-line no-new + + if (fileEl.querySelector('.diff-file .js-single-image')) { + diff = new ImageDiff(fileEl, options); + diff.init(); + } else if (fileEl.querySelector('.diff-file .js-replaced-image')) { + diff = new ReplacedImageDiff(fileEl, options); + diff.init(); + } + + return diff; +} diff --git a/app/assets/javascripts/image_diff/image_badge.js b/app/assets/javascripts/image_diff/image_badge.js new file mode 100644 index 00000000000..51a8cda98d7 --- /dev/null +++ b/app/assets/javascripts/image_diff/image_badge.js @@ -0,0 +1,23 @@ +import imageDiffHelper from './helpers/index'; + +const defaultMeta = { + x: 0, + y: 0, + width: 0, + height: 0, +}; + +export default class ImageBadge { + constructor(options) { + const { noteId, discussionId } = options; + + this.actual = options.actual || defaultMeta; + this.browser = options.browser || defaultMeta; + this.noteId = noteId; + this.discussionId = discussionId; + + if (options.imageEl && !options.browser) { + this.browser = imageDiffHelper.resizeCoordinatesToImageElement(options.imageEl, this.actual); + } + } +} diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js new file mode 100644 index 00000000000..f3af92cf2b0 --- /dev/null +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -0,0 +1,143 @@ +import imageDiffHelper from './helpers/index'; +import ImageBadge from './image_badge'; +import { isImageLoaded } from '../lib/utils/image_utility'; + +export default class ImageDiff { + constructor(el, options) { + this.el = el; + this.canCreateNote = !!(options && options.canCreateNote); + this.renderCommentBadge = !!(options && options.renderCommentBadge); + this.$noteContainer = $('.note-container', this.el); + this.imageBadges = []; + } + + init() { + this.imageFrameEl = this.el.querySelector('.diff-file .js-image-frame'); + this.imageEl = this.imageFrameEl.querySelector('img'); + + this.bindEvents(); + } + + bindEvents() { + this.imageClickedWrapper = this.imageClicked.bind(this); + this.imageBlurredWrapper = imageDiffHelper.removeCommentIndicator.bind(null, this.imageFrameEl); + this.addBadgeWrapper = this.addBadge.bind(this); + this.removeBadgeWrapper = this.removeBadge.bind(this); + this.renderBadgesWrapper = this.renderBadges.bind(this); + + // Render badges + if (isImageLoaded(this.imageEl)) { + this.renderBadges(); + } else { + this.imageEl.addEventListener('load', this.renderBadgesWrapper); + } + + // jquery makes the event delegation here much simpler + this.$noteContainer.on('click', '.js-diff-notes-toggle', imageDiffHelper.toggleCollapsed); + $(this.el).on('click', '.comment-indicator', imageDiffHelper.commentIndicatorOnClick); + + if (this.canCreateNote) { + this.el.addEventListener('click.imageDiff', this.imageClickedWrapper); + this.el.addEventListener('blur.imageDiff', this.imageBlurredWrapper); + this.el.addEventListener('addBadge.imageDiff', this.addBadgeWrapper); + this.el.addEventListener('removeBadge.imageDiff', this.removeBadgeWrapper); + } + } + + imageClicked(event) { + const customEvent = event.detail; + const selection = imageDiffHelper.getTargetSelection(customEvent); + const el = customEvent.currentTarget; + + imageDiffHelper.setPositionDataAttribute(el, selection.actual); + imageDiffHelper.showCommentIndicator(this.imageFrameEl, selection.browser); + } + + renderBadges() { + const discussionsEls = this.el.querySelectorAll('.note-container .discussion-notes .notes'); + [...discussionsEls].forEach(this.renderBadge.bind(this)); + } + + renderBadge(discussionEl, index) { + const imageBadge = imageDiffHelper + .generateBadgeFromDiscussionDOM(this.imageFrameEl, discussionEl); + + this.imageBadges.push(imageBadge); + + const options = { + coordinate: imageBadge.browser, + noteId: imageBadge.noteId, + }; + + if (this.renderCommentBadge) { + imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options); + } else { + const numberBadgeOptions = Object.assign({}, options, { + badgeText: index + 1, + }); + + imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions); + } + } + + addBadge(event) { + const { x, y, width, height, noteId, discussionId } = event.detail; + const badgeText = this.imageBadges.length + 1; + const imageBadge = new ImageBadge({ + actual: { + x, + y, + width, + height, + }, + imageEl: this.imageFrameEl.querySelector('img'), + noteId, + discussionId, + }); + + this.imageBadges.push(imageBadge); + + imageDiffHelper.addImageBadge(this.imageFrameEl, { + coordinate: imageBadge.browser, + badgeText, + noteId, + }); + + imageDiffHelper.addAvatarBadge(this.el, { + detail: { + noteId, + badgeNumber: badgeText, + }, + }); + + const discussionEl = this.el.querySelector(`#discussion_${discussionId}`); + imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, badgeText); + } + + removeBadge(event) { + const { badgeNumber } = event.detail; + const indexToRemove = badgeNumber - 1; + const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge'); + + if (this.imageBadges.length !== badgeNumber) { + // Cascade badges count numbers for (avatar badges + image badges) + this.imageBadges.forEach((badge, index) => { + if (index > indexToRemove) { + const { discussionId } = badge; + const updatedBadgeNumber = index; + const discussionEl = this.el.querySelector(`#discussion_${discussionId}`); + + imageBadgeEls[index].innerText = updatedBadgeNumber; + + imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber); + imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber); + } + }); + } + + this.imageBadges.splice(indexToRemove, 1); + + const imageBadgeEl = imageBadgeEls[indexToRemove]; + imageBadgeEl.remove(); + } +} diff --git a/app/assets/javascripts/image_diff/init_discussion_tab.js b/app/assets/javascripts/image_diff/init_discussion_tab.js new file mode 100644 index 00000000000..2f16c6ef115 --- /dev/null +++ b/app/assets/javascripts/image_diff/init_discussion_tab.js @@ -0,0 +1,12 @@ +import imageDiffHelper from './helpers/index'; + +export default () => { + // Always pass can-create-note as false because a user + // cannot place new badge markers on discussion tab + const canCreateNote = false; + const renderCommentBadge = true; + + const diffFileEls = document.querySelectorAll('.timeline-content .diff-file.js-image-file'); + [...diffFileEls].forEach(diffFileEl => + imageDiffHelper.initImageDiff(diffFileEl, canCreateNote, renderCommentBadge)); +}; diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js new file mode 100644 index 00000000000..4abd13fb472 --- /dev/null +++ b/app/assets/javascripts/image_diff/replaced_image_diff.js @@ -0,0 +1,92 @@ +import imageDiffHelper from './helpers/index'; +import { viewTypes, isValidViewType } from './view_types'; +import ImageDiff from './image_diff'; + +export default class ReplacedImageDiff extends ImageDiff { + init(defaultViewType = viewTypes.TWO_UP) { + this.imageFrameEls = { + [viewTypes.TWO_UP]: this.el.querySelector('.two-up .js-image-frame'), + [viewTypes.SWIPE]: this.el.querySelector('.swipe .js-image-frame'), + [viewTypes.ONION_SKIN]: this.el.querySelector('.onion-skin .js-image-frame'), + }; + + const viewModesEl = this.el.querySelector('.view-modes-menu'); + this.viewModesEls = { + [viewTypes.TWO_UP]: viewModesEl.querySelector('.two-up'), + [viewTypes.SWIPE]: viewModesEl.querySelector('.swipe'), + [viewTypes.ONION_SKIN]: viewModesEl.querySelector('.onion-skin'), + }; + + this.currentView = defaultViewType; + this.generateImageEls(); + this.bindEvents(); + } + + generateImageEls() { + this.imageEls = {}; + + const viewTypeNames = Object.getOwnPropertyNames(viewTypes); + viewTypeNames.forEach((viewType) => { + this.imageEls[viewType] = this.imageFrameEls[viewType].querySelector('img'); + }); + } + + bindEvents() { + super.bindEvents(); + + this.changeToViewTwoUp = this.changeView.bind(this, viewTypes.TWO_UP); + this.changeToViewSwipe = this.changeView.bind(this, viewTypes.SWIPE); + this.changeToViewOnionSkin = this.changeView.bind(this, viewTypes.ONION_SKIN); + + this.viewModesEls[viewTypes.TWO_UP].addEventListener('click', this.changeToViewTwoUp); + this.viewModesEls[viewTypes.SWIPE].addEventListener('click', this.changeToViewSwipe); + this.viewModesEls[viewTypes.ONION_SKIN].addEventListener('click', this.changeToViewOnionSkin); + } + + get imageEl() { + return this.imageEls[this.currentView]; + } + + get imageFrameEl() { + return this.imageFrameEls[this.currentView]; + } + + changeView(newView) { + if (!isValidViewType(newView)) { + return; + } + + const indicator = imageDiffHelper.removeCommentIndicator(this.imageFrameEl); + + this.currentView = newView; + + // Clear existing badges on new view + const existingBadges = this.imageFrameEl.querySelectorAll('.badge'); + [...existingBadges].map(badge => badge.remove()); + + // Remove existing references to old view image badges + this.imageBadges = []; + + // Image_file.js has a fade animation of 200ms for loading the view + // Need to wait an additional 250ms for the images to be displayed + // on window in order to re-normalize their dimensions + setTimeout(this.renderNewView.bind(this, indicator), 250); + } + + renderNewView(indicator) { + // Generate badge coordinates on new view + this.renderBadges(); + + // Re-render indicator in new view + if (indicator.removed) { + const normalizedIndicator = imageDiffHelper + .resizeCoordinatesToImageElement(this.imageEl, { + x: indicator.x, + y: indicator.y, + width: indicator.image.width, + height: indicator.image.height, + }); + imageDiffHelper.showCommentIndicator(this.imageFrameEl, normalizedIndicator); + } + } +} diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js new file mode 100644 index 00000000000..ab0a595571f --- /dev/null +++ b/app/assets/javascripts/image_diff/view_types.js @@ -0,0 +1,9 @@ +export const viewTypes = { + TWO_UP: 'TWO_UP', + SWIPE: 'SWIPE', + ONION_SKIN: 'ONION_SKIN', +}; + +export function isValidViewType(validate) { + return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate); +} diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js index ae41cc5e8a8..0bdb547d31a 100644 --- a/app/assets/javascripts/lib/utils/csrf.js +++ b/app/assets/javascripts/lib/utils/csrf.js @@ -14,6 +14,9 @@ If you need to compose a headers object, use the spread operator: someOtherHeader: '12345', } ``` + +see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf +and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62 */ const csrf = { @@ -53,4 +56,3 @@ if ($.rails) { } export default csrf; - diff --git a/app/assets/javascripts/lib/utils/image_utility.js b/app/assets/javascripts/lib/utils/image_utility.js new file mode 100644 index 00000000000..2977ec821cb --- /dev/null +++ b/app/assets/javascripts/lib/utils/image_utility.js @@ -0,0 +1,5 @@ +/* eslint-disable import/prefer-default-export */ + +export function isImageLoaded(element) { + return element.complete && element.naturalHeight !== 0; +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9f674166884..5858c2b6fd8 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -35,12 +35,9 @@ import './shortcuts_network'; import './templates/issuable_template_selector'; import './templates/issuable_template_selectors'; -// commit -import './commit/file'; import './commit/image_file'; // lib/utils -import './lib/utils/bootstrap_linked_tabs'; import { handleLocationHash } from './lib/utils/common_utils'; import './lib/utils/datetime_utility'; import './lib/utils/pretty_time'; @@ -71,7 +68,7 @@ import './build'; import './build_artifacts'; import './build_variables'; import './ci_lint_editor'; -import './commit'; +import './commits'; import './compare'; import './compare_autocomplete'; import './confirm_danger_modal'; @@ -110,7 +107,6 @@ import './merge_request'; import './merge_request_tabs'; import './milestone'; import './milestone_select'; -import './mini_pipeline_graph_dropdown'; import './namespace_select'; import './new_branch_form'; import './new_commit_form'; @@ -118,7 +114,6 @@ import './notes'; import './notifications_dropdown'; import './notifications_form'; import './pager'; -import './pipelines'; import './preview_markdown'; import './project'; import './project_avatar'; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index d3299c15720..c042b22d1fd 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -13,6 +13,8 @@ import { isMetaClick, } from './lib/utils/common_utils'; +import initDiscussionTab from './image_diff/init_discussion_tab'; + /* eslint-disable max-len */ // MergeRequestTabs // @@ -154,6 +156,8 @@ import { } this.resetViewContainer(); this.destroyPipelinesView(); + + initDiscussionTab(); } if (this.setUrl) { this.setCurrentAction(action); diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index dbc48c63747..85b6d7f4cbe 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -79,7 +79,11 @@ }, formatMetricUsage(series) { - return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`; + const value = series.values[this.currentDataIndex].value; + if (isNaN(value)) { + return '-'; + } + return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`; }, createSeriesString(index, series) { diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index 3cbe06d8fd6..65eec0d8d02 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -56,12 +56,16 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra timeSeriesScaleX.ticks(d3.time.minute, 60); timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]); + const defined = d => !isNaN(d.value) && d.value != null; + const lineFunction = d3.svg.line() + .defined(defined) .interpolate('linear') .x(d => timeSeriesScaleX(d.time)) .y(d => timeSeriesScaleY(d.value)); const areaFunction = d3.svg.area() + .defined(defined) .interpolate('linear') .x(d => timeSeriesScaleX(d.time)) .y0(graphHeight - graphHeightOffset) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 93aa29454a0..24de21f2ce2 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -24,6 +24,7 @@ import './autosave'; import './dropzone_input'; import TaskList from './task_list'; import { ajaxPost, isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; +import imageDiffHelper from './image_diff/helpers/index'; window.autosize = autosize; window.Dropzone = Dropzone; @@ -42,6 +43,7 @@ export default class Notes { this.visibilityChange = this.visibilityChange.bind(this); this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); this.onAddDiffNote = this.onAddDiffNote.bind(this); + this.onAddImageDiffNote = this.onAddImageDiffNote.bind(this); this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this); this.removeNote = this.removeNote.bind(this); @@ -114,6 +116,8 @@ export default class Notes { $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); + // add diff note for images + $(document).on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); // hide diff note form $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list @@ -140,6 +144,7 @@ export default class Notes { $(document).off('click', '.js-note-attachment-delete'); $(document).off('click', '.js-discussion-reply-button'); $(document).off('click', '.js-add-diff-note-button'); + $(document).off('click', '.js-add-image-diff-note-button'); $(document).off('visibilitychange'); $(document).off('keyup input', '.js-note-text'); $(document).off('click', '.js-note-target-reopen'); @@ -412,6 +417,11 @@ export default class Notes { this.note_ids.push(noteEntity.id); form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); row = form.closest('tr'); + + if (noteEntity.on_image) { + row = form; + } + lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? @@ -423,7 +433,7 @@ export default class Notes { if (noteEntity.diff_discussion_html) { var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { + if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { // insert the note and the reply button after the temp row row.after($discussion); } else { @@ -449,6 +459,7 @@ export default class Notes { if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } @@ -561,7 +572,7 @@ export default class Notes { form.find('#note_line_code').val(), // DiffNote - form.find('#note_position').val() + form.find('#note_position').val(), ]; return new Autosave(textarea, key); } @@ -783,9 +794,22 @@ export default class Notes { $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); // The notes tr can contain multiple lists of notes, like on the parallel diff - if (notesTr.find('.discussion-notes').length > 1) { + // notesTr does not exist for image diffs + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }); + + $diffFile[0].dispatchEvent(removeBadgeEvent); + } + $notes.remove(); - } else { + } else if (notesTr.length > 0) { notesTr.remove(); } } @@ -841,7 +865,11 @@ export default class Notes { */ setupDiscussionNoteForm(dataHolder, form) { // setup note target - const diffFileData = dataHolder.closest('.text-file'); + let diffFileData = dataHolder.closest('.text-file'); + + if (diffFileData.length === 0) { + diffFileData = dataHolder.closest('.image'); + } var discussionID = dataHolder.data('discussionId'); @@ -907,6 +935,31 @@ export default class Notes { }); } + onAddImageDiffNote(e) { + const $link = $(e.currentTarget || e.target); + const $diffFile = $link.closest('.diff-file'); + + const clickEvent = new CustomEvent('click.imageDiff', { + detail: e, + }); + + $diffFile[0].dispatchEvent(clickEvent); + + // Setup comment form + let newForm; + const $noteContainer = $link.closest('.diff-viewer').find('.note-container'); + const $form = $noteContainer.find('> .discussion-form'); + + if ($form.length === 0) { + newForm = this.cleanForm(this.formClone.clone()); + newForm.appendTo($noteContainer); + } else { + newForm = $form; + } + + this.setupDiscussionNoteForm($link, newForm); + } + toggleDiffNote({ target, lineType, @@ -999,10 +1052,25 @@ export default class Notes { } cancelDiscussionForm(e) { - var form; e.preventDefault(); - form = $(e.target).closest('.js-discussion-note-form'); - return this.removeDiscussionNoteForm(form); + const $form = $(e.target).closest('.js-discussion-note-form'); + const $discussionNote = $(e.target).closest('.discussion-notes'); + + if ($discussionNote.length === 0) { + // Only send blur event when the discussion form + // is not part of a discussion note + const $diffFile = $form.closest('.diff-file'); + + if ($diffFile.length > 0) { + const blurEvent = new CustomEvent('blur.imageDiff', { + detail: e, + }); + + $diffFile[0].dispatchEvent(blurEvent); + } + } + + return this.removeDiscussionNoteForm($form); } /** @@ -1414,6 +1482,15 @@ export default class Notes { // Submission successful! remove placeholder $notesContainer.find(`#${noteUniqueId}`).remove(); + const $diffFile = $form.closest('.diff-file'); + if ($diffFile.length > 0) { + const blurEvent = new CustomEvent('blur.imageDiff', { + detail: e, + }); + + $diffFile[0].dispatchEvent(blurEvent); + } + // Reset cached commands list when command is applied if (hasQuickActions) { $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); @@ -1436,7 +1513,28 @@ export default class Notes { } // Show final note element on UI - this.addDiscussionNote($form, note, $notesContainer.length === 0); + const isNewDiffComment = $notesContainer.length === 0; + this.addDiscussionNote($form, note, isNewDiffComment); + + if (isNewDiffComment) { + // Add image badge, avatar badge and toggle discussion badge for new image diffs + const notePosition = $form.find('#note_position').val(); + if ($diffFile.length > 0 && notePosition.length > 0) { + const { x, y, width, height } = JSON.parse(notePosition); + const addBadgeEvent = new CustomEvent('addBadge.imageDiff', { + detail: { + x, + y, + width, + height, + noteId: `note_${note.id}`, + discussionId: note.discussion_id, + }, + }); + + $diffFile[0].dispatchEvent(addBadgeEvent); + } + } // append flash-container to the Notes list if ($notesContainer.length) { @@ -1457,6 +1555,16 @@ export default class Notes { // Submission failed, remove placeholder note and show Flash error message $notesContainer.find(`#${noteUniqueId}`).remove(); + const blurEvent = new CustomEvent('blur.imageDiff', { + detail: e, + }); + + const closestDiffFile = $form.closest('.diff-file'); + + if (closestDiffFile.length) { + closestDiffFile[0].dispatchEvent(blurEvent); + } + if (hasQuickActions) { $notesContainer.find(`#${systemNoteUniqueId}`).remove(); } @@ -1500,6 +1608,8 @@ export default class Notes { const $noteBody = $editingNote.find('.js-task-list-container'); const $noteBodyText = $noteBody.find('.note-text'); const { formData, formContent, formAction } = this.getFormData($form); + const $diffFile = $form.closest('.diff-file'); + const $notesContainer = $form.closest('.notes'); // Cache original comment content const cachedNoteBodyText = $noteBodyText.html(); diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue new file mode 100644 index 00000000000..b2b34cb83e1 --- /dev/null +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -0,0 +1,146 @@ +<script> + import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + import { __, s__, sprintf } from '../../../locale'; + import csrf from '../../../lib/utils/csrf'; + + export default { + props: { + actionUrl: { + type: String, + required: true, + }, + confirmWithPassword: { + type: Boolean, + required: true, + }, + username: { + type: String, + required: true, + }, + }, + data() { + return { + enteredPassword: '', + enteredUsername: '', + isOpen: false, + }; + }, + components: { + popupDialog, + }, + computed: { + csrfToken() { + return csrf.token; + }, + inputLabel() { + let confirmationValue; + if (this.confirmWithPassword) { + confirmationValue = __('password'); + } else { + confirmationValue = __('username'); + } + + confirmationValue = `<code>${confirmationValue}</code>`; + + return sprintf( + s__('Profiles|Type your %{confirmationValue} to confirm:'), + { confirmationValue }, + false, + ); + }, + text() { + return sprintf( + s__(`Profiles| +You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. +Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), + { + yourAccount: `<strong>${s__('Profiles|your account')}</strong>`, + deleteAccount: `<strong>${s__('Profiles|Delete Account')}</strong>`, + }, + false, + ); + }, + }, + methods: { + canSubmit() { + if (this.confirmWithPassword) { + return this.enteredPassword !== ''; + } + + return this.enteredUsername === this.username; + }, + onSubmit(status) { + if (status) { + if (!this.canSubmit()) { + return; + } + + this.$refs.form.submit(); + } + + this.toggleOpen(false); + }, + toggleOpen(isOpen) { + this.isOpen = isOpen; + }, + }, + }; +</script> + +<template> + <div> + <popup-dialog + v-if="isOpen" + :title="s__('Profiles|Delete your account?')" + :text="text" + :kind="`danger ${!canSubmit() && 'disabled'}`" + :primary-button-label="s__('Profiles|Delete account')" + @toggle="toggleOpen" + @submit="onSubmit"> + + <template slot="body" scope="props"> + <p v-html="props.text"></p> + + <form + ref="form" + :action="actionUrl" + method="post"> + + <input + type="hidden" + name="_method" + value="delete" /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" /> + + <p id="input-label" v-html="inputLabel"></p> + + <input + v-if="confirmWithPassword" + name="password" + class="form-control" + type="password" + v-model="enteredPassword" + aria-labelledby="input-label" /> + <input + v-else + name="username" + class="form-control" + type="text" + v-model="enteredUsername" + aria-labelledby="input-label" /> + </form> + </template> + + </popup-dialog> + + <button + type="button" + class="btn btn-danger" + @click="toggleOpen(true)"> + {{ s__('Profiles|Delete account') }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js new file mode 100644 index 00000000000..635056e0eeb --- /dev/null +++ b/app/assets/javascripts/profile/account/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; + +import deleteAccountModal from './components/delete_account_modal.vue'; + +const deleteAccountModalEl = document.getElementById('delete-account-modal'); +// eslint-disable-next-line no-new +new Vue({ + el: deleteAccountModalEl, + components: { + deleteAccountModal, + }, + render(createElement) { + return createElement('delete-account-modal', { + props: { + actionUrl: deleteAccountModalEl.dataset.actionUrl, + confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword, + username: deleteAccountModalEl.dataset.username, + }, + }); + }, +}); diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index d6c864cb976..cc60aa5939c 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -62,7 +62,7 @@ export default { :primary-button-label="__('Discard changes')" kind="warning" :title="__('Are you sure?')" - :body="__('Are you sure you want to discard your changes?')" + :text="__('Are you sure you want to discard your changes?')" @toggle="toggleDialogOpen" @submit="dialogSubmitted" /> diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index e754f6c4460..f63b99ba1c5 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -18,23 +18,8 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; Mousetrap.bind('f', (e => this.focusFilter(e))); Mousetrap.bind('p b', this.onTogglePerfBar); - const $globalDropdownMenu = $('.global-dropdown-menu'); - const $globalDropdownToggle = $('.global-dropdown-toggle'); const findFileURL = document.body.dataset.findFile; - $('.global-dropdown').on('hide.bs.dropdown', () => { - $globalDropdownMenu.removeClass('shortcuts'); - }); - - Mousetrap.bind('n', () => { - $globalDropdownMenu.toggleClass('shortcuts'); - $globalDropdownToggle.trigger('click'); - - if (!$globalDropdownMenu.is(':visible')) { - $globalDropdownToggle.blur(); - } - }); - Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos')); Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity')); Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues')); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 4505a79a2df..3f811c59cb9 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ import FilesCommentButton from './files_comment_button'; +import imageDiffHelper from './image_diff/helpers/index'; const WRAPPER = '<div class="diff-content"></div>'; const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; @@ -74,7 +75,11 @@ export default class SingleFileDiff { gl.diffNotesCompileComponents(); } - FilesCommentButton.init($(_this.file)); + const $file = $(_this.file); + FilesCommentButton.init($file); + + const canCreateNote = $file.closest('.files').is('[data-can-create-note]'); + imageDiffHelper.initImageDiff($file[0], canCreateNote); if (cb) cb(); }; diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue index 994b33bc1c9..9279b50cd55 100644 --- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -7,7 +7,7 @@ export default { type: String, required: true, }, - body: { + text: { type: String, required: true, }, @@ -63,7 +63,9 @@ export default { <h4 class="modal-title">{{this.title}}</h4> </div> <div class="modal-body"> - <p>{{this.body}}</p> + <slot name="body" :text="text"> + <p>{{text}}</p> + </slot> </div> <div class="modal-footer"> <button diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index e8037c77aab..c7be94e2c8e 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -30,10 +30,9 @@ @import "framework/media_object"; @import "framework/mobile"; @import "framework/modal"; -@import "framework/nav"; -@import "framework/new-nav"; @import "framework/pagination"; @import "framework/panels"; +@import "framework/secondary-navigation-elements"; @import "framework/selects"; @import "framework/sidebar"; @import "framework/new-sidebar"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 667b73e150d..f0e6b23757f 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -115,8 +115,7 @@ @return $unfoldedTransition; } -.btn, -.global-dropdown-toggle { +.btn { @include transition(background-color, border-color, color, box-shadow); } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 1d72a70f0f5..dbd990f84c1 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -207,6 +207,16 @@ &.user-cover-block { padding: 24px 0 0; + + .nav-links { + justify-content: center; + width: 100%; + float: none; + + &.scrolling-tabs { + float: none; + } + } } .group-info { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index c77160a678b..b131e2d57ee 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -1,3 +1,25 @@ +@mixin btn-comment-icon { + border-radius: 50%; + background: $white-light; + padding: 1px 5px; + font-size: 12px; + color: $blue-500; + width: 23px; + height: 23px; + border: 1px solid $blue-500; + + &:hover, + &.inverted { + background: $blue-500; + border-color: $blue-600; + color: $white-light; + } + + &:active { + outline: 0; + } +} + @mixin btn-default { border-radius: 3px; font-size: $gl-font-size; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index fa92d4ccf4f..5b950ae0ba0 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -749,7 +749,7 @@ margin-bottom: $dropdown-vertical-offset; } - li { + li:not(.dropdown-bold-header) { display: block; padding: 0 1px; @@ -889,7 +889,7 @@ @include new-style-dropdown('.breadcrumbs-list .dropdown '); @include new-style-dropdown('.js-namespace-select + '); -header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu { +header.header-content .dropdown-menu.projects-dropdown-menu { padding: 0; } diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index a6bdcf46aa7..52b87de7a3d 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -5,7 +5,7 @@ @mixin gitlab-theme($color-100, $color-200, $color-500, $color-700, $color-800, $color-900, $color-alternate) { // Header - header.navbar-gitlab-new { + .navbar-gitlab { background-color: $color-900; .navbar-collapse { @@ -200,9 +200,9 @@ body { &.ui_light { @include gitlab-theme($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-700, $theme-gray-700, $theme-gray-100, $theme-gray-700); - header.navbar-gitlab-new { + .navbar-gitlab { background-color: $theme-gray-100; - box-shadow: 0 2px 0 0 $border-color; + box-shadow: 0 1px 0 0 $border-color; .logo-text svg { fill: $theme-gray-900; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d932ea8794f..22945e935ef 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,112 +1,37 @@ -/* - * Application Header - * - */ +.content-wrapper.page-with-new-nav { + margin-top: $header-height; +} -header { +.navbar-gitlab { @include new-style-dropdown; - transition: padding $sidebar-transition-duration; - - &.navbar-empty { - height: $header-height; - background: $white-light; - border-bottom: 1px solid $white-normal; - - .center-logo { - margin: 8px 0; - text-align: center; - - .tanuki-logo, - img { - height: 36px; - } - } - } - &.navbar-gitlab { padding: 0 16px; z-index: 1000; margin-bottom: 0; min-height: $header-height; - background-color: $gray-light; border: none; border-bottom: 1px solid $border-color; position: fixed; top: 0; left: 0; right: 0; - color: $gl-text-color-secondary; border-radius: 0; - @media (max-width: $screen-xs-min) { - padding: 0 16px; - } - - &.with-horizontal-nav { - border-bottom: 0; + .logo-text { + line-height: initial; - .navbar-border { - height: 1px; - position: absolute; - right: 0; - left: 0; - bottom: -1px; - background-color: $border-color; - opacity: 0; + svg { + width: 55px; + height: 14px; + margin: 0; + fill: $white-light; } } .container-fluid { - width: 100% !important; - filter: none; padding: 0; - .nav > li > a { - color: currentColor; - font-size: 18px; - padding: 0; - margin: (($header-height - 28) / 2) 3px; - margin-left: 8px; - height: 28px; - min-width: 32px; - line-height: 28px; - text-align: center; - - &.header-user-dropdown-toggle { - margin-left: 14px; - - &:hover, - &:focus, - &:active { - .header-user-avatar { - border-color: rgba($avatar-border, .2); - } - } - } - - &:hover, - &:focus, - &:active { - background-color: transparent; - color: $gl-text-color; - - svg { - fill: $gl-text-color; - } - } - - .fa-caret-down { - font-size: 14px; - } - - .fa-chevron-down { - position: relative; - top: -3px; - font-size: 10px; - } - } - .user-counter { svg { margin-right: 3px; @@ -114,81 +39,117 @@ header { } .navbar-toggle { - color: $nav-toggle-gray; - margin: 5px 0; - border-radius: 0; right: -10px; - padding: 6px 10px; + border-radius: 0; + min-width: 45px; + padding: 0; + margin-right: -7px; + font-size: 14px; + text-align: center; + color: currentColor; - &:hover { - background-color: $white-normal; + &:hover, + &:focus, + &.active { + color: currentColor; + background-color: transparent; } - &.active { - color: $gl-text-color-secondary; + .more-icon, + .close-icon { + fill: $white-light; + margin: auto; } } } } - &.navbar-gitlab-new { - .close-icon { + .close-icon { + display: none; + } + + .menu-expanded { + .more-icon { display: none; } - .menu-expanded { - .more-icon { - display: none; - } - - .close-icon { - display: block; - } + .close-icon { + display: block; } } - .global-dropdown { - position: absolute; - left: -10px; + .header-content { + display: -webkit-flex; + display: flex; + justify-content: space-between; + position: relative; + min-height: $header-height; + padding-left: 0; - .badge { - font-size: 11px; + .title-container { + display: -webkit-flex; + display: flex; + -webkit-align-items: stretch; + align-items: stretch; + -webkit-flex: 1 1 auto; + flex: 1 1 auto; + padding-top: 0; + overflow: visible; } - li { - &.active a { - font-weight: $gl-font-weight-bold; + .title { + padding-right: 0; + color: currentColor; + display: -webkit-flex; + display: flex; + position: relative; + margin: 0; + font-size: 18px; + vertical-align: top; + white-space: nowrap; + + img { + height: 28px; + margin-right: 8px; } - } - } - .global-dropdown-toggle { - margin: 7px 0; - font-size: 18px; - padding: 6px 10px; - border: none; - background-color: $gray-light; + &.wrap { + white-space: normal; + } - &:hover { - background-color: $white-normal; - } + &.initializing { + opacity: 0; + } + + a { + display: -webkit-flex; + display: flex; + align-items: center; + padding: 2px 8px; + margin: 5px 2px 5px -8px; + border-radius: $border-radius-default; - &:focus { - outline: none; - background-color: $white-normal; + svg { + @media (min-width: $screen-sm-min) { + margin-right: 8px; + } + } + } + + .project-item-select { + right: auto; + left: 0; + } } - } - .header-content { - display: flex; - justify-content: space-between; - position: relative; - min-height: $header-height; - padding-left: 30px; + .dropdown.open { + > a { + border-bottom-color: $white-light; + } + } &.menu-expanded { @media (max-width: $screen-xs-max) { - .header-logo, .title-container { display: none; } @@ -198,111 +159,180 @@ header { } } } + } - .dropdown-menu { - margin-top: -5px; - } + .dropdown-bold-header { + color: $gl-text-color-secondary; + font-size: 12px; + } - .header-logo { - display: inline-block; - margin: 0 12px 0 2px; - position: relative; - top: 10px; - transition-duration: .3s; + .navbar-collapse { + flex: 0 0 auto; + border-top: none; + padding: 0; - svg, - img { - height: 28px; - } + @media (max-width: $screen-xs-max) { + flex: 1 1 auto; + } - &:hover { - cursor: pointer; + .nav { + > li:not(.hidden-xs) a { + @media (max-width: $screen-xs-max) { + margin-left: 0; + min-width: 100%; + } } } + } - .group-name-toggle { - margin: 3px 5px; - } + .container-fluid { - .group-title { - &.is-hidden { - .hidable:not(:last-of-type) { - display: none; + .navbar-nav { + @media (max-width: $screen-xs-max) { + display: -webkit-flex; + display: flex; + padding-right: 10px; + } + + li { + .badge { + box-shadow: none; + font-weight: $gl-font-weight-bold; } } } - .title-container { - display: flex; - align-items: flex-start; - flex: 1 1 auto; - padding-top: 14px; - overflow: hidden; - } + .nav > li { + &.header-user { + @media (max-width: $screen-xs-max) { + padding-left: 10px; + } + } - .title { - position: relative; - padding-right: 20px; - margin: 0; - font-size: 18px; - line-height: 22px; - display: inline-block; - font-weight: $gl-font-weight-normal; - color: $gl-text-color; - vertical-align: top; - white-space: nowrap; + > a { + will-change: color; + margin: 4px 2px; + padding: 6px 8px; + height: 32px; - &.wrap { - white-space: normal; + @media (max-width: $screen-xs-max) { + padding: 0; + } + + &.header-user-dropdown-toggle { + margin-left: 2px; + + .header-user-avatar { + margin-right: 0; + } + } + + &:hover, + &:focus { + text-decoration: none; + outline: 0; + opacity: 1; + color: $white-light; + + svg { + fill: currentColor; + } + + &.header-user-dropdown-toggle { + .header-user-avatar { + border-color: $white-light; + } + } + } } - &.initializing { - opacity: 0; + .header-new-dropdown-toggle { + margin-right: 0; } - a { - color: currentColor; + .impersonated-user, + .impersonated-user:hover { + margin-right: 1px; + background-color: $white-light; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } - &:hover { - text-decoration: underline; - color: $gl-header-nav-hover-color; + .impersonation-btn, + .impersonation-btn:hover { + background-color: $white-light; + margin-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + i { + color: $orange-500; + font-size: 20px; } } - .dropdown-toggle-caret { - color: $gl-text-color; - border: transparent; - background: transparent; - position: absolute; - top: 2px; - right: 3px; - width: 12px; - line-height: 19px; - padding: 0; - font-size: 10px; - text-align: center; - cursor: pointer; + &.active > a, + &.dropdown.open > a { - &:hover { - color: $gl-header-nav-hover-color; + svg { + fill: currentColor; } } + } + } +} - .project-item-select { - right: auto; - left: 0; +.navbar-sub-nav, +.navbar-nav { + > li { + > a:hover, + > a:focus { + text-decoration: none; + outline: 0; + color: $white-light; + + svg { + fill: currentColor; } } - .navbar-collapse { - flex: 0 0 auto; - border-top: none; - padding: 0; - - @media (max-width: $screen-xs-max) { - flex: 1 1 auto; + > a { + display: -webkit-flex; + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + margin: 4px 2px; + font-size: 12px; + color: currentColor; + border-radius: $border-radius-default; + height: 32px; + font-weight: $gl-font-weight-bold; + + svg { + fill: currentColor; } } + + &.line-separator { + margin: 8px; + } + } +} + +.navbar-sub-nav { + display: -webkit-flex; + display: flex; + margin: 0 0 0 6px; + + .projects-dropdown-menu { + padding: 0; + } + + .dropdown-chevron { + position: relative; + top: -1px; + font-size: 10px; } .project-item-select-holder { @@ -314,8 +344,123 @@ header { } } -.with-performance-bar header.navbar-gitlab { - top: $performance-bar-height; +.caret-down { + height: 11px; + width: 11px; + margin-left: 4px; + fill: currentColor; +} + +.header-user .dropdown-menu-nav, +.header-new .dropdown-menu-nav { + margin-top: $dropdown-vertical-offset; +} + +.breadcrumbs { + display: -webkit-flex; + display: flex; + min-height: 48px; + color: $gl-text-color; +} + +.breadcrumbs-container { + display: -webkit-flex; + display: flex; + width: 100%; + position: relative; + padding-top: $gl-padding / 2; + padding-bottom: $gl-padding / 2; + align-items: center; + border-bottom: 1px solid $border-color; +} + +.breadcrumbs-links { + -webkit-flex: 1; + flex: 1; + min-width: 0; + align-self: center; + color: $gl-text-color-secondary; + + .avatar-tile { + margin-right: 4px; + border: 1px solid $border-color; + border-radius: 50%; + vertical-align: sub; + } + + .text-expander { + margin-left: 0; + margin-right: 2px; + + > i { + position: relative; + top: 1px; + } + } +} + +.breadcrumbs-list { + display: -webkit-flex; + display: flex; + flex-wrap: wrap; + margin-bottom: 0; + line-height: 16px; + + > li { + display: flex; + align-items: center; + position: relative; + padding: 2px 0; + + &:not(:last-child) { + margin-right: 20px; + } + + > a { + font-size: 12px; + color: currentColor; + } + } +} + +.breadcrumb-item-text { + @include str-truncated(128px); + text-decoration: inherit; +} + +.breadcrumbs-list-angle { + position: absolute; + right: -12px; + top: 50%; + color: $gl-text-color-tertiary; + transform: translateY(-50%); +} + +.breadcrumbs-extra { + display: -webkit-flex; + display: flex; + flex: 0 0 auto; + margin-left: auto; +} + +.breadcrumbs-sub-title { + margin: 0; + font-size: 12px; + font-weight: 600; + line-height: 16px; + + a { + color: $gl-text-color; + } +} + +.btn-sign-in { + margin-top: 3px; + font-weight: $gl-font-weight-bold; + + &:hover { + background-color: $white-light; + } } .navbar-nav { @@ -347,11 +492,10 @@ header { } @media (max-width: $screen-xs-max) { - header .container-fluid { + .navbar-gitlab .container-fluid { font-size: 18px; .navbar-nav { - display: table; table-layout: fixed; width: 100%; margin: 0; @@ -359,7 +503,8 @@ header { } .navbar-collapse { - padding-left: 5px; + margin-left: -8px; + margin-right: -10px; .nav > li:not(.hidden-xs) { display: table-cell !important; @@ -385,11 +530,11 @@ header { .dropdown-menu-nav { width: auto; min-width: 140px; - margin-top: -5px; + margin-top: 4px; color: $gl-text-color; left: auto; - .current-user { + li.current-user { padding: 5px 18px; .user-name { @@ -405,3 +550,23 @@ header { border-radius: 50%; border: 1px solid $avatar-border; } + +.with-performance-bar .navbar-gitlab { + top: $performance-bar-height; +} + +.navbar-empty { + height: $header-height; + background: $white-light; + border-bottom: 1px solid $white-normal; + + .center-logo { + margin: 8px 0; + text-align: center; + + .tanuki-logo, + img { + height: 36px; + } + } +} diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 59bfc5a8d77..6819fd88b7f 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -28,6 +28,7 @@ svg { &.s8 { @include svg-size(8px); } + &.s12 { @include svg-size(12px); } &.s16 { @include svg-size(16px); } &.s18 { @include svg-size(18px); } &.s24 { @include svg-size(24px); } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index bd521028c44..69d19ea2962 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -25,10 +25,6 @@ body { .content-wrapper { padding-bottom: 100px; - - &:not(.page-with-layout-nav) { - margin-top: $header-height; - } } .container { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 5b581780447..1cebd02df48 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -1,10 +1,17 @@ +.modal-header { + padding: #{3 * $grid-size} #{2 * $grid-size}; + + .page-title { + margin-top: 0; + } +} + .modal-body { position: relative; - padding: 15px; + padding: #{3 * $grid-size} #{2 * $grid-size}; .form-actions { - margin: -$gl-padding + 1; - margin-top: 15px; + margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size}; } .text-danger { diff --git a/app/assets/stylesheets/framework/new-nav.scss b/app/assets/stylesheets/framework/new-nav.scss index 7899be2c2d3..e69de29bb2d 100644 --- a/app/assets/stylesheets/framework/new-nav.scss +++ b/app/assets/stylesheets/framework/new-nav.scss @@ -1,404 +0,0 @@ -@import "framework/variables"; -@import 'framework/tw_bootstrap_variables'; -@import "bootstrap/variables"; -@import "framework/mixins"; - -.content-wrapper.page-with-new-nav { - margin-top: $new-navbar-height; -} - -header.navbar-gitlab-new { - color: $white-light; - border-bottom: 0; - min-height: $new-navbar-height; - - .logo-text { - line-height: initial; - - svg { - width: 55px; - height: 14px; - margin: 0; - fill: $white-light; - } - } - - .header-content { - display: -webkit-flex; - display: flex; - padding-left: 0; - min-height: $new-navbar-height; - - .title-container { - display: -webkit-flex; - display: flex; - -webkit-align-items: stretch; - align-items: stretch; - -webkit-flex: 1 1 auto; - flex: 1 1 auto; - padding-top: 0; - overflow: visible; - } - - .title { - display: -webkit-flex; - display: flex; - padding-right: 0; - color: currentColor; - - img { - height: 28px; - margin-right: 8px; - } - - a { - display: -webkit-flex; - display: flex; - align-items: center; - padding: 2px 8px; - margin: 5px 2px 5px -8px; - border-radius: $border-radius-default; - - svg { - @media (min-width: $screen-sm-min) { - margin-right: 8px; - } - } - } - } - - .dropdown.open { - > a { - border-bottom-color: $white-light; - } - } - - .dropdown-menu { - margin-top: 4px; - min-width: 130px; - - @media (max-width: $screen-xs-max) { - left: auto; - right: 0; - } - } - - &.menu-expanded { - @media (max-width: $screen-xs-max) { - .title-container, - .header-logo, { - display: none; - } - } - } - } - - .dropdown-bold-header { - color: $gl-text-color-secondary; - font-size: 12px; - } - - .navbar-collapse { - padding-left: 0; - box-shadow: 0; - - @media (max-width: $screen-xs-max) { - margin-left: -8px; - margin-right: -10px; - } - - .nav { - > li:not(.hidden-xs) a { - @media (max-width: $screen-xs-max) { - margin-left: 0; - min-width: 100%; - } - } - } - } - - .container-fluid { - .navbar-toggle { - min-width: 45px; - padding: 0 $gl-padding; - margin-right: -7px; - text-align: center; - color: currentColor; - - svg { - fill: currentColor; - } - - &:hover, - &:focus, - &.active { - color: currentColor; - background-color: transparent; - - svg { - fill: currentColor; - } - } - } - - .navbar-nav { - @media (max-width: $screen-xs-max) { - display: flex; - padding-right: 10px; - } - - li { - .badge { - box-shadow: none; - font-weight: $gl-font-weight-bold; - } - } - } - - .nav > li { - &.header-user { - @media (max-width: $screen-xs-max) { - padding-left: 10px; - } - } - - > a { - will-change: color; - margin: 4px 2px; - padding: 6px 8px; - height: 32px; - - @media (max-width: $screen-xs-max) { - padding: 0; - } - - &.header-user-dropdown-toggle { - margin-left: 2px; - - .header-user-avatar { - margin-right: 0; - } - } - - &:hover, - &:focus { - text-decoration: none; - outline: 0; - opacity: 1; - color: $white-light; - - svg { - fill: currentColor; - } - - &.header-user-dropdown-toggle { - .header-user-avatar { - border-color: $white-light; - } - } - } - } - - .header-new-dropdown-toggle { - margin-right: 0; - } - - .impersonated-user, - .impersonated-user:hover { - margin-right: 1px; - background-color: $white-light; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - .impersonation-btn, - .impersonation-btn:hover { - background-color: $white-light; - margin-left: 0; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - i { - color: $orange-500; - font-size: 20px; - } - } - - &.active > a, - &.dropdown.open > a { - - svg { - fill: currentColor; - } - } - } - } -} - -.navbar-sub-nav { - display: -webkit-flex; - display: flex; - margin: 0 0 0 6px; - - .dropdown-chevron { - position: relative; - top: -1px; - font-size: 10px; - } -} - -.navbar-gitlab-new { - .navbar-sub-nav, - .navbar-nav { - > li { - > a:hover, - > a:focus { - text-decoration: none; - outline: 0; - color: $white-light; - - svg { - fill: currentColor; - } - } - - > a { - display: flex; - align-items: center; - justify-content: center; - padding: 6px 8px; - margin: 4px 2px; - font-size: 12px; - color: currentColor; - border-radius: $border-radius-default; - height: 32px; - font-weight: $gl-font-weight-bold; - - svg { - fill: currentColor; - } - } - - &.line-separator { - margin: 8px; - } - } - } -} - -.caret-down { - height: 11px; - width: 11px; - margin-left: 4px; - fill: currentColor; -} - -.header-user .dropdown-menu-nav, -.header-new .dropdown-menu-nav { - margin-top: $dropdown-vertical-offset; -} - -.breadcrumbs { - display: flex; - min-height: 48px; - color: $gl-text-color; -} - -.breadcrumbs-container { - display: -webkit-flex; - display: flex; - width: 100%; - position: relative; - padding-top: $gl-padding / 2; - padding-bottom: $gl-padding / 2; - align-items: center; - border-bottom: 1px solid $border-color; -} - -.breadcrumbs-links { - -webkit-flex: 1; - flex: 1; - min-width: 0; - align-self: center; - color: $gl-text-color-secondary; - - .avatar-tile { - margin-right: 4px; - border: 1px solid $border-color; - border-radius: 50%; - vertical-align: sub; - } - - .text-expander { - margin-left: 0; - margin-right: 2px; - - > i { - position: relative; - top: 1px; - } - } -} - -.breadcrumbs-list { - display: -webkit-flex; - display: flex; - flex-wrap: wrap; - margin-bottom: 0; - line-height: 16px; - - > li { - display: flex; - align-items: center; - position: relative; - padding: 2px 0; - - &:not(:last-child) { - margin-right: 20px; - } - - > a { - font-size: 12px; - color: currentColor; - } - } -} - -.breadcrumb-item-text { - @include str-truncated(128px); - text-decoration: inherit; -} - -.breadcrumbs-list-angle { - position: absolute; - right: -12px; - top: 50%; - color: $gl-text-color-tertiary; - transform: translateY(-50%); -} - -.breadcrumbs-extra { - display: flex; - flex: 0 0 auto; - margin-left: auto; -} - -.breadcrumbs-sub-title { - margin: 0; - font-size: 12px; - font-weight: 600; - line-height: 16px; - - a { - color: $gl-text-color; - } -} - -.btn-sign-in { - margin-top: 3px; - font-weight: $gl-font-weight-bold; - - &:hover { - background-color: $white-light; - } -} diff --git a/app/assets/stylesheets/framework/new-sidebar.scss b/app/assets/stylesheets/framework/new-sidebar.scss index 8332cec2962..caf4c7a40b1 100644 --- a/app/assets/stylesheets/framework/new-sidebar.scss +++ b/app/assets/stylesheets/framework/new-sidebar.scss @@ -24,7 +24,7 @@ $new-sidebar-collapsed-width: 50px; // Override position: absolute .right-sidebar { position: fixed; - height: calc(100% - #{$new-navbar-height}); + height: calc(100% - #{$header-height}); } .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { @@ -87,7 +87,7 @@ $new-sidebar-collapsed-width: 50px; z-index: 400; width: $new-sidebar-width; transition: left $sidebar-transition-duration; - top: $new-navbar-height; + top: $header-height; bottom: 0; left: 0; background-color: $gray-normal; @@ -197,7 +197,7 @@ $new-sidebar-collapsed-width: 50px; } .with-performance-bar .nav-sidebar { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; } .sidebar-sub-level-items { @@ -495,7 +495,7 @@ $new-sidebar-collapsed-width: 50px; // Make issue boards full-height now that sub-nav is gone .boards-list { - height: calc(100vh - #{$new-navbar-height}); + height: calc(100vh - #{$header-height}); @media (min-width: $screen-sm-min) { height: 475px; // Needed for PhantomJS @@ -506,5 +506,5 @@ $new-sidebar-collapsed-width: 50px; } .with-performance-bar .boards-list { - height: calc(100vh - #{$new-navbar-height} - #{$performance-bar-height}); + height: calc(100vh - #{$header-height} - #{$performance-bar-height}); } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/secondary-navigation-elements.scss index f8777d1fd9d..5c96b3b78e7 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/secondary-navigation-elements.scss @@ -1,12 +1,11 @@ - - +// For tabbed navigation links, scrolling tabs, etc. For all top/main navigation, +// please check nav.scss .nav-links { display: flex; padding: 0; margin: 0; list-style: none; height: auto; - border-bottom: 1px solid $border-color; li { display: flex; @@ -24,7 +23,6 @@ &:active, &:focus { text-decoration: none; - border-bottom: 2px solid $gray-darkest; color: $black; .badge { @@ -34,7 +32,6 @@ } &.active a { - border-bottom: 2px solid $link-underline-blue; color: $black; font-weight: $gl-font-weight-bold; @@ -43,35 +40,6 @@ } } } - - &.sub-nav { - text-align: center; - background-color: $gray-normal; - - .container-fluid { - background-color: $gray-normal; - margin-bottom: 0; - display: flex; - } - - li { - &.active a { - border-bottom: none; - color: $link-underline-blue; - } - - a { - margin: 0; - padding: 11px 10px 9px; - - &:hover, - &:active, - &:focus { - border-color: transparent; - } - } - } - } } .top-area { @@ -91,17 +59,6 @@ } } - .nav-search { - display: inline-block; - width: 100%; - padding: 11px 0; - - /* Small devices (phones, tablets, 768px and lower) */ - @media (min-width: $screen-sm-min) { - width: 50%; - } - } - .nav-links { margin-bottom: 0; border-bottom: none; @@ -150,12 +107,6 @@ } } - &.nav-controls-new-nav { - > .dropdown { - margin-right: 0; - } - } - > .btn-grouped { float: none; } @@ -248,114 +199,43 @@ pre { width: 100%; } -} - -.project-item-select-holder.btn-group { - display: flex; - max-width: 350px; - overflow: hidden; - float: right; - - .new-project-item-link { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .new-project-item-select-button { - width: 32px; - } -} - -.empty-state .project-item-select-holder.btn-group { - float: none; - display: inline-block; - - .btn { - // overrides styles applied to plain `.empty-state .btn` - margin: 10px 0; - max-width: 300px; - width: auto; - - @media(max-width: $screen-xs-max) { - max-width: 250px; - } - - } -} -.new-project-item-select-button .fa-caret-down { - margin-left: 2px; -} - -.layout-nav { - width: 100%; - background: $gray-light; - border-bottom: 1px solid $border-color; - transition: padding $sidebar-transition-duration; - text-align: center; - margin-top: $new-navbar-height; + @media (max-width: $screen-xs-max) { + flex-flow: row wrap; - .container-fluid { - position: relative; + .nav-controls { + $controls-margin: $btn-xs-side-margin - 2px; + flex: 0 0 100%; - .nav-control { - @media (max-width: $screen-sm-max) { - margin-right: 2px; + &.controls-flex { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: center; + padding: 0 0 $gl-padding-top; } - } - } - - .controls { - float: right; - padding: 7px 0 0; - - i { - color: $layout-link-gray; - } - - .fa-rss, - .fa-cog { - font-size: 16px; - } - .fa-caret-down { - margin-left: 5px; - color: $gl-text-color-secondary; - } - - .dropdown { - position: absolute; - top: 7px; - right: 15px; - z-index: 300; + .controls-item, + .controls-item-full, + .controls-item:last-child { + flex: 1 1 35%; + display: block; + width: 100%; + margin: $controls-margin; - li.active { - font-weight: $gl-font-weight-bold; + .btn, + .dropdown { + margin: 0; + } } - } - } - .nav-links { - border-bottom: none; - height: 51px; - - @media (min-width: $screen-sm-min) { - justify-content: center; - } - - li { - a { - padding-top: 10px; + .controls-item-full { + flex: 1 1 100%; } } } } -.with-performance-bar .layout-nav { - margin-top: $header-height + $performance-bar-height; -} - .scrolling-tabs-container { position: relative; @@ -385,25 +265,41 @@ left: -7px; } } +} - &.sub-nav-scroll { +.inner-page-scroll-tabs { + position: relative; - .fade-right { - @include fade(left, $gray-normal); - right: 0; + .fade-right { + @include fade(left, $white-light); + right: 0; + text-align: right; - .fa { - right: -23px; - } + .fa { + right: 5px; } + } - .fade-left { - @include fade(right, $gray-normal); - left: 0; + .fade-left { + @include fade(right, $white-light); + left: 0; + text-align: left; - .fa { - left: 10px; - } + .fa { + left: 5px; + } + } + + .fade-right, + .fade-left { + top: 16px; + bottom: auto; + } + + &.is-smaller { + .fade-right, + .fade-left { + top: 11px; } } } @@ -432,41 +328,7 @@ } } } -} - -.page-with-layout-nav { - .right-sidebar { - top: ($header-height + 1) * 2; - } - - &.page-with-sub-nav { - .right-sidebar { - top: ($header-height + 1) * 3; - - &.affix { - top: $header-height; - } - } - } -} - -.with-performance-bar .page-with-layout-nav { - .right-sidebar { - top: ($header-height + 1) * 2 + $performance-bar-height; - } - - &.page-with-sub-nav { - .right-sidebar { - top: ($header-height + 1) * 3 + $performance-bar-height; - - &.affix { - top: $header-height + $performance-bar-height; - } - } - } -} -.nav-block { &.activities { border-bottom: 1px solid $border-color; @@ -476,76 +338,39 @@ } } -@media (max-width: $screen-xs-max) { - .top-area { - flex-flow: row wrap; - - .nav-controls { - $controls-margin: $btn-xs-side-margin - 2px; - flex: 0 0 100%; - - &.controls-flex { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - padding: 0 0 $gl-padding-top; - } - - .controls-item, - .controls-item-full, - .controls-item:last-child { - flex: 1 1 35%; - display: block; - width: 100%; - margin: $controls-margin; +.project-item-select-holder.btn-group { + display: flex; + max-width: 350px; + overflow: hidden; + float: right; - .btn, - .dropdown { - margin: 0; - } - } + .new-project-item-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .controls-item-full { - flex: 1 1 100%; - } - } + .new-project-item-select-button { + width: 32px; } } -.inner-page-scroll-tabs { - position: relative; - - .fade-right { - @include fade(left, $white-light); - right: 0; - text-align: right; - - .fa { - right: 5px; - } - } +.empty-state .project-item-select-holder.btn-group { + float: none; + display: inline-block; - .fade-left { - @include fade(right, $white-light); - left: 0; - text-align: left; + .btn { + // overrides styles applied to plain `.empty-state .btn` + margin: 10px 0; + max-width: 300px; + width: auto; - .fa { - left: 5px; + @media(max-width: $screen-xs-max) { + max-width: 250px; } } +} - .fade-right, - .fade-left { - top: 16px; - bottom: auto; - } - - &.is-smaller { - .fade-right, - .fade-left { - top: 11px; - } - } +.new-project-item-select-button .fa-caret-down { + margin-left: 2px; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 48dc25d343b..ef58382ba41 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -78,16 +78,16 @@ .right-sidebar { border-left: 1px solid $border-color; - height: calc(100% - #{$new-navbar-height}); + height: calc(100% - #{$header-height}); &.affix { position: fixed; - top: $new-navbar-height; + top: $header-height; } } .with-performance-bar .right-sidebar.affix { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; } @mixin maintain-sidebar-dimensions { diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 3d68a50f91f..f718ec4bcad 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -17,15 +17,19 @@ .diff-file { border: 1px solid $border-color; - border-bottom: none; margin: 0; } + + &.text-file .diff-file { + border-bottom: none; + } } .timeline-entry { border-color: $white-normal; color: $gl-text-color; border-bottom: 1px solid $border-white-light; + background: $white-light; .timeline-entry-inner { position: relative; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 5ab40947da9..bbbd16322eb 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -1,6 +1,7 @@ /* * Layout */ +$grid-size: 8px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 250px; @@ -224,8 +225,7 @@ $gl-sidebar-padding: 22px; $row-hover: $blue-50; $row-hover-border: $blue-200; $progress-color: #c0392b; -$header-height: 50px; -$new-navbar-height: 40px; +$header-height: 40px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; $limited-layout-width-sm: 790px; @@ -322,6 +322,7 @@ $diff-image-info-color: grey; $diff-swipe-border: #999; $diff-view-modes-color: grey; $diff-view-modes-border: #c1c1c1; +$diff-jagged-border-gradient-color: darken($white-normal, 8%); /* * Fonts @@ -711,3 +712,9 @@ Issuable warning */ $issuable-warning-size: 24px; $issuable-warning-icon-margin: 4px; + +/* +Image Commenting cursor +*/ +$image-comment-cursor-left-offset: 12; +$image-comment-cursor-top-offset: 30; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 3305a482a0d..ca61f7a30c3 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -414,7 +414,6 @@ margin: 5px; } -.page-with-layout-nav.page-with-sub-nav .issue-boards-sidebar, .page-with-new-sidebar.page-with-sidebar .issue-boards-sidebar { .issuable-sidebar-header { position: relative; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 359dd388d05..50ec5110bf1 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -64,10 +64,10 @@ color: $gl-text-color; position: sticky; position: -webkit-sticky; - top: $new-navbar-height; + top: $header-height; &.affix { - top: $new-navbar-height; + top: $header-height; } // with sidebar @@ -174,10 +174,10 @@ .with-performance-bar .build-page { .top-bar { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; &.affix { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; } } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index fb23343b966..ffb5fc94475 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -297,6 +297,7 @@ .drag-track { display: block; position: absolute; + top: 0; left: 12px; height: 10px; width: 276px; @@ -547,16 +548,23 @@ } .diff-notes-collapse { - width: 19px; - height: 19px; + width: 24px; + height: 24px; + border-radius: 50%; padding: 0; transition: transform .1s ease-out; z-index: 100; + .collapse-icon { + height: 50%; + width: 100%; + } + svg { - vertical-align: text-top; + vertical-align: middle; } + .collapse-icon, path { fill: $white-light; } @@ -644,3 +652,157 @@ text-overflow: ellipsis; white-space: nowrap; } + +.note-container { + background-color: $gray-light; + border-top: 1px solid $white-normal; + + // double jagged line divider + .discussion-notes + .discussion-notes::before, + .discussion-notes + .discussion-form::before { + content: ''; + position: relative; + display: block; + width: 100%; + height: 10px; + background-color: $white-light; + background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), + linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), + linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), + linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); + background-position: 5px 5px,0 5px,0 5px,5px 5px; + background-size: 10px 10px; + background-repeat: repeat; + } + + .notes { + position: relative; + } + + .diff-notes-collapse { + position: absolute; + left: -12px; + } +} + +.diff-file .note-container > .new-note, +.note-container .discussion-notes { + margin-left: 100px; + border-left: 1px solid $white-normal; +} + +.notes.active { + .diff-file .note-container > .new-note, + .note-container .discussion-notes { + // Override our margin and border (set for diff tab) + // when user is on the discussion tab for MR + margin-left: inherit; + border-left: inherit; + } +} + +.files:not([data-can-create-note]) .frame { + cursor: auto; +} + +.frame.click-to-comment { + position: relative; + cursor: url(icon_image_comment.svg) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + + // Retina cursor + cursor: -webkit-image-set(url(icon_image_comment.svg) 1x, url(icon_image_comment@2x.svg) 2x) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + + .comment-indicator { + position: absolute; + padding: 0; + width: (2px * $image-comment-cursor-left-offset); + height: (1px * $image-comment-cursor-top-offset); + // center the indicator to match the top left click region + margin-top: (-1px * $image-comment-cursor-top-offset) + 2; + margin-left: (-1px * $image-comment-cursor-left-offset) + 1; + + svg { + width: 100%; + height: 100%; + } + + &:focus { + outline: none; + } + } +} + +.frame .badge, +.image-diff-avatar-link .badge, +.notes > .badge { + position: absolute; + background-color: $blue-400; + color: $white-light; + border: $white-light 1px solid; + min-height: $gl-padding; + padding: 5px 8px; + border-radius: 12px; + + &:focus { + outline: none; + } +} + +.frame .badge, +.frame .image-comment-badge { + // Center align badges on the frame + transform: translate3d(-50%, -50%, 0); +} + +.image-comment-badge { + @include btn-comment-icon; + position: absolute; + + &.inverted { + border-color: $white-light; + } +} + +.image-diff-avatar-link { + position: relative; + + .badge, + .image-comment-badge { + top: 25px; + right: 8px; + } +} + +.notes > .badge { + display: none; + left: -13px; +} + +.discussion-notes { + min-height: 35px; + + &:first-child { + // First child does not have the jagged borders + min-height: 25px; + } + + &.collapsed { + background-color: $white-light; + + .diff-notes-collapse, + .note, + .discussion-reply-holder, { + display: none; + } + + .notes > .badge { + display: block; + } + } +} + +.discussion-body .image .frame { + position: relative; +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index db3b7e89d7b..dae3ec7ac42 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -222,7 +222,7 @@ .right-sidebar { position: absolute; - top: $new-navbar-height; + top: $header-height; bottom: 0; right: 0; transition: width $right-sidebar-transition-duration; @@ -487,10 +487,10 @@ } .with-performance-bar .right-sidebar { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; .issuable-sidebar { - height: calc(100% - #{$new-navbar-height} - #{$performance-bar-height}); + height: calc(100% - #{$header-height} - #{$performance-bar-height}); } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 09a14578dd3..d9fb3b44d29 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -649,7 +649,7 @@ } .merge-request-tabs-holder { - top: $new-navbar-height; + top: $header-height; z-index: 200; background-color: $white-light; border-bottom: 1px solid $border-color; @@ -679,7 +679,7 @@ } .with-performance-bar .merge-request-tabs-holder { - top: $new-navbar-height + $performance-bar-height; + top: $header-height + $performance-bar-height; } .merge-request-tabs { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 420bca9ece5..04b132415eb 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -161,10 +161,13 @@ } .discussion-form { - padding: $gl-padding-top $gl-padding $gl-padding; background-color: $white-light; } +.discussion-form-container { + padding: $gl-padding-top $gl-padding $gl-padding; +} + .discussion-notes .disabled-comment { padding: 6px 0; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 925fe4513ee..96b7db3b85d 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -650,29 +650,12 @@ ul.notes { } .add-diff-note { + @include btn-comment-icon; opacity: 0; margin-top: -2px; - border-radius: 50%; - background: $white-light; - padding: 1px 5px; - font-size: 12px; - color: $blue-500; margin-left: -55px; position: absolute; z-index: 10; - width: 23px; - height: 23px; - border: 1px solid $blue-500; - - &:hover { - background: $blue-500; - border-color: $blue-600; - color: $white-light; - } - - &:active { - outline: 0; - } } .discussion-body, diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 89ebe3f9917..db0a04a5eb3 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -47,6 +47,7 @@ input[type="checkbox"]:hover { } .location-badge { + height: 32px; font-size: 12px; margin: -4px 4px -4px -4px; line-height: 25px; diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index a4648b33cfa..c27f2ee3c09 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -3,9 +3,23 @@ # Automatically sets the layout and ensures an administrator is logged in class Admin::ApplicationController < ApplicationController before_action :authenticate_admin! + before_action :display_read_only_information layout 'admin' def authenticate_admin! render_404 unless current_user.admin? end + + def display_read_only_information + return unless Gitlab::Database.read_only? + + flash.now[:notice] = read_only_message + end + + private + + # Overridden in EE + def read_only_message + _('You are on a read-only GitLab instance.') + end end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 0d74078645a..737656b3dcc 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -10,7 +10,7 @@ module Boards def index issues = Boards::Issues::ListService.new(board_parent, current_user, filter_params).execute issues = issues.page(params[:page]).per(params[:per] || 20) - make_sure_position_is_set(issues) + make_sure_position_is_set(issues) if Gitlab::Database.read_write? issues = issues.preload(:project, :milestone, :assignees, diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 915f32b4c33..1126f706393 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -96,7 +96,8 @@ module NotesActions id: note.id, discussion_id: note.discussion_id(noteable), html: note_html(note), - note: note.note + note: note.note, + on_image: note.try(:on_image?) ) discussion = note.to_discussion(noteable) @@ -122,7 +123,9 @@ module NotesActions def diff_discussion_html(discussion) return unless discussion.diff_discussion? - if params[:view] == 'parallel' + on_image = discussion.on_image? + + if params[:view] == 'parallel' && !on_image template = "discussions/_parallel_diff_discussion" locals = if params[:line_type] == 'old' @@ -132,7 +135,9 @@ module NotesActions end else template = "discussions/_diff_discussion" - locals = { discussions: [discussion] } + @fresh_discussion = true + + locals = { discussions: [discussion], on_image: on_image } end render_to_string( diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 1b0d3aab3fa..536f908d2c5 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -2,6 +2,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController include LfsRequest skip_before_action :lfs_check_access!, only: [:deprecated] + before_action :lfs_check_batch_operation!, only: [:batch] def batch unless objects.present? @@ -90,4 +91,21 @@ class Projects::LfsApiController < Projects::GitHttpClientController } } end + + def lfs_check_batch_operation! + if upload_request? && Gitlab::Database.read_only? + render( + json: { + message: lfs_read_only_message + }, + content_type: 'application/vnd.git-lfs+json', + status: 403 + ) + end + end + + # Overridden in EE + def lfs_read_only_message + _('You cannot write to this read-only GitLab instance.') + end end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index eb7d7bf374c..0e71977a58a 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -13,7 +13,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont # Make sure merge requests created before 8.0 # have head file in refs/merge-requests/ def ensure_ref_fetched - @merge_request.ensure_ref_fetched + @merge_request.ensure_ref_fetched if Gitlab::Database.read_write? end def merge_request_params diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 1096afbb798..99dc3dda9e7 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -120,10 +120,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def selected_target_project - if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil? + if @project.id.to_s == params[:target_project_id] || !@project.forked? @project + elsif params[:target_project_id].present? + MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: @project) + .execute.find(params[:target_project_id]) else - @project.forked_project_link.forked_from_project + @project.forked_from_project end end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 5ea3a5d5562..d9142311b6f 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -25,18 +25,33 @@ class RegistrationsController < Devise::RegistrationsController end def destroy - current_user.delete_async(deleted_by: current_user) - - respond_to do |format| - format.html do - session.try(:destroy) - redirect_to new_user_session_path, status: 302, notice: "Account scheduled for removal." - end + if destroy_confirmation_valid? + current_user.delete_async(deleted_by: current_user) + session.try(:destroy) + redirect_to new_user_session_path, status: 303, notice: s_('Profiles|Account scheduled for removal.') + else + redirect_to profile_account_path, status: 303, alert: destroy_confirmation_failure_message end end protected + def destroy_confirmation_valid? + if current_user.confirm_deletion_with_password? + current_user.valid_password?(params[:password]) + else + current_user.username == params[:username] + end + end + + def destroy_confirmation_failure_message + if current_user.confirm_deletion_with_password? + s_('Profiles|Invalid password') + else + s_('Profiles|Invalid username') + end + end + def build_resource(hash = nil) super end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ada91694fd6..c01be42c3ee 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -8,8 +8,7 @@ class SessionsController < Devise::SessionsController prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] - prepend_before_action :store_redirect_path, only: [:new] - + prepend_before_action :store_redirect_uri, only: [:new] before_action :auto_sign_in_with_provider, only: [:new] before_action :load_recaptcha @@ -86,28 +85,36 @@ class SessionsController < Devise::SessionsController end end - def store_redirect_path - redirect_path = + def stored_redirect_uri + @redirect_to ||= stored_location_for(:redirect) + end + + def store_redirect_uri + redirect_uri = if request.referer.present? && (params['redirect_to_referer'] == 'yes') - referer_uri = URI(request.referer) - if referer_uri.host == Gitlab.config.gitlab.host - referer_uri.request_uri - else - request.fullpath - end + URI(request.referer) else - request.fullpath + URI(request.url) end # Prevent a 'you are already signed in' message directly after signing: # we should never redirect to '/users/sign_in' after signing in successfully. - unless URI(redirect_path).path == new_user_session_path - store_location_for(:redirect, redirect_path) - end + return true if redirect_uri.path == new_user_session_path + + redirect_to = redirect_uri.to_s if redirect_allowed_to?(redirect_uri) + + @redirect_to = redirect_to + store_location_for(:redirect, redirect_to) + end + + # Overridden in EE + def redirect_allowed_to?(uri) + uri.host == Gitlab.config.gitlab.host && + uri.port == Gitlab.config.gitlab.port end def two_factor_enabled? - find_user.try(:two_factor_enabled?) + find_user&.two_factor_enabled? end def auto_sign_in_with_provider diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb new file mode 100644 index 00000000000..189eb3847eb --- /dev/null +++ b/app/finders/merge_request_target_project_finder.rb @@ -0,0 +1,18 @@ +class MergeRequestTargetProjectFinder + attr_reader :current_user, :source_project + + def initialize(current_user: nil, source_project:) + @current_user = current_user + @source_project = source_project + end + + def execute + if @source_project.fork_network + @source_project.fork_network.projects + .public_or_visible_to_user(current_user) + .with_feature_available_for_user(:merge_requests, current_user) + else + Project.where(id: source_project) + end + end +end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index c31023f2d9a..5b2c58d193d 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -73,7 +73,8 @@ module MergeRequestsHelper end def target_projects(project) - [project, project.default_merge_request_target].uniq + MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: project) + .execute end def merge_request_button_visibility(merge_request, closed) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index dd315866e60..6ca46ae89c1 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,6 +11,7 @@ module Ci has_many :deployments, as: :deployable has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' + has_many :trace_sections, class_name: 'Ci::BuildTraceSection' # The "environment" field for builds is a String, and is the unexpanded name def persisted_environment @@ -265,6 +266,10 @@ module Ci update_attributes(coverage: coverage) if coverage.present? end + def parse_trace_sections! + ExtractSectionsFromBuildTraceService.new(project, user).execute(self) + end + def trace Gitlab::Ci::Trace.new(self) end diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb new file mode 100644 index 00000000000..ccdb95546c8 --- /dev/null +++ b/app/models/ci/build_trace_section.rb @@ -0,0 +1,11 @@ +module Ci + class BuildTraceSection < ActiveRecord::Base + extend Gitlab::Ci::Model + + belongs_to :build, class_name: 'Ci::Build' + belongs_to :project + belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName' + + validates :section_name, :build, :project, presence: true, allow_blank: false + end +end diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb new file mode 100644 index 00000000000..0fdcb1ea329 --- /dev/null +++ b/app/models/ci/build_trace_section_name.rb @@ -0,0 +1,11 @@ +module Ci + class BuildTraceSectionName < ActiveRecord::Base + extend Gitlab::Ci::Model + + belongs_to :project + has_many :trace_sections, class_name: 'Ci::BuildTraceSection', foreign_key: :section_name_id + + validates :name, :project, presence: true, allow_blank: false + validates :name, uniqueness: { scope: :project_id } + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 193e459977a..9417033d1f6 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -59,7 +59,7 @@ module CacheMarkdownField # Update every column in a row if any one is invalidated, as we only store # one version per row - def refresh_markdown_cache!(do_update: false) + def refresh_markdown_cache options = { skip_project_check: skip_project_check? } updates = cached_markdown_fields.markdown_fields.map do |markdown_field| @@ -71,8 +71,14 @@ module CacheMarkdownField updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION updates.each {|html_field, data| write_attribute(html_field, data) } + end + + def refresh_markdown_cache! + updates = refresh_markdown_cache + + return unless persisted? && Gitlab::Database.read_write? - update_columns(updates) if persisted? && do_update + update_columns(updates) end def cached_html_up_to_date?(markdown_field) @@ -124,8 +130,8 @@ module CacheMarkdownField end # Using before_update here conflicts with elasticsearch-model somehow - before_create :refresh_markdown_cache!, if: :invalidated_markdown_cache? - before_update :refresh_markdown_cache!, if: :invalidated_markdown_cache? + before_create :refresh_markdown_cache, if: :invalidated_markdown_cache? + before_update :refresh_markdown_cache, if: :invalidated_markdown_cache? end class_methods do diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index eee1a36ac6b..f5cbb3becad 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -28,6 +28,10 @@ module DiscussionOnDiff true end + def file_new_path + first_note.position.new_path + end + # Returns an array of at most 16 highlighted lines above a diff note def truncated_diff_lines(highlight: true) lines = highlight ? highlighted_diff_lines : diff_lines diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 12e93be2104..22fde2eb134 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -156,6 +156,8 @@ module Routable end def update_route + return if Gitlab::Database.read_only? + prepare_route route.save end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index a7d5de48c66..ec3543f7053 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -43,15 +43,17 @@ module TokenAuthenticatable write_attribute(token_field, token) if token end + # Returns a token, but only saves when the database is in read & write mode define_method("ensure_#{token_field}!") do send("reset_#{token_field}!") if read_attribute(token_field).blank? # rubocop:disable GitlabSecurity/PublicSend read_attribute(token_field) end + # Resets the token, but only saves when the database is in read & write mode define_method("reset_#{token_field}!") do write_new_token(token_field) - save! + save! if Gitlab::Database.read_write? end end end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 07c4846e2ac..6eba87da1a1 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -11,6 +11,8 @@ class DiffDiscussion < Discussion delegate :position, :original_position, :change_position, + :on_text?, + :on_image?, to: :first_note diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index e9a60e6ce09..d88a92dc027 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -12,8 +12,8 @@ class DiffNote < Note validates :original_position, presence: true validates :position, presence: true - validates :diff_line, presence: true - validates :line_code, presence: true, line_code: true + validates :diff_line, presence: true, if: :on_text? + validates :line_code, presence: true, line_code: true, if: :on_text? validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete validate :verify_supported @@ -43,6 +43,14 @@ class DiffNote < Note end end + def on_text? + position.position_type == "text" + end + + def on_image? + position.position_type == "image" + end + def diff_file @diff_file ||= self.original_position.diff_file(self.project.repository) end @@ -56,6 +64,8 @@ class DiffNote < Note end def original_line_code + return unless on_text? + self.diff_file.line_code(self.diff_line) end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index b80da7b246a..437df923d2d 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -66,6 +66,10 @@ class Discussion @context_noteable = context_noteable end + def on_image? + false + end + def ==(other) other.class == self.class && other.context_noteable == self.context_noteable && diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb new file mode 100644 index 00000000000..218e37a5312 --- /dev/null +++ b/app/models/fork_network.rb @@ -0,0 +1,15 @@ +class ForkNetwork < ActiveRecord::Base + belongs_to :root_project, class_name: 'Project' + has_many :fork_network_members + has_many :projects, through: :fork_network_members + + after_create :add_root_as_member, if: :root_project + + def add_root_as_member + projects << root_project + end + + def find_forks_in(other_projects) + projects.where(id: other_projects) + end +end diff --git a/app/models/fork_network_member.rb b/app/models/fork_network_member.rb new file mode 100644 index 00000000000..6a9b52a1ef8 --- /dev/null +++ b/app/models/fork_network_member.rb @@ -0,0 +1,7 @@ +class ForkNetworkMember < ActiveRecord::Base + belongs_to :fork_network + belongs_to :project + belongs_to :forked_from_project, class_name: 'Project' + + validates :fork_network, :project, presence: true +end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 675e7a2456d..bf88d75246f 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -60,6 +60,8 @@ class GpgSignature < ActiveRecord::Base end def gpg_commit + return unless commit + Gitlab::Gpg::Commit.new(commit) end end diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb index 3c1d34db5fa..80fc6304fd4 100644 --- a/app/models/legacy_diff_discussion.rb +++ b/app/models/legacy_diff_discussion.rb @@ -17,6 +17,14 @@ class LegacyDiffDiscussion < Discussion true end + def on_image? + false + end + + def on_text? + true + end + def active?(*args) return @active if @active.present? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 086226618e6..292122f779e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -403,7 +403,7 @@ class MergeRequest < ActiveRecord::Base return false unless for_fork? return true unless source_project - !source_project.forked_from?(target_project) + !source_project.in_fork_network_of?(target_project) end def reopenable? @@ -477,7 +477,7 @@ class MergeRequest < ActiveRecord::Base end def check_if_can_be_merged - return unless unchecked? + return unless unchecked? && Gitlab::Database.read_write? can_be_merged = !broken? && project.repository.can_be_merged?(diff_head_sha, target_branch) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index e279d8dd8c5..4672881e220 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -139,7 +139,9 @@ class Namespace < ActiveRecord::Base end def find_fork_of(project) - projects.joins(:forked_project_link).find_by('forked_project_links.forked_from_project_id = ?', project.id) + return nil unless project.fork_network + + project.fork_network.find_forks_in(projects).first end def lfs_enabled? diff --git a/app/models/note.rb b/app/models/note.rb index f44590e2144..ceded9f2aef 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -134,14 +134,22 @@ class Note < ActiveRecord::Base Discussion.build(notes) end + # Group diff discussions by line code or file path. + # It is not needed to group by line code when comment is + # on an image. def grouped_diff_discussions(diff_refs = nil) groups = {} diff_notes.fresh.discussions.each do |discussion| - line_code = discussion.line_code_in_diffs(diff_refs) - - if line_code - discussions = groups[line_code] ||= [] + group_key = + if discussion.on_image? + discussion.file_new_path + else + discussion.line_code_in_diffs(diff_refs) + end + + if group_key + discussions = groups[group_key] ||= [] discussions << discussion end end diff --git a/app/models/project.rb b/app/models/project.rb index e51e70f01b7..57e91ab3b88 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -118,11 +118,20 @@ class Project < ActiveRecord::Base has_one :mock_monitoring_service has_one :microsoft_teams_service + # TODO: replace these relations with the fork network versions has_one :forked_project_link, foreign_key: "forked_to_project_id" has_one :forked_from_project, through: :forked_project_link has_many :forked_project_links, foreign_key: "forked_from_project_id" has_many :forks, through: :forked_project_links, source: :forked_to_project + # TODO: replace these relations with the fork network versions + + has_one :root_of_fork_network, + foreign_key: 'root_project_id', + inverse_of: :root_project, + class_name: 'ForkNetwork' + has_one :fork_network_member + has_one :fork_network, through: :fork_network_member # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' @@ -180,6 +189,7 @@ class Project < ActiveRecord::Base # bulk that doesn't involve loading the rows into memory. As a result we're # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :runner_projects, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' @@ -814,7 +824,7 @@ class Project < ActiveRecord::Base end def cache_has_external_issue_tracker - update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) + update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write? end def has_wiki? @@ -834,7 +844,7 @@ class Project < ActiveRecord::Base end def cache_has_external_wiki - update_column(:has_external_wiki, services.external_wikis.any?) + update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? end def find_or_initialize_services(exceptions: []) @@ -999,6 +1009,11 @@ class Project < ActiveRecord::Base end def forked? + return true if fork_network && fork_network.root_project != self + + # TODO: Use only the above conditional using the `fork_network` + # This is the old conditional that looks at the `forked_project_link`, we + # fall back to this while we're migrating the new models !(forked_project_link.nil? || forked_project_link.forked_from_project.nil?) end @@ -1118,8 +1133,19 @@ class Project < ActiveRecord::Base end end - def forked_from?(project) - forked? && project == forked_from_project + def forked_from?(other_project) + forked? && forked_from_project == other_project + end + + def in_fork_network_of?(other_project) + # TODO: Remove this in a next release when all fork_networks are populated + # This makes sure all MergeRequests remain valid while the projects don't + # have a fork_network yet. + return true if forked_from?(other_project) + + return false if fork_network.nil? || other_project.fork_network.nil? + + fork_network == other_project.fork_network end def origin_merge_requests diff --git a/app/models/user.rb b/app/models/user.rb index 4ba9130a75a..533a776bc65 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -459,6 +459,14 @@ class User < ActiveRecord::Base reset_password_sent_at.present? && reset_password_sent_at >= 1.minute.ago end + def remember_me! + super if ::Gitlab::Database.read_write? + end + + def forget_me! + super if ::Gitlab::Database.read_write? + end + def disable_two_factor! transaction do update_attributes( @@ -654,6 +662,10 @@ class User < ActiveRecord::Base Ability.allowed?(self, action, subject) end + def confirm_deletion_with_password? + !password_automatically_set? && allow_password_authentication? + end + def first_name name.split.first unless name.blank? end @@ -693,15 +705,7 @@ class User < ActiveRecord::Base end def fork_of(project) - links = ForkedProjectLink.where( - forked_from_project_id: project, - forked_to_project_id: personal_projects.unscope(:order) - ) - if links.any? - links.first.forked_to_project - else - nil - end + namespace.find_fork_of(project) end def ldap_user? diff --git a/app/services/ci/extract_sections_from_build_trace_service.rb b/app/services/ci/extract_sections_from_build_trace_service.rb new file mode 100644 index 00000000000..75f9e0f897d --- /dev/null +++ b/app/services/ci/extract_sections_from_build_trace_service.rb @@ -0,0 +1,30 @@ +module Ci + class ExtractSectionsFromBuildTraceService < BaseService + def execute(build) + return false unless build.trace_sections.empty? + + Gitlab::Database.bulk_insert(BuildTraceSection.table_name, extract_sections(build)) + true + end + + private + + def find_or_create_name(name) + project.build_trace_section_names.find_or_create_by!(name: name) + rescue ActiveRecord::RecordInvalid + project.build_trace_section_names.find_by!(name: name) + end + + def extract_sections(build) + build.trace.extract_sections.map do |attr| + name = attr.delete(:name) + name_record = find_or_create_name(name) + + attr.merge( + build_id: build.id, + project_id: project.id, + section_name_id: name_record.id) + end + end + end +end diff --git a/app/services/keys/last_used_service.rb b/app/services/keys/last_used_service.rb index 066f3246158..dbd79f7da55 100644 --- a/app/services/keys/last_used_service.rb +++ b/app/services/keys/last_used_service.rb @@ -16,6 +16,8 @@ module Keys end def update? + return false if ::Gitlab::Database.read_only? + last_used = key.last_used_at return false if last_used && (Time.zone.now - last_used) <= TIMEOUT diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 54eb75ab9bf..19d75ff2efa 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -22,6 +22,13 @@ module Projects Projects::UnlinkForkService.new(project, current_user).execute + # The project is not necessarily a fork, so update the fork network originating + # from this project + if fork_network = project.root_of_fork_network + fork_network.update(root_project: nil, + deleted_root_project_name: project.full_name) + end + attempt_destroy_transaction(project) system_hook_service.execute_hooks_for(project, :destroy) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index ad67e68a86a..eb5cce5ab98 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -23,11 +23,31 @@ module Projects refresh_forks_count + link_fork_network(new_project) + new_project end private + def fork_network + if @project.fork_network + @project.fork_network + elsif forked_from_project = @project.forked_from_project + # TODO: remove this case when all background migrations have completed + # this only happens when a project had a `forked_project_link` that was + # not migrated to the `fork_network` relation + forked_from_project.fork_network || forked_from_project.create_root_of_fork_network + else + @project.create_root_of_fork_network + end + end + + def link_fork_network(new_project) + fork_network.fork_network_members.create(project: new_project, + forked_from_project: @project) + end + def refresh_forks_count Projects::ForksCountService.new(@project).refresh_cache end diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index f30b40423c8..abe414d0c05 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -16,6 +16,7 @@ module Projects refresh_forks_count(@project.forked_from_project) @project.forked_project_link.destroy + @project.fork_network_member.destroy end def refresh_forks_count(project) diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index ab532a1fdcf..5803404c3c8 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -14,7 +14,7 @@ module Users private def record_activity - Gitlab::UserActivities.record(@author.id) + Gitlab::UserActivities.record(@author.id) if Gitlab::Database.read_write? Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username})") end diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index e6d307e5568..52279d0a870 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -1,6 +1,10 @@ -- expanded = local_assigns.fetch(:expanded, true) -%tr.notes_holder{ class: ('hide' unless expanded) } - %td.notes_line{ colspan: 2 } - %td.notes_content - .content{ class: ('hide' unless expanded) } - = render partial: "discussions/notes", collection: discussions, as: :discussion +- if local_assigns[:on_image] + = render partial: "discussions/notes", collection: discussions, as: :discussion +- else + -# Text diff discussions + - expanded = local_assigns.fetch(:expanded, true) + %tr.notes_holder{ class: ('hide' unless expanded) } + %td.notes_line{ colspan: 2 } + %td.notes_content + .content{ class: ('hide' unless expanded) } + = render partial: "discussions/notes", collection: discussions, as: :discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 4a41be972da..636d06cab53 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -1,18 +1,27 @@ - diff_file = discussion.diff_file - blob = discussion.blob +- discussions = { discussion.original_line_code => [discussion] } +- diff_file_class = diff_file.text? ? 'text-file' : 'js-image-file' -.diff-file.file-holder +.diff-file.file-holder{ class: diff_file_class } .js-file-title.file-title.file-title-flex-parent .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false - .diff-content.code.js-syntax-highlight - %table - - discussions = { discussion.original_line_code => [discussion] } - = render partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: diff_file, - discussions: discussions, - discussion_expanded: true, - plain: true } + - if diff_file.text? + .diff-content.code.js-syntax-highlight + %table + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: diff_file, + discussions: discussions, + discussion_expanded: true, + plain: true } + - else + - partial = (diff_file.new_file? || diff_file.deleted_file?) ? 'single_image_diff' : 'replaced_image_diff' + + = render partial: "projects/diffs/#{partial}", locals: { diff_file: diff_file, position: discussion.position.to_json, click_to_comment: false } + + .note-container + = render partial: "discussions/notes", locals: { discussion: discussion, show_toggle: false, show_image_comment_badge: true, disable_collapse: true } diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index db5ab939948..9efcfef690f 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,6 +1,19 @@ -.discussion-notes - %ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "shared/notes/note", collection: discussion.notes, as: :note +- disable_collapse = local_assigns.fetch(:disable_collapse, false) +- collapsed_class = 'collapsed' if discussion.resolved? && !disable_collapse +- badge_counter = discussion_counter + 1 if local_assigns[:discussion_counter] +- show_toggle = local_assigns.fetch(:show_toggle, true) +- show_image_comment_badge = local_assigns.fetch(:show_image_comment_badge, false) + +.discussion-notes{ class: collapsed_class } + -# Save the first note position data so that we have a reference and can go + -# to the first note position when we click on a badge diff discussion + %ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } } + - if discussion.try(:on_image?) && show_toggle + %button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' } + = sprite_icon('collapse', css_class: 'collapse-icon') + %button.btn-transparent.badge.js-diff-notes-toggle{ type: 'button' } + = badge_counter + = render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge } .flash-container diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 7e9b76da570..5ff6ac5fc00 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,4 @@ -%header.navbar.navbar-gitlab.navbar-gitlab-new +%header.navbar.navbar-gitlab %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content .container-fluid .header-content @@ -73,7 +73,7 @@ %button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' } %span.sr-only Toggle navigation - = sprite_icon('more', size: 16, css_class: 'more-icon js-navbar-toggle-right') - = sprite_icon('close', size: 16, css_class: 'close-icon js-navbar-toggle-left') + = sprite_icon('more', size: 12, css_class: 'more-icon js-navbar-toggle-right') + = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') = render 'shared/outdated_browser' diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 8abbd828032..7f79168dfb3 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -97,21 +97,29 @@ .row.prepend-top-default .col-lg-4.profile-settings-sidebar %h4.prepend-top-0.danger-title - Remove account + = s_('Profiles|Delete account') .col-lg-8 - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p - Deleting an account has the following effects: + = s_('Profiles|Deleting an account has the following effects:') = render 'users/deletion_guidance', user: current_user - = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" + + #delete-account-modal{ data: { action_url: user_registration_path, + confirm_with_password: ('true' if current_user.confirm_deletion_with_password?), + username: current_user.username } } + %button.btn.btn-danger.disabled + = s_('Profiles|Delete account') - else - if @user.solo_owned_groups.present? %p - Your account is currently an owner in these groups: + = s_('Profiles|Your account is currently an owner in these groups:') %strong= @user.solo_owned_groups.map(&:name).join(', ') %p - You must transfer ownership or delete these groups before you can delete your account. + = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.') - else %p - You don't have access to delete this user. + = s_("Profiles|You don't have access to delete this user.") .append-bottom-default + +- content_for :page_specific_javascripts do + = webpack_bundle_tag('account') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 873b3045ea9..619b632918e 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,4 +1,6 @@ - empty_repo = @project.empty_repo? +- fork_network = @project.fork_network +- forked_from_project = @project.forked_from_project || fork_network&.root_project .project-home-panel.text-center{ class: ("empty-project" if empty_repo) } .limit-container-width{ class: container_class } .avatar-container.s70.project-avatar @@ -12,11 +14,15 @@ - if @project.description.present? = markdown_field(@project, :description) - - if forked_from_project = @project.forked_from_project + - if @project.forked? %p - #{ s_('ForkedFromProjectPath|Forked from') } - = link_to project_path(forked_from_project) do - = forked_from_project.namespace.try(:name) + - if forked_from_project + #{ s_('ForkedFromProjectPath|Forked from') } + = link_to project_path(forked_from_project) do + = forked_from_project.full_name + - else + - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') + = deleted_message % { project_name: fork_network.deleted_root_project_name } .project-repo-buttons .count-buttons diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index c06e9f323af..71d30da14a9 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -6,18 +6,9 @@ #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data - .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } - %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box', "@click" => "dismissOverviewDialog()" } - = icon("times") - .svg-container - = custom_icon('icon_cycle_analytics_splash') - .inner-content - %h4 - {{ __('Introducing Cycle Analytics') }} - %p - {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} - %p - = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + %banner{ "v-if" => "!isOverviewDialogDismissed", + "documentation-link": help_page_path('user/project/cycle_analytics'), + "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" } = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default diff --git a/app/views/projects/diffs/_image_diff_frame.html.haml b/app/views/projects/diffs/_image_diff_frame.html.haml new file mode 100644 index 00000000000..dae73e10460 --- /dev/null +++ b/app/views/projects/diffs/_image_diff_frame.html.haml @@ -0,0 +1,5 @@ +- class_name = local_assigns.fetch(:class_name, '') +- note_type = local_assigns.fetch(:note_type, '') + +.frame{ class: class_name, data: { position: position, note_type: note_type } } + = image_tag(image_path, alt: alt, draggable: false, lazy: false) diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml new file mode 100644 index 00000000000..8fc232b464e --- /dev/null +++ b/app/views/projects/diffs/_replaced_image_diff.html.haml @@ -0,0 +1,61 @@ +- blob = diff_file.blob +- old_blob = diff_file.old_blob +- blob_raw_path = diff_file_blob_raw_path(diff_file) +- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file) +- click_to_comment = local_assigns.fetch(:click_to_comment, true) +- diff_view_data = local_assigns.fetch(:diff_view_data, '') +- class_name = '' + +- if click_to_comment + - class_name = 'js-add-image-diff-note-button click-to-comment' + +.image.js-replaced-image{ data: diff_view_data } + .two-up.view + .wrap + .frame.deleted + = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false) + %p.image-info.hide + %span.meta-filesize= number_to_human_size(old_blob.size) + | + %strong W: + %span.meta-width + | + %strong H: + %span.meta-height + .wrap + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_path, alt: diff_file.new_path } + %p.image-info.hide + %span.meta-filesize= number_to_human_size(blob.size) + | + %strong W: + %span.meta-width + | + %strong H: + %span.meta-height + + .swipe.view.hide + .swipe-frame + .frame.deleted + = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false) + .swipe-wrap + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_path, alt: diff_file.new_path } + %span.swipe-bar + %span.top-handle + %span.bottom-handle + + .onion-skin.view.hide + .onion-skin-frame + .frame.deleted + = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false) + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_path, alt: diff_file.new_path } + .controls + .transparent + .drag-track + .dragger{ :style => "left: 0px;" } + .opaque + +.view-modes.hide + %ul.view-modes-menu + %li.two-up{ data: { mode: 'two-up' } } 2-up + %li.swipe{ data: { mode: 'swipe' } } Swipe + %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml new file mode 100644 index 00000000000..6b0c6bbe48f --- /dev/null +++ b/app/views/projects/diffs/_single_image_diff.html.haml @@ -0,0 +1,16 @@ +- blob = diff_file.blob +- old_blob = diff_file.old_blob +- blob_raw_path = diff_file_blob_raw_path(diff_file) +- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file) +- click_to_comment = local_assigns.fetch(:click_to_comment, true) +- diff_view_data = local_assigns.fetch(:diff_view_data, '') +- class_name = '' + +- if click_to_comment + - class_name = 'js-add-image-diff-note-button click-to-comment' + +.image.js-single-image{ data: diff_view_data } + .wrap + - single_class_name = diff_file.deleted_file? ? 'deleted' : 'added' + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_path, alt: diff_file.file_path } + %p.image-info= number_to_human_size(blob.size) diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml index 6b5233833c6..f190073c2fc 100644 --- a/app/views/projects/diffs/viewers/_image.html.haml +++ b/app/views/projects/diffs/viewers/_image.html.haml @@ -1,67 +1,13 @@ - diff_file = viewer.diff_file -- blob = diff_file.blob -- old_blob = diff_file.old_blob -- blob_raw_path = diff_file_blob_raw_path(diff_file) -- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file) +- image_point = Gitlab::Diff::ImagePoint.new(nil, nil, nil, nil) +- discussions = @grouped_diff_discussions[diff_file.new_path] if @grouped_diff_discussions + +- locals = { diff_file: diff_file, position: diff_file.position(image_point, position_type: :image).to_json, click_to_comment: true, diff_view_data: diff_view_data } - if diff_file.new_file? || diff_file.deleted_file? - .image - %span.wrap - .frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') } - = image_tag(blob_raw_path, alt: diff_file.file_path) - %p.image-info= number_to_human_size(blob.size) + = render partial: "projects/diffs/single_image_diff", locals: locals - else - .image - .two-up.view - %span.wrap - .frame.deleted - = image_tag(old_blob_raw_path, alt: diff_file.old_path) - %p.image-info.hide - %span.meta-filesize= number_to_human_size(old_blob.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - %span.wrap - .frame.added - = image_tag(blob_raw_path, alt: diff_file.new_path) - %p.image-info.hide - %span.meta-filesize= number_to_human_size(blob.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - - .swipe.view.hide - .swipe-frame - .frame.deleted - = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false) - .swipe-wrap - .frame.added - = image_tag(blob_raw_path, alt: diff_file.new_path, lazy: false) - %span.swipe-bar - %span.top-handle - %span.bottom-handle - - .onion-skin.view.hide - .onion-skin-frame - .frame.deleted - = image_tag(old_blob_raw_path, alt: diff_file.old_path, lazy: false) - .frame.added - = image_tag(blob_raw_path, alt: diff_file.new_path, lazy: false) - .controls - .transparent - .drag-track - .dragger{ :style => "left: 0px;" } - .opaque - + = render partial: "projects/diffs/replaced_image_diff", locals: locals - .view-modes.hide - %ul.view-modes-menu - %li.two-up{ data: { mode: 'two-up' } } 2-up - %li.swipe{ data: { mode: 'swipe' } } Swipe - %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin +.note-container + = render partial: "discussions/notes", collection: discussions, as: :discussion diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 725bf916592..71c0d740bc8 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -24,20 +24,21 @@ -# DiffNote = f.hidden_field :position - = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do - = render 'projects/zen', f: f, - attr: :note, - classes: 'note-textarea js-note-text', - placeholder: "Write a comment or drag your files here...", - supports_quick_actions: supports_quick_actions, - supports_autocomplete: supports_autocomplete - = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions - .error-alert - - .note-form-actions.clearfix - = render partial: 'shared/notes/comment_button' - - = yield(:note_actions) - - %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } - Discard draft + .discussion-form-container + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do + = render 'projects/zen', f: f, + attr: :note, + classes: 'note-textarea js-note-text', + placeholder: "Write a comment or drag your files here...", + supports_quick_actions: supports_quick_actions, + supports_autocomplete: supports_autocomplete + = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions + .error-alert + + .note-form-actions.clearfix + = render partial: 'shared/notes/comment_button' + + = yield(:note_actions) + + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } + Discard draft diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 4f00a9f2759..b6085fd3af0 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -1,7 +1,10 @@ - return unless note.author - return if note.cross_reference_not_visible_for?(current_user) +- show_image_comment_badge = local_assigns.fetch(:show_image_comment_badge, false) - note_editable = note_editable?(note) +- note_counter = local_assigns.fetch(:note_counter, 0) + %li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: { author_id: note.author.id, @@ -12,8 +15,18 @@ - if note.system = icon_for_system_note(note) - else - %a{ href: user_path(note.author) } + %a.image-diff-avatar-link{ href: user_path(note.author) } = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' + - if note.is_a?(DiffNote) && note.on_image? + - if show_image_comment_badge && note_counter == 0 + -# Only show this for the first comment in the discussion + %span.image-comment-badge.inverted + = icon('comment-o') + - elsif note_counter == 0 + - counter = badge_counter if local_assigns[:badge_counter] + - badge_class = "hidden" if @fresh_discussion || counter.nil? + %span.badge{ class: badge_class } + = counter .timeline-content .note-header .note-header-info diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index d0ffcc88d43..6c3cd6ecefe 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -9,7 +9,7 @@ = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile - .cover-block.user-cover-block.layout-nav + .cover-block.user-cover-block.top-area .cover-controls - if @user == current_user = link_to profile_path, class: 'btn btn-gray has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index e2a1b3dcc41..52e7d346e74 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -6,6 +6,7 @@ class BuildFinishedWorker def perform(build_id) Ci::Build.find_by(id: build_id).try do |build| + BuildTraceSectionsWorker.perform_async(build.id) BuildCoverageWorker.new.perform(build.id) BuildHooksWorker.new.perform(build.id) end diff --git a/app/workers/build_trace_sections_worker.rb b/app/workers/build_trace_sections_worker.rb new file mode 100644 index 00000000000..8c57e8f767b --- /dev/null +++ b/app/workers/build_trace_sections_worker.rb @@ -0,0 +1,8 @@ +class BuildTraceSectionsWorker + include Sidekiq::Worker + include PipelineQueue + + def perform(build_id) + Ci::Build.find_by(id: build_id)&.parse_trace_sections! + end +end diff --git a/changelogs/unreleased/36255-metrics-that-do-not-have-a-complete-history-are-not-shown-at-all.yml b/changelogs/unreleased/36255-metrics-that-do-not-have-a-complete-history-are-not-shown-at-all.yml new file mode 100644 index 00000000000..a820ecee7d2 --- /dev/null +++ b/changelogs/unreleased/36255-metrics-that-do-not-have-a-complete-history-are-not-shown-at-all.yml @@ -0,0 +1,5 @@ +--- +title: Allow prometheus graphs to correctly handle NaN values +merge_request: 14741 +author: +type: fixed diff --git a/changelogs/unreleased/37970-ci-sections-tracking.yml b/changelogs/unreleased/37970-ci-sections-tracking.yml new file mode 100644 index 00000000000..a9011b22c6c --- /dev/null +++ b/changelogs/unreleased/37970-ci-sections-tracking.yml @@ -0,0 +1,5 @@ +--- +title: Parse and store gitlab-runner timestamped section markers +merge_request: 14551 +author: +type: added diff --git a/changelogs/unreleased/bvl-fork-network-schema.yml b/changelogs/unreleased/bvl-fork-network-schema.yml new file mode 100644 index 00000000000..97b2d5acada --- /dev/null +++ b/changelogs/unreleased/bvl-fork-network-schema.yml @@ -0,0 +1,5 @@ +--- +title: Allow creating merge requests across a fork network +merge_request: 14422 +author: +type: changed diff --git a/changelogs/unreleased/issue_35873.yml b/changelogs/unreleased/issue_35873.yml new file mode 100644 index 00000000000..65064b97e56 --- /dev/null +++ b/changelogs/unreleased/issue_35873.yml @@ -0,0 +1,5 @@ +--- +title: Commenting on image diffs +merge_request: 14061 +author: +type: added diff --git a/changelogs/unreleased/mk-normalize-ldap-user-dns.yml b/changelogs/unreleased/mk-normalize-ldap-user-dns.yml new file mode 100644 index 00000000000..5a128d6acc1 --- /dev/null +++ b/changelogs/unreleased/mk-normalize-ldap-user-dns.yml @@ -0,0 +1,5 @@ +--- +title: Search or compare LDAP DNs case-insensitively and ignore excess whitespace +merge_request: 14697 +author: +type: fixed diff --git a/changelogs/unreleased/tc-geo-read-only-idea.yml b/changelogs/unreleased/tc-geo-read-only-idea.yml new file mode 100644 index 00000000000..e1b52eef2ca --- /dev/null +++ b/changelogs/unreleased/tc-geo-read-only-idea.yml @@ -0,0 +1,5 @@ +--- +title: Create idea of read-only database +merge_request: 14688 +author: +type: changed diff --git a/changelogs/unreleased/winh-delete-account-modal.yml b/changelogs/unreleased/winh-delete-account-modal.yml new file mode 100644 index 00000000000..f1e2710fdcc --- /dev/null +++ b/changelogs/unreleased/winh-delete-account-modal.yml @@ -0,0 +1,5 @@ +--- +title: Show confirmation modal before deleting account +merge_request: 14360 +author: +type: changed diff --git a/config/application.rb b/config/application.rb index ca2ab83becc..31e91835b9e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -154,6 +154,9 @@ module Gitlab ENV['GITLAB_PATH_OUTSIDE_HOOK'] = ENV['PATH'] ENV['GIT_TERMINAL_PROMPT'] = '0' + # Gitlab Read-only middleware support + config.middleware.insert_after ActionDispatch::Flash, 'Gitlab::Middleware::ReadOnly' + config.generators do |g| g.factory_girl false end diff --git a/config/webpack.config.js b/config/webpack.config.js index c515a170d2d..8cded750a66 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -26,6 +26,7 @@ var config = { }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { + account: './profile/account/index.js', balsamiq_viewer: './blob/balsamiq_viewer.js', blob: './blob_edit/blob_bundle.js', boards: './boards/boards_bundle.js', diff --git a/db/migrate/20170928124105_create_fork_networks.rb b/db/migrate/20170928124105_create_fork_networks.rb new file mode 100644 index 00000000000..ca906b953a3 --- /dev/null +++ b/db/migrate/20170928124105_create_fork_networks.rb @@ -0,0 +1,28 @@ +class CreateForkNetworks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :fork_networks do |t| + t.references :root_project, + references: :projects, + index: { unique: true } + + t.string :deleted_root_project_name + end + + add_concurrent_foreign_key :fork_networks, :projects, + column: :root_project_id, + on_delete: :nullify + end + + def down + if foreign_keys_for(:fork_networks, :root_project_id).any? + remove_foreign_key :fork_networks, column: :root_project_id + end + drop_table :fork_networks + end +end diff --git a/db/migrate/20170928133643_create_fork_network_members.rb b/db/migrate/20170928133643_create_fork_network_members.rb new file mode 100644 index 00000000000..836f023efdc --- /dev/null +++ b/db/migrate/20170928133643_create_fork_network_members.rb @@ -0,0 +1,26 @@ +class CreateForkNetworkMembers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + create_table :fork_network_members do |t| + t.references :fork_network, null: false, index: true, foreign_key: { on_delete: :cascade } + t.references :project, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + t.references :forked_from_project, references: :projects + end + + add_concurrent_foreign_key :fork_network_members, :projects, + column: :forked_from_project_id, + on_delete: :nullify + end + + def down + if foreign_keys_for(:fork_network_members, :forked_from_project_id).any? + remove_foreign_key :fork_network_members, column: :forked_from_project_id + end + drop_table :fork_network_members + end +end diff --git a/db/migrate/20170929131201_populate_fork_networks.rb b/db/migrate/20170929131201_populate_fork_networks.rb new file mode 100644 index 00000000000..1214962770f --- /dev/null +++ b/db/migrate/20170929131201_populate_fork_networks.rb @@ -0,0 +1,30 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class PopulateForkNetworks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + MIGRATION = 'PopulateForkNetworksRange'.freeze + BATCH_SIZE = 100 + DELAY_INTERVAL = 15.seconds + + disable_ddl_transaction! + + class ForkedProjectLink < ActiveRecord::Base + include EachBatch + + self.table_name = 'forked_project_links' + end + + def up + say 'Populating the `fork_networks` based on existing `forked_project_links`' + + queue_background_migration_jobs_by_range_at_intervals(ForkedProjectLink, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE) + end + + def down + # nothing + end +end diff --git a/db/migrate/20171006090001_create_ci_build_trace_sections.rb b/db/migrate/20171006090001_create_ci_build_trace_sections.rb new file mode 100644 index 00000000000..ab5ef319618 --- /dev/null +++ b/db/migrate/20171006090001_create_ci_build_trace_sections.rb @@ -0,0 +1,19 @@ +class CreateCiBuildTraceSections < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :ci_build_trace_sections do |t| + t.references :project, null: false, index: true, foreign_key: { on_delete: :cascade } + t.datetime_with_timezone :date_start, null: false + t.datetime_with_timezone :date_end, null: false + t.integer :byte_start, limit: 8, null: false + t.integer :byte_end, limit: 8, null: false + t.integer :build_id, null: false + t.integer :section_name_id, null: false + end + + add_index :ci_build_trace_sections, [:build_id, :section_name_id], unique: true + end +end diff --git a/db/migrate/20171006090010_add_build_foreign_key_to_ci_build_trace_sections.rb b/db/migrate/20171006090010_add_build_foreign_key_to_ci_build_trace_sections.rb new file mode 100644 index 00000000000..d279463eb4b --- /dev/null +++ b/db/migrate/20171006090010_add_build_foreign_key_to_ci_build_trace_sections.rb @@ -0,0 +1,15 @@ +class AddBuildForeignKeyToCiBuildTraceSections < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key(:ci_build_trace_sections, :ci_builds, column: :build_id) + end + + def down + remove_foreign_key(:ci_build_trace_sections, column: :build_id) + end +end diff --git a/db/migrate/20171006090100_create_ci_build_trace_section_names.rb b/db/migrate/20171006090100_create_ci_build_trace_section_names.rb new file mode 100644 index 00000000000..88f3e60699a --- /dev/null +++ b/db/migrate/20171006090100_create_ci_build_trace_section_names.rb @@ -0,0 +1,19 @@ +class CreateCiBuildTraceSectionNames < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + create_table :ci_build_trace_section_names do |t| + t.references :project, null: false, foreign_key: { on_delete: :cascade } + t.string :name, null: false + end + + add_index :ci_build_trace_section_names, [:project_id, :name], unique: true + end + + def down + remove_foreign_key :ci_build_trace_section_names, column: :project_id + drop_table :ci_build_trace_section_names + end +end diff --git a/db/migrate/20171006091000_add_name_foreign_key_to_ci_build_trace_sections.rb b/db/migrate/20171006091000_add_name_foreign_key_to_ci_build_trace_sections.rb new file mode 100644 index 00000000000..08422885a98 --- /dev/null +++ b/db/migrate/20171006091000_add_name_foreign_key_to_ci_build_trace_sections.rb @@ -0,0 +1,15 @@ +class AddNameForeignKeyToCiBuildTraceSections < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key(:ci_build_trace_sections, :ci_build_trace_section_names, column: :section_name_id) + end + + def down + remove_foreign_key(:ci_build_trace_sections, column: :section_name_id) + end +end diff --git a/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb b/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb new file mode 100644 index 00000000000..2230bb0e53c --- /dev/null +++ b/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class NormalizeLdapExternUids < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + MIGRATION = 'NormalizeLdapExternUidsRange'.freeze + DELAY_INTERVAL = 10.seconds + + disable_ddl_transaction! + + class Identity < ActiveRecord::Base + include EachBatch + + self.table_name = 'identities' + end + + def up + ldap_identities = Identity.where("provider like 'ldap%'") + + if ldap_identities.any? + queue_background_migration_jobs_by_range_at_intervals(Identity, MIGRATION, DELAY_INTERVAL) + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index a71ab36b839..aac37b6b455 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171005130944) do +ActiveRecord::Schema.define(version: 20171006091000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -207,6 +207,26 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_index "chat_teams", ["namespace_id"], name: "index_chat_teams_on_namespace_id", unique: true, using: :btree + create_table "ci_build_trace_section_names", force: :cascade do |t| + t.integer "project_id", null: false + t.string "name", null: false + end + + add_index "ci_build_trace_section_names", ["project_id", "name"], name: "index_ci_build_trace_section_names_on_project_id_and_name", unique: true, using: :btree + + create_table "ci_build_trace_sections", force: :cascade do |t| + t.integer "project_id", null: false + t.datetime_with_timezone "date_start", null: false + t.datetime_with_timezone "date_end", null: false + t.integer "byte_start", limit: 8, null: false + t.integer "byte_end", limit: 8, null: false + t.integer "build_id", null: false + t.integer "section_name_id", null: false + end + + add_index "ci_build_trace_sections", ["build_id", "section_name_id"], name: "index_ci_build_trace_sections_on_build_id_and_section_name_id", unique: true, using: :btree + add_index "ci_build_trace_sections", ["project_id"], name: "index_ci_build_trace_sections_on_project_id", using: :btree + create_table "ci_builds", force: :cascade do |t| t.string "status" t.datetime "finished_at" @@ -571,6 +591,22 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_index "features", ["key"], name: "index_features_on_key", unique: true, using: :btree + create_table "fork_network_members", force: :cascade do |t| + t.integer "fork_network_id", null: false + t.integer "project_id", null: false + t.integer "forked_from_project_id" + end + + add_index "fork_network_members", ["fork_network_id"], name: "index_fork_network_members_on_fork_network_id", using: :btree + add_index "fork_network_members", ["project_id"], name: "index_fork_network_members_on_project_id", unique: true, using: :btree + + create_table "fork_networks", force: :cascade do |t| + t.integer "root_project_id" + t.string "deleted_root_project_name" + end + + add_index "fork_networks", ["root_project_id"], name: "index_fork_networks_on_root_project_id", unique: true, using: :btree + create_table "forked_project_links", force: :cascade do |t| t.integer "forked_to_project_id", null: false t.integer "forked_from_project_id", null: false @@ -1745,6 +1781,10 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade + add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade + add_foreign_key "ci_build_trace_sections", "ci_build_trace_section_names", column: "section_name_id", name: "fk_264e112c66", on_delete: :cascade + add_foreign_key "ci_build_trace_sections", "ci_builds", column: "build_id", name: "fk_4ebe41f502", on_delete: :cascade + add_foreign_key "ci_build_trace_sections", "projects", on_delete: :cascade add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade add_foreign_key "ci_builds", "projects", name: "fk_befce0568a", on_delete: :cascade @@ -1769,6 +1809,10 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade add_foreign_key "events", "projects", on_delete: :cascade add_foreign_key "events", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade + add_foreign_key "fork_network_members", "fork_networks", on_delete: :cascade + add_foreign_key "fork_network_members", "projects", column: "forked_from_project_id", name: "fk_b01280dae4", on_delete: :nullify + add_foreign_key "fork_network_members", "projects", on_delete: :cascade + add_foreign_key "fork_networks", "projects", column: "root_project_id", name: "fk_e7b436b2b5", on_delete: :nullify add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade add_foreign_key "gcp_clusters", "projects", on_delete: :cascade add_foreign_key "gcp_clusters", "services", on_delete: :nullify diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md index ad904908472..ad903aef896 100644 --- a/doc/administration/auth/ldap.md +++ b/doc/administration/auth/ldap.md @@ -287,11 +287,11 @@ LDAP email address, and then sign into GitLab via their LDAP credentials. There are two encryption methods, `simple_tls` and `start_tls`. -For either encryption method, if setting `validate_certificates: false`, TLS +For either encryption method, if setting `verify_certificates: false`, TLS encryption is established with the LDAP server before any LDAP-protocol data is exchanged but no validation of the LDAP server's SSL certificate is performed. ->**Note**: Before GitLab 9.5, `validate_certificates: false` is the default if +>**Note**: Before GitLab 9.5, `verify_certificates: false` is the default if unspecified. ## Limitations diff --git a/doc/development/ux_guide/img/illustration-size-large-horizontal.png b/doc/development/ux_guide/img/illustration-size-large-horizontal.png Binary files differindex 8aa835adccc..8aa835adccc 100755..100644 --- a/doc/development/ux_guide/img/illustration-size-large-horizontal.png +++ b/doc/development/ux_guide/img/illustration-size-large-horizontal.png diff --git a/doc/development/ux_guide/img/illustration-size-medium.png b/doc/development/ux_guide/img/illustration-size-medium.png Binary files differindex 55cfe1dcb91..55cfe1dcb91 100755..100644 --- a/doc/development/ux_guide/img/illustration-size-medium.png +++ b/doc/development/ux_guide/img/illustration-size-medium.png diff --git a/doc/development/ux_guide/img/illustrations-border-radius.png b/doc/development/ux_guide/img/illustrations-border-radius.png Binary files differindex 4e2fef5c7f5..4e2fef5c7f5 100755..100644 --- a/doc/development/ux_guide/img/illustrations-border-radius.png +++ b/doc/development/ux_guide/img/illustrations-border-radius.png diff --git a/doc/development/ux_guide/img/illustrations-caps-do.png b/doc/development/ux_guide/img/illustrations-caps-do.png Binary files differindex 7a2c74382f6..7a2c74382f6 100755..100644 --- a/doc/development/ux_guide/img/illustrations-caps-do.png +++ b/doc/development/ux_guide/img/illustrations-caps-do.png diff --git a/doc/development/ux_guide/img/illustrations-caps-don't.png b/doc/development/ux_guide/img/illustrations-caps-don't.png Binary files differindex 848f72dbe30..848f72dbe30 100755..100644 --- a/doc/development/ux_guide/img/illustrations-caps-don't.png +++ b/doc/development/ux_guide/img/illustrations-caps-don't.png diff --git a/doc/development/ux_guide/img/illustrations-color-grey.png b/doc/development/ux_guide/img/illustrations-color-grey.png Binary files differindex 63855026c2b..63855026c2b 100755..100644 --- a/doc/development/ux_guide/img/illustrations-color-grey.png +++ b/doc/development/ux_guide/img/illustrations-color-grey.png diff --git a/doc/development/ux_guide/img/illustrations-color-orange.png b/doc/development/ux_guide/img/illustrations-color-orange.png Binary files differindex 96765c8c28c..96765c8c28c 100755..100644 --- a/doc/development/ux_guide/img/illustrations-color-orange.png +++ b/doc/development/ux_guide/img/illustrations-color-orange.png diff --git a/doc/development/ux_guide/img/illustrations-color-purple.png b/doc/development/ux_guide/img/illustrations-color-purple.png Binary files differindex 745d2c853ba..745d2c853ba 100755..100644 --- a/doc/development/ux_guide/img/illustrations-color-purple.png +++ b/doc/development/ux_guide/img/illustrations-color-purple.png diff --git a/doc/development/ux_guide/img/illustrations-geometric.png b/doc/development/ux_guide/img/illustrations-geometric.png Binary files differindex 33f05547bac..33f05547bac 100755..100644 --- a/doc/development/ux_guide/img/illustrations-geometric.png +++ b/doc/development/ux_guide/img/illustrations-geometric.png diff --git a/doc/development/ux_guide/img/illustrations-palette-oragne.png b/doc/development/ux_guide/img/illustrations-palette-oragne.png Binary files differindex 15f35912646..15f35912646 100755..100644 --- a/doc/development/ux_guide/img/illustrations-palette-oragne.png +++ b/doc/development/ux_guide/img/illustrations-palette-oragne.png diff --git a/doc/development/ux_guide/img/illustrations-palette-purple.png b/doc/development/ux_guide/img/illustrations-palette-purple.png Binary files differindex e0f5839705e..e0f5839705e 100755..100644 --- a/doc/development/ux_guide/img/illustrations-palette-purple.png +++ b/doc/development/ux_guide/img/illustrations-palette-purple.png diff --git a/doc/development/verifying_database_capabilities.md b/doc/development/verifying_database_capabilities.md index cc6d62957e3..ffdeff47d4a 100644 --- a/doc/development/verifying_database_capabilities.md +++ b/doc/development/verifying_database_capabilities.md @@ -24,3 +24,15 @@ else run_query end ``` + +# Read-only database + +The database can be used in read-only mode. In this case we have to +make sure all GET requests don't attempt any write operations to the +database. If one of those requests wants to write to the database, it needs +to be wrapped in a `Gitlab::Database.read_only?` or `Gitlab::Database.read_write?` +guard, to make sure it doesn't for read-only databases. + +We have a Rails Middleware that filters any potentially writing +operations (the CUD operations of CRUD) and prevent the user from trying +to update the database and getting a 500 error (see `Gitlab::Middleware::ReadOnly`). diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index 3490bbd968c..60707f26aee 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -58,13 +58,13 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps step 'I should see my fork on the list' do page.within('.js-projects-list-holder') do - project = @user.fork_of(@project) + project = @user.fork_of(@project.reload) expect(page).to have_content("#{project.namespace.human_name} / #{project.name}") end end step 'I make forked repo invalid' do - project = @user.fork_of(@project) + project = @user.fork_of(@project.reload) project.path = 'test-crappy-path' project.save! end diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 420ac8a695a..6781a906a94 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -5,6 +5,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps include SharedPaths include Select2Helper include WaitForRequests + include ProjectForksHelper step 'I am a member of project "Shop"' do @project = ::Project.find_by(name: "Shop") @@ -13,7 +14,9 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps end step 'I have a project forked off of "Shop" called "Forked Shop"' do - @forked_project = Projects::ForkService.new(@project, @user).execute + @forked_project = fork_project(@project, @user, + namespace: @user.namespace, + repository: true) end step 'I click link "New Merge Request"' do diff --git a/features/support/env.rb b/features/support/env.rb index 608d988755c..5962745d501 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -10,7 +10,7 @@ if ENV['CI'] Knapsack::Adapters::SpinachAdapter.bind end -%w(select2_helper test_env repo_helpers wait_for_requests sidekiq).each do |f| +%w(select2_helper test_env repo_helpers wait_for_requests sidekiq project_forks_helper).each do |f| require Rails.root.join('spec', 'support', f) end diff --git a/lib/banzai/renderer.rb b/lib/banzai/renderer.rb index ceca9296851..5f91884a878 100644 --- a/lib/banzai/renderer.rb +++ b/lib/banzai/renderer.rb @@ -40,7 +40,7 @@ module Banzai return cacheless_render_field(object, field) end - object.refresh_markdown_cache!(do_update: update_object?(object)) unless object.cached_html_up_to_date?(field) + object.refresh_markdown_cache! unless object.cached_html_up_to_date?(field) object.cached_html_for(field) end @@ -162,10 +162,5 @@ module Banzai return unless cache_key Rails.cache.__send__(:expanded_key, full_cache_key(cache_key, pipeline_name)) # rubocop:disable GitlabSecurity/PublicSend end - - # GitLab EE needs to disable updates on GET requests in Geo - def self.update_object?(object) - true - end end end diff --git a/lib/gitlab/background_migration/create_fork_network_memberships_range.rb b/lib/gitlab/background_migration/create_fork_network_memberships_range.rb new file mode 100644 index 00000000000..4b468e9cd58 --- /dev/null +++ b/lib/gitlab/background_migration/create_fork_network_memberships_range.rb @@ -0,0 +1,60 @@ +module Gitlab + module BackgroundMigration + class CreateForkNetworkMembershipsRange + RESCHEDULE_DELAY = 15 + + class ForkedProjectLink < ActiveRecord::Base + self.table_name = 'forked_project_links' + end + + def perform(start_id, end_id) + log("Creating memberships for forks: #{start_id} - #{end_id}") + + ActiveRecord::Base.connection.execute <<~INSERT_MEMBERS + INSERT INTO fork_network_members (fork_network_id, project_id, forked_from_project_id) + + SELECT fork_network_members.fork_network_id, + forked_project_links.forked_to_project_id, + forked_project_links.forked_from_project_id + + FROM forked_project_links + + INNER JOIN fork_network_members + ON forked_project_links.forked_from_project_id = fork_network_members.project_id + + WHERE forked_project_links.id BETWEEN #{start_id} AND #{end_id} + AND NOT EXISTS ( + SELECT true + FROM fork_network_members existing_members + WHERE existing_members.project_id = forked_project_links.forked_to_project_id + ) + INSERT_MEMBERS + + if missing_members?(start_id, end_id) + BackgroundMigrationWorker.perform_in(RESCHEDULE_DELAY, "CreateForkNetworkMembershipsRange", [start_id, end_id]) + end + end + + def missing_members?(start_id, end_id) + count_sql = <<~MISSING_MEMBERS + SELECT COUNT(*) + + FROM forked_project_links + + WHERE NOT EXISTS ( + SELECT true + FROM fork_network_members + WHERE fork_network_members.project_id = forked_project_links.forked_to_project_id + ) + AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} + MISSING_MEMBERS + + ForkNetworkMember.count_by_sql(count_sql) > 0 + end + + def log(message) + Rails.logger.info("#{self.class.name} - #{message}") + end + end + end +end diff --git a/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb b/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb new file mode 100644 index 00000000000..bc53e6d7f94 --- /dev/null +++ b/lib/gitlab/background_migration/normalize_ldap_extern_uids_range.rb @@ -0,0 +1,313 @@ +module Gitlab + module BackgroundMigration + class NormalizeLdapExternUidsRange + class Identity < ActiveRecord::Base + self.table_name = 'identities' + end + + # Copied this class to make this migration resilient to future code changes. + # And if the normalize behavior is changed in the future, it must be + # accompanied by another migration. + module Gitlab + module LDAP + class DN + FormatError = Class.new(StandardError) + MalformedError = Class.new(FormatError) + UnsupportedError = Class.new(FormatError) + + def self.normalize_value(given_value) + dummy_dn = "placeholder=#{given_value}" + normalized_dn = new(*dummy_dn).to_normalized_s + normalized_dn.sub(/\Aplaceholder=/, '') + end + + ## + # Initialize a DN, escaping as required. Pass in attributes in name/value + # pairs. If there is a left over argument, it will be appended to the dn + # without escaping (useful for a base string). + # + # Most uses of this class will be to escape a DN, rather than to parse it, + # so storing the dn as an escaped String and parsing parts as required + # with a state machine seems sensible. + def initialize(*args) + if args.length > 1 + initialize_array(args) + else + initialize_string(args[0]) + end + end + + ## + # Parse a DN into key value pairs using ASN from + # http://tools.ietf.org/html/rfc2253 section 3. + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def each_pair + state = :key + key = StringIO.new + value = StringIO.new + hex_buffer = "" + + @dn.each_char.with_index do |char, dn_index| + case state + when :key then + case char + when 'a'..'z', 'A'..'Z' then + state = :key_normal + key << char + when '0'..'9' then + state = :key_oid + key << char + when ' ' then state = :key + else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"") + end + when :key_normal then + case char + when '=' then state = :value + when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"") + end + when :key_oid then + case char + when '=' then state = :value + when '0'..'9', '.', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"") + end + when :value then + case char + when '\\' then state = :value_normal_escape + when '"' then state = :value_quoted + when ' ' then state = :value + when '#' then + state = :value_hexstring + value << char + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else + state = :value_normal + value << char + end + when :value_normal then + case char + when '\\' then state = :value_normal_escape + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported") + else value << char + end + when :value_normal_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal_escape_hex + hex_buffer = char + else + state = :value_normal + value << char + end + when :value_normal_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"") + end + when :value_quoted then + case char + when '\\' then state = :value_quoted_escape + when '"' then state = :value_end + else value << char + end + when :value_quoted_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted_escape_hex + hex_buffer = char + else + state = :value_quoted + value << char + end + when :value_quoted_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"") + end + when :value_hexstring then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring_hex + value << char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"") + end + when :value_hexstring_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring + value << char + else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"") + end + when :value_end then + case char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"") + end + else raise "Fell out of state machine" + end + end + + # Last pair + raise(MalformedError, 'DN string ended unexpectedly') unless + [:value, :value_normal, :value_hexstring, :value_end].include? state + + yield key.string.strip, rstrip_except_escaped(value.string, @dn.length) + end + + def rstrip_except_escaped(str, dn_index) + str_ends_with_whitespace = str.match(/\s\z/) + + if str_ends_with_whitespace + dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/) + + if dn_part_ends_with_escaped_whitespace + dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1] + num_chars_to_remove = dn_part_rwhitespace.length - 1 + str = str[0, str.length - num_chars_to_remove] + else + str.rstrip! + end + end + + str + end + + ## + # Returns the DN as an array in the form expected by the constructor. + def to_a + a = [] + self.each_pair { |key, value| a << key << value } unless @dn.empty? + a + end + + ## + # Return the DN as an escaped string. + def to_s + @dn + end + + ## + # Return the DN as an escaped and normalized string. + def to_normalized_s + self.class.new(*to_a).to_s.downcase + end + + # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions + # for DN values. All of the following must be escaped in any normal string + # using a single backslash ('\') as escape. The space character is left + # out here because in a "normalized" string, spaces should only be escaped + # if necessary (i.e. leading or trailing space). + NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze + + # The following must be represented as escaped hex + HEX_ESCAPES = { + "\n" => '\0a', + "\r" => '\0d' + }.freeze + + # Compiled character class regexp using the keys from the above hash, and + # checking for a space or # at the start, or space at the end, of the + # string. + ESCAPE_RE = Regexp.new("(^ |^#| $|[" + + NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join + + "])") + + HEX_ESCAPE_RE = Regexp.new("([" + + HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join + + "])") + + ## + # Escape a string for use in a DN value + def self.escape(string) + escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char } + escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] } + end + + private + + def initialize_array(args) + buffer = StringIO.new + + args.each_with_index do |arg, index| + if index.even? # key + buffer << "," if index > 0 + buffer << arg + else # value + buffer << "=" + buffer << self.class.escape(arg) + end + end + + @dn = buffer.string + end + + def initialize_string(arg) + @dn = arg.to_s + end + + ## + # Proxy all other requests to the string object, because a DN is mainly + # used within the library as a string + # rubocop:disable GitlabSecurity/PublicSend + def method_missing(method, *args, &block) + @dn.send(method, *args, &block) + end + + ## + # Redefined to be consistent with redefined `method_missing` behavior + def respond_to?(sym, include_private = false) + @dn.respond_to?(sym, include_private) + end + end + end + end + + def perform(start_id, end_id) + return unless migrate? + + ldap_identities = Identity.where("provider like 'ldap%'").where(id: start_id..end_id) + ldap_identities.each do |identity| + begin + identity.extern_uid = Gitlab::LDAP::DN.new(identity.extern_uid).to_normalized_s + unless identity.save + Rails.logger.info "Unable to normalize \"#{identity.extern_uid}\". Skipping." + end + rescue Gitlab::LDAP::DN::FormatError => e + Rails.logger.info "Unable to normalize \"#{identity.extern_uid}\" due to \"#{e.message}\". Skipping." + end + end + end + + def migrate? + Identity.table_exists? + end + end + end +end diff --git a/lib/gitlab/background_migration/populate_fork_networks_range.rb b/lib/gitlab/background_migration/populate_fork_networks_range.rb new file mode 100644 index 00000000000..6c355ed1e75 --- /dev/null +++ b/lib/gitlab/background_migration/populate_fork_networks_range.rb @@ -0,0 +1,54 @@ +module Gitlab + module BackgroundMigration + class PopulateForkNetworksRange + def perform(start_id, end_id) + log("Creating fork networks for forked project links: #{start_id} - #{end_id}") + + ActiveRecord::Base.connection.execute <<~INSERT_NETWORKS + INSERT INTO fork_networks (root_project_id) + SELECT DISTINCT forked_project_links.forked_from_project_id + + FROM forked_project_links + + WHERE NOT EXISTS ( + SELECT true + FROM forked_project_links inner_links + WHERE inner_links.forked_to_project_id = forked_project_links.forked_from_project_id + ) + AND NOT EXISTS ( + SELECT true + FROM fork_networks + WHERE forked_project_links.forked_from_project_id = fork_networks.root_project_id + ) + AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} + INSERT_NETWORKS + + log("Creating memberships for root projects: #{start_id} - #{end_id}") + + ActiveRecord::Base.connection.execute <<~INSERT_ROOT + INSERT INTO fork_network_members (fork_network_id, project_id) + SELECT DISTINCT fork_networks.id, fork_networks.root_project_id + + FROM fork_networks + + INNER JOIN forked_project_links + ON forked_project_links.forked_from_project_id = fork_networks.root_project_id + + WHERE NOT EXISTS ( + SELECT true + FROM fork_network_members + WHERE fork_network_members.project_id = fork_networks.root_project_id + ) + AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} + INSERT_ROOT + + delay = BackgroundMigration::CreateForkNetworkMembershipsRange::RESCHEDULE_DELAY + BackgroundMigrationWorker.perform_in(delay, "CreateForkNetworkMembershipsRange", [start_id, end_id]) + end + + def log(message) + Rails.logger.info("#{self.class.name} - #{message}") + end + end + end +end diff --git a/lib/gitlab/ci/ansi2html.rb b/lib/gitlab/ci/ansi2html.rb index 088adbdd267..72b75791bbb 100644 --- a/lib/gitlab/ci/ansi2html.rb +++ b/lib/gitlab/ci/ansi2html.rb @@ -155,7 +155,7 @@ module Gitlab stream.each_line do |line| s = StringScanner.new(line) until s.eos? - if s.scan(/section_((?:start)|(?:end)):(\d+):([^\r]+)\r\033\[0K/) + if s.scan(Gitlab::Regex.build_trace_section_regex) handle_section(s) elsif s.scan(/\e([@-_])(.*?)([@-~])/) handle_sequence(s) diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb index 5b835bb669a..baf55b1fa07 100644 --- a/lib/gitlab/ci/trace.rb +++ b/lib/gitlab/ci/trace.rb @@ -27,6 +27,12 @@ module Gitlab end end + def extract_sections + read do |stream| + stream.extract_sections + end + end + def set(data) write do |stream| data = job.hide_secrets(data) diff --git a/lib/gitlab/ci/trace/section_parser.rb b/lib/gitlab/ci/trace/section_parser.rb new file mode 100644 index 00000000000..9bb0166c9e3 --- /dev/null +++ b/lib/gitlab/ci/trace/section_parser.rb @@ -0,0 +1,97 @@ +module Gitlab + module Ci + class Trace + class SectionParser + def initialize(lines) + @lines = lines + end + + def parse! + @markers = {} + + @lines.each do |line, pos| + parse_line(line, pos) + end + end + + def sections + sanitize_markers.map do |name, markers| + start_, end_ = markers + + { + name: name, + byte_start: start_[:marker], + byte_end: end_[:marker], + date_start: start_[:timestamp], + date_end: end_[:timestamp] + } + end + end + + private + + def parse_line(line, line_start_position) + s = StringScanner.new(line) + until s.eos? + find_next_marker(s) do |scanner| + marker_begins_at = line_start_position + scanner.pointer + + if scanner.scan(Gitlab::Regex.build_trace_section_regex) + marker_ends_at = line_start_position + scanner.pointer + handle_line(scanner[1], scanner[2].to_i, scanner[3], marker_begins_at, marker_ends_at) + true + else + false + end + end + end + end + + def sanitize_markers + @markers.select do |_, markers| + markers.size == 2 && markers[0][:action] == :start && markers[1][:action] == :end + end + end + + def handle_line(action, time, name, marker_start, marker_end) + action = action.to_sym + timestamp = Time.at(time).utc + marker = if action == :start + marker_end + else + marker_start + end + + @markers[name] ||= [] + @markers[name] << { + name: name, + action: action, + timestamp: timestamp, + marker: marker + } + end + + def beginning_of_section_regex + @beginning_of_section_regex ||= /section_/.freeze + end + + def find_next_marker(s) + beginning_of_section_len = 8 + maybe_marker = s.exist?(beginning_of_section_regex) + + if maybe_marker.nil? + s.terminate + else + # repositioning at the beginning of the match + s.pos += maybe_marker - beginning_of_section_len + if block_given? + good_marker = yield(s) + # if not a good marker: Consuming the matched beginning_of_section_regex + s.pos += beginning_of_section_len unless good_marker + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index ab3408f48d6..d52194f688b 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -90,8 +90,25 @@ module Gitlab # so we just silently ignore error for now end + def extract_sections + return [] unless valid? + + lines = to_enum(:each_line_with_pos) + parser = SectionParser.new(lines) + + parser.parse! + parser.sections + end + private + def each_line_with_pos + stream.seek(0, IO::SEEK_SET) + stream.each_line do |line| + yield [line, stream.pos - line.bytesize] + end + end + def read_last_lines(limit) to_enum(:reverse_line).first(limit).reverse.join end diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 243c1f1394d..7e7aaeeaa17 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -23,7 +23,8 @@ module Gitlab @extractor.analyze(closing_statements.join(" ")) @extractor.issues.reject do |issue| - @extractor.project.forked_from?(issue.project) # Don't extract issues on original project + # Don't extract issues from the project this project was forked from + @extractor.project.forked_from?(issue.project) end end end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index a6ec75da385..357f16936c6 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -29,6 +29,15 @@ module Gitlab adapter_name.casecmp('postgresql').zero? end + # Overridden in EE + def self.read_only? + false + end + + def self.read_write? + !self.read_only? + end + def self.version database_version.match(/\A(?:PostgreSQL |)([^\s]+).*\z/)[1] end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index fcac85ff892..599c3c5deab 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -27,16 +27,23 @@ module Gitlab @fallback_diff_refs = fallback_diff_refs end - def position(line) + def position(position_marker, position_type: :text) return unless diff_refs - Position.new( + data = { + diff_refs: diff_refs, + position_type: position_type.to_s, old_path: old_path, - new_path: new_path, - old_line: line.old_line, - new_line: line.new_line, - diff_refs: diff_refs - ) + new_path: new_path + } + + if position_type == :text + data.merge!(text_position_properties(position_marker)) + else + data.merge!(image_position_properties(position_marker)) + end + + Position.new(data) end def line_code(line) @@ -228,6 +235,14 @@ module Gitlab private + def text_position_properties(line) + { old_line: line.old_line, new_line: line.new_line } + end + + def image_position_properties(image_point) + image_point.to_h + end + def blobs_changed? old_blob && new_blob && old_blob.id != new_blob.id end diff --git a/lib/gitlab/diff/formatters/base_formatter.rb b/lib/gitlab/diff/formatters/base_formatter.rb new file mode 100644 index 00000000000..5e923b9e602 --- /dev/null +++ b/lib/gitlab/diff/formatters/base_formatter.rb @@ -0,0 +1,61 @@ +module Gitlab + module Diff + module Formatters + class BaseFormatter + attr_reader :old_path + attr_reader :new_path + attr_reader :base_sha + attr_reader :start_sha + attr_reader :head_sha + attr_reader :position_type + + def initialize(attrs) + if diff_file = attrs[:diff_file] + attrs[:diff_refs] = diff_file.diff_refs + attrs[:old_path] = diff_file.old_path + attrs[:new_path] = diff_file.new_path + end + + if diff_refs = attrs[:diff_refs] + attrs[:base_sha] = diff_refs.base_sha + attrs[:start_sha] = diff_refs.start_sha + attrs[:head_sha] = diff_refs.head_sha + end + + @old_path = attrs[:old_path] + @new_path = attrs[:new_path] + @base_sha = attrs[:base_sha] + @start_sha = attrs[:start_sha] + @head_sha = attrs[:head_sha] + end + + def key + [base_sha, start_sha, head_sha, Digest::SHA1.hexdigest(old_path || ""), Digest::SHA1.hexdigest(new_path || "")] + end + + def to_h + { + base_sha: base_sha, + start_sha: start_sha, + head_sha: head_sha, + old_path: old_path, + new_path: new_path, + position_type: position_type + } + end + + def position_type + raise NotImplementedError + end + + def ==(other) + raise NotImplementedError + end + + def complete? + raise NotImplementedError + end + end + end + end +end diff --git a/lib/gitlab/diff/formatters/image_formatter.rb b/lib/gitlab/diff/formatters/image_formatter.rb new file mode 100644 index 00000000000..ccd0d309972 --- /dev/null +++ b/lib/gitlab/diff/formatters/image_formatter.rb @@ -0,0 +1,43 @@ +module Gitlab + module Diff + module Formatters + class ImageFormatter < BaseFormatter + attr_reader :width + attr_reader :height + attr_reader :x + attr_reader :y + + def initialize(attrs) + @x = attrs[:x] + @y = attrs[:y] + @width = attrs[:width] + @height = attrs[:height] + + super(attrs) + end + + def key + @key ||= super.push(x, y) + end + + def complete? + x && y && width && height + end + + def to_h + super.merge(width: width, height: height, x: x, y: y) + end + + def position_type + "image" + end + + def ==(other) + other.is_a?(self.class) && + x == other.x && + y == other.y + end + end + end + end +end diff --git a/lib/gitlab/diff/formatters/text_formatter.rb b/lib/gitlab/diff/formatters/text_formatter.rb new file mode 100644 index 00000000000..01c7e9f51ab --- /dev/null +++ b/lib/gitlab/diff/formatters/text_formatter.rb @@ -0,0 +1,49 @@ +module Gitlab + module Diff + module Formatters + class TextFormatter < BaseFormatter + attr_reader :old_line + attr_reader :new_line + + def initialize(attrs) + @old_line = attrs[:old_line] + @new_line = attrs[:new_line] + + super(attrs) + end + + def key + @key ||= super.push(old_line, new_line) + end + + def complete? + old_line || new_line + end + + def to_h + super.merge(old_line: old_line, new_line: new_line) + end + + def line_age + if old_line && new_line + nil + elsif new_line + 'new' + else + 'old' + end + end + + def position_type + "text" + end + + def ==(other) + other.is_a?(self.class) && + new_line == other.new_line && + old_line == other.old_line + end + end + end + end +end diff --git a/lib/gitlab/diff/image_point.rb b/lib/gitlab/diff/image_point.rb new file mode 100644 index 00000000000..65332dfd239 --- /dev/null +++ b/lib/gitlab/diff/image_point.rb @@ -0,0 +1,23 @@ +module Gitlab + module Diff + class ImagePoint + attr_reader :width, :height, :x, :y + + def initialize(width, height, x, y) + @width = width + @height = height + @x = x + @y = y + end + + def to_h + { + width: width, + height: height, + x: x, + y: y + } + end + end + end +end diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index b8db3adef0a..bd0a9502a5e 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -1,37 +1,25 @@ -# Defines a specific location, identified by paths and line numbers, +# Defines a specific location, identified by paths line numbers and image coordinates, # within a specific diff, identified by start, head and base commit ids. module Gitlab module Diff class Position - attr_reader :old_path - attr_reader :new_path - attr_reader :old_line - attr_reader :new_line - attr_reader :base_sha - attr_reader :start_sha - attr_reader :head_sha - + attr_accessor :formatter + + delegate :old_path, + :new_path, + :base_sha, + :start_sha, + :head_sha, + :old_line, + :new_line, + :position_type, to: :formatter + + # A position can belong to a text line or to an image coordinate + # it depends of the position_type argument. + # Text position will have: new_line and old_line + # Image position will have: width, height, x, y def initialize(attrs = {}) - if diff_file = attrs[:diff_file] - attrs[:diff_refs] = diff_file.diff_refs - attrs[:old_path] = diff_file.old_path - attrs[:new_path] = diff_file.new_path - end - - if diff_refs = attrs[:diff_refs] - attrs[:base_sha] = diff_refs.base_sha - attrs[:start_sha] = diff_refs.start_sha - attrs[:head_sha] = diff_refs.head_sha - end - - @old_path = attrs[:old_path] - @new_path = attrs[:new_path] - @base_sha = attrs[:base_sha] - @start_sha = attrs[:start_sha] - @head_sha = attrs[:head_sha] - - @old_line = attrs[:old_line] - @new_line = attrs[:new_line] + @formatter = get_formatter_class(attrs[:position_type]).new(attrs) end # `Gitlab::Diff::Position` objects are stored as serialized attributes in @@ -46,7 +34,11 @@ module Gitlab end def encode_with(coder) - coder['attributes'] = self.to_h + coder['attributes'] = formatter.to_h + end + + def key + formatter.key end def ==(other) @@ -54,20 +46,11 @@ module Gitlab other.diff_refs == diff_refs && other.old_path == old_path && other.new_path == new_path && - other.old_line == old_line && - other.new_line == new_line + other.formatter == formatter end def to_h - { - old_path: old_path, - new_path: new_path, - old_line: old_line, - new_line: new_line, - base_sha: base_sha, - start_sha: start_sha, - head_sha: head_sha - } + formatter.to_h end def inspect @@ -75,23 +58,15 @@ module Gitlab end def complete? - file_path.present? && - (old_line || new_line) && - diff_refs.complete? + file_path.present? && formatter.complete? && diff_refs.complete? end def to_json(opts = nil) - JSON.generate(self.to_h, opts) + JSON.generate(formatter.to_h, opts) end def type - if old_line && new_line - nil - elsif new_line - 'new' - else - 'old' - end + formatter.line_age end def unchanged? @@ -150,6 +125,17 @@ module Gitlab diff_refs.compare_in(repository.project).diffs(paths: paths, expanded: true).diff_files.first end + + def get_formatter_class(type) + type ||= "text" + + case type + when 'image' + Gitlab::Diff::Formatters::ImageFormatter + else + Gitlab::Diff::Formatters::TextFormatter + end + end end end end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index db67ede9d9e..42b59c106e2 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -17,7 +17,8 @@ module Gitlab command_not_allowed: "The command you're trying to execute is not allowed.", upload_pack_disabled_over_http: 'Pulling over HTTP is not allowed.', receive_pack_disabled_over_http: 'Pushing over HTTP is not allowed.', - readonly: 'The repository is temporarily read-only. Please try again later.' + read_only: 'The repository is temporarily read-only. Please try again later.', + cannot_push_to_read_only: "You can't push code to a read-only GitLab instance." }.freeze DOWNLOAD_COMMANDS = %w{ git-upload-pack git-upload-archive }.freeze @@ -161,7 +162,11 @@ module Gitlab def check_push_access!(changes) if project.repository_read_only? - raise UnauthorizedError, ERROR_MESSAGES[:readonly] + raise UnauthorizedError, ERROR_MESSAGES[:read_only] + end + + if Gitlab::Database.read_only? + raise UnauthorizedError, ERROR_MESSAGES[:cannot_push_to_read_only] end if deploy_key diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb index 1fe5155c093..98f1f45b338 100644 --- a/lib/gitlab/git_access_wiki.rb +++ b/lib/gitlab/git_access_wiki.rb @@ -1,6 +1,7 @@ module Gitlab class GitAccessWiki < GitAccess ERROR_MESSAGES = { + read_only: "You can't push code to a read-only GitLab instance.", write_to_wiki: "You are not allowed to write to this project's wiki." }.freeze @@ -17,6 +18,10 @@ module Gitlab raise UnauthorizedError, ERROR_MESSAGES[:write_to_wiki] end + if Gitlab::Database.read_only? + raise UnauthorizedError, ERROR_MESSAGES[:read_only] + end + true end end diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index b7fb9dde2bc..1991911ef6a 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -10,7 +10,7 @@ module Gitlab .select(:id, :commit_sha, :project_id) .where('gpg_key_id IS NULL OR verification_status <> ?', GpgSignature.verification_statuses[:verified]) .where(gpg_key_primary_keyid: @gpg_key.keyids) - .find_each { |sig| sig.gpg_commit.update_signature!(sig) } + .find_each { |sig| sig.gpg_commit&.update_signature!(sig) } end end end diff --git a/lib/gitlab/ldap/auth_hash.rb b/lib/gitlab/ldap/auth_hash.rb index 4fbc5fa5262..3123da17fd9 100644 --- a/lib/gitlab/ldap/auth_hash.rb +++ b/lib/gitlab/ldap/auth_hash.rb @@ -3,6 +3,10 @@ module Gitlab module LDAP class AuthHash < Gitlab::OAuth::AuthHash + def uid + Gitlab::LDAP::Person.normalize_dn(super) + end + private def get_info(key) diff --git a/lib/gitlab/ldap/dn.rb b/lib/gitlab/ldap/dn.rb new file mode 100644 index 00000000000..d6142dc6549 --- /dev/null +++ b/lib/gitlab/ldap/dn.rb @@ -0,0 +1,301 @@ +# -*- ruby encoding: utf-8 -*- + +# Based on the `ruby-net-ldap` gem's `Net::LDAP::DN` +# +# For our purposes, this class is used to normalize DNs in order to allow proper +# comparison. +# +# E.g. DNs should be compared case-insensitively (in basically all LDAP +# implementations or setups), therefore we downcase every DN. + +## +# Objects of this class represent an LDAP DN ("Distinguished Name"). A DN +# ("Distinguished Name") is a unique identifier for an entry within an LDAP +# directory. It is made up of a number of other attributes strung together, +# to identify the entry in the tree. +# +# Each attribute that makes up a DN needs to have its value escaped so that +# the DN is valid. This class helps take care of that. +# +# A fully escaped DN needs to be unescaped when analysing its contents. This +# class also helps take care of that. +module Gitlab + module LDAP + class DN + FormatError = Class.new(StandardError) + MalformedError = Class.new(FormatError) + UnsupportedError = Class.new(FormatError) + + def self.normalize_value(given_value) + dummy_dn = "placeholder=#{given_value}" + normalized_dn = new(*dummy_dn).to_normalized_s + normalized_dn.sub(/\Aplaceholder=/, '') + end + + ## + # Initialize a DN, escaping as required. Pass in attributes in name/value + # pairs. If there is a left over argument, it will be appended to the dn + # without escaping (useful for a base string). + # + # Most uses of this class will be to escape a DN, rather than to parse it, + # so storing the dn as an escaped String and parsing parts as required + # with a state machine seems sensible. + def initialize(*args) + if args.length > 1 + initialize_array(args) + else + initialize_string(args[0]) + end + end + + ## + # Parse a DN into key value pairs using ASN from + # http://tools.ietf.org/html/rfc2253 section 3. + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def each_pair + state = :key + key = StringIO.new + value = StringIO.new + hex_buffer = "" + + @dn.each_char.with_index do |char, dn_index| + case state + when :key then + case char + when 'a'..'z', 'A'..'Z' then + state = :key_normal + key << char + when '0'..'9' then + state = :key_oid + key << char + when ' ' then state = :key + else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"") + end + when :key_normal then + case char + when '=' then state = :value + when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"") + end + when :key_oid then + case char + when '=' then state = :value + when '0'..'9', '.', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"") + end + when :value then + case char + when '\\' then state = :value_normal_escape + when '"' then state = :value_quoted + when ' ' then state = :value + when '#' then + state = :value_hexstring + value << char + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else + state = :value_normal + value << char + end + when :value_normal then + case char + when '\\' then state = :value_normal_escape + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported") + else value << char + end + when :value_normal_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal_escape_hex + hex_buffer = char + else + state = :value_normal + value << char + end + when :value_normal_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"") + end + when :value_quoted then + case char + when '\\' then state = :value_quoted_escape + when '"' then state = :value_end + else value << char + end + when :value_quoted_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted_escape_hex + hex_buffer = char + else + state = :value_quoted + value << char + end + when :value_quoted_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"") + end + when :value_hexstring then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring_hex + value << char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"") + end + when :value_hexstring_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring + value << char + else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"") + end + when :value_end then + case char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"") + end + else raise "Fell out of state machine" + end + end + + # Last pair + raise(MalformedError, 'DN string ended unexpectedly') unless + [:value, :value_normal, :value_hexstring, :value_end].include? state + + yield key.string.strip, rstrip_except_escaped(value.string, @dn.length) + end + + def rstrip_except_escaped(str, dn_index) + str_ends_with_whitespace = str.match(/\s\z/) + + if str_ends_with_whitespace + dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/) + + if dn_part_ends_with_escaped_whitespace + dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1] + num_chars_to_remove = dn_part_rwhitespace.length - 1 + str = str[0, str.length - num_chars_to_remove] + else + str.rstrip! + end + end + + str + end + + ## + # Returns the DN as an array in the form expected by the constructor. + def to_a + a = [] + self.each_pair { |key, value| a << key << value } unless @dn.empty? + a + end + + ## + # Return the DN as an escaped string. + def to_s + @dn + end + + ## + # Return the DN as an escaped and normalized string. + def to_normalized_s + self.class.new(*to_a).to_s.downcase + end + + # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions + # for DN values. All of the following must be escaped in any normal string + # using a single backslash ('\') as escape. The space character is left + # out here because in a "normalized" string, spaces should only be escaped + # if necessary (i.e. leading or trailing space). + NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze + + # The following must be represented as escaped hex + HEX_ESCAPES = { + "\n" => '\0a', + "\r" => '\0d' + }.freeze + + # Compiled character class regexp using the keys from the above hash, and + # checking for a space or # at the start, or space at the end, of the + # string. + ESCAPE_RE = Regexp.new("(^ |^#| $|[" + + NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join + + "])") + + HEX_ESCAPE_RE = Regexp.new("([" + + HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join + + "])") + + ## + # Escape a string for use in a DN value + def self.escape(string) + escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char } + escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] } + end + + private + + def initialize_array(args) + buffer = StringIO.new + + args.each_with_index do |arg, index| + if index.even? # key + buffer << "," if index > 0 + buffer << arg + else # value + buffer << "=" + buffer << self.class.escape(arg) + end + end + + @dn = buffer.string + end + + def initialize_string(arg) + @dn = arg.to_s + end + + ## + # Proxy all other requests to the string object, because a DN is mainly + # used within the library as a string + # rubocop:disable GitlabSecurity/PublicSend + def method_missing(method, *args, &block) + @dn.send(method, *args, &block) + end + + ## + # Redefined to be consistent with redefined `method_missing` behavior + def respond_to?(sym, include_private = false) + @dn.respond_to?(sym, include_private) + end + end + end +end diff --git a/lib/gitlab/ldap/person.rb b/lib/gitlab/ldap/person.rb index 9a6f7827b16..38d7a9ba2f5 100644 --- a/lib/gitlab/ldap/person.rb +++ b/lib/gitlab/ldap/person.rb @@ -36,6 +36,26 @@ module Gitlab ] end + def self.normalize_dn(dn) + ::Gitlab::LDAP::DN.new(dn).to_normalized_s + rescue ::Gitlab::LDAP::DN::FormatError => e + Rails.logger.info("Returning original DN \"#{dn}\" due to error during normalization attempt: #{e.message}") + + dn + end + + # Returns the UID in a normalized form. + # + # 1. Excess spaces are stripped + # 2. The string is downcased (for case-insensitivity) + def self.normalize_uid(uid) + ::Gitlab::LDAP::DN.normalize_value(uid) + rescue ::Gitlab::LDAP::DN::FormatError => e + Rails.logger.info("Returning original UID \"#{uid}\" due to error during normalization attempt: #{e.message}") + + uid + end + def initialize(entry, provider) Rails.logger.debug { "Instantiating #{self.class.name} with LDIF:\n#{entry.to_ldif}" } @entry = entry @@ -58,7 +78,9 @@ module Gitlab attribute_value(:email) end - delegate :dn, to: :entry + def dn + self.class.normalize_dn(entry.dn) + end private diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb new file mode 100644 index 00000000000..0de0cddcce4 --- /dev/null +++ b/lib/gitlab/middleware/read_only.rb @@ -0,0 +1,88 @@ +module Gitlab + module Middleware + class ReadOnly + DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze + APPLICATION_JSON = 'application/json'.freeze + API_VERSIONS = (3..4) + + def initialize(app) + @app = app + @whitelisted = internal_routes + end + + def call(env) + @env = env + + if disallowed_request? && Gitlab::Database.read_only? + Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') + error_message = 'You cannot do writing operations on a read-only GitLab instance' + + if json_request? + return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] + else + rack_flash.alert = error_message + rack_session['flash'] = rack_flash.to_session_value + + return [301, { 'Location' => last_visited_url }, []] + end + end + + @app.call(env) + end + + private + + def internal_routes + API_VERSIONS.flat_map { |version| "api/v#{version}/internal" } + end + + def disallowed_request? + DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && !whitelisted_routes + end + + def json_request? + request.media_type == APPLICATION_JSON + end + + def rack_flash + @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session) + end + + def rack_session + @env['rack.session'] + end + + def request + @env['rack.request'] ||= Rack::Request.new(@env) + end + + def last_visited_url + @env['HTTP_REFERER'] || rack_session['user_return_to'] || Rails.application.routes.url_helpers.root_url + end + + def route_hash + @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} + end + + def whitelisted_routes + logout_route || grack_route || @whitelisted.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route + end + + def logout_route + route_hash[:controller] == 'sessions' && route_hash[:action] == 'destroy' + end + + def sidekiq_route + request.path.start_with?('/admin/sidekiq') + end + + def grack_route + request.path.end_with?('.git/git-upload-pack') + end + + def lfs_route + request.path.end_with?('/info/lfs/objects/batch') + end + end + end +end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 58f6245579a..bd677ec4bf3 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -65,5 +65,9 @@ module Gitlab "can contain only lowercase letters, digits, and '-'. " \ "Must start with a letter, and cannot end with '-'" end + + def build_trace_section_regex + @build_trace_section_regexp ||= /section_((?:start)|(?:end)):(\d+):([^\r]+)\r\033\[0K/.freeze + end end end diff --git a/lib/system_check/app/git_user_default_ssh_config_check.rb b/lib/system_check/app/git_user_default_ssh_config_check.rb index dfa8b8b3f5b..9af21078403 100644 --- a/lib/system_check/app/git_user_default_ssh_config_check.rb +++ b/lib/system_check/app/git_user_default_ssh_config_check.rb @@ -11,10 +11,10 @@ module SystemCheck ].freeze set_name 'Git user has default SSH configuration?' - set_skip_reason 'skipped (git user is not present or configured)' + set_skip_reason 'skipped (GitLab read-only, or git user is not present / configured)' def skip? - !home_dir || !File.directory?(home_dir) + Gitlab::Database.read_only? || !home_dir || !File.directory?(home_dir) end def check? diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7569af9d175..c73e582e608 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-04 23:47+0100\n" -"PO-Revision-Date: 2017-10-04 23:47+0100\n" +"POT-Creation-Date: 2017-10-06 18:33+0200\n" +"PO-Revision-Date: 2017-10-06 18:33+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -23,6 +23,11 @@ msgid_plural "%d commits" msgstr[0] "" msgstr[1] "" +msgid "%d layer" +msgid_plural "%d layers" +msgstr[0] "" +msgstr[1] "" + msgid "%s additional commit has been omitted to prevent performance issues." msgid_plural "%s additional commits have been omitted to prevent performance issues." msgstr[0] "" @@ -59,6 +64,9 @@ msgstr[1] "" msgid "1st contribution!" msgstr "" +msgid "2FA enabled" +msgstr "" + msgid "A collection of graphs regarding Continuous Integration" msgstr "" @@ -528,6 +536,51 @@ msgstr "" msgid "Compare" msgstr "" +msgid "Container Registry" +msgstr "" + +msgid "ContainerRegistry|Created" +msgstr "" + +msgid "ContainerRegistry|First log in to GitLab’s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:" +msgstr "" + +msgid "ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:" +msgstr "" + +msgid "ContainerRegistry|How to use the Container Registry" +msgstr "" + +msgid "ContainerRegistry|Learn more about" +msgstr "" + +msgid "ContainerRegistry|No tags in Container Registry for this container image." +msgstr "" + +msgid "ContainerRegistry|Once you log in, you’re free to create and upload a container image using the common %{build} and %{push} commands" +msgstr "" + +msgid "ContainerRegistry|Remove repository" +msgstr "" + +msgid "ContainerRegistry|Remove tag" +msgstr "" + +msgid "ContainerRegistry|Size" +msgstr "" + +msgid "ContainerRegistry|Tag" +msgstr "" + +msgid "ContainerRegistry|Tag ID" +msgstr "" + +msgid "ContainerRegistry|Use different image names" +msgstr "" + +msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images." +msgstr "" + msgid "Contribution guide" msgstr "" @@ -739,6 +792,9 @@ msgstr[1] "" msgid "ForkedFromProjectPath|Forked from" msgstr "" +msgid "ForkedFromProjectPath|Forked from %{project_name} (deleted)" +msgstr "" + msgid "Format" msgstr "" @@ -949,6 +1005,9 @@ msgstr "" msgid "New tag" msgstr "" +msgid "No container images stored for this project. Add one by following the instructions above." +msgstr "" + msgid "No repository" msgstr "" @@ -1024,6 +1083,9 @@ msgstr "" msgid "OpenedNDaysAgo|Opened" msgstr "" +msgid "Opens in a new window" +msgstr "" + msgid "Options" msgstr "" @@ -1350,6 +1412,15 @@ msgstr[1] "" msgid "Snippets" msgstr "" +msgid "Something went wrong on our end." +msgstr "" + +msgid "Something went wrong while fetching the projects." +msgstr "" + +msgid "Something went wrong while fetching the registry list." +msgstr "" + msgid "SortOptions|Access level, ascending" msgstr "" @@ -1905,3 +1976,6 @@ msgid "parent" msgid_plural "parents" msgstr[0] "" msgstr[1] "" + +msgid "personal access token" +msgstr "" diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 64b9af7b845..fb76b7fdf38 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe Projects::BlobController do + include ProjectForksHelper + let(:project) { create(:project, :public, :repository) } describe "GET show" do @@ -226,9 +228,8 @@ describe Projects::BlobController do end context 'when user has forked project' do - let(:forked_project_link) { create(:forked_project_link, forked_from_project: project) } - let!(:forked_project) { forked_project_link.forked_to_project } - let(:guest) { forked_project.owner } + let!(:forked_project) { fork_project(project, guest, namespace: guest.namespace, repository: true) } + let(:guest) { create(:user) } before do sign_in(guest) diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index fad2c8f3ab7..7260350d5fb 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::MergeRequests::DiffsController do + include ProjectForksHelper + let(:project) { create(:project, :repository) } let(:user) { project.owner } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } @@ -37,12 +39,12 @@ describe Projects::MergeRequests::DiffsController do render_views let(:project) { create(:project, :repository) } - let(:fork_project) { create(:forked_project_with_submodules) } - let(:merge_request) { create(:merge_request_with_diffs, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } + let(:forked_project) { fork_project_with_submodules(project) } + let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } before do - fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) - fork_project.save + project.add_developer(user) + merge_request.reload go end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index e46d1995498..707e7c32283 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::MergeRequestsController do + include ProjectForksHelper + let(:project) { create(:project, :repository) } let(:user) { project.owner } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } @@ -204,14 +206,11 @@ describe Projects::MergeRequestsController do context 'there is no source project' do let(:project) { create(:project, :repository) } - let(:fork_project) { create(:forked_project_with_submodules) } - let(:merge_request) { create(:merge_request, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } + let(:forked_project) { fork_project_with_submodules(project) } + let!(:merge_request) { create(:merge_request, source_project: forked_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } before do - fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) - fork_project.save - merge_request.reload - fork_project.destroy + forked_project.destroy end it 'closes MR without errors' do @@ -599,21 +598,16 @@ describe Projects::MergeRequestsController do describe 'GET ci_environments_status' do context 'the environment is from a forked project' do - let!(:forked) { create(:project, :repository) } + let!(:forked) { fork_project(project, user, repository: true) } let!(:environment) { create(:environment, project: forked) } let!(:deployment) { create(:deployment, environment: environment, sha: forked.commit.id, ref: 'master') } let(:admin) { create(:admin) } let(:merge_request) do - create(:forked_project_link, forked_to_project: forked, - forked_from_project: project) - create(:merge_request, source_project: forked, target_project: project) end before do - forked.team << [user, :master] - get :ci_environments_status, namespace_id: merge_request.project.namespace.to_param, project_id: merge_request.project, diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index e3114e5c06e..135fd6449ff 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::NotesController do + include ProjectForksHelper + let(:user) { create(:user) } let(:project) { create(:project) } let(:issue) { create(:issue, project: project) } @@ -210,18 +212,16 @@ describe Projects::NotesController do context 'when creating a commit comment from an MR fork' do let(:project) { create(:project, :repository) } - let(:fork_project) do - create(:project, :repository).tap do |fork| - create(:forked_project_link, forked_to_project: fork, forked_from_project: project) - end + let(:forked_project) do + fork_project(project, nil, repository: true) end let(:merge_request) do - create(:merge_request, source_project: fork_project, target_project: project, source_branch: 'feature', target_branch: 'master') + create(:merge_request, source_project: forked_project, target_project: project, source_branch: 'feature', target_branch: 'master') end let(:existing_comment) do - create(:note_on_commit, note: 'a note', project: fork_project, commit_id: merge_request.commit_shas.first) + create(:note_on_commit, note: 'a note', project: forked_project, commit_id: merge_request.commit_shas.first) end def post_create(extra_params = {}) @@ -231,7 +231,7 @@ describe Projects::NotesController do project_id: project, target_type: 'merge_request', target_id: merge_request.id, - note_project_id: fork_project.id, + note_project_id: forked_project.id, in_reply_to_discussion_id: existing_comment.discussion_id }.merge(extra_params) end @@ -253,16 +253,16 @@ describe Projects::NotesController do end context 'when the user has access to the fork' do - let(:discussion) { fork_project.notes.find_discussion(existing_comment.discussion_id) } + let(:discussion) { forked_project.notes.find_discussion(existing_comment.discussion_id) } before do - fork_project.add_developer(user) + forked_project.add_developer(user) existing_comment end it 'creates the note' do - expect { post_create }.to change { fork_project.notes.count }.by(1) + expect { post_create }.to change { forked_project.notes.count }.by(1) end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 491f35d0fb6..d7148e888ab 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -1,6 +1,8 @@ require('spec_helper') describe ProjectsController do + include ProjectForksHelper + let(:project) { create(:project) } let(:public_project) { create(:project, :public) } let(:user) { create(:user) } @@ -377,10 +379,10 @@ describe ProjectsController do context "when the project is forked" do let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, forked_from_project: project) } + let(:forked_project) { fork_project(project, nil, repository: true) } let(:merge_request) do create(:merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end @@ -388,7 +390,7 @@ describe ProjectsController do project.merge_requests << merge_request sign_in(admin) - delete :destroy, namespace_id: fork_project.namespace, id: fork_project + delete :destroy, namespace_id: forked_project.namespace, id: forked_project expect(merge_request.reload.state).to eq('closed') end @@ -455,18 +457,14 @@ describe ProjectsController do end context 'with forked project' do - let(:project_fork) { create(:project, :repository, namespace: user.namespace) } - - before do - create(:forked_project_link, forked_to_project: project_fork) - end + let(:forked_project) { fork_project(create(:project, :public), user) } it 'removes fork from project' do delete(:remove_fork, - namespace_id: project_fork.namespace.to_param, - id: project_fork.to_param, format: :js) + namespace_id: forked_project.namespace.to_param, + id: forked_project.to_param, format: :js) - expect(project_fork.forked?).to be_falsey + expect(forked_project.reload.forked?).to be_falsey expect(flash[:notice]).to eq('The fork relationship has been removed.') expect(response).to render_template(:remove_fork) end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 5a4ab39ab86..1d3ddfbd220 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -76,12 +76,68 @@ describe RegistrationsController do sign_in(user) end - it 'schedules the user for destruction' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {}) + def expect_failure(message) + expect(flash[:alert]).to eq(message) + expect(response.status).to eq(303) + expect(response).to redirect_to profile_account_path + end + + def expect_password_failure + expect_failure('Invalid password') + end + + def expect_username_failure + expect_failure('Invalid username') + end + + def expect_success + expect(flash[:notice]).to eq 'Account scheduled for removal.' + expect(response.status).to eq(303) + expect(response).to redirect_to new_user_session_path + end - post(:destroy) + context 'user requires password confirmation' do + it 'fails if password confirmation is not provided' do + post :destroy - expect(response.status).to eq(302) + expect_password_failure + end + + it 'fails if password confirmation is wrong' do + post :destroy, password: 'wrong password' + + expect_password_failure + end + + it 'succeeds if password is confirmed' do + post :destroy, password: '12345678' + + expect_success + end + end + + context 'user does not require password confirmation' do + before do + stub_application_setting(password_authentication_enabled: false) + end + + it 'fails if username confirmation is not provided' do + post :destroy + + expect_username_failure + end + + it 'fails if username confirmation is wrong' do + post :destroy, username: 'wrong username' + + expect_username_failure + end + + it 'succeeds if username is confirmed' do + post :destroy, username: user.username + + expect_success + end end end end diff --git a/spec/factories/ci/build_trace_section_names.rb b/spec/factories/ci/build_trace_section_names.rb new file mode 100644 index 00000000000..1c16225f0e5 --- /dev/null +++ b/spec/factories/ci/build_trace_section_names.rb @@ -0,0 +1,6 @@ +FactoryGirl.define do + factory :ci_build_trace_section_name, class: Ci::BuildTraceSectionName do + sequence(:name) { |n| "section_#{n}" } + project factory: :project + end +end diff --git a/spec/factories/fork_networks.rb b/spec/factories/fork_networks.rb new file mode 100644 index 00000000000..f42d36f3d19 --- /dev/null +++ b/spec/factories/fork_networks.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :fork_network do + association :root_project, factory: :project + end +end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index cbec716d6ea..2c732aaf4ed 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -22,6 +22,11 @@ FactoryGirl.define do trait :with_diffs do end + trait :with_image_diffs do + source_branch "add_images_and_changes" + target_branch "master" + end + trait :without_diffs do source_branch "improve/awesome" target_branch "master" diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 958d62181a2..4034e7905ad 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -149,7 +149,7 @@ FactoryGirl.define do end end - trait :readonly do + trait :read_only do repository_read_only true end diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 8204828b5b9..d26428ec286 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -3,12 +3,13 @@ require 'spec_helper' feature 'Dashboard Merge Requests' do include FilterItemSelectHelper include SortingHelper + include ProjectForksHelper let(:current_user) { create :user } let(:project) { create(:project) } let(:public_project) { create(:project, :public, :repository) } - let(:forked_project) { Projects::ForkService.new(public_project, current_user).execute } + let(:forked_project) { fork_project(public_project, current_user, repository: true) } before do project.add_master(current_user) diff --git a/spec/features/explore/new_menu_spec.rb b/spec/features/explore/new_menu_spec.rb index e1c74a24890..c5ec495a418 100644 --- a/spec/features/explore/new_menu_spec.rb +++ b/spec/features/explore/new_menu_spec.rb @@ -128,12 +128,6 @@ feature 'Top Plus Menu', :js do expect(find('.header-new.dropdown')).not_to have_selector('.header-new-project-snippet') end - scenario 'public project has no New Issue Button' do - visit project_path(public_project) - - hasnot_topmenuitem("New issue") - end - scenario 'public project has no New merge request menu item' do visit project_path(public_project) diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb index 28b636f9359..c0c396af93f 100644 --- a/spec/features/issues/issue_detail_spec.rb +++ b/spec/features/issues/issue_detail_spec.rb @@ -28,8 +28,7 @@ feature 'Issue Detail', :js do fill_in 'issue-title', with: 'issue title' click_button 'Save' - visit profile_account_path - click_link 'Delete account' + Users::DestroyService.new(user).execute(user) visit project_issue_path(project, issue) end diff --git a/spec/features/merge_requests/create_new_mr_from_fork_spec.rb b/spec/features/merge_requests/create_new_mr_from_fork_spec.rb new file mode 100644 index 00000000000..93c40ff6443 --- /dev/null +++ b/spec/features/merge_requests/create_new_mr_from_fork_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +feature 'Creating a merge request from a fork', :js do + include ProjectForksHelper + + let(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let!(:source_project) do + fork_project(project, user, + repository: true, + namespace: user.namespace) + end + + before do + source_project.add_master(user) + + sign_in(user) + end + + shared_examples 'create merge request to other project' do + it 'has all possible target projects' do + visit project_new_merge_request_path(source_project) + + first('.js-target-project').click + + within('.dropdown-target-project .dropdown-content') do + expect(page).to have_content(project.full_path) + expect(page).to have_content(target_project.full_path) + expect(page).to have_content(source_project.full_path) + end + end + + it 'allows creating the merge request to another target project' do + visit project_merge_requests_path(source_project) + + page.within '.content' do + click_link 'New merge request' + end + + find('.js-source-branch', match: :first).click + find('.dropdown-source-branch .dropdown-content a', match: :first).click + + first('.js-target-project').click + find('.dropdown-target-project .dropdown-content a', text: target_project.full_path).click + + click_button 'Compare branches and continue' + + wait_for_requests + + expect { click_button 'Submit merge request' } + .to change { target_project.merge_requests.reload.size }.by(1) + end + + it 'updates the branches when selecting a new target project' do + target_project_member = target_project.owner + CreateBranchService.new(target_project, target_project_member) + .execute('a-brand-new-branch-to-test', 'master') + visit project_new_merge_request_path(source_project) + + first('.js-target-project').click + find('.dropdown-target-project .dropdown-content a', text: target_project.full_path).click + + wait_for_requests + + first('.js-target-branch').click + + within('.dropdown-target-branch .dropdown-content') do + expect(page).to have_content('a-brand-new-branch-to-test') + end + end + end + + context 'creating to the source of a fork' do + let!(:target_project) { project } + + it_behaves_like('create merge request to other project') + end + + context 'creating to a sibling of a fork' do + let!(:target_project) do + other_user = create(:user) + fork_project(project, other_user, + repository: true, + namespace: other_user.namespace) + end + + it_behaves_like('create merge request to other project') + end +end diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 09541873f71..f9bc3ee6c58 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -1,21 +1,20 @@ require 'spec_helper' feature 'Merge request created from fork' do + include ProjectForksHelper + given(:user) { create(:user) } given(:project) { create(:project, :public, :repository) } - given(:fork_project) { create(:project, :public, :repository) } + given(:forked_project) { fork_project(project, user, repository: true) } given!(:merge_request) do - create(:forked_project_link, forked_to_project: fork_project, - forked_from_project: project) - - create(:merge_request_with_diffs, source_project: fork_project, + create(:merge_request_with_diffs, source_project: forked_project, target_project: project, description: 'Test merge request') end background do - fork_project.team << [user, :master] + forked_project.team << [user, :master] sign_in user end @@ -31,7 +30,7 @@ feature 'Merge request created from fork' do background do create(:note_on_commit, note: comment, - project: fork_project, + project: forked_project, commit_id: merge_request.commit_shas.first) end @@ -55,7 +54,7 @@ feature 'Merge request created from fork' do context 'source project is deleted' do background do MergeRequests::MergeService.new(project, user).execute(merge_request) - fork_project.destroy! + forked_project.destroy! end scenario 'user can access merge request', js: true do @@ -69,7 +68,7 @@ feature 'Merge request created from fork' do context 'pipeline present in source project' do given(:pipeline) do create(:ci_pipeline, - project: fork_project, + project: forked_project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) end diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index e9068f722d5..ee9bb50a881 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Diffs URL', js: true do + include ProjectForksHelper + let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } @@ -42,8 +44,12 @@ feature 'Diffs URL', js: true do visit "#{diffs_project_merge_request_path(project, merge_request)}#{fragment}" end - it 'shows expanded note' do - expect(page).to have_selector(fragment, visible: true) + it 'shows collapsed note' do + wait_for_requests + + expect(page).to have_selector('.discussion-notes.collapsed') do |note_container| + expect(note_container).to have_selector(fragment, visible: false) + end end end end @@ -64,7 +70,7 @@ feature 'Diffs URL', js: true do context 'when editing file' do let(:author_user) { create(:user) } let(:user) { create(:user) } - let(:forked_project) { Projects::ForkService.new(project, author_user).execute } + let(:forked_project) { fork_project(project, author_user, repository: true) } let(:merge_request) { create(:merge_request_with_diffs, source_project: forked_project, target_project: project, author: author_user) } let(:changelog_id) { Digest::SHA1.hexdigest("CHANGELOG") } diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb index de98b147d04..758fc9b139d 100644 --- a/spec/features/merge_requests/form_spec.rb +++ b/spec/features/merge_requests/form_spec.rb @@ -1,8 +1,10 @@ require 'rails_helper' describe 'New/edit merge request', :js do + include ProjectForksHelper + let!(:project) { create(:project, :public, :repository) } - let(:fork_project) { create(:project, :repository, forked_from_project: project) } + let(:forked_project) { fork_project(project, nil, repository: true) } let!(:user) { create(:user) } let!(:user2) { create(:user) } let!(:milestone) { create(:milestone, project: project) } @@ -170,16 +172,16 @@ describe 'New/edit merge request', :js do context 'forked project' do before do - fork_project.team << [user, :master] + forked_project.team << [user, :master] sign_in(user) end context 'new merge request' do before do visit project_new_merge_request_path( - fork_project, + forked_project, merge_request: { - source_project_id: fork_project.id, + source_project_id: forked_project.id, target_project_id: project.id, source_branch: 'fix', target_branch: 'master' @@ -238,7 +240,7 @@ describe 'New/edit merge request', :js do context 'edit merge request' do before do merge_request = create(:merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project, source_branch: 'fix', target_branch: 'master' diff --git a/spec/features/merge_requests/image_diff_notes.rb b/spec/features/merge_requests/image_diff_notes.rb new file mode 100644 index 00000000000..e8ca8a98d70 --- /dev/null +++ b/spec/features/merge_requests/image_diff_notes.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +feature 'image diff notes', js: true do + include NoteInteractionHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + + before do + project.team << [user, :master] + sign_in user + + page.driver.set_cookie('sidebar_collapsed', 'true') + + # Stub helper to return any blob file as image from public app folder. + # This is necessary to run this specs since we don't display repo images in capybara. + allow_any_instance_of(DiffHelper).to receive(:diff_file_blob_raw_path).and_return('/apple-touch-icon.png') + end + + context 'create commit diff notes' do + commit_id = '2f63565e7aac07bcdadb654e253078b727143ec4' + + describe 'create a new diff note' do + before do + visit project_commit_path(project, commit_id) + create_image_diff_note + end + + it 'shows indicator badge on image diff' do + indicator = find('.js-image-badge') + + expect(indicator).to have_content('1') + end + + it 'shows the avatar badge on the new note' do + badge = find('.image-diff-avatar-link .badge') + + expect(badge).to have_content('1') + end + + it 'allows collapsing/expanding the discussion notes' do + find('.js-diff-notes-toggle', :first).click + + expect(page).not_to have_content('image diff test comment') + + find('.js-diff-notes-toggle').click + + expect(page).to have_content('image diff test comment') + end + end + + describe 'render commit diff notes' do + let(:path) { "files/images/6049019_460s.jpg" } + let(:commit) { project.commit('2f63565e7aac07bcdadb654e253078b727143ec4') } + + let(:note1_position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + width: 100, + height: 100, + x: 10, + y: 10, + position_type: "image", + diff_refs: commit.diff_refs + ) + end + + let(:note2_position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + width: 100, + height: 100, + x: 20, + y: 20, + position_type: "image", + diff_refs: commit.diff_refs + ) + end + + let!(:note1) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note1_position, note: 'my note 1') } + let!(:note2) { create(:diff_note_on_commit, commit_id: commit.id, project: project, position: note2_position, note: 'my note 2') } + + before do + visit project_commit_path(project, commit.id) + wait_for_requests + end + + it 'render diff indicators within the image diff frame' do + expect(page).to have_css('.js-image-badge', count: 2) + end + + it 'shows the diff notes' do + expect(page).to have_css('.diff-content .note', count: 2) + end + + it 'shows the diff notes with correct avatar badge numbers' do + expect(page).to have_css('.image-diff-avatar-link', text: 1) + expect(page).to have_css('.image-diff-avatar-link', text: 2) + end + end + end + + %w(inline parallel).each do |view| + context "#{view} view" do + let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) } + let(:path) { "files/images/ee_repo_logo.png" } + + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + width: 100, + height: 100, + x: 1, + y: 1, + position_type: "image", + diff_refs: merge_request.diff_refs + ) + end + + let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) } + + describe 'creating a new diff note' do + before do + visit diffs_project_merge_request_path(project, merge_request, view: view) + create_image_diff_note + end + + it 'shows indicator badge on image diff' do + indicator = find('.js-image-badge', match: :first) + + expect(indicator).to have_content('1') + end + + it 'shows the avatar badge on the new note' do + badge = find('.image-diff-avatar-link .badge', match: :first) + + expect(badge).to have_content('1') + end + + it 'allows expanding/collapsing the discussion notes' do + page.all('.js-diff-notes-toggle')[0].trigger('click') + page.all('.js-diff-notes-toggle')[1].trigger('click') + + expect(page).not_to have_content('image diff test comment') + + page.all('.js-diff-notes-toggle')[0].trigger('click') + page.all('.js-diff-notes-toggle')[1].trigger('click') + + expect(page).to have_content('image diff test comment') + end + end + end + end + + describe 'discussion tab polling', :js do + let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, author: user) } + let(:path) { "files/images/ee_repo_logo.png" } + + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + width: 100, + height: 100, + x: 50, + y: 50, + position_type: "image", + diff_refs: merge_request.diff_refs + ) + end + + before do + visit project_merge_request_path(project, merge_request) + end + + it 'render diff indicators within the image frame' do + diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + + wait_for_requests + + expect(page).to have_selector('.image-comment-badge') + expect(page).to have_content(diff_note.note) + end + end +end + +def create_image_diff_note + find('.js-add-image-diff-note-button', match: :first).click + page.all('.js-add-image-diff-note-button')[0].trigger('click') + find('.diff-content .note-textarea').native.send_keys('image diff test comment') + click_button 'Comment' + wait_for_requests +end diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb index e1ca4fe186c..7a773fb2baa 100644 --- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb @@ -227,6 +227,7 @@ feature 'Merge requests > User posts diff notes', :js do write_comment_on_line(line_holder, diff_side) click_button 'Comment' + wait_for_requests assert_comment_persistence(line_holder, asset_form_reset: asset_form_reset) diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index f183dd8cb75..1cddd35fd8a 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -12,11 +12,47 @@ describe 'Profile account page' do visit profile_account_path end - it { expect(page).to have_content('Remove account') } + it { expect(page).to have_content('Delete account') } - it 'deletes the account' do - expect { click_link 'Delete account' }.to change { User.where(id: user.id).count }.by(-1) - expect(current_path).to eq(new_user_session_path) + it 'does not immediately delete the account' do + click_button 'Delete account' + + expect(User.exists?(user.id)).to be_truthy + end + + it 'deletes user', :js do + click_button 'Delete account' + + fill_in 'password', with: '12345678' + + page.within '.popup-dialog' do + click_button 'Delete account' + end + + expect(page).to have_content('Account scheduled for removal') + expect(User.exists?(user.id)).to be_falsy + end + + it 'shows invalid password flash message', :js do + click_button 'Delete account' + + fill_in 'password', with: 'testing123' + + page.within '.popup-dialog' do + click_button 'Delete account' + end + + expect(page).to have_content('Invalid password') + end + + it 'does not show delete button when user owns a group' do + group = create(:group) + group.add_owner(user) + + visit profile_account_path + + expect(page).not_to have_button('Delete account') + expect(page).to have_content("Your account is currently an owner in these groups: #{group.name}") end end diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index 1f9b52dd998..c9b8ef4e37b 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'issuable templates', js: true do + include ProjectForksHelper + let(:user) { create(:user) } let(:project) { create(:project, :public, :repository) } let(:issue_form_location) { '#content-body .issuable-details .detail-page-description' } @@ -116,15 +118,13 @@ feature 'issuable templates', js: true do context 'user creates a merge request from a forked project using templates' do let(:template_content) { 'this is a test "feature-proposal" template' } let(:fork_user) { create(:user) } - let(:fork_project) { create(:project, :public, :repository) } - let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project, target_project: project) } + let(:forked_project) { fork_project(project, fork_user) } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: forked_project, target_project: project) } background do sign_out(:user) project.team << [fork_user, :developer] - fork_project.team << [fork_user, :master] - create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project) sign_in(fork_user) diff --git a/spec/features/projects/user_creates_directory_spec.rb b/spec/features/projects/user_creates_directory_spec.rb index 1ba5d83eadf..e8f2e4813c5 100644 --- a/spec/features/projects/user_creates_directory_spec.rb +++ b/spec/features/projects/user_creates_directory_spec.rb @@ -79,7 +79,7 @@ feature 'User creates a directory', js: true do fill_in(:commit_message, with: 'New commit message', visible: true) click_button('Create directory') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) end diff --git a/spec/features/projects/user_creates_files_spec.rb b/spec/features/projects/user_creates_files_spec.rb index 3d335687510..51d918bc85d 100644 --- a/spec/features/projects/user_creates_files_spec.rb +++ b/spec/features/projects/user_creates_files_spec.rb @@ -142,7 +142,7 @@ describe 'User creates files' do fill_in(:commit_message, with: 'New commit message', visible: true) click_button('Commit changes') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) expect(page).to have_content('New commit message') diff --git a/spec/features/projects/user_deletes_files_spec.rb b/spec/features/projects/user_deletes_files_spec.rb index 95cd316be0e..7f48a69d9b7 100644 --- a/spec/features/projects/user_deletes_files_spec.rb +++ b/spec/features/projects/user_deletes_files_spec.rb @@ -59,7 +59,7 @@ describe 'User deletes files' do fill_in(:commit_message, with: 'New commit message', visible: true) click_button('Delete file') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) expect(page).to have_content('New commit message') diff --git a/spec/features/projects/user_edits_files_spec.rb b/spec/features/projects/user_edits_files_spec.rb index 19954313c23..2798041fa0c 100644 --- a/spec/features/projects/user_edits_files_spec.rb +++ b/spec/features/projects/user_edits_files_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe 'User edits files' do + include ProjectForksHelper let(:project) { create(:project, :repository, name: 'Shop') } let(:project2) { create(:project, :repository, name: 'Another Project', path: 'another-project') } let(:project_tree_path_root_ref) { project_tree_path(project, project.repository.root_ref) } @@ -122,7 +123,7 @@ describe 'User edits files' do fill_in(:commit_message, with: 'New commit message', visible: true) click_button('Commit changes') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) @@ -130,5 +131,34 @@ describe 'User edits files' do expect(page).to have_content('New commit message') end + + context 'when the user already had a fork of the project', :js do + let!(:forked_project) { fork_project(project2, user, namespace: user.namespace, repository: true) } + before do + visit(project2_tree_path_root_ref) + end + + it 'links to the forked project for editing' do + click_link('.gitignore') + find('.js-edit-blob').click + + expect(page).not_to have_link('Fork') + expect(page).not_to have_button('Cancel') + + execute_script("ace.edit('editor').setValue('*.rbca')") + fill_in(:commit_message, with: 'Another commit', visible: true) + click_button('Commit changes') + + fork = user.fork_of(project2) + + expect(current_path).to eq(project_new_merge_request_path(fork)) + + wait_for_requests + + expect(page).to have_content('Another commit') + expect(page).to have_content("From #{forked_project.full_path}") + expect(page).to have_content("into #{project2.full_path}") + end + end end end diff --git a/spec/features/projects/user_replaces_files_spec.rb b/spec/features/projects/user_replaces_files_spec.rb index e284fdefd4f..a9628198d5b 100644 --- a/spec/features/projects/user_replaces_files_spec.rb +++ b/spec/features/projects/user_replaces_files_spec.rb @@ -74,7 +74,7 @@ describe 'User replaces files' do expect(page).to have_content('Replacement file commit message') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) diff --git a/spec/features/projects/user_uploads_files_spec.rb b/spec/features/projects/user_uploads_files_spec.rb index 98871317ca3..8014c299980 100644 --- a/spec/features/projects/user_uploads_files_spec.rb +++ b/spec/features/projects/user_uploads_files_spec.rb @@ -39,6 +39,9 @@ describe 'User uploads files' do expect(current_path).to eq(project_new_merge_request_path(project)) click_link('Changes') + find("a[data-action='diffs']", text: 'Changes').click + + wait_for_requests expect(page).to have_content('Lorem ipsum dolor sit amet') expect(page).to have_content('Sed ut perspiciatis unde omnis') @@ -51,7 +54,7 @@ describe 'User uploads files' do visit(project2_tree_path_root_ref) end - it 'uploads and commit a new fileto a forked project', js: true do + it 'uploads and commit a new file to a forked project', js: true do find('.add-to-tree').click click_link('Upload file') @@ -69,11 +72,13 @@ describe 'User uploads files' do expect(page).to have_content('New commit message') - fork = user.fork_of(project2) + fork = user.fork_of(project2.reload) expect(current_path).to eq(project_new_merge_request_path(fork)) - click_link('Changes') + find("a[data-action='diffs']", text: 'Changes').click + + wait_for_requests expect(page).to have_content('Lorem ipsum dolor sit amet') expect(page).to have_content('Sed ut perspiciatis unde omnis') diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 81f7ab80a04..4b2c54d54b5 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Project' do + include ProjectForksHelper + describe 'creating from template' do let(:user) { create(:user) } let(:template) { Gitlab::ProjectTemplate.find(:rails) } @@ -57,11 +59,10 @@ feature 'Project' do describe 'remove forked relationship', js: true do let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } + let(:project) { fork_project(create(:project, :public), user, namespace_id: user.namespace) } before do sign_in user - create(:forked_project_link, forked_to_project: project) visit edit_project_path(project) end @@ -71,11 +72,60 @@ feature 'Project' do remove_with_confirm('Remove fork relationship', project.path) expect(page).to have_content 'The fork relationship has been removed.' - expect(project.forked?).to be_falsey + expect(project.reload.forked?).to be_falsey expect(page).not_to have_content 'Remove fork relationship' end end + describe 'showing information about source of a project fork' do + let(:user) { create(:user) } + let(:base_project) { create(:project, :public, :repository) } + let(:forked_project) { fork_project(base_project, user, repository: true) } + + before do + sign_in user + end + + it 'shows a link to the source project when it is available' do + visit project_path(forked_project) + + expect(page).to have_content('Forked from') + expect(page).to have_link(base_project.full_name) + end + + it 'does not contain fork network information for the root project' do + forked_project + + visit project_path(base_project) + + expect(page).not_to have_content('In fork network of') + expect(page).not_to have_content('Forked from') + end + + it 'shows the name of the deleted project when the source was deleted' do + forked_project + Projects::DestroyService.new(base_project, base_project.owner).execute + + visit project_path(forked_project) + + expect(page).to have_content("Forked from #{base_project.full_name} (deleted)") + end + + context 'a fork of a fork' do + let(:fork_of_fork) { fork_project(forked_project, user, repository: true) } + + it 'links to the base project if the source project is removed' do + fork_of_fork + Projects::DestroyService.new(forked_project, user).execute + + visit project_path(fork_of_fork) + + expect(page).to have_content("Forked from") + expect(page).to have_link(base_project.full_name) + end + end + end + describe 'removal', js: true do let(:user) { create(:user, username: 'test', name: 'test') } let(:project) { create(:project, namespace: user.namespace, name: 'project1') } diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index aaf3d6d28ca..2ab1eda90f1 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -64,16 +64,24 @@ feature 'Protected Branches', :js do describe "Saved defaults" do it "keeps the allowed to merge and push dropdowns defaults based on the previous selection" do visit project_protected_branches_path(project) - find(".js-allowed-to-merge").trigger('click') - click_link 'No one' - find(".js-allowed-to-push").trigger('click') - click_link 'Developers + Masters' - visit project_protected_branches_path(project) - page.within(".js-allowed-to-merge") do - expect(page.find(".dropdown-toggle-text")).to have_content("No one") + form = '.js-new-protected-branch' + + within form do + find(".js-allowed-to-merge").trigger('click') + click_link 'No one' + find(".js-allowed-to-push").trigger('click') + click_link 'Developers + Masters' end - page.within(".js-allowed-to-push") do - expect(page.find(".dropdown-toggle-text")).to have_content("Developers + Masters") + + visit project_protected_branches_path(project) + + within form do + page.within(".js-allowed-to-merge") do + expect(page.find(".dropdown-toggle-text")).to have_content("No one") + end + page.within(".js-allowed-to-push") do + expect(page.find(".dropdown-toggle-text")).to have_content("Developers + Masters") + end end end end diff --git a/spec/finders/merge_request_target_project_finder_spec.rb b/spec/finders/merge_request_target_project_finder_spec.rb new file mode 100644 index 00000000000..c81bfd7932c --- /dev/null +++ b/spec/finders/merge_request_target_project_finder_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe MergeRequestTargetProjectFinder do + include ProjectForksHelper + + let(:user) { create(:user) } + subject(:finder) { described_class.new(current_user: user, source_project: forked_project) } + + shared_examples 'finding related projects' do + it 'finds sibling projects and base project' do + other_fork + + expect(finder.execute).to contain_exactly(base_project, other_fork, forked_project) + end + + it 'does not include projects that have merge requests turned off' do + other_fork.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) + base_project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) + + expect(finder.execute).to contain_exactly(forked_project) + end + end + + context 'public projects' do + let(:base_project) { create(:project, :public, path: 'base') } + let(:forked_project) { fork_project(base_project) } + let(:other_fork) { fork_project(base_project) } + + it_behaves_like 'finding related projects' + end + + context 'private projects' do + let(:base_project) { create(:project, :private, path: 'base') } + let(:forked_project) { fork_project(base_project, base_project.owner) } + let(:other_fork) { fork_project(base_project, base_project.owner) } + + context 'when the user is a member of all projects' do + before do + base_project.add_developer(user) + forked_project.add_developer(user) + other_fork.add_developer(user) + end + + it_behaves_like 'finding related projects' + end + + it 'only finds the projects the user is a member of' do + other_fork.add_developer(user) + base_project.add_developer(user) + + expect(finder.execute).to contain_exactly(other_fork, base_project) + end + end +end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 95f445e7905..883bdf3746a 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -1,12 +1,18 @@ require 'spec_helper' describe MergeRequestsFinder do + include ProjectForksHelper + let(:user) { create :user } let(:user2) { create :user } - let(:project1) { create(:project) } - let(:project2) { create(:project, forked_from_project: project1) } - let(:project3) { create(:project, :archived, forked_from_project: project1) } + let(:project1) { create(:project, :public) } + let(:project2) { fork_project(project1, user) } + let(:project3) do + p = fork_project(project1, user) + p.update!(archived: true) + p + end let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) } let!(:merge_request2) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1, state: 'closed') } diff --git a/spec/fixtures/trace/trace_with_sections b/spec/fixtures/trace/trace_with_sections new file mode 100644 index 00000000000..21dff3928c3 --- /dev/null +++ b/spec/fixtures/trace/trace_with_sections @@ -0,0 +1,15 @@ +[0KRunning with gitlab-runner dev (HEAD) + on kitsune minikube (a21b584f) +[0;m[0;33mWARNING: Namespace is empty, therefore assuming 'default'. +[0;m[0KUsing Kubernetes namespace: default +[0;m[0KUsing Kubernetes executor with image alpine:3.4 ... +[0;msection_start:1506004954:prepare_script
[0KWaiting for pod default/runner-a21b584f-project-1208199-concurrent-0sg03f to be running, status is Pending +Running on runner-a21b584f-project-1208199-concurrent-0sg03f via kitsune.local... +section_end:1506004957:prepare_script
[0Ksection_start:1506004957:get_sources
[0K[32;1mCloning repository...[0;m +Cloning into '/nolith/ci-tests'... +[32;1mChecking out dddd7a6e as master...[0;m +[32;1mSkipping Git submodules setup[0;m +section_end:1506004958:get_sources
[0Ksection_start:1506004958:restore_cache
[0Ksection_end:1506004958:restore_cache
[0Ksection_start:1506004958:download_artifacts
[0Ksection_end:1506004958:download_artifacts
[0Ksection_start:1506004958:build_script
[0K[32;1m$ whoami[0;m +root +section_end:1506004959:build_script
[0Ksection_start:1506004959:after_script
[0Ksection_end:1506004959:after_script
[0Ksection_start:1506004959:archive_cache
[0Ksection_end:1506004959:archive_cache
[0Ksection_start:1506004959:upload_artifacts
[0Ksection_end:1506004959:upload_artifacts
[0K[32;1mJob succeeded +[0;m diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 7d1c17909bf..fd7900c32f4 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe MergeRequestsHelper do + include ProjectForksHelper describe 'ci_build_details_path' do let(:project) { create(:project) } let(:merge_request) { MergeRequest.new } @@ -31,10 +32,10 @@ describe MergeRequestsHelper do describe 'within different projects' do let(:project) { create(:project) } - let(:fork_project) { create(:project, forked_from_project: project) } - let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: project) } + let(:forked_project) { fork_project(project) } + let(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project) } subject { format_mr_branch_names(merge_request) } - let(:source_title) { "#{fork_project.full_path}:#{merge_request.source_branch}" } + let(:source_title) { "#{forked_project.full_path}:#{merge_request.source_branch}" } let(:target_title) { "#{project.full_path}:#{merge_request.target_branch}" } it { is_expected.to eq([source_title, target_title]) } diff --git a/spec/javascripts/cycle_analytics/banner_spec.js b/spec/javascripts/cycle_analytics/banner_spec.js new file mode 100644 index 00000000000..fb6b7fee168 --- /dev/null +++ b/spec/javascripts/cycle_analytics/banner_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import banner from '~/cycle_analytics/components/banner.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +describe('Cycle analytics banner', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(banner); + vm = mountComponent(Component, { + documentationLink: 'path', + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render cycle analytics information', () => { + expect( + vm.$el.querySelector('h4').textContent.trim(), + ).toEqual('Introducing Cycle Analytics'); + expect( + vm.$el.querySelector('p').textContent.trim(), + ).toContain('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.'); + expect( + vm.$el.querySelector('a').textContent.trim(), + ).toEqual('Read more'); + expect( + vm.$el.querySelector('a').getAttribute('href'), + ).toEqual('path'); + }); + + it('should emit an event when close button is clicked', () => { + spyOn(vm, '$emit'); + + vm.$el.querySelector('.js-ca-dismiss-button').click(); + + expect(vm.$emit).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/image_diff/helpers/badge_helper_spec.js b/spec/javascripts/image_diff/helpers/badge_helper_spec.js new file mode 100644 index 00000000000..fb9c7e59031 --- /dev/null +++ b/spec/javascripts/image_diff/helpers/badge_helper_spec.js @@ -0,0 +1,132 @@ +import * as badgeHelper from '~/image_diff/helpers/badge_helper'; +import * as mockData from '../mock_data'; + +describe('badge helper', () => { + const { coordinate, noteId, badgeText, badgeNumber } = mockData; + let containerEl; + let buttonEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('createImageBadge', () => { + beforeEach(() => { + buttonEl = badgeHelper.createImageBadge(noteId, coordinate); + }); + + it('should create button', () => { + expect(buttonEl.tagName).toEqual('BUTTON'); + expect(buttonEl.getAttribute('type')).toEqual('button'); + }); + + it('should set disabled attribute', () => { + expect(buttonEl.hasAttribute('disabled')).toEqual(true); + }); + + it('should set noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + describe('classNames', () => { + it('should set .js-image-badge by default', () => { + expect(buttonEl.className).toEqual('js-image-badge'); + }); + + it('should add additional class names if parameter is passed', () => { + const classNames = ['first-class', 'second-class']; + buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames); + + expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' ')); + }); + }); + }); + + describe('addImageBadge', () => { + beforeEach(() => { + badgeHelper.addImageBadge(containerEl, { + coordinate, + badgeText, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should appends button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should set the badge text', () => { + expect(buttonEl.innerText).toEqual(badgeText); + }); + + it('should set the button coordinates', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should set the button noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + }); + + describe('addImageCommentBadge', () => { + beforeEach(() => { + badgeHelper.addImageCommentBadge(containerEl, { + coordinate, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append icon button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should create icon comment button', () => { + const iconEl = buttonEl.querySelector('i'); + expect(iconEl).toBeDefined(); + expect(iconEl.classList.contains('fa')).toEqual(true); + expect(iconEl.classList.contains('fa-comment-o')).toEqual(true); + }); + + it('should have .image-comment-badge.inverted in button class', () => { + expect(buttonEl.classList.contains('image-comment-badge')).toEqual(true); + expect(buttonEl.classList.contains('inverted')).toEqual(true); + }); + }); + + describe('addAvatarBadge', () => { + let avatarBadgeEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div id="${noteId}"> + <div class="badge hidden"> + </div> + </div> + `; + + badgeHelper.addAvatarBadge(containerEl, { + detail: { + noteId, + badgeNumber, + }, + }); + avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`); + }); + + it('should update badge number', () => { + expect(avatarBadgeEl.innerText).toEqual(badgeNumber.toString()); + }); + + it('should remove hidden class', () => { + expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false); + }); + }); +}); diff --git a/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js b/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js new file mode 100644 index 00000000000..a284b981d2a --- /dev/null +++ b/spec/javascripts/image_diff/helpers/comment_indicator_helper_spec.js @@ -0,0 +1,139 @@ +import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper'; +import * as mockData from '../mock_data'; + +describe('commentIndicatorHelper', () => { + const { coordinate } = mockData; + let containerEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('addCommentIndicator', () => { + let buttonEl; + + beforeEach(() => { + commentIndicatorHelper.addCommentIndicator(containerEl, coordinate); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + describe('button', () => { + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should contain image-comment-dark svg', () => { + const svgEl = buttonEl.querySelector('svg'); + expect(svgEl).toBeDefined(); + + const svgLink = svgEl.querySelector('use').getAttribute('xlink:href'); + expect(svgLink.indexOf('image-comment-dark') !== -1).toEqual(true); + }); + }); + }); + + describe('removeCommentIndicator', () => { + it('should return removed false if there is no comment-indicator', () => { + const result = commentIndicatorHelper.removeCommentIndicator(containerEl); + expect(result.removed).toEqual(false); + }); + + describe('has comment indicator', () => { + let result; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + `; + result = commentIndicatorHelper.removeCommentIndicator(containerEl); + }); + + it('should remove comment indicator', () => { + expect(containerEl.querySelector('.comment-indicator')).toBeNull(); + }); + + it('should return removed true', () => { + expect(result.removed).toEqual(true); + }); + + it('should return indicator meta', () => { + expect(result.x).toEqual(coordinate.x); + expect(result.y).toEqual(coordinate.y); + expect(result.image).toBeDefined(); + expect(result.image.width).toBeDefined(); + expect(result.image.height).toBeDefined(); + }); + }); + }); + + describe('showCommentIndicator', () => { + describe('commentIndicator exists', () => { + beforeEach(() => { + containerEl.innerHTML = ` + <button class="comment-indicator"></button> + `; + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should set commentIndicator coordinates', () => { + const commentIndicatorEl = containerEl.querySelector('.comment-indicator'); + expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`); + expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + + describe('commentIndicator does not exist', () => { + beforeEach(() => { + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should addCommentIndicator', () => { + const buttonEl = containerEl.querySelector('.comment-indicator'); + expect(buttonEl).toBeDefined(); + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + }); + + describe('commentIndicatorOnClick', () => { + let event; + let textAreaEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="diff-viewer"> + <button></button> + <div class="note-container"> + <textarea class="note-textarea"></textarea> + </div> + </div> + `; + textAreaEl = containerEl.querySelector('textarea'); + + event = { + stopPropagation: () => {}, + currentTarget: containerEl.querySelector('button'), + }; + + spyOn(event, 'stopPropagation'); + spyOn(textAreaEl, 'focus'); + commentIndicatorHelper.commentIndicatorOnClick(event); + }); + + it('should stopPropagation', () => { + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should focus textAreaEl', () => { + expect(textAreaEl.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/image_diff/helpers/dom_helper_spec.js b/spec/javascripts/image_diff/helpers/dom_helper_spec.js new file mode 100644 index 00000000000..8dde924e8ae --- /dev/null +++ b/spec/javascripts/image_diff/helpers/dom_helper_spec.js @@ -0,0 +1,118 @@ +import * as domHelper from '~/image_diff/helpers/dom_helper'; +import * as mockData from '../mock_data'; + +describe('domHelper', () => { + const { imageMeta, badgeNumber } = mockData; + + describe('setPositionDataAttribute', () => { + let containerEl; + let attributeAfterCall; + const position = { + myProperty: 'myProperty', + }; + + beforeEach(() => { + containerEl = document.createElement('div'); + containerEl.dataset.position = JSON.stringify(position); + domHelper.setPositionDataAttribute(containerEl, imageMeta); + attributeAfterCall = JSON.parse(containerEl.dataset.position); + }); + + it('should set x, y, width, height', () => { + expect(attributeAfterCall.x).toEqual(imageMeta.x); + expect(attributeAfterCall.y).toEqual(imageMeta.y); + expect(attributeAfterCall.width).toEqual(imageMeta.width); + expect(attributeAfterCall.height).toEqual(imageMeta.height); + }); + + it('should not override other properties', () => { + expect(attributeAfterCall.myProperty).toEqual('myProperty'); + }); + }); + + describe('updateDiscussionAvatarBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <a href="#" class="image-diff-avatar-link"> + <div class="badge"></div> + </a> + `; + domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update avatar badge number', () => { + expect(discussionEl.querySelector('.badge').innerText).toEqual(badgeNumber.toString()); + }); + }); + + describe('updateDiscussionBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <div class="badge"></div> + `; + domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update discussion badge number', () => { + expect(discussionEl.querySelector('.badge').innerText).toEqual(badgeNumber.toString()); + }); + }); + + describe('toggleCollapsed', () => { + let element; + let discussionNotesEl; + + beforeEach(() => { + element = document.createElement('div'); + element.innerHTML = ` + <div class="discussion-notes"> + <button></button> + <form class="discussion-form"></form> + </div> + `; + discussionNotesEl = element.querySelector('.discussion-notes'); + }); + + describe('not collapsed', () => { + beforeEach(() => { + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should add collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true); + }); + + it('should force formEl to display none', () => { + const formEl = element.querySelector('.discussion-form'); + expect(formEl.style.display).toEqual('none'); + }); + }); + + describe('collapsed', () => { + beforeEach(() => { + discussionNotesEl.classList.add('collapsed'); + + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should remove collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false); + }); + + it('should force formEl to display block', () => { + const formEl = element.querySelector('.discussion-form'); + expect(formEl.style.display).toEqual('block'); + }); + }); + }); +}); diff --git a/spec/javascripts/image_diff/helpers/utils_helper_spec.js b/spec/javascripts/image_diff/helpers/utils_helper_spec.js new file mode 100644 index 00000000000..56d77a05c4c --- /dev/null +++ b/spec/javascripts/image_diff/helpers/utils_helper_spec.js @@ -0,0 +1,207 @@ +import * as utilsHelper from '~/image_diff/helpers/utils_helper'; +import ImageDiff from '~/image_diff/image_diff'; +import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; +import ImageBadge from '~/image_diff/image_badge'; +import * as mockData from '../mock_data'; + +describe('utilsHelper', () => { + const { + noteId, + discussionId, + image, + imageProperties, + imageMeta, + } = mockData; + + describe('resizeCoordinatesToImageElement', () => { + let result; + + beforeEach(() => { + result = utilsHelper.resizeCoordinatesToImageElement(image, imageMeta); + }); + + it('should return x based on widthRatio', () => { + expect(result.x).toEqual(imageMeta.x * 0.5); + }); + + it('should return y based on heightRatio', () => { + expect(result.y).toEqual(imageMeta.y * 0.5); + }); + + it('should return image width', () => { + expect(result.width).toEqual(image.width); + }); + + it('should return image height', () => { + expect(result.height).toEqual(image.height); + }); + }); + + describe('generateBadgeFromDiscussionDOM', () => { + let discussionEl; + let result; + + beforeEach(() => { + const imageFrameEl = document.createElement('div'); + imageFrameEl.innerHTML = ` + <img src="${gl.TEST_HOST}/image.png"> + `; + discussionEl = document.createElement('div'); + discussionEl.dataset.discussionId = discussionId; + discussionEl.innerHTML = ` + <div class="note" id="${noteId}"></div> + `; + discussionEl.dataset.position = JSON.stringify(imageMeta); + result = utilsHelper.generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl); + }); + + it('should return actual image properties', () => { + const { actual } = result; + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should return browser image properties', () => { + const { browser } = result; + expect(browser.x).toBeDefined(); + expect(browser.y).toBeDefined(); + expect(browser.width).toBeDefined(); + expect(browser.height).toBeDefined(); + }); + + it('should return instance of ImageBadge', () => { + expect(result instanceof ImageBadge).toEqual(true); + }); + + it('should return noteId', () => { + expect(result.noteId).toEqual(noteId); + }); + + it('should return discussionId', () => { + expect(result.discussionId).toEqual(discussionId); + }); + }); + + describe('getTargetSelection', () => { + let containerEl; + + beforeEach(() => { + containerEl = { + querySelector: () => imageProperties, + }; + }); + + function generateEvent(offsetX, offsetY) { + return { + currentTarget: containerEl, + offsetX, + offsetY, + }; + } + + it('should return browser properties', () => { + const event = generateEvent(25, 25); + const result = utilsHelper.getTargetSelection(event); + + const { browser } = result; + expect(browser.x).toEqual(event.offsetX); + expect(browser.y).toEqual(event.offsetY); + expect(browser.width).toEqual(imageProperties.width); + expect(browser.height).toEqual(imageProperties.height); + }); + + it('should return resized actual image properties', () => { + const event = generateEvent(50, 50); + const result = utilsHelper.getTargetSelection(event); + + const { actual } = result; + expect(actual.x).toEqual(100); + expect(actual.y).toEqual(100); + expect(actual.width).toEqual(imageProperties.naturalWidth); + expect(actual.height).toEqual(imageProperties.naturalHeight); + }); + + describe('normalize coordinates', () => { + it('should return x = 0 if x < 0', () => { + const event = generateEvent(-5, 50); + const result = utilsHelper.getTargetSelection(event); + expect(result.browser.x).toEqual(0); + }); + + it('should return x = width if x > width', () => { + const event = generateEvent(1000, 50); + const result = utilsHelper.getTargetSelection(event); + expect(result.browser.x).toEqual(imageProperties.width); + }); + + it('should return y = 0 if y < 0', () => { + const event = generateEvent(50, -10); + const result = utilsHelper.getTargetSelection(event); + expect(result.browser.y).toEqual(0); + }); + + it('should return y = height if y > height', () => { + const event = generateEvent(50, 1000); + const result = utilsHelper.getTargetSelection(event); + expect(result.browser.y).toEqual(imageProperties.height); + }); + }); + }); + + describe('initImageDiff', () => { + let glCache; + let fileEl; + + beforeEach(() => { + window.gl = window.gl || (window.gl = {}); + glCache = window.gl; + window.gl.ImageFile = () => {}; + fileEl = document.createElement('div'); + fileEl.innerHTML = ` + <div class="diff-file"></div> + `; + + spyOn(ImageDiff.prototype, 'init').and.callFake(() => {}); + spyOn(ReplacedImageDiff.prototype, 'init').and.callFake(() => {}); + }); + + afterEach(() => { + window.gl = glCache; + }); + + it('should initialize gl.ImageFile', () => { + spyOn(window.gl, 'ImageFile'); + + utilsHelper.initImageDiff(fileEl, false, false); + expect(gl.ImageFile).toHaveBeenCalled(); + }); + + it('should initialize ImageDiff if js-single-image', () => { + const diffFileEl = fileEl.querySelector('.diff-file'); + diffFileEl.innerHTML = ` + <div class="js-single-image"> + </div> + `; + + const imageDiff = utilsHelper.initImageDiff(fileEl, true, false); + expect(ImageDiff.prototype.init).toHaveBeenCalled(); + expect(imageDiff.canCreateNote).toEqual(true); + expect(imageDiff.renderCommentBadge).toEqual(false); + }); + + it('should initialize ReplacedImageDiff if js-replaced-image', () => { + const diffFileEl = fileEl.querySelector('.diff-file'); + diffFileEl.innerHTML = ` + <div class="js-replaced-image"> + </div> + `; + + const replacedImageDiff = utilsHelper.initImageDiff(fileEl, false, true); + expect(ReplacedImageDiff.prototype.init).toHaveBeenCalled(); + expect(replacedImageDiff.canCreateNote).toEqual(false); + expect(replacedImageDiff.renderCommentBadge).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/image_diff/image_badge_spec.js b/spec/javascripts/image_diff/image_badge_spec.js new file mode 100644 index 00000000000..87f98fc0926 --- /dev/null +++ b/spec/javascripts/image_diff/image_badge_spec.js @@ -0,0 +1,84 @@ +import ImageBadge from '~/image_diff/image_badge'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import * as mockData from './mock_data'; + +describe('ImageBadge', () => { + const { noteId, discussionId, imageMeta } = mockData; + const options = { + noteId, + discussionId, + }; + + it('should save actual property', () => { + const imageBadge = new ImageBadge(Object.assign({}, options, { + actual: imageMeta, + })); + + const { actual } = imageBadge; + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should save browser property', () => { + const imageBadge = new ImageBadge(Object.assign({}, options, { + browser: imageMeta, + })); + + const { browser } = imageBadge; + expect(browser.x).toEqual(imageMeta.x); + expect(browser.y).toEqual(imageMeta.y); + expect(browser.width).toEqual(imageMeta.width); + expect(browser.height).toEqual(imageMeta.height); + }); + + it('should save noteId', () => { + const imageBadge = new ImageBadge(options); + expect(imageBadge.noteId).toEqual(noteId); + }); + + it('should save discussionId', () => { + const imageBadge = new ImageBadge(options); + expect(imageBadge.discussionId).toEqual(discussionId); + }); + + describe('default values', () => { + let imageBadge; + + beforeEach(() => { + imageBadge = new ImageBadge(options); + }); + + it('should return defaultimageMeta if actual property is not provided', () => { + const { actual } = imageBadge; + expect(actual.x).toEqual(0); + expect(actual.y).toEqual(0); + expect(actual.width).toEqual(0); + expect(actual.height).toEqual(0); + }); + + it('should return defaultimageMeta if browser property is not provided', () => { + const { browser } = imageBadge; + expect(browser.x).toEqual(0); + expect(browser.y).toEqual(0); + expect(browser.width).toEqual(0); + expect(browser.height).toEqual(0); + }); + }); + + describe('imageEl property is provided and not browser property', () => { + beforeEach(() => { + spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.returnValue(true); + }); + + it('should generate browser property', () => { + const imageBadge = new ImageBadge(Object.assign({}, options, { + imageEl: document.createElement('img'), + })); + + expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled(); + expect(imageBadge.browser).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/image_diff/image_diff_spec.js b/spec/javascripts/image_diff/image_diff_spec.js new file mode 100644 index 00000000000..346282328c7 --- /dev/null +++ b/spec/javascripts/image_diff/image_diff_spec.js @@ -0,0 +1,361 @@ +import ImageDiff from '~/image_diff/image_diff'; +import * as imageUtility from '~/lib/utils/image_utility'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import * as mockData from './mock_data'; + +describe('ImageDiff', () => { + let element; + let imageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="diff-file"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + <div class="comment-indicator"></div> + <div id="badge-1" class="badge">1</div> + <div id="badge-2" class="badge">2</div> + <div id="badge-3" class="badge">3</div> + </div> + <div class="note-container"> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + </div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + describe('constructor', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element, { + canCreateNote: true, + renderCommentBadge: true, + }); + }); + + it('should set el', () => { + expect(imageDiff.el).toEqual(element); + }); + + it('should set canCreateNote', () => { + expect(imageDiff.canCreateNote).toEqual(true); + }); + + it('should set renderCommentBadge', () => { + expect(imageDiff.renderCommentBadge).toEqual(true); + }); + + it('should set $noteContainer', () => { + expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container')); + }); + + describe('default', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element); + }); + + it('should set canCreateNote as false', () => { + expect(imageDiff.canCreateNote).toEqual(false); + }); + + it('should set renderCommentBadge as false', () => { + expect(imageDiff.renderCommentBadge).toEqual(false); + }); + }); + }); + + describe('init', () => { + beforeEach(() => { + spyOn(ImageDiff.prototype, 'bindEvents').and.callFake(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.init(); + }); + + it('should set imageFrameEl', () => { + expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame')); + }); + + it('should set imageEl', () => { + expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img')); + }); + + it('should call bindEvents', () => { + expect(imageDiff.bindEvents).toHaveBeenCalled(); + }); + }); + + describe('bindEvents', () => { + let imageEl; + + beforeEach(() => { + spyOn(imageDiffHelper, 'toggleCollapsed').and.callFake(() => {}); + spyOn(imageDiffHelper, 'commentIndicatorOnClick').and.callFake(() => {}); + spyOn(imageDiffHelper, 'removeCommentIndicator').and.callFake(() => {}); + spyOn(ImageDiff.prototype, 'imageClicked').and.callFake(() => {}); + spyOn(ImageDiff.prototype, 'addBadge').and.callFake(() => {}); + spyOn(ImageDiff.prototype, 'removeBadge').and.callFake(() => {}); + spyOn(ImageDiff.prototype, 'renderBadges').and.callFake(() => {}); + imageEl = element.querySelector('.diff-file .js-image-frame img'); + }); + + describe('default', () => { + beforeEach(() => { + spyOn(imageUtility, 'isImageLoaded').and.returnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click event delegation to js-diff-notes-toggle', () => { + element.querySelector('.js-diff-notes-toggle').click(); + expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled(); + }); + + it('should register click event delegation to comment-indicator', () => { + element.querySelector('.comment-indicator').click(); + expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled(); + }); + }); + + describe('image loaded', () => { + beforeEach(() => { + spyOn(imageUtility, 'isImageLoaded').and.returnValue(true); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + }); + + it('should renderBadges', () => {}); + }); + + describe('image not loaded', () => { + beforeEach(() => { + spyOn(imageUtility, 'isImageLoaded').and.returnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should registers load eventListener', () => { + const loadEvent = new Event('load'); + imageEl.dispatchEvent(loadEvent); + expect(imageDiff.renderBadges).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote', () => { + beforeEach(() => { + spyOn(imageUtility, 'isImageLoaded').and.returnValue(false); + imageDiff = new ImageDiff(element, { + canCreateNote: true, + }); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + expect(imageDiff.imageClicked).toHaveBeenCalled(); + }); + + it('should register blur.imageDiff event', () => { + const event = new CustomEvent('blur.imageDiff'); + element.dispatchEvent(event); + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should register addBadge.imageDiff event', () => { + const event = new CustomEvent('addBadge.imageDiff'); + element.dispatchEvent(event); + expect(imageDiff.addBadge).toHaveBeenCalled(); + }); + + it('should register removeBadge.imageDiff event', () => { + const event = new CustomEvent('removeBadge.imageDiff'); + element.dispatchEvent(event); + expect(imageDiff.removeBadge).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote is false', () => { + beforeEach(() => { + spyOn(imageUtility, 'isImageLoaded').and.returnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should not register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + expect(imageDiff.imageClicked).not.toHaveBeenCalled(); + }); + }); + }); + + describe('imageClicked', () => { + beforeEach(() => { + spyOn(imageDiffHelper, 'getTargetSelection').and.returnValue({ + actual: {}, + browser: {}, + }); + spyOn(imageDiffHelper, 'setPositionDataAttribute').and.callFake(() => {}); + spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageClicked({ + detail: { + currentTarget: {}, + }, + }); + }); + + it('should call getTargetSelection', () => { + expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled(); + }); + + it('should call setPositionDataAttribute', () => { + expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled(); + }); + + it('should call showCommentIndicator', () => { + expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled(); + }); + }); + + describe('renderBadges', () => { + beforeEach(() => { + spyOn(ImageDiff.prototype, 'renderBadge').and.callFake(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.renderBadges(); + }); + + it('should call renderBadge for each discussionEl', () => { + const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + expect(imageDiff.renderBadge.calls.count()).toEqual(discussionEls.length); + }); + }); + + describe('renderBadge', () => { + let discussionEls; + + beforeEach(() => { + spyOn(imageDiffHelper, 'addImageBadge').and.callFake(() => {}); + spyOn(imageDiffHelper, 'addImageCommentBadge').and.callFake(() => {}); + spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').and.returnValue({ + browser: {}, + noteId: 'noteId', + }); + discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + imageDiff = new ImageDiff(element); + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should populate imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + describe('renderCommentBadge', () => { + beforeEach(() => { + imageDiff.renderCommentBadge = true; + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should call addImageCommentBadge', () => { + expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled(); + }); + }); + + describe('renderCommentBadge is false', () => { + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + }); + }); + + describe('addBadge', () => { + beforeEach(() => { + spyOn(imageDiffHelper, 'addImageBadge').and.callFake(() => {}); + spyOn(imageDiffHelper, 'addAvatarBadge').and.callFake(() => {}); + spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').and.callFake(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.addBadge({ + detail: { + x: 0, + y: 1, + width: 25, + height: 50, + noteId: 'noteId', + discussionId: 'discussionId', + }, + }); + }); + + it('should add imageBadge to imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + + it('should call addAvatarBadge', () => { + expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled(); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + }); + + describe('removeBadge', () => { + beforeEach(() => { + const { imageMeta } = mockData; + + spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').and.callFake(() => {}); + spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').and.callFake(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta]; + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.removeBadge({ + detail: { + badgeNumber: 2, + }, + }); + }); + + describe('cascade badge count', () => { + it('should update next imageBadgeEl value', () => { + const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge'); + expect(imageBadgeEls[0].innerText).toEqual('1'); + expect(imageBadgeEls[1].innerText).toEqual('2'); + expect(imageBadgeEls.length).toEqual(2); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + + it('should call updateDiscussionAvatarBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled(); + }); + }); + + it('should remove badge from imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(2); + }); + + it('should remove imageBadgeEl', () => { + expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/image_diff/init_discussion_tab_spec.js b/spec/javascripts/image_diff/init_discussion_tab_spec.js new file mode 100644 index 00000000000..7c447d6f70d --- /dev/null +++ b/spec/javascripts/image_diff/init_discussion_tab_spec.js @@ -0,0 +1,37 @@ +import initDiscussionTab from '~/image_diff/init_discussion_tab'; +import imageDiffHelper from '~/image_diff/helpers/index'; + +describe('initDiscussionTab', () => { + beforeEach(() => { + setFixtures(` + <div class="timeline-content"> + <div class="diff-file js-image-file"></div> + <div class="diff-file js-image-file"></div> + </div> + `); + }); + + it('should pass canCreateNote as false to initImageDiff', (done) => { + spyOn(imageDiffHelper, 'initImageDiff').and.callFake((diffFileEl, canCreateNote) => { + expect(canCreateNote).toEqual(false); + done(); + }); + + initDiscussionTab(); + }); + + it('should pass renderCommentBadge as true to initImageDiff', (done) => { + spyOn(imageDiffHelper, 'initImageDiff').and.callFake((diffFileEl, canCreateNote, renderCommentBadge) => { + expect(renderCommentBadge).toEqual(true); + done(); + }); + + initDiscussionTab(); + }); + + it('should call initImageDiff for each diffFileEls', () => { + spyOn(imageDiffHelper, 'initImageDiff').and.callFake(() => {}); + initDiscussionTab(); + expect(imageDiffHelper.initImageDiff.calls.count()).toEqual(2); + }); +}); diff --git a/spec/javascripts/image_diff/mock_data.js b/spec/javascripts/image_diff/mock_data.js new file mode 100644 index 00000000000..a0d1732dd0a --- /dev/null +++ b/spec/javascripts/image_diff/mock_data.js @@ -0,0 +1,28 @@ +export const noteId = 'noteId'; +export const discussionId = 'discussionId'; +export const badgeText = 'badgeText'; +export const badgeNumber = 5; + +export const coordinate = { + x: 100, + y: 100, +}; + +export const image = { + width: 100, + height: 100, +}; + +export const imageProperties = { + width: image.width, + height: image.height, + naturalWidth: image.width * 2, + naturalHeight: image.height * 2, +}; + +export const imageMeta = { + x: coordinate.x, + y: coordinate.y, + width: imageProperties.naturalWidth, + height: imageProperties.naturalHeight, +}; diff --git a/spec/javascripts/image_diff/replaced_image_diff_spec.js b/spec/javascripts/image_diff/replaced_image_diff_spec.js new file mode 100644 index 00000000000..5f8cd7c531a --- /dev/null +++ b/spec/javascripts/image_diff/replaced_image_diff_spec.js @@ -0,0 +1,312 @@ +import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; +import ImageDiff from '~/image_diff/image_diff'; +import { viewTypes } from '~/image_diff/view_types'; +import imageDiffHelper from '~/image_diff/helpers/index'; + +describe('ReplacedImageDiff', () => { + let element; + let replacedImageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="two-up"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="swipe"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="onion-skin"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="view-modes-menu"> + <div class="two-up">2-up</div> + <div class="swipe">Swipe</div> + <div class="onion-skin">Onion skin</div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + function setupImageFrameEls() { + replacedImageDiff.imageFrameEls = []; + replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector('.two-up .js-image-frame'); + replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector('.swipe .js-image-frame'); + replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin .js-image-frame'); + } + + function setupViewModesEls() { + replacedImageDiff.viewModesEls = []; + replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector('.view-modes-menu .two-up'); + replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector('.view-modes-menu .swipe'); + replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector('.view-modes-menu .onion-skin'); + } + + function setupImageEls() { + replacedImageDiff.imageEls = []; + replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img'); + replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img'); + replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img'); + } + + it('should extend ImageDiff', () => { + replacedImageDiff = new ReplacedImageDiff(element); + expect(replacedImageDiff instanceof ImageDiff).toEqual(true); + }); + + describe('init', () => { + beforeEach(() => { + spyOn(ReplacedImageDiff.prototype, 'bindEvents').and.callFake(() => {}); + spyOn(ReplacedImageDiff.prototype, 'generateImageEls').and.callFake(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.init(); + }); + + it('should set imageFrameEls', () => { + const { imageFrameEls } = replacedImageDiff; + expect(imageFrameEls).toBeDefined(); + expect(imageFrameEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up .js-image-frame')); + expect(imageFrameEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe .js-image-frame')); + expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin .js-image-frame')); + }); + + it('should set viewModesEls', () => { + const { viewModesEls } = replacedImageDiff; + expect(viewModesEls).toBeDefined(); + expect(viewModesEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.view-modes-menu .two-up')); + expect(viewModesEls[viewTypes.SWIPE]).toEqual(element.querySelector('.view-modes-menu .swipe')); + expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.view-modes-menu .onion-skin')); + }); + + it('should generateImageEls', () => { + expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled(); + }); + + it('should bindEvents', () => { + expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + describe('currentView', () => { + it('should set currentView', () => { + replacedImageDiff.init(viewTypes.ONION_SKIN); + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should default to viewTypes.TWO_UP', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP); + }); + }); + }); + + describe('generateImageEls', () => { + beforeEach(() => { + spyOn(ReplacedImageDiff.prototype, 'bindEvents').and.callFake(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element, { + canCreateNote: false, + renderCommentBadge: false, + }); + + setupImageFrameEls(); + }); + + it('should set imageEls', () => { + replacedImageDiff.generateImageEls(); + const { imageEls } = replacedImageDiff; + expect(imageEls).toBeDefined(); + expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img')); + expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img')); + expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img')); + }); + }); + + describe('bindEvents', () => { + beforeEach(() => { + spyOn(ImageDiff.prototype, 'bindEvents').and.callFake(() => {}); + replacedImageDiff = new ReplacedImageDiff(element); + + setupViewModesEls(); + }); + + it('should call super.bindEvents', () => { + replacedImageDiff.bindEvents(); + expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + it('should register click eventlistener to 2-up view mode', (done) => { + spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake((viewMode) => { + expect(viewMode).toEqual(viewTypes.TWO_UP); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click(); + }); + + it('should register click eventlistener to swipe view mode', (done) => { + spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake((viewMode) => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + + it('should register click eventlistener to onion skin view mode', (done) => { + spyOn(ReplacedImageDiff.prototype, 'changeView').and.callFake((viewMode) => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + }); + + describe('getters', () => { + describe('imageEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageEls(); + }); + + it('should return imageEl based on currentView', () => { + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img')); + + replacedImageDiff.currentView = viewTypes.SWIPE; + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img')); + }); + }); + + describe('imageFrameEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageFrameEls(); + }); + + it('should return imageFrameEl based on currentView', () => { + expect(replacedImageDiff.imageFrameEl).toEqual(element.querySelector('.two-up .js-image-frame')); + + replacedImageDiff.currentView = viewTypes.ONION_SKIN; + expect(replacedImageDiff.imageFrameEl).toEqual(element.querySelector('.onion-skin .js-image-frame')); + }); + }); + }); + + describe('changeView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + spyOn(imageDiffHelper, 'removeCommentIndicator').and.returnValue({ + removed: false, + }); + setupImageFrameEls(); + }); + + describe('invalid viewType', () => { + beforeEach(() => { + replacedImageDiff.changeView('some-view-name'); + }); + + it('should not call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled(); + }); + }); + + describe('valid viewType', () => { + beforeEach(() => { + jasmine.clock().install(); + spyOn(ReplacedImageDiff.prototype, 'renderNewView').and.callFake(() => {}); + replacedImageDiff.changeView(viewTypes.ONION_SKIN); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('should call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should update currentView to newView', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should clear imageBadges', () => { + expect(replacedImageDiff.imageBadges.length).toEqual(0); + }); + + it('should call renderNewView', () => { + jasmine.clock().tick(251); + expect(replacedImageDiff.renderNewView).toHaveBeenCalled(); + }); + }); + }); + + describe('renderNewView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + }); + + it('should call renderBadges', () => { + spyOn(ReplacedImageDiff.prototype, 'renderBadges').and.callFake(() => {}); + + replacedImageDiff.renderNewView({ + removed: false, + }); + + expect(replacedImageDiff.renderBadges).toHaveBeenCalled(); + }); + + describe('removeIndicator', () => { + const indicator = { + removed: true, + x: 0, + y: 1, + image: { + width: 50, + height: 100, + }, + }; + + beforeEach(() => { + setupImageEls(); + setupImageFrameEls(); + }); + + it('should pass showCommentIndicator normalized indicator values', (done) => { + spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake(() => {}); + spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.callFake((imageEl, meta) => { + expect(meta.x).toEqual(indicator.x); + expect(meta.y).toEqual(indicator.y); + expect(meta.width).toEqual(indicator.image.width); + expect(meta.height).toEqual(indicator.image.height); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + + it('should call showCommentIndicator', (done) => { + const normalized = { + normalized: true, + }; + spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').and.returnValue(normalized); + spyOn(imageDiffHelper, 'showCommentIndicator').and.callFake((imageFrameEl, normalizedIndicator) => { + expect(normalizedIndicator).toEqual(normalized); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + }); + }); +}); diff --git a/spec/javascripts/image_diff/view_types_spec.js b/spec/javascripts/image_diff/view_types_spec.js new file mode 100644 index 00000000000..e9639f46497 --- /dev/null +++ b/spec/javascripts/image_diff/view_types_spec.js @@ -0,0 +1,24 @@ +import { viewTypes, isValidViewType } from '~/image_diff/view_types'; + +describe('viewTypes', () => { + describe('isValidViewType', () => { + it('should return true for TWO_UP', () => { + expect(isValidViewType(viewTypes.TWO_UP)).toEqual(true); + }); + + it('should return true for SWIPE', () => { + expect(isValidViewType(viewTypes.SWIPE)).toEqual(true); + }); + + it('should return true for ONION_SKIN', () => { + expect(isValidViewType(viewTypes.ONION_SKIN)).toEqual(true); + }); + + it('should return false for non view types', () => { + expect(isValidViewType('some-view-type')).toEqual(false); + expect(isValidViewType(null)).toEqual(false); + expect(isValidViewType(undefined)).toEqual(false); + expect(isValidViewType('')).toEqual(false); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/image_utility_spec.js b/spec/javascripts/lib/utils/image_utility_spec.js new file mode 100644 index 00000000000..75addfcc833 --- /dev/null +++ b/spec/javascripts/lib/utils/image_utility_spec.js @@ -0,0 +1,32 @@ +import * as imageUtility from '~/lib/utils/image_utility'; + +describe('imageUtility', () => { + describe('isImageLoaded', () => { + it('should return false when image.complete is false', () => { + const element = { + complete: false, + naturalHeight: 100, + }; + + expect(imageUtility.isImageLoaded(element)).toEqual(false); + }); + + it('should return false when naturalHeight = 0', () => { + const element = { + complete: true, + naturalHeight: 0, + }; + + expect(imageUtility.isImageLoaded(element)).toEqual(false); + }); + + it('should return true when image.complete and naturalHeight != 0', () => { + const element = { + complete: true, + naturalHeight: 100, + }; + + expect(imageUtility.isImageLoaded(element)).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/profile/account/components/delete_account_modal_spec.js b/spec/javascripts/profile/account/components/delete_account_modal_spec.js new file mode 100644 index 00000000000..2e94948cfb2 --- /dev/null +++ b/spec/javascripts/profile/account/components/delete_account_modal_spec.js @@ -0,0 +1,129 @@ +import Vue from 'vue'; + +import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue'; + +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('DeleteAccountModal component', () => { + const actionUrl = `${gl.TEST_HOST}/delete/user`; + const username = 'hasnoname'; + let Component; + let vm; + + beforeEach(() => { + Component = Vue.extend(deleteAccountModal); + }); + + afterEach(() => { + vm.$destroy(); + }); + + const findElements = () => { + const confirmation = vm.confirmWithPassword ? 'password' : 'username'; + return { + form: vm.$refs.form, + input: vm.$el.querySelector(`[name="${confirmation}"]`), + submitButton: vm.$el.querySelector('.btn-danger'), + }; + }; + + describe('with password confirmation', () => { + beforeEach((done) => { + vm = mountComponent(Component, { + actionUrl, + confirmWithPassword: true, + username, + }); + + vm.isOpen = true; + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('does not accept empty password', (done) => { + const { form, input, submitButton } = findElements(); + spyOn(form, 'submit'); + input.value = ''; + input.dispatchEvent(new Event('input')); + + Vue.nextTick() + .then(() => { + expect(vm.enteredPassword).toBe(input.value); + expect(submitButton).toHaveClass('disabled'); + submitButton.click(); + expect(form.submit).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('submits form with password', (done) => { + const { form, input, submitButton } = findElements(); + spyOn(form, 'submit'); + input.value = 'anything'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick() + .then(() => { + expect(vm.enteredPassword).toBe(input.value); + expect(submitButton).not.toHaveClass('disabled'); + submitButton.click(); + expect(form.submit).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('with username confirmation', () => { + beforeEach((done) => { + vm = mountComponent(Component, { + actionUrl, + confirmWithPassword: false, + username, + }); + + vm.isOpen = true; + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('does not accept wrong username', (done) => { + const { form, input, submitButton } = findElements(); + spyOn(form, 'submit'); + input.value = 'this is wrong'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick() + .then(() => { + expect(vm.enteredUsername).toBe(input.value); + expect(submitButton).toHaveClass('disabled'); + submitButton.click(); + expect(form.submit).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('submits form with correct username', (done) => { + const { form, input, submitButton } = findElements(); + spyOn(form, 'submit'); + input.value = username; + input.dispatchEvent(new Event('input')); + + Vue.nextTick() + .then(() => { + expect(vm.enteredUsername).toBe(input.value); + expect(submitButton).not.toHaveClass('disabled'); + submitButton.click(); + expect(form.submit).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb index da42272bbef..81a04a2d46d 100644 --- a/spec/lib/banzai/renderer_spec.rb +++ b/spec/lib/banzai/renderer_spec.rb @@ -31,7 +31,14 @@ describe Banzai::Renderer do let(:object) { fake_object(fresh: false) } it 'caches and returns the result' do - expect(object).to receive(:refresh_markdown_cache!).with(do_update: true) + expect(object).to receive(:refresh_markdown_cache!) + + is_expected.to eq('field_html') + end + + it "skips database caching on a GitLab read-only instance" do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + expect(object).to receive(:refresh_markdown_cache!) is_expected.to eq('field_html') end diff --git a/spec/lib/gitlab/background_migration/create_fork_network_memberships_range_spec.rb b/spec/lib/gitlab/background_migration/create_fork_network_memberships_range_spec.rb new file mode 100644 index 00000000000..1a4ea2bac48 --- /dev/null +++ b/spec/lib/gitlab/background_migration/create_fork_network_memberships_range_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::CreateForkNetworkMembershipsRange, :migration, schema: 20170929131201 do + let(:migration) { described_class.new } + + let(:base1) { create(:project) } + let(:base1_fork1) { create(:project) } + let(:base1_fork2) { create(:project) } + + let(:base2) { create(:project) } + let(:base2_fork1) { create(:project) } + let(:base2_fork2) { create(:project) } + + let(:fork_of_fork) { create(:project) } + let(:fork_of_fork2) { create(:project) } + let(:second_level_fork) { create(:project) } + let(:third_level_fork) { create(:project) } + + let(:fork_network1) { fork_networks.find_by(root_project_id: base1.id) } + let(:fork_network2) { fork_networks.find_by(root_project_id: base2.id) } + + let!(:forked_project_links) { table(:forked_project_links) } + let!(:fork_networks) { table(:fork_networks) } + let!(:fork_network_members) { table(:fork_network_members) } + + before do + # The fork-network relation created for the forked project + fork_networks.create(id: 1, root_project_id: base1.id) + fork_network_members.create(project_id: base1.id, fork_network_id: 1) + fork_networks.create(id: 2, root_project_id: base2.id) + fork_network_members.create(project_id: base2.id, fork_network_id: 2) + + # Normal fork links + forked_project_links.create(id: 1, forked_from_project_id: base1.id, forked_to_project_id: base1_fork1.id) + forked_project_links.create(id: 2, forked_from_project_id: base1.id, forked_to_project_id: base1_fork2.id) + forked_project_links.create(id: 3, forked_from_project_id: base2.id, forked_to_project_id: base2_fork1.id) + forked_project_links.create(id: 4, forked_from_project_id: base2.id, forked_to_project_id: base2_fork2.id) + + # Fork links + forked_project_links.create(id: 5, forked_from_project_id: base1_fork1.id, forked_to_project_id: fork_of_fork.id) + forked_project_links.create(id: 6, forked_from_project_id: base1_fork1.id, forked_to_project_id: fork_of_fork2.id) + + # Forks 3 levels down + forked_project_links.create(id: 7, forked_from_project_id: fork_of_fork.id, forked_to_project_id: second_level_fork.id) + forked_project_links.create(id: 8, forked_from_project_id: second_level_fork.id, forked_to_project_id: third_level_fork.id) + + migration.perform(1, 8) + end + + it 'creates a memberships for the direct forks' do + base1_fork1_membership = fork_network_members.find_by(fork_network_id: fork_network1.id, + project_id: base1_fork1.id) + base1_fork2_membership = fork_network_members.find_by(fork_network_id: fork_network1.id, + project_id: base1_fork2.id) + base2_fork1_membership = fork_network_members.find_by(fork_network_id: fork_network2.id, + project_id: base2_fork1.id) + base2_fork2_membership = fork_network_members.find_by(fork_network_id: fork_network2.id, + project_id: base2_fork2.id) + + expect(base1_fork1_membership.forked_from_project_id).to eq(base1.id) + expect(base1_fork2_membership.forked_from_project_id).to eq(base1.id) + expect(base2_fork1_membership.forked_from_project_id).to eq(base2.id) + expect(base2_fork2_membership.forked_from_project_id).to eq(base2.id) + end + + it 'adds the fork network members for forks of forks' do + fork_of_fork_membership = fork_network_members.find_by(project_id: fork_of_fork.id, + fork_network_id: fork_network1.id) + fork_of_fork2_membership = fork_network_members.find_by(project_id: fork_of_fork2.id, + fork_network_id: fork_network1.id) + second_level_fork_membership = fork_network_members.find_by(project_id: second_level_fork.id, + fork_network_id: fork_network1.id) + third_level_fork_membership = fork_network_members.find_by(project_id: third_level_fork.id, + fork_network_id: fork_network1.id) + + expect(fork_of_fork_membership.forked_from_project_id).to eq(base1_fork1.id) + expect(fork_of_fork2_membership.forked_from_project_id).to eq(base1_fork1.id) + expect(second_level_fork_membership.forked_from_project_id).to eq(fork_of_fork.id) + expect(third_level_fork_membership.forked_from_project_id).to eq(second_level_fork.id) + end + + it 'reschedules itself when there are missing members' do + allow(migration).to receive(:missing_members?).and_return(true) + + expect(BackgroundMigrationWorker) + .to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, "CreateForkNetworkMembershipsRange", [1, 3]) + + migration.perform(1, 3) + end + + it 'can be repeated without effect' do + expect { fork_network_members.count }.not_to change { migration.perform(1, 7) } + end + + it 'knows it is finished for this range' do + expect(migration.missing_members?(1, 7)).to be_falsy + end + + context 'with more forks' do + before do + forked_project_links.create(id: 9, forked_from_project_id: fork_of_fork.id, forked_to_project_id: create(:project).id) + forked_project_links.create(id: 10, forked_from_project_id: fork_of_fork.id, forked_to_project_id: create(:project).id) + end + + it 'only processes a single batch of links at a time' do + expect(fork_network_members.count).to eq(10) + + migration.perform(8, 10) + + expect(fork_network_members.count).to eq(12) + end + + it 'knows when not all memberships withing a batch have been created' do + expect(migration.missing_members?(8, 10)).to be_truthy + end + end +end diff --git a/spec/lib/gitlab/background_migration/normalize_ldap_extern_uids_range_spec.rb b/spec/lib/gitlab/background_migration/normalize_ldap_extern_uids_range_spec.rb new file mode 100644 index 00000000000..dfbf1bb681a --- /dev/null +++ b/spec/lib/gitlab/background_migration/normalize_ldap_extern_uids_range_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::NormalizeLdapExternUidsRange, :migration, schema: 20170921101004 do + let!(:identities) { table(:identities) } + + before do + # LDAP identities + (1..4).each do |i| + identities.create!(id: i, provider: 'ldapmain', extern_uid: " uid = foo #{i}, ou = People, dc = example, dc = com ", user_id: i) + end + + # Non-LDAP identity + identities.create!(id: 5, provider: 'foo', extern_uid: " uid = foo 5, ou = People, dc = example, dc = com ", user_id: 5) + + # Another LDAP identity + identities.create!(id: 6, provider: 'ldapmain', extern_uid: " uid = foo 6, ou = People, dc = example, dc = com ", user_id: 6) + end + + it 'normalizes the LDAP identities in the range' do + described_class.new.perform(1, 3) + expect(identities.find(1).extern_uid).to eq("uid=foo 1,ou=people,dc=example,dc=com") + expect(identities.find(2).extern_uid).to eq("uid=foo 2,ou=people,dc=example,dc=com") + expect(identities.find(3).extern_uid).to eq("uid=foo 3,ou=people,dc=example,dc=com") + expect(identities.find(4).extern_uid).to eq(" uid = foo 4, ou = People, dc = example, dc = com ") + expect(identities.find(5).extern_uid).to eq(" uid = foo 5, ou = People, dc = example, dc = com ") + expect(identities.find(6).extern_uid).to eq(" uid = foo 6, ou = People, dc = example, dc = com ") + + described_class.new.perform(4, 6) + expect(identities.find(1).extern_uid).to eq("uid=foo 1,ou=people,dc=example,dc=com") + expect(identities.find(2).extern_uid).to eq("uid=foo 2,ou=people,dc=example,dc=com") + expect(identities.find(3).extern_uid).to eq("uid=foo 3,ou=people,dc=example,dc=com") + expect(identities.find(4).extern_uid).to eq("uid=foo 4,ou=people,dc=example,dc=com") + expect(identities.find(5).extern_uid).to eq(" uid = foo 5, ou = People, dc = example, dc = com ") + expect(identities.find(6).extern_uid).to eq("uid=foo 6,ou=people,dc=example,dc=com") + end +end diff --git a/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb b/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb new file mode 100644 index 00000000000..3ef1873e615 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Gitlab::BackgroundMigration::PopulateForkNetworksRange, :migration, schema: 20170929131201 do + let(:migration) { described_class.new } + let(:base1) { create(:project) } + let(:base1_fork1) { create(:project) } + let(:base1_fork2) { create(:project) } + + let(:base2) { create(:project) } + let(:base2_fork1) { create(:project) } + let(:base2_fork2) { create(:project) } + + let!(:forked_project_links) { table(:forked_project_links) } + let!(:fork_networks) { table(:fork_networks) } + let!(:fork_network_members) { table(:fork_network_members) } + + let(:fork_network1) { fork_networks.find_by(root_project_id: base1.id) } + let(:fork_network2) { fork_networks.find_by(root_project_id: base2.id) } + + before do + # A normal fork link + forked_project_links.create(id: 1, + forked_from_project_id: base1.id, + forked_to_project_id: base1_fork1.id) + forked_project_links.create(id: 2, + forked_from_project_id: base1.id, + forked_to_project_id: base1_fork2.id) + + forked_project_links.create(id: 3, + forked_from_project_id: base2.id, + forked_to_project_id: base2_fork1.id) + forked_project_links.create(id: 4, + forked_from_project_id: base2_fork1.id, + forked_to_project_id: create(:project).id) + + forked_project_links.create(id: 5, + forked_from_project_id: base2.id, + forked_to_project_id: base2_fork2.id) + + migration.perform(1, 3) + end + + it 'it creates the fork network' do + expect(fork_network1).not_to be_nil + expect(fork_network2).not_to be_nil + end + + it 'does not create a fork network for a fork-of-fork' do + # perfrom the entire batch + migration.perform(1, 5) + + expect(fork_networks.find_by(root_project_id: base2_fork1.id)).to be_nil + end + + it 'creates memberships for the root of fork networks' do + base1_membership = fork_network_members.find_by(fork_network_id: fork_network1.id, + project_id: base1.id) + base2_membership = fork_network_members.find_by(fork_network_id: fork_network2.id, + project_id: base2.id) + + expect(base1_membership).not_to be_nil + expect(base2_membership).not_to be_nil + end + + it 'schedules a job for inserting memberships for forks-of-forks' do + delay = Gitlab::BackgroundMigration::CreateForkNetworkMembershipsRange::RESCHEDULE_DELAY + + expect(BackgroundMigrationWorker) + .to receive(:perform_in).with(delay, "CreateForkNetworkMembershipsRange", [1, 3]) + + migration.perform(1, 3) + end + + it 'only processes a single batch of links at a time' do + expect(fork_network_members.count).to eq(5) + + migration.perform(3, 5) + + expect(fork_network_members.count).to eq(7) + end + + it 'can be repeated without effect' do + expect { migration.perform(1, 3) }.not_to change { fork_network_members.count } + end +end diff --git a/spec/lib/gitlab/ci/trace/section_parser_spec.rb b/spec/lib/gitlab/ci/trace/section_parser_spec.rb new file mode 100644 index 00000000000..ca53ff87c6f --- /dev/null +++ b/spec/lib/gitlab/ci/trace/section_parser_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe Gitlab::Ci::Trace::SectionParser do + def lines_with_pos(text) + pos = 0 + StringIO.new(text).each_line do |line| + yield line, pos + pos += line.bytesize + 1 # newline + end + end + + def build_lines(text) + to_enum(:lines_with_pos, text) + end + + def section(name, start, duration, text) + end_ = start + duration + "section_start:#{start.to_i}:#{name}\r\033[0K#{text}section_end:#{end_.to_i}:#{name}\r\033[0K" + end + + let(:lines) { build_lines('') } + subject { described_class.new(lines) } + + describe '#sections' do + before do + subject.parse! + end + + context 'empty trace' do + let(:lines) { build_lines('') } + + it { expect(subject.sections).to be_empty } + end + + context 'with a sectionless trace' do + let(:lines) { build_lines("line 1\nline 2\n") } + + it { expect(subject.sections).to be_empty } + end + + context 'with trace markers' do + let(:start_time) { Time.new(2017, 10, 5).utc } + let(:section_b_duration) { 1.second } + let(:section_a) { section('a', start_time, 0, 'a line') } + let(:section_b) { section('b', start_time, section_b_duration, "another line\n") } + let(:lines) { build_lines(section_a + section_b) } + + it { expect(subject.sections.size).to eq(2) } + it { expect(subject.sections[1][:name]).to eq('b') } + it { expect(subject.sections[1][:date_start]).to eq(start_time) } + it { expect(subject.sections[1][:date_end]).to eq(start_time + section_b_duration) } + end + end + + describe '#parse!' do + context 'multiple "section_" but no complete markers' do + let(:lines) { build_lines('section_section_section_') } + + it 'must find 3 possible section start but no complete sections' do + expect(subject).to receive(:find_next_marker).exactly(3).times.and_call_original + + subject.parse! + + expect(subject.sections).to be_empty + end + end + + context 'trace with UTF-8 chars' do + let(:line) { 'GitLab ❤️ 狸 (tanukis)\n' } + let(:trace) { section('test_section', Time.new(2017, 10, 5).utc, 3.seconds, line) } + let(:lines) { build_lines(trace) } + + it 'must handle correctly byte positioning' do + expect(subject).to receive(:find_next_marker).exactly(2).times.and_call_original + + subject.parse! + + sections = subject.sections + + expect(sections.size).to eq(1) + s = sections[0] + len = s[:byte_end] - s[:byte_start] + expect(trace.byteslice(s[:byte_start], len)).to eq(line) + end + end + end +end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 9cb0b62590a..3546532b9b4 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -61,6 +61,93 @@ describe Gitlab::Ci::Trace do end end + describe '#extract_sections' do + let(:log) { 'No sections' } + let(:sections) { trace.extract_sections } + + before do + trace.set(log) + end + + context 'no sections' do + it 'returs []' do + expect(trace.extract_sections).to eq([]) + end + end + + context 'multiple sections available' do + let(:log) { File.read(expand_fixture_path('trace/trace_with_sections')) } + let(:sections_data) do + [ + { name: 'prepare_script', lines: 2, duration: 3.seconds }, + { name: 'get_sources', lines: 4, duration: 1.second }, + { name: 'restore_cache', lines: 0, duration: 0.seconds }, + { name: 'download_artifacts', lines: 0, duration: 0.seconds }, + { name: 'build_script', lines: 2, duration: 1.second }, + { name: 'after_script', lines: 0, duration: 0.seconds }, + { name: 'archive_cache', lines: 0, duration: 0.seconds }, + { name: 'upload_artifacts', lines: 0, duration: 0.seconds } + ] + end + + it "returns valid sections" do + expect(sections).not_to be_empty + expect(sections.size).to eq(sections_data.size), + "expected #{sections_data.size} sections, got #{sections.size}" + + buff = StringIO.new(log) + sections.each_with_index do |s, i| + expected = sections_data[i] + + expect(s[:name]).to eq(expected[:name]) + expect(s[:date_end] - s[:date_start]).to eq(expected[:duration]) + + buff.seek(s[:byte_start], IO::SEEK_SET) + length = s[:byte_end] - s[:byte_start] + lines = buff.read(length).count("\n") + expect(lines).to eq(expected[:lines]) + end + end + end + + context 'logs contains "section_start"' do + let(:log) { "section_start:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_end:1506417477:a_section\r\033[0K"} + + it "returns only one section" do + expect(sections).not_to be_empty + expect(sections.size).to eq(1) + + section = sections[0] + expect(section[:name]).to eq('a_section') + expect(section[:byte_start]).not_to eq(section[:byte_end]), "got an empty section" + end + end + + context 'missing section_end' do + let(:log) { "section_start:1506417476:a_section\r\033[0KSome logs\nNo section_end\n"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + + context 'missing section_start' do + let(:log) { "Some logs\nNo section_start\nsection_end:1506417476:a_section\r\033[0K"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + + context 'inverted section_start section_end' do + let(:log) { "section_end:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_start:1506417477:a_section\r\033[0K"} + + it "returns no sections" do + expect(sections).to be_empty + end + end + end + describe '#set' do before do trace.set("12") diff --git a/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb b/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb new file mode 100644 index 00000000000..2f99febe04e --- /dev/null +++ b/spec/lib/gitlab/diff/formatters/image_formatter_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Diff::Formatters::ImageFormatter do + it_behaves_like "position formatter" do + let(:base_attrs) do + { + base_sha: 123, + start_sha: 456, + head_sha: 789, + old_path: 'old_image.png', + new_path: 'new_image.png', + position_type: 'image' + } + end + + let(:attrs) do + base_attrs.merge(width: 100, height: 100, x: 1, y: 2) + end + end +end diff --git a/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb b/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb new file mode 100644 index 00000000000..897dc917f6a --- /dev/null +++ b/spec/lib/gitlab/diff/formatters/text_formatter_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Gitlab::Diff::Formatters::TextFormatter do + let!(:base) do + { + base_sha: 123, + start_sha: 456, + head_sha: 789, + old_path: 'old_path.txt', + new_path: 'new_path.txt' + } + end + + let!(:complete) do + base.merge(old_line: 1, new_line: 2) + end + + it_behaves_like "position formatter" do + let(:base_attrs) { base } + + let(:attrs) { complete } + end + + # Specific text formatter examples + let!(:formatter) { described_class.new(attrs) } + + describe '#line_age' do + subject { formatter.line_age } + + context ' when there is only new_line' do + let(:attrs) { base.merge(new_line: 1) } + + it { is_expected.to eq('new') } + end + + context ' when there is only old_line' do + let(:attrs) { base.merge(old_line: 1) } + + it { is_expected.to eq('old') } + end + end +end diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb index 7798736a4dc..9bf54fdecc4 100644 --- a/spec/lib/gitlab/diff/position_spec.rb +++ b/spec/lib/gitlab/diff/position_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Diff::Position do let(:project) { create(:project, :repository) } - describe "position for an added file" do + describe "position for an added text file" do let(:commit) { project.commit("2ea1f3dec713d940208fb5ce4a38765ecb5d3f73") } subject do @@ -47,6 +47,31 @@ describe Gitlab::Diff::Position do end end + describe "position for an added image file" do + let(:commit) { project.commit("33f3729a45c02fc67d00adb1b8bca394b0e761d9") } + + subject do + described_class.new( + old_path: "files/images/6049019_460s.jpg", + new_path: "files/images/6049019_460s.jpg", + width: 100, + height: 100, + x: 1, + y: 100, + diff_refs: commit.diff_refs, + position_type: "image" + ) + end + + it "returns the correct diff file" do + diff_file = subject.diff_file(project.repository) + + expect(diff_file.new_file?).to be true + expect(diff_file.new_path).to eq(subject.new_path) + expect(diff_file.diff_refs).to eq(subject.diff_refs) + end + end + describe "position for a changed file" do let(:commit) { project.commit("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") } @@ -468,26 +493,54 @@ describe Gitlab::Diff::Position do end describe "#to_json" do - let(:hash) do - { - old_path: "files/ruby/popen.rb", - new_path: "files/ruby/popen.rb", - old_line: nil, - new_line: 14, - base_sha: nil, - head_sha: nil, - start_sha: nil - } + shared_examples "diff position json" do + it "returns the position as JSON" do + expect(JSON.parse(diff_position.to_json)).to eq(hash.stringify_keys) + end + + it "works when nested under another hash" do + expect(JSON.parse(JSON.generate(pos: diff_position))).to eq('pos' => hash.stringify_keys) + end end - let(:diff_position) { described_class.new(hash) } + context "for text positon" do + let(:hash) do + { + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + base_sha: nil, + head_sha: nil, + start_sha: nil, + position_type: "text" + } + end + + let(:diff_position) { described_class.new(hash) } - it "returns the position as JSON" do - expect(JSON.parse(diff_position.to_json)).to eq(hash.stringify_keys) + it_behaves_like "diff position json" end - it "works when nested under another hash" do - expect(JSON.parse(JSON.generate(pos: diff_position))).to eq('pos' => hash.stringify_keys) + context "for image positon" do + let(:hash) do + { + old_path: "files/any.img", + new_path: "files/any.img", + base_sha: nil, + head_sha: nil, + start_sha: nil, + width: 100, + height: 100, + x: 1, + y: 100, + position_type: "image" + } + end + + let(:diff_position) { described_class.new(hash) } + + it_behaves_like "diff position json" end end end diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index 4fa30d8df8b..e5138705443 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -71,6 +71,10 @@ describe Gitlab::Diff::PositionTracer do Gitlab::Diff::DiffRefs.new(base_sha: base_commit.id, head_sha: head_commit.id) end + def text_position_attrs + [:old_line, :new_line] + end + def position(attrs = {}) attrs.reverse_merge!( diff_refs: old_diff_refs @@ -91,7 +95,11 @@ describe Gitlab::Diff::PositionTracer do expect(new_position.diff_refs).to eq(new_diff_refs) attrs.each do |attr, value| - expect(new_position.send(attr)).to eq(value) + if text_position_attrs.include?(attr) + expect(new_position.formatter.send(attr)).to eq(value) + else + expect(new_position.send(attr)).to eq(value) + end end end end @@ -110,7 +118,11 @@ describe Gitlab::Diff::PositionTracer do expect(change_position.diff_refs).to eq(change_diff_refs) attrs.each do |attr, value| - expect(change_position.send(attr)).to eq(value) + if text_position_attrs.include?(attr) + expect(change_position.formatter.send(attr)).to eq(value) + else + expect(change_position.send(attr)).to eq(value) + end end end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 458627ee4de..c54327bd2e4 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -598,6 +598,19 @@ describe Gitlab::GitAccess do admin: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false })) end end + + context "when in a read-only GitLab instance" do + before do + create(:protected_branch, name: 'feature', project: project) + allow(Gitlab::Database).to receive(:read_only?) { true } + end + + # Only check admin; if an admin can't do it, other roles can't either + matrix = permissions_matrix[:admin].dup + matrix.each { |key, _| matrix[key] = false } + + run_permission_checks(admin: matrix) + end end describe 'build authentication abilities' do @@ -632,6 +645,16 @@ describe Gitlab::GitAccess do end end + context 'when the repository is read only' do + let(:project) { create(:project, :repository, :read_only) } + + it 'denies push access' do + project.add_master(user) + + expect { push_access_check }.to raise_unauthorized('The repository is temporarily read-only. Please try again later.') + end + end + describe 'deploy key permissions' do let(:key) { create(:deploy_key, user: user, can_push: can_push) } let(:actor) { key } diff --git a/spec/lib/gitlab/git_access_wiki_spec.rb b/spec/lib/gitlab/git_access_wiki_spec.rb index 0376b4ee783..1056074264a 100644 --- a/spec/lib/gitlab/git_access_wiki_spec.rb +++ b/spec/lib/gitlab/git_access_wiki_spec.rb @@ -4,6 +4,7 @@ describe Gitlab::GitAccessWiki do let(:access) { described_class.new(user, project, 'web', authentication_abilities: authentication_abilities, redirected_path: redirected_path) } let(:project) { create(:project, :repository) } let(:user) { create(:user) } + let(:changes) { ['6f6d7e7ed 570e7b2ab refs/heads/master'] } let(:redirected_path) { nil } let(:authentication_abilities) do [ @@ -13,19 +14,27 @@ describe Gitlab::GitAccessWiki do ] end - describe 'push_allowed?' do - before do - create(:protected_branch, name: 'master', project: project) - project.team << [user, :developer] - end + describe '#push_access_check' do + context 'when user can :create_wiki' do + before do + create(:protected_branch, name: 'master', project: project) + project.team << [user, :developer] + end - subject { access.check('git-receive-pack', changes) } + subject { access.check('git-receive-pack', changes) } - it { expect { subject }.not_to raise_error } - end + it { expect { subject }.not_to raise_error } + + context 'when in a read-only GitLab instance' do + before do + allow(Gitlab::Database).to receive(:read_only?) { true } + end - def changes - ['6f6d7e7ed 570e7b2ab refs/heads/master'] + it 'does not give access to upload wiki code' do + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, "You can't push code to a read-only GitLab instance.") + end + end + end end describe '#access_check_download!' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index ec425fd2803..29baa70d5ae 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -271,6 +271,10 @@ project: - container_repositories - uploads - members_and_requesters +- build_trace_section_names +- root_of_fork_network +- fork_network_member +- fork_network award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb index c7fbc2bc92f..dd0ce0dae41 100644 --- a/spec/lib/gitlab/import_export/fork_spec.rb +++ b/spec/lib/gitlab/import_export/fork_spec.rb @@ -1,13 +1,15 @@ require 'spec_helper' describe 'forked project import' do + include ProjectForksHelper + let(:user) { create(:user) } let!(:project_with_repo) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') } let!(:project) { create(:project, name: 'test-repo-restorer-no-repo', path: 'test-repo-restorer-no-repo') } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.full_path) } let(:forked_from_project) { create(:project, :repository) } - let(:fork_link) { create(:forked_project_link, forked_from_project: project_with_repo) } + let(:forked_project) { fork_project(project_with_repo, nil, repository: true) } let(:repo_saver) { Gitlab::ImportExport::RepoSaver.new(project: project_with_repo, shared: shared) } let(:bundle_path) { File.join(shared.export_path, Gitlab::ImportExport.project_bundle_filename) } @@ -16,7 +18,7 @@ describe 'forked project import' do end let!(:merge_request) do - create(:merge_request, source_project: fork_link.forked_to_project, target_project: project_with_repo) + create(:merge_request, source_project: forked_project, target_project: project_with_repo) end let(:saver) do diff --git a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb index 4d87f27ce05..473ba40fae7 100644 --- a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb +++ b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb @@ -1,13 +1,14 @@ require 'spec_helper' describe Gitlab::ImportExport::MergeRequestParser do + include ProjectForksHelper + let(:user) { create(:user) } let!(:project) { create(:project, :repository, name: 'test-repo-restorer', path: 'test-repo-restorer') } - let(:forked_from_project) { create(:project, :repository) } - let(:fork_link) { create(:forked_project_link, forked_from_project: project) } + let(:forked_project) { fork_project(project) } let!(:merge_request) do - create(:merge_request, source_project: fork_link.forked_to_project, target_project: project) + create(:merge_request, source_project: forked_project, target_project: project) end let(:parsed_merge_request) do diff --git a/spec/lib/gitlab/ldap/auth_hash_spec.rb b/spec/lib/gitlab/ldap/auth_hash_spec.rb index 8370adf9211..1785094af10 100644 --- a/spec/lib/gitlab/ldap/auth_hash_spec.rb +++ b/spec/lib/gitlab/ldap/auth_hash_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::LDAP::AuthHash do let(:auth_hash) do described_class.new( OmniAuth::AuthHash.new( - uid: '123456', + uid: given_uid, provider: 'ldapmain', info: info, extra: { @@ -32,6 +32,8 @@ describe Gitlab::LDAP::AuthHash do end context "without overridden attributes" do + let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' } + it "has the correct username" do expect(auth_hash.username).to eq("123456") end @@ -42,6 +44,8 @@ describe Gitlab::LDAP::AuthHash do end context "with overridden attributes" do + let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' } + let(:attributes) do { 'username' => %w(mail email), @@ -61,4 +65,22 @@ describe Gitlab::LDAP::AuthHash do expect(auth_hash.name).to eq("John Smith") end end + + describe '#uid' do + context 'when there is extraneous (but valid) whitespace' do + let(:given_uid) { 'uid =john smith , ou = people, dc= example,dc =com' } + + it 'removes the extraneous whitespace' do + expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com') + end + end + + context 'when there are upper case characters' do + let(:given_uid) { 'UID=John Smith,ou=People,dc=example,dc=com' } + + it 'downcases' do + expect(auth_hash.uid).to eq('uid=john smith,ou=people,dc=example,dc=com') + end + end + end end diff --git a/spec/lib/gitlab/ldap/dn_spec.rb b/spec/lib/gitlab/ldap/dn_spec.rb new file mode 100644 index 00000000000..8e21ecdf9ab --- /dev/null +++ b/spec/lib/gitlab/ldap/dn_spec.rb @@ -0,0 +1,224 @@ +require 'spec_helper' + +describe Gitlab::LDAP::DN do + using RSpec::Parameterized::TableSyntax + + describe '#normalize_value' do + subject { described_class.normalize_value(given) } + + it_behaves_like 'normalizes a DN attribute value' + + context 'when the given DN is malformed' do + context 'when ending with a comma' do + let(:given) { 'John Smith,' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'when given a BER encoded attribute value with a space in it' do + let(:given) { '#aa aa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"") + end + end + + context 'when given a BER encoded attribute value with a non-hex character in it' do + let(:given) { '#aaXaaa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"") + end + end + + context 'when given a BER encoded attribute value with a non-hex character in it' do + let(:given) { '#aaaYaa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"") + end + end + + context 'when given a hex pair with a non-hex character in it, inside double quotes' do + let(:given) { '"Sebasti\\cX\\a1n"' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"") + end + end + + context 'with an open (as opposed to closed) double quote' do + let(:given) { '"James' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'with an invalid escaped hex code' do + let(:given) { 'J\ames' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"') + end + end + + context 'with a value ending with the escape character' do + let(:given) { 'foo\\' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + end + end + + describe '#to_normalized_s' do + subject { described_class.new(given).to_normalized_s } + + it_behaves_like 'normalizes a DN' + + context 'when we do not support the given DN format' do + context 'multivalued RDNs' do + context 'without extraneous whitespace' do + let(:given) { 'uid=john smith+telephonenumber=+1 555-555-5555,ou=people,dc=example,dc=com' } + + it 'raises UnsupportedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError) + end + end + + context 'with extraneous whitespace' do + context 'around the phone number plus sign' do + let(:given) { 'uid = John Smith + telephoneNumber = + 1 555-555-5555 , ou = People,dc=example,dc=com' } + + it 'raises UnsupportedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError) + end + end + + context 'not around the phone number plus sign' do + let(:given) { 'uid = John Smith + telephoneNumber = +1 555-555-5555 , ou = People,dc=example,dc=com' } + + it 'raises UnsupportedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::UnsupportedError) + end + end + end + end + end + + context 'when the given DN is malformed' do + context 'when ending with a comma' do + let(:given) { 'uid=John Smith,' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'when given a BER encoded attribute value with a space in it' do + let(:given) { '0.9.2342.19200300.100.1.25=#aa aa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the end of an attribute value, but got \"a\"") + end + end + + context 'when given a BER encoded attribute value with a non-hex character in it' do + let(:given) { '0.9.2342.19200300.100.1.25=#aaXaaa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the first character of a hex pair, but got \"X\"") + end + end + + context 'when given a BER encoded attribute value with a non-hex character in it' do + let(:given) { '0.9.2342.19200300.100.1.25=#aaaYaa' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair, but got \"Y\"") + end + end + + context 'when given a hex pair with a non-hex character in it, inside double quotes' do + let(:given) { 'uid="Sebasti\\cX\\a1n"' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"X\"") + end + end + + context 'without a name value pair' do + let(:given) { 'John' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'with an open (as opposed to closed) double quote' do + let(:given) { 'cn="James' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'with an invalid escaped hex code' do + let(:given) { 'cn=J\ames' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Invalid escaped hex code "\am"') + end + end + + context 'with a value ending with the escape character' do + let(:given) { 'cn=\\' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'DN string ended unexpectedly') + end + end + + context 'with an invalid OID attribute type name' do + let(:given) { '1.2.d=Value' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN OID attribute type name character "d"') + end + end + + context 'with a period in a non-OID attribute type name' do + let(:given) { 'd1.2=Value' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "."') + end + end + + context 'when starting with non-space, non-alphanumeric character' do + let(:given) { ' -uid=John Smith' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized first character of an RDN attribute type name "-"') + end + end + + context 'when given a UID with an escaped equal sign' do + let(:given) { 'uid\\=john' } + + it 'raises MalformedError' do + expect { subject }.to raise_error(Gitlab::LDAP::DN::MalformedError, 'Unrecognized RDN attribute type name character "\\"') + end + end + end + end + + def assert_generic_test(test_description, got, expected) + test_failure_message = "Failed test description: '#{test_description}'\n\n expected: \"#{expected}\"\n got: \"#{got}\"" + expect(got).to eq(expected), test_failure_message + end +end diff --git a/spec/lib/gitlab/ldap/person_spec.rb b/spec/lib/gitlab/ldap/person_spec.rb index 087c4d8c92c..d204050ef66 100644 --- a/spec/lib/gitlab/ldap/person_spec.rb +++ b/spec/lib/gitlab/ldap/person_spec.rb @@ -16,6 +16,34 @@ describe Gitlab::LDAP::Person do ) end + describe '.normalize_dn' do + subject { described_class.normalize_dn(given) } + + it_behaves_like 'normalizes a DN' + + context 'with an exception during normalization' do + let(:given) { 'John "Smith,' } # just something that will cause an exception + + it 'returns the given DN unmodified' do + expect(subject).to eq(given) + end + end + end + + describe '.normalize_uid' do + subject { described_class.normalize_uid(given) } + + it_behaves_like 'normalizes a DN attribute value' + + context 'with an exception during normalization' do + let(:given) { 'John "Smith,' } # just something that will cause an exception + + it 'returns the given UID unmodified' do + expect(subject).to eq(given) + end + end + end + describe '#name' do it 'uses the configured name attribute and handles values as an array' do name = 'John Doe' @@ -43,4 +71,9 @@ describe Gitlab::LDAP::Person do expect(person.email).to eq([user_principal_name]) end end + + def assert_generic_test(test_description, got, expected) + test_failure_message = "Failed test description: '#{test_description}'\n\n expected: #{expected}\n got: #{got}" + expect(got).to eq(expected), test_failure_message + end end diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index 6a6e465cea2..9a4705d1cee 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -11,7 +11,7 @@ describe Gitlab::LDAP::User do } end let(:auth_hash) do - OmniAuth::AuthHash.new(uid: 'my-uid', provider: 'ldapmain', info: info) + OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info) end let(:ldap_user_upper_case) { described_class.new(auth_hash_upper_case) } let(:info_upper_case) do @@ -22,12 +22,12 @@ describe Gitlab::LDAP::User do } end let(:auth_hash_upper_case) do - OmniAuth::AuthHash.new(uid: 'my-uid', provider: 'ldapmain', info: info_upper_case) + OmniAuth::AuthHash.new(uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain', info: info_upper_case) end describe '#changed?' do it "marks existing ldap user as changed" do - create(:omniauth_user, extern_uid: 'my-uid', provider: 'ldapmain') + create(:omniauth_user, extern_uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain') expect(ldap_user.changed?).to be_truthy end @@ -37,7 +37,7 @@ describe Gitlab::LDAP::User do end it "does not mark existing ldap user as changed" do - create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain') + create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain') ldap_user.gl_user.user_synced_attributes_metadata(provider: 'ldapmain', email: true) expect(ldap_user.changed?).to be_falsey end @@ -60,7 +60,7 @@ describe Gitlab::LDAP::User do describe 'find or create' do it "finds the user if already existing" do - create(:omniauth_user, extern_uid: 'my-uid', provider: 'ldapmain') + create(:omniauth_user, extern_uid: 'uid=John Smith,ou=People,dc=example,dc=com', provider: 'ldapmain') expect { ldap_user.save }.not_to change { User.count } end @@ -70,7 +70,7 @@ describe Gitlab::LDAP::User do expect { ldap_user.save }.not_to change { User.count } existing_user.reload - expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid' + expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com' expect(existing_user.ldap_identity.provider).to eql 'ldapmain' end @@ -79,7 +79,7 @@ describe Gitlab::LDAP::User do expect { ldap_user.save }.not_to change { User.count } existing_user.reload - expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid' + expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com' expect(existing_user.ldap_identity.provider).to eql 'ldapmain' expect(existing_user.id).to eql ldap_user.gl_user.id end @@ -89,7 +89,7 @@ describe Gitlab::LDAP::User do expect { ldap_user_upper_case.save }.not_to change { User.count } existing_user.reload - expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid' + expect(existing_user.ldap_identity.extern_uid).to eql 'uid=john smith,ou=people,dc=example,dc=com' expect(existing_user.ldap_identity.provider).to eql 'ldapmain' expect(existing_user.id).to eql ldap_user.gl_user.id end diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb new file mode 100644 index 00000000000..742a792a1af --- /dev/null +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -0,0 +1,142 @@ +require 'spec_helper' + +describe Gitlab::Middleware::ReadOnly do + include Rack::Test::Methods + + RSpec::Matchers.define :be_a_redirect do + match do |response| + response.status == 301 + end + end + + RSpec::Matchers.define :disallow_request do + match do |middleware| + flash = middleware.send(:rack_flash) + flash['alert'] && flash['alert'].include?('You cannot do writing operations') + end + end + + RSpec::Matchers.define :disallow_request_in_json do + match do |response| + json_response = JSON.parse(response.body) + response.body.include?('You cannot do writing operations') && json_response.key?('message') + end + end + + let(:rack_stack) do + rack = Rack::Builder.new do + use ActionDispatch::Session::CacheStore + use ActionDispatch::Flash + use ActionDispatch::ParamsParser + end + + rack.run(subject) + rack.to_app + end + + subject { described_class.new(fake_app) } + + let(:request) { Rack::MockRequest.new(rack_stack) } + + context 'normal requests to a read-only Gitlab instance' do + let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } + + before do + allow(Gitlab::Database).to receive(:read_only?) { true } + end + + it 'expects PATCH requests to be disallowed' do + response = request.patch('/test_request') + + expect(response).to be_a_redirect + expect(subject).to disallow_request + end + + it 'expects PUT requests to be disallowed' do + response = request.put('/test_request') + + expect(response).to be_a_redirect + expect(subject).to disallow_request + end + + it 'expects POST requests to be disallowed' do + response = request.post('/test_request') + + expect(response).to be_a_redirect + expect(subject).to disallow_request + end + + it 'expects a internal POST request to be allowed after a disallowed request' do + response = request.post('/test_request') + + expect(response).to be_a_redirect + + response = request.post("/api/#{API::API.version}/internal") + + expect(response).not_to be_a_redirect + end + + it 'expects DELETE requests to be disallowed' do + response = request.delete('/test_request') + + expect(response).to be_a_redirect + expect(subject).to disallow_request + end + + context 'whitelisted requests' do + it 'expects DELETE request to logout to be allowed' do + response = request.delete('/users/sign_out') + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end + + it 'expects a POST internal request to be allowed' do + response = request.post("/api/#{API::API.version}/internal") + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end + + it 'expects a POST LFS request to batch URL to be allowed' do + response = request.post('/root/rouge.git/info/lfs/objects/batch') + + expect(response).not_to be_a_redirect + expect(subject).not_to disallow_request + end + end + end + + context 'json requests to a read-only GitLab instance' do + let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'application/json' }, ['OK']] } } + let(:content_json) { { 'CONTENT_TYPE' => 'application/json' } } + + before do + allow(Gitlab::Database).to receive(:read_only?) { true } + end + + it 'expects PATCH requests to be disallowed' do + response = request.patch('/test_request', content_json) + + expect(response).to disallow_request_in_json + end + + it 'expects PUT requests to be disallowed' do + response = request.put('/test_request', content_json) + + expect(response).to disallow_request_in_json + end + + it 'expects POST requests to be disallowed' do + response = request.post('/test_request', content_json) + + expect(response).to disallow_request_in_json + end + + it 'expects DELETE requests to be disallowed' do + response = request.delete('/test_request', content_json) + + expect(response).to disallow_request_in_json + end + end +end diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 4c5efbde69a..e44a7c23452 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Gitlab::SearchResults do + include ProjectForksHelper + let(:user) { create(:user) } let!(:project) { create(:project, name: 'foo') } let!(:issue) { create(:issue, project: project, title: 'foo') } @@ -42,7 +44,7 @@ describe Gitlab::SearchResults do end it 'includes merge requests from source and target projects' do - forked_project = create(:project, forked_from_project: project) + forked_project = fork_project(project, user) merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo') results = described_class.new(user, Project.where(id: forked_project.id), 'foo') diff --git a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb index a0fb86345f3..b4b83b70d1c 100644 --- a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb +++ b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb @@ -39,6 +39,14 @@ describe SystemCheck::App::GitUserDefaultSSHConfigCheck do it { is_expected.to eq(expected_result) } end + + it 'skips GitLab read-only instances' do + stub_user + stub_home_dir + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + + is_expected.to be_truthy + end end describe '#check?' do diff --git a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb index 862907c5d01..84c2e9f7e52 100644 --- a/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb +++ b/spec/migrations/add_head_pipeline_for_each_merge_request_spec.rb @@ -2,11 +2,12 @@ require 'spec_helper' require Rails.root.join('db', 'post_migrate', '20170508170547_add_head_pipeline_for_each_merge_request.rb') describe AddHeadPipelineForEachMergeRequest, :truncate do + include ProjectForksHelper + let(:migration) { described_class.new } let!(:project) { create(:project) } - let!(:forked_project_link) { create(:forked_project_link, forked_from_project: project) } - let!(:other_project) { forked_project_link.forked_to_project } + let!(:other_project) { fork_project(project) } let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: "branch_1") } let!(:pipeline_2) { create(:ci_pipeline, project: other_project, ref: "branch_1") } diff --git a/spec/migrations/normalize_ldap_extern_uids_spec.rb b/spec/migrations/normalize_ldap_extern_uids_spec.rb new file mode 100644 index 00000000000..262d7742aaf --- /dev/null +++ b/spec/migrations/normalize_ldap_extern_uids_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170921101004_normalize_ldap_extern_uids') + +describe NormalizeLdapExternUids, :migration, :sidekiq do + let!(:identities) { table(:identities) } + + around do |example| + Timecop.freeze { example.run } + end + + before do + stub_const("Gitlab::Database::MigrationHelpers::BACKGROUND_MIGRATION_BATCH_SIZE", 2) + stub_const("Gitlab::Database::MigrationHelpers::BACKGROUND_MIGRATION_JOB_BUFFER_SIZE", 2) + + # LDAP identities + (1..4).each do |i| + identities.create!(id: i, provider: 'ldapmain', extern_uid: " uid = foo #{i}, ou = People, dc = example, dc = com ", user_id: i) + end + + # Non-LDAP identity + identities.create!(id: 5, provider: 'foo', extern_uid: " uid = foo 5, ou = People, dc = example, dc = com ", user_id: 5) + end + + it 'correctly schedules background migrations' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([described_class::MIGRATION, [1, 2]]) + expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.seconds.from_now.to_f) + expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([described_class::MIGRATION, [3, 4]]) + expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.seconds.from_now.to_f) + expect(BackgroundMigrationWorker.jobs[2]['args']).to eq([described_class::MIGRATION, [5, 5]]) + expect(BackgroundMigrationWorker.jobs[2]['at']).to eq(30.seconds.from_now.to_f) + expect(BackgroundMigrationWorker.jobs.size).to eq 3 + end + end + end + + it 'migrates the LDAP identities' do + Sidekiq::Testing.inline! do + migrate! + identities.where(id: 1..4).each do |identity| + expect(identity.extern_uid).to eq("uid=foo #{identity.id},ou=people,dc=example,dc=com") + end + end + end + + it 'does not modify non-LDAP identities' do + Sidekiq::Testing.inline! do + migrate! + identity = identities.last + expect(identity.extern_uid).to eq(" uid = foo 5, ou = People, dc = example, dc = com ") + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 451968c7342..06f76b5501e 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -18,6 +18,7 @@ describe Ci::Build do it { is_expected.to belong_to(:trigger_request) } it { is_expected.to belong_to(:erased_by) } it { is_expected.to have_many(:deployments) } + it { is_expected.to have_many(:trace_sections)} it { is_expected.to validate_presence_of(:ref) } it { is_expected.to respond_to(:has_trace?) } it { is_expected.to respond_to(:trace) } @@ -320,6 +321,17 @@ describe Ci::Build do end end + describe '#parse_trace_sections!' do + it 'calls ExtractSectionsFromBuildTraceService' do + expect(Ci::ExtractSectionsFromBuildTraceService) + .to receive(:new).with(project, build.user).once.and_call_original + expect_any_instance_of(Ci::ExtractSectionsFromBuildTraceService) + .to receive(:execute).with(build).once + + build.parse_trace_sections! + end + end + describe '#trace' do subject { build.trace } diff --git a/spec/models/ci/build_trace_section_name_spec.rb b/spec/models/ci/build_trace_section_name_spec.rb new file mode 100644 index 00000000000..386ee6880cb --- /dev/null +++ b/spec/models/ci/build_trace_section_name_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe Ci::BuildTraceSectionName, model: true do + subject { build(:ci_build_trace_section_name) } + + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:trace_sections)} + + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } +end diff --git a/spec/models/ci/build_trace_section_spec.rb b/spec/models/ci/build_trace_section_spec.rb new file mode 100644 index 00000000000..541a9a36fb8 --- /dev/null +++ b/spec/models/ci/build_trace_section_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Ci::BuildTraceSection, model: true do + it { is_expected.to belong_to(:build)} + it { is_expected.to belong_to(:project)} + it { is_expected.to belong_to(:section_name)} + + it { is_expected.to validate_presence_of(:section_name) } + it { is_expected.to validate_presence_of(:build) } + it { is_expected.to validate_presence_of(:project) } +end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 40bbb10eaac..129dfa07f15 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -178,57 +178,59 @@ describe CacheMarkdownField do end end - describe '#refresh_markdown_cache!' do + describe '#refresh_markdown_cache' do before do thing.foo = updated_markdown end - context 'do_update: false' do - it 'fills all html fields' do - thing.refresh_markdown_cache! + it 'fills all html fields' do + thing.refresh_markdown_cache - expect(thing.foo_html).to eq(updated_html) - expect(thing.foo_html_changed?).to be_truthy - expect(thing.baz_html_changed?).to be_truthy - end + expect(thing.foo_html).to eq(updated_html) + expect(thing.foo_html_changed?).to be_truthy + expect(thing.baz_html_changed?).to be_truthy + end - it 'does not save the result' do - expect(thing).not_to receive(:update_columns) + it 'does not save the result' do + expect(thing).not_to receive(:update_columns) - thing.refresh_markdown_cache! - end + thing.refresh_markdown_cache + end - it 'updates the markdown cache version' do - thing.cached_markdown_version = nil - thing.refresh_markdown_cache! + it 'updates the markdown cache version' do + thing.cached_markdown_version = nil + thing.refresh_markdown_cache - expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_VERSION) - end + expect(thing.cached_markdown_version).to eq(CacheMarkdownField::CACHE_VERSION) end + end - context 'do_update: true' do - it 'fills all html fields' do - thing.refresh_markdown_cache!(do_update: true) + describe '#refresh_markdown_cache!' do + before do + thing.foo = updated_markdown + end - expect(thing.foo_html).to eq(updated_html) - expect(thing.foo_html_changed?).to be_truthy - expect(thing.baz_html_changed?).to be_truthy - end + it 'fills all html fields' do + thing.refresh_markdown_cache! - it 'skips saving if not persisted' do - expect(thing).to receive(:persisted?).and_return(false) - expect(thing).not_to receive(:update_columns) + expect(thing.foo_html).to eq(updated_html) + expect(thing.foo_html_changed?).to be_truthy + expect(thing.baz_html_changed?).to be_truthy + end - thing.refresh_markdown_cache!(do_update: true) - end + it 'skips saving if not persisted' do + expect(thing).to receive(:persisted?).and_return(false) + expect(thing).not_to receive(:update_columns) - it 'saves the changes using #update_columns' do - expect(thing).to receive(:persisted?).and_return(true) - expect(thing).to receive(:update_columns) - .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => CacheMarkdownField::CACHE_VERSION) + thing.refresh_markdown_cache! + end - thing.refresh_markdown_cache!(do_update: true) - end + it 'saves the changes using #update_columns' do + expect(thing).to receive(:persisted?).and_return(true) + expect(thing).to receive(:update_columns) + .with("foo_html" => updated_html, "baz_html" => "", "cached_markdown_version" => CacheMarkdownField::CACHE_VERSION) + + thing.refresh_markdown_cache! end end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index b463d12e448..ab8773b7ede 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -12,6 +12,16 @@ describe Group, 'Routable' do it { is_expected.to have_many(:redirect_routes).dependent(:destroy) } end + describe 'GitLab read-only instance' do + it 'does not save route if route is not present' do + group.route.path = '' + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + expect(group).to receive(:update_route).and_call_original + + expect { group.full_path }.to change { Route.count }.by(0) + end + end + describe 'Callbacks' do it 'creates route record on create' do expect(group.route.path).to eq(group.path) diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 4aa9ec789a3..eb0a3e9e0d3 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe DiffNote do include RepoHelpers - let(:merge_request) { create(:merge_request) } + let!(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } let(:commit) { project.commit(sample_commit.id) } @@ -98,14 +98,14 @@ describe DiffNote do diff_line = subject.diff_line expect(diff_line.added?).to be true - expect(diff_line.new_line).to eq(position.new_line) + expect(diff_line.new_line).to eq(position.formatter.new_line) expect(diff_line.text).to eq("+ vars = {") end end describe "#line_code" do it "returns the correct line code" do - line_code = Gitlab::Diff::LineCode.generate(position.file_path, position.new_line, 15) + line_code = Gitlab::Diff::LineCode.generate(position.file_path, position.formatter.new_line, 15) expect(subject.line_code).to eq(line_code) end @@ -255,4 +255,38 @@ describe DiffNote do end end end + + describe "image diff notes" do + let(:path) { "files/images/any_image.png" } + + let!(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + width: 10, + height: 10, + x: 1, + y: 1, + diff_refs: merge_request.diff_refs, + position_type: "image" + ) + end + + describe "validations" do + subject { build(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } + + it { is_expected.not_to validate_presence_of(:line_code) } + + it "does not validate diff line" do + diff_line = subject.diff_line + + expect(diff_line).to be nil + expect(subject).to be_valid + end + end + + it "returns true for on_image?" do + expect(subject.on_image?).to be_truthy + end + end end diff --git a/spec/models/fork_network_member_spec.rb b/spec/models/fork_network_member_spec.rb new file mode 100644 index 00000000000..532ca1fca8c --- /dev/null +++ b/spec/models/fork_network_member_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe ForkNetworkMember do + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:fork_network) } + end +end diff --git a/spec/models/fork_network_spec.rb b/spec/models/fork_network_spec.rb new file mode 100644 index 00000000000..605ccd6db06 --- /dev/null +++ b/spec/models/fork_network_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe ForkNetwork do + include ProjectForksHelper + + describe '#add_root_as_member' do + it 'adds the root project as a member when creating a new root network' do + project = create(:project) + fork_network = described_class.create(root_project: project) + + expect(fork_network.projects).to include(project) + end + end + + describe '#find_fork_in' do + it 'finds all fork of the current network in al collection' do + network = create(:fork_network) + root_project = network.root_project + another_project = fork_project(root_project) + create(:project) + + expect(network.find_forks_in(Project.all)) + .to contain_exactly(another_project, root_project) + end + end + + context 'for a deleted project' do + it 'keeps the fork network' do + project = create(:project, :public) + forked = fork_project(project) + project.destroy! + + fork_network = forked.reload.fork_network + + expect(fork_network.projects).to contain_exactly(forked) + expect(fork_network.root_project).to be_nil + end + + it 'allows multiple fork networks where the root project is deleted' do + first_project = create(:project) + second_project = create(:project) + first_fork = fork_project(first_project) + second_fork = fork_project(second_project) + + first_project.destroy + second_project.destroy + + expect(first_fork.fork_network).not_to be_nil + expect(first_fork.fork_network.root_project).to be_nil + expect(second_fork.fork_network).not_to be_nil + expect(second_fork.fork_network.root_project).to be_nil + end + end +end diff --git a/spec/models/forked_project_link_spec.rb b/spec/models/forked_project_link_spec.rb index 7dbeb4d2e74..32e33e8f42f 100644 --- a/spec/models/forked_project_link_spec.rb +++ b/spec/models/forked_project_link_spec.rb @@ -1,10 +1,11 @@ require 'spec_helper' describe ForkedProjectLink, "add link on fork" do + include ProjectForksHelper + let(:project_from) { create(:project, :repository) } let(:project_to) { fork_project(project_from, user) } let(:user) { create(:user) } - let(:namespace) { user.namespace } before do project_from.add_reporter(user) @@ -64,13 +65,4 @@ describe ForkedProjectLink, "add link on fork" do expect(ForkedProjectLink.exists?(id: forked_project_link.id)).to eq(false) end end - - def fork_project(from_project, user) - service = Projects::ForkService.new(from_project, user) - shell = double('gitlab_shell', fork_repository: true) - - allow(service).to receive(:gitlab_shell).and_return(shell) - - service.execute - end end diff --git a/spec/models/gpg_signature_spec.rb b/spec/models/gpg_signature_spec.rb index db033016c37..0136bb61c07 100644 --- a/spec/models/gpg_signature_spec.rb +++ b/spec/models/gpg_signature_spec.rb @@ -1,6 +1,10 @@ require 'rails_helper' RSpec.describe GpgSignature do + let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } + let!(:project) { create(:project, :repository, path: 'sample-project') } + let!(:commit) { create(:commit, project: project, sha: commit_sha) } + let(:gpg_signature) { create(:gpg_signature, commit_sha: commit_sha) } let(:gpg_key) { create(:gpg_key) } let(:gpg_key_subkey) { create(:gpg_key_subkey) } @@ -19,11 +23,6 @@ RSpec.describe GpgSignature do describe '#commit' do it 'fetches the commit through the project' do - commit_sha = '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' - project = create :project, :repository - commit = create :commit, project: project - gpg_signature = create :gpg_signature, commit_sha: commit_sha - expect_any_instance_of(Project).to receive(:commit).with(commit_sha).and_return(commit) gpg_signature.commit @@ -44,11 +43,28 @@ RSpec.describe GpgSignature do end it 'clears gpg_key and gpg_key_subkey_id when passing nil' do - gpg_signature = create(:gpg_signature, gpg_key: gpg_key_subkey) gpg_signature.update_attribute(:gpg_key, nil) expect(gpg_signature.gpg_key_id).to be_nil expect(gpg_signature.gpg_key_subkey_id).to be_nil end end + + describe '#gpg_commit' do + context 'when commit does not exist' do + it 'returns nil' do + allow(gpg_signature).to receive(:commit).and_return(nil) + + expect(gpg_signature.gpg_commit).to be_nil + end + end + + context 'when commit exists' do + it 'returns an instance of Gitlab::Gpg::Commit' do + allow(gpg_signature).to receive(:commit).and_return(commit) + + expect(gpg_signature.gpg_commit).to be_an_instance_of(Gitlab::Gpg::Commit) + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 188a0a98ec3..950af653c80 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe MergeRequest do include RepoHelpers + include ProjectForksHelper subject { create(:merge_request) } @@ -49,6 +50,19 @@ describe MergeRequest do expect(subject).to be_valid end end + + context 'for forks' do + let(:project) { create(:project) } + let(:fork1) { fork_project(project) } + let(:fork2) { fork_project(project) } + + it 'allows merge requests for sibling-forks' do + subject.source_project = fork1 + subject.target_project = fork2 + + expect(subject).to be_valid + end + end end describe 'respond to' do @@ -672,7 +686,7 @@ describe MergeRequest do describe '#diverged_commits_count' do let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, forked_from_project: project) } + let(:forked_project) { fork_project(project, nil, repository: true) } context 'when the target branch does not exist anymore' do subject { create(:merge_request, source_project: project, target_project: project) } @@ -700,7 +714,7 @@ describe MergeRequest do end context 'diverged on fork' do - subject(:merge_request_fork_with_divergence) { create(:merge_request, :diverged, source_project: fork_project, target_project: project) } + subject(:merge_request_fork_with_divergence) { create(:merge_request, :diverged, source_project: forked_project, target_project: project) } it 'counts commits that are on target branch but not on source branch' do expect(subject.diverged_commits_count).to eq(29) @@ -708,7 +722,7 @@ describe MergeRequest do end context 'rebased on fork' do - subject(:merge_request_rebased) { create(:merge_request, :rebased, source_project: fork_project, target_project: project) } + subject(:merge_request_rebased) { create(:merge_request, :rebased, source_project: forked_project, target_project: project) } it 'counts commits that are on target branch but not on source branch' do expect(subject.diverged_commits_count).to eq(0) @@ -1257,11 +1271,7 @@ describe MergeRequest do end context 'with environments on source project' do - let(:source_project) do - create(:project, :repository) do |fork_project| - fork_project.create_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) - end - end + let(:source_project) { fork_project(project, nil, repository: true) } let(:merge_request) do create(:merge_request, @@ -1425,14 +1435,14 @@ describe MergeRequest do describe "#source_project_missing?" do let(:project) { create(:project) } - let(:fork_project) { create(:project, forked_from_project: project) } + let(:forked_project) { fork_project(project) } let(:user) { create(:user) } - let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) } context "when the fork exists" do let(:merge_request) do create(:merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end @@ -1446,9 +1456,9 @@ describe MergeRequest do end context "when the fork does not exist" do - let(:merge_request) do + let!(:merge_request) do create(:merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end @@ -1471,14 +1481,14 @@ describe MergeRequest do describe "#closed_without_fork?" do let(:project) { create(:project) } - let(:fork_project) { create(:project, forked_from_project: project) } + let(:forked_project) { fork_project(project) } let(:user) { create(:user) } - let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) } context "when the merge request is closed" do let(:closed_merge_request) do create(:closed_merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end @@ -1497,7 +1507,7 @@ describe MergeRequest do context "when the merge request is open" do let(:open_merge_request) do create(:merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end @@ -1516,24 +1526,24 @@ describe MergeRequest do end context 'forked project' do - let(:project) { create(:project) } + let(:project) { create(:project, :public) } let(:user) { create(:user) } - let(:fork_project) { create(:project, forked_from_project: project, namespace: user.namespace) } + let(:forked_project) { fork_project(project, user) } let!(:merge_request) do create(:closed_merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project) end it 'returns false if unforked' do - Projects::UnlinkForkService.new(fork_project, user).execute + Projects::UnlinkForkService.new(forked_project, user).execute expect(merge_request.reload.reopenable?).to be_falsey end it 'returns false if the source project is deleted' do - Projects::DestroyService.new(fork_project, user).execute + Projects::DestroyService.new(forked_project, user).execute expect(merge_request.reload.reopenable?).to be_falsey end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 3ea614776ca..2ebf6acd42a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Namespace do + include ProjectForksHelper + let!(:namespace) { create(:namespace) } describe 'associations' do @@ -520,4 +522,25 @@ describe Namespace do end end end + + describe '#has_forks_of?' do + let(:project) { create(:project, :public) } + let!(:forked_project) { fork_project(project, namespace.owner, namespace: namespace) } + + before do + # Reset the fork network relation + project.reload + end + + it 'knows if there is a direct fork in the namespace' do + expect(namespace.find_fork_of(project)).to eq(forked_project) + end + + it 'knows when there is as fork-of-fork in the namespace' do + other_namespace = create(:namespace) + other_fork = fork_project(forked_project, other_namespace.owner, namespace: other_namespace) + + expect(other_namespace.find_fork_of(project)).to eq(other_fork) + end + end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index b214074fdce..1ecb50586c7 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -314,6 +314,56 @@ describe Note do expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) end + + context 'with image discussions' do + let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images and changes") } + let(:image_path) { "files/images/ee_repo_logo.png" } + let(:text_path) { "bar/branch-test.txt" } + let!(:image_note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position) } + let!(:text_note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: text_position) } + + let(:image_position) do + Gitlab::Diff::Position.new( + old_path: image_path, + new_path: image_path, + width: 100, + height: 100, + x: 1, + y: 1, + position_type: "image", + diff_refs: merge_request2.diff_refs + ) + end + + let(:text_position) do + Gitlab::Diff::Position.new( + old_path: text_path, + new_path: text_path, + old_line: nil, + new_line: 2, + position_type: "text", + diff_refs: merge_request2.diff_refs + ) + end + + it "groups image discussions by file identifier" do + diff_discussion = DiffDiscussion.new([image_note]) + + discussions = merge_request2.notes.grouped_diff_discussions + + expect(discussions.size).to eq(2) + expect(discussions[image_note.diff_file.new_path]).to include(diff_discussion) + end + + it "groups text discussions by line code" do + diff_discussion = DiffDiscussion.new([text_note]) + + discussions = merge_request2.notes.grouped_diff_discussions + + expect(discussions.size).to eq(2) + expect(discussions[text_note.line_code]).to include(diff_discussion) + end + end end context 'diff discussions for older diff refs' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 9b470e79a76..a26c71e5155 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -57,6 +57,7 @@ describe Project do it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:pipelines) } it { is_expected.to have_many(:builds) } + it { is_expected.to have_many(:build_trace_section_names)} it { is_expected.to have_many(:runner_projects) } it { is_expected.to have_many(:runners) } it { is_expected.to have_many(:active_runners) } @@ -692,6 +693,44 @@ describe Project do project.cache_has_external_issue_tracker end.to change { project.has_external_issue_tracker}.to(false) end + + it 'does not cache data when in a read-only GitLab instance' do + allow(Gitlab::Database).to receive(:read_only?) { true } + + expect do + project.cache_has_external_issue_tracker + end.not_to change { project.has_external_issue_tracker } + end + end + + describe '#cache_has_external_wiki' do + let(:project) { create(:project, has_external_wiki: nil) } + + it 'stores true if there is any external_wikis' do + services = double(:service, external_wikis: [ExternalWikiService.new]) + expect(project).to receive(:services).and_return(services) + + expect do + project.cache_has_external_wiki + end.to change { project.has_external_wiki}.to(true) + end + + it 'stores false if there is no external_wikis' do + services = double(:service, external_wikis: []) + expect(project).to receive(:services).and_return(services) + + expect do + project.cache_has_external_wiki + end.to change { project.has_external_wiki}.to(false) + end + + it 'does not cache data when in a read-only GitLab instance' do + allow(Gitlab::Database).to receive(:read_only?) { true } + + expect do + project.cache_has_external_wiki + end.not_to change { project.has_external_wiki } + end end describe '#has_wiki?' do @@ -1817,6 +1856,59 @@ describe Project do end end + context 'forks' do + include ProjectForksHelper + + let(:project) { create(:project, :public) } + let!(:forked_project) { fork_project(project) } + + describe '#fork_network' do + it 'includes a fork of the project' do + expect(project.fork_network.projects).to include(forked_project) + end + + it 'includes a fork of a fork' do + other_fork = fork_project(forked_project) + + expect(project.fork_network.projects).to include(other_fork) + end + + it 'includes sibling forks' do + other_fork = fork_project(project) + + expect(forked_project.fork_network.projects).to include(other_fork) + end + + it 'includes the base project' do + expect(forked_project.fork_network.projects).to include(project.reload) + end + end + + describe '#in_fork_network_of?' do + it 'is true for a real fork' do + expect(forked_project.in_fork_network_of?(project)).to be_truthy + end + + it 'is true for a fork of a fork', :postgresql do + other_fork = fork_project(forked_project) + + expect(other_fork.in_fork_network_of?(project)).to be_truthy + end + + it 'is true for sibling forks' do + sibling = fork_project(project) + + expect(sibling.in_fork_network_of?(forked_project)).to be_truthy + end + + it 'is false when another project is given' do + other_project = build_stubbed(:project) + + expect(forked_project.in_fork_network_of?(other_project)).to be_falsy + end + end + end + describe '#pushes_since_gc' do let(:project) { create(:project) } @@ -2446,7 +2538,7 @@ describe Project do expect(project.migrate_to_hashed_storage!).to be_truthy end - it 'flags as readonly' do + it 'flags as read-only' do expect { project.migrate_to_hashed_storage! }.to change { project.repository_read_only }.to(true) end @@ -2573,7 +2665,7 @@ describe Project do expect(project.migrate_to_hashed_storage!).to be_nil end - it 'does not flag as readonly' do + it 'does not flag as read-only' do expect { project.migrate_to_hashed_storage! }.not_to change { project.repository_read_only } end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9f517e4af72..ece6968dde6 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe User do include Gitlab::CurrentSettings + include ProjectForksHelper describe 'modules' do subject { described_class } @@ -1431,7 +1432,7 @@ describe User do describe "#contributed_projects" do subject { create(:user) } let!(:project1) { create(:project) } - let!(:project2) { create(:project, forked_from_project: project3) } + let!(:project2) { fork_project(project3) } let!(:project3) { create(:project) } let!(:merge_request) { create(:merge_request, source_project: project2, target_project: project3, author: subject) } let!(:push_event) { create(:push_event, project: project1, author: subject) } @@ -1455,6 +1456,23 @@ describe User do end end + describe '#fork_of' do + let(:user) { create(:user) } + + it "returns a user's fork of a project" do + project = create(:project, :public) + user_fork = fork_project(project, user, namespace: user.namespace) + + expect(user.fork_of(project)).to eq(user_fork) + end + + it 'returns nil if the project does not have a fork network' do + project = create(:project) + + expect(user.fork_of(project)).to be_nil + end + end + describe '#can_be_removed?' do subject { create(:user) } @@ -2282,4 +2300,49 @@ describe User do end end end + + describe '#confirm_deletion_with_password?' do + where( + password_automatically_set: [true, false], + ldap_user: [true, false], + password_authentication_disabled: [true, false] + ) + + with_them do + let!(:user) { create(:user, password_automatically_set: password_automatically_set) } + let!(:identity) { create(:identity, user: user) if ldap_user } + + # Only confirm deletion with password if all inputs are false + let(:expected) { !(password_automatically_set || ldap_user || password_authentication_disabled) } + + before do + stub_application_setting(password_authentication_enabled: !password_authentication_disabled) + end + + it 'returns false unless all inputs are true' do + expect(user.confirm_deletion_with_password?).to eq(expected) + end + end + end + + describe '#delete_async' do + let(:user) { create(:user) } + let(:deleted_by) { create(:user) } + + it 'blocks the user then schedules them for deletion if a hard delete is specified' do + expect(DeleteUserWorker).to receive(:perform_async).with(deleted_by.id, user.id, hard_delete: true) + + user.delete_async(deleted_by: deleted_by, params: { hard_delete: true }) + + expect(user).to be_blocked + end + + it 'schedules user for deletion without blocking them' do + expect(DeleteUserWorker).to receive(:perform_async).with(deleted_by.id, user.id, {}) + + user.delete_async(deleted_by: deleted_by) + + expect(user).not_to be_blocked + end + end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index c4f6e97b915..5e66e1607ba 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1,6 +1,8 @@ require "spec_helper" describe API::MergeRequests do + include ProjectForksHelper + let(:base_time) { Time.now } let(:user) { create(:user) } let(:admin) { create(:user, :admin) } @@ -616,17 +618,17 @@ describe API::MergeRequests do context 'forked projects' do let!(:user2) { create(:user) } - let!(:fork_project) { create(:project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) } + let!(:forked_project) { fork_project(project, user2) } let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) } before do - fork_project.add_reporter(user2) + forked_project.add_reporter(user2) allow_any_instance_of(MergeRequest).to receive(:write_ref) end it "returns merge_request" do - post api("/projects/#{fork_project.id}/merge_requests", user2), + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", author: user2, target_project_id: project.id, description: 'Test description for Test merge_request' expect(response).to have_gitlab_http_status(201) @@ -635,10 +637,10 @@ describe API::MergeRequests do end it "does not return 422 when source_branch equals target_branch" do - expect(project.id).not_to eq(fork_project.id) - expect(fork_project.forked?).to be_truthy - expect(fork_project.forked_from_project).to eq(project) - post api("/projects/#{fork_project.id}/merge_requests", user2), + expect(project.id).not_to eq(forked_project.id) + expect(forked_project.forked?).to be_truthy + expect(forked_project.forked_from_project).to eq(project) + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(201) expect(json_response['title']).to eq('Test merge_request') @@ -647,7 +649,7 @@ describe API::MergeRequests do 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), + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test', target_branch: 'master', source_branch: 'markdown', @@ -658,36 +660,26 @@ describe API::MergeRequests do end it "returns 400 when source_branch is missing" do - post api("/projects/#{fork_project.id}/merge_requests", user2), + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end it "returns 400 when target_branch is missing" do - post api("/projects/#{fork_project.id}/merge_requests", user2), + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end it "returns 400 when title is missing" do - post api("/projects/#{fork_project.id}/merge_requests", user2), + post api("/projects/#{forked_project.id}/merge_requests", user2), target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end context 'when target_branch is specified' do - it 'returns 422 if not a forked project' do - post api("/projects/#{project.id}/merge_requests", user), - title: 'Test merge_request', - target_branch: 'master', - source_branch: 'markdown', - author: user, - target_project_id: fork_project.id - expect(response).to have_gitlab_http_status(422) - end - it 'returns 422 if targeting a different fork' do - post api("/projects/#{fork_project.id}/merge_requests", user2), + post api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', @@ -698,8 +690,8 @@ describe API::MergeRequests do end it "returns 201 when target_branch is specified and for the same project" do - post api("/projects/#{fork_project.id}/merge_requests", user2), - title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id + post api("/projects/#{forked_project.id}/merge_requests", user2), + title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: forked_project.id expect(response).to have_gitlab_http_status(201) end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 18f6f7df1fa..5964244f8c5 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -64,9 +64,12 @@ describe API::Projects do create(:project, :public) end + # TODO: We're currently querying to detect if a project is a fork + # in 2 ways. Lower this back to 8 when `ForkedProjectLink` relation is + # removed expect do get api('/projects', current_user) - end.not_to exceed_query_limit(control).with_threshold(8) + end.not_to exceed_query_limit(control).with_threshold(9) end end diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb index 86f38dd4ec1..df73c731c96 100644 --- a/spec/requests/api/v3/merge_requests_spec.rb +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -1,6 +1,8 @@ require "spec_helper" describe API::MergeRequests do + include ProjectForksHelper + let(:base_time) { Time.now } let(:user) { create(:user) } let(:admin) { create(:user, :admin) } @@ -312,17 +314,17 @@ describe API::MergeRequests do context 'forked projects' do let!(:user2) { create(:user) } - let!(:fork_project) { create(:project, forked_from_project: project, namespace: user2.namespace, creator_id: user2.id) } + let!(:forked_project) { fork_project(project, user2) } let!(:unrelated_project) { create(:project, namespace: create(:user).namespace, creator_id: user2.id) } before do - fork_project.add_reporter(user2) + forked_project.add_reporter(user2) allow_any_instance_of(MergeRequest).to receive(:write_ref) end it "returns merge_request" do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master", author: user2, target_project_id: project.id, description: 'Test description for Test merge_request' expect(response).to have_gitlab_http_status(201) @@ -331,10 +333,10 @@ describe API::MergeRequests do end it "does not return 422 when source_branch equals target_branch" do - expect(project.id).not_to eq(fork_project.id) - expect(fork_project.forked?).to be_truthy - expect(fork_project.forked_from_project).to eq(project) - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + expect(project.id).not_to eq(forked_project.id) + expect(forked_project.forked?).to be_truthy + expect(forked_project.forked_from_project).to eq(project) + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(201) expect(json_response['title']).to eq('Test merge_request') @@ -343,7 +345,7 @@ describe API::MergeRequests do 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), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test', target_branch: "master", source_branch: 'markdown', @@ -354,36 +356,26 @@ describe API::MergeRequests do end it "returns 400 when source_branch is missing" do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end it "returns 400 when target_branch is missing" do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end it "returns 400 when title is missing" do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id expect(response).to have_gitlab_http_status(400) end context 'when target_branch is specified' do - it 'returns 422 if not a forked project' do - post v3_api("/projects/#{project.id}/merge_requests", user), - title: 'Test merge_request', - target_branch: 'master', - source_branch: 'markdown', - author: user, - target_project_id: fork_project.id - expect(response).to have_gitlab_http_status(422) - end - it 'returns 422 if targeting a different fork' do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', @@ -394,8 +386,8 @@ describe API::MergeRequests do end it "returns 201 when target_branch is specified and for the same project" do - post v3_api("/projects/#{fork_project.id}/merge_requests", user2), - title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id + post v3_api("/projects/#{forked_project.id}/merge_requests", user2), + title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: forked_project.id expect(response).to have_gitlab_http_status(201) end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 27d09b8202e..bca5bf81c5c 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe 'Git LFS API and storage' do include WorkhorseHelpers + include ProjectForksHelper let(:user) { create(:user) } let!(:lfs_object) { create(:lfs_object, :with_file) } @@ -824,6 +825,34 @@ describe 'Git LFS API and storage' do end end + describe 'when handling lfs batch request on a read-only GitLab instance' do + let(:authorization) { authorize_user } + let(:project) { create(:project) } + let(:path) { "#{project.http_url_to_repo}/info/lfs/objects/batch" } + let(:body) do + { 'objects' => [{ 'oid' => sample_oid, 'size' => sample_size }] } + end + + before do + allow(Gitlab::Database).to receive(:read_only?) { true } + project.team << [user, :master] + enable_lfs + end + + it 'responds with a 200 message on download' do + post_lfs_json path, body.merge('operation' => 'download'), headers + + expect(response).to have_gitlab_http_status(200) + end + + it 'responds with a 403 message on upload' do + post_lfs_json path, body.merge('operation' => 'upload'), headers + + expect(response).to have_gitlab_http_status(403) + expect(json_response).to include('message' => 'You cannot write to this read-only GitLab instance.') + end + end + describe 'when pushing a lfs object' do before do enable_lfs @@ -1173,11 +1202,6 @@ describe 'Git LFS API and storage' do ActionController::HttpAuthentication::Basic.encode_credentials(user.username, Gitlab::LfsToken.new(user).token) end - def fork_project(project, user, object = nil) - allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) - Projects::ForkService.new(project, user, {}).execute - end - def post_lfs_json(url, body = nil, headers = nil) post(url, body.try(:to_json), (headers || {}).merge('Content-Type' => 'application/vnd.git-lfs+json')) end diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb index 5b7822d5d8e..f6bd6e9ede4 100644 --- a/spec/serializers/build_details_entity_spec.rb +++ b/spec/serializers/build_details_entity_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe BuildDetailsEntity do + include ProjectForksHelper + set(:user) { create(:admin) } it 'inherits from JobEntity' do @@ -56,18 +58,16 @@ describe BuildDetailsEntity do end context 'when merge request is from a fork' do - let(:fork_project) do - create(:project, forked_from_project: project) - end + let(:forked_project) { fork_project(project) } - let(:pipeline) { create(:ci_pipeline, project: fork_project) } + let(:pipeline) { create(:ci_pipeline, project: forked_project) } before do allow(build).to receive(:merge_request).and_return(merge_request) end let(:merge_request) do - create(:merge_request, source_project: fork_project, + create(:merge_request, source_project: forked_project, target_project: project, source_branch: build.ref) end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index eb6e683cc23..08847183bf4 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Ci::CreatePipelineService do + include ProjectForksHelper + set(:project) { create(:project, :repository) } let(:user) { create(:admin) } let(:ref_name) { 'refs/heads/master' } @@ -82,13 +84,9 @@ describe Ci::CreatePipelineService do end context 'when merge request target project is different from source project' do + let!(:project) { fork_project(target_project, nil, repository: true) } let!(:target_project) { create(:project, :repository) } - let!(:forked_project_link) do - create(:forked_project_link, forked_to_project: project, - forked_from_project: target_project) - end - it 'updates head pipeline for merge request' do merge_request = create(:merge_request, source_branch: 'master', target_branch: "branch_1", diff --git a/spec/services/ci/extract_sections_from_build_trace_service_spec.rb b/spec/services/ci/extract_sections_from_build_trace_service_spec.rb new file mode 100644 index 00000000000..28f2fa7903a --- /dev/null +++ b/spec/services/ci/extract_sections_from_build_trace_service_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Ci::ExtractSectionsFromBuildTraceService, '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:build) { create(:ci_build, project: project) } + + subject { described_class.new(project, user) } + + shared_examples 'build trace has sections markers' do + before do + build.trace.set(File.read(expand_fixture_path('trace/trace_with_sections'))) + end + + it 'saves the correct extracted sections' do + expect(build.trace_sections).to be_empty + expect(subject.execute(build)).to be(true) + expect(build.trace_sections).not_to be_empty + end + + it "fails if trace_sections isn't empty" do + expect(subject.execute(build)).to be(true) + expect(build.trace_sections).not_to be_empty + + expect(subject.execute(build)).to be(false) + expect(build.trace_sections).not_to be_empty + end + end + + shared_examples 'build trace has no sections markers' do + before do + build.trace.set('no markerts') + end + + it 'extracts no sections' do + expect(build.trace_sections).to be_empty + expect(subject.execute(build)).to be(true) + expect(build.trace_sections).to be_empty + end + end + + context 'when the build has no user' do + it_behaves_like 'build trace has sections markers' + it_behaves_like 'build trace has no sections markers' + end + + context 'when the build has a valid user' do + before do + build.user = user + end + + it_behaves_like 'build trace has sections markers' + it_behaves_like 'build trace has no sections markers' + end +end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index fbb3213f42b..9db3568abee 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -20,7 +20,7 @@ describe Ci::RetryBuildService do erased_at auto_canceled_by].freeze IGNORE_ACCESSORS = - %i[type lock_version target_url base_tags + %i[type lock_version target_url base_tags trace_sections commit_id deployments erased_by_id last_deployment project_id runner_id tag_taggings taggings tags trigger_request_id user_id auto_canceled_by_id retried failure_reason].freeze diff --git a/spec/services/delete_merged_branches_service_spec.rb b/spec/services/delete_merged_branches_service_spec.rb index 03c682ae0d7..5a9eb359ee1 100644 --- a/spec/services/delete_merged_branches_service_spec.rb +++ b/spec/services/delete_merged_branches_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe DeleteMergedBranchesService do + include ProjectForksHelper + subject(:service) { described_class.new(project, project.owner) } let(:project) { create(:project, :repository) } @@ -50,9 +52,9 @@ describe DeleteMergedBranchesService do context 'open merge requests' do it 'does not delete branches from open merge requests' do - fork_link = create(:forked_project_link, forked_from_project: project) + forked_project = fork_project(project) create(:merge_request, :opened, source_project: project, target_project: project, source_branch: 'branch-merged', target_branch: 'master') - create(:merge_request, :opened, source_project: fork_link.forked_to_project, target_project: project, target_branch: 'improve/awesome', source_branch: 'master') + create(:merge_request, :opened, source_project: forked_project, target_project: project, target_branch: 'improve/awesome', source_branch: 'master') service.execute diff --git a/spec/services/discussions/update_diff_position_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb index 82b156f5ebe..2b84206318f 100644 --- a/spec/services/discussions/update_diff_position_service_spec.rb +++ b/spec/services/discussions/update_diff_position_service_spec.rb @@ -164,8 +164,8 @@ describe Discussions::UpdateDiffPositionService do change_position = discussion.change_position expect(change_position.start_sha).to eq(old_diff_refs.head_sha) expect(change_position.head_sha).to eq(new_diff_refs.head_sha) - expect(change_position.old_line).to eq(9) - expect(change_position.new_line).to be_nil + expect(change_position.formatter.old_line).to eq(9) + expect(change_position.formatter.new_line).to be_nil end it 'creates a system discussion' do @@ -184,7 +184,7 @@ describe Discussions::UpdateDiffPositionService do expect(discussion.original_position).to eq(old_position) expect(discussion.position).not_to eq(old_position) - expect(discussion.position.new_line).to eq(22) + expect(discussion.position.formatter.new_line).to eq(22) end context 'when the resolve_outdated_diff_discussions setting is set' do diff --git a/spec/services/merge_requests/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb index 6f49a65d795..9c9b0c4c4a1 100644 --- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb +++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb @@ -1,14 +1,16 @@ require 'spec_helper' describe MergeRequests::Conflicts::ResolveService do + include ProjectForksHelper let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:fork_project) do - create(:forked_project_with_submodules) do |fork_project| - fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) - fork_project.save - end + let(:project) { create(:project, :public, :repository) } + + let(:forked_project) do + forked_project = fork_project(project, user) + TestEnv.copy_repo(forked_project, + bare_repo: TestEnv.forked_repo_path_bare, + refs: TestEnv::FORKED_BRANCH_SHA) + forked_project end let(:merge_request) do @@ -19,7 +21,7 @@ describe MergeRequests::Conflicts::ResolveService do let(:merge_request_from_fork) do create(:merge_request, - source_branch: 'conflict-resolvable-fork', source_project: fork_project, + source_branch: 'conflict-resolvable-fork', source_project: forked_project, target_branch: 'conflict-start', target_project: project) end @@ -114,7 +116,7 @@ describe MergeRequests::Conflicts::ResolveService do end it 'gets conflicts from the source project' do - expect(fork_project.repository.rugged).to receive(:merge_commits).and_call_original + expect(forked_project.repository.rugged).to receive(:merge_commits).and_call_original expect(project.repository.rugged).not_to receive(:merge_commits) resolve_conflicts diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index 25599dea19f..274624aa8bb 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -1,6 +1,8 @@ require "spec_helper" describe MergeRequests::GetUrlsService do + include ProjectForksHelper + let(:project) { create(:project, :public, :repository) } let(:service) { described_class.new(project) } let(:source_branch) { "merge-test" } @@ -85,7 +87,7 @@ describe MergeRequests::GetUrlsService do context 'pushing to existing branch from forked project' do let(:user) { create(:user) } - let!(:forked_project) { Projects::ForkService.new(project, user).execute } + let!(:forked_project) { fork_project(project, user, repository: true) } let!(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project, source_branch: source_branch) } let(:changes) { existing_branch_changes } # Source project is now the forked one diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 64e676f22a0..62dbe362ec8 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe MergeRequests::RefreshService do + include ProjectForksHelper + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:service) { described_class } @@ -12,7 +14,8 @@ describe MergeRequests::RefreshService do group.add_owner(@user) @project = create(:project, :repository, namespace: group) - @fork_project = Projects::ForkService.new(@project, @user).execute + @fork_project = fork_project(@project, @user, repository: true) + @merge_request = create(:merge_request, source_project: @project, source_branch: 'master', @@ -311,8 +314,7 @@ describe MergeRequests::RefreshService do context 'when the merge request is sourced from a different project' do it 'creates a `MergeRequestsClosingIssues` record for each issue closed by a commit' do - forked_project = create(:project, :repository) - create(:forked_project_link, forked_to_project: forked_project, forked_from_project: @project) + forked_project = fork_project(@project, @user, repository: true) merge_request = create(:merge_request, target_branch: 'master', diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index c867139d1de..c90bad46295 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -212,6 +212,19 @@ describe Projects::DestroyService do end end + context 'as the root of a fork network' do + let!(:fork_network) { create(:fork_network, root_project: project) } + + it 'updates the fork network with the project name' do + destroy_project(project, user) + + fork_network.reload + + expect(fork_network.deleted_root_project_name).to eq(project.full_name) + expect(fork_network.root_project).to be_nil + end + end + def destroy_project(project, user, params = {}) if async Projects::DestroyService.new(project, user, params).async_execute diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index fa9d6969830..53862283a27 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Projects::ForkService do + include ProjectForksHelper let(:gitlab_shell) { Gitlab::Shell.new } describe 'fork by user' do @@ -33,7 +34,7 @@ describe Projects::ForkService do end describe "successfully creates project in the user namespace" do - let(:to_project) { fork_project(@from_project, @to_user) } + let(:to_project) { fork_project(@from_project, @to_user, namespace: @to_user.namespace) } it { expect(to_project).to be_persisted } it { expect(to_project.errors).to be_empty } @@ -60,13 +61,40 @@ describe Projects::ForkService do expect(@from_project.forks_count).to eq(1) end + + it 'creates a fork network with the new project and the root project set' do + to_project + fork_network = @from_project.reload.fork_network + + expect(fork_network).not_to be_nil + expect(fork_network.root_project).to eq(@from_project) + expect(fork_network.projects).to contain_exactly(@from_project, to_project) + end + end + + context 'creating a fork of a fork' do + let(:from_forked_project) { fork_project(@from_project, @to_user) } + let(:other_namespace) do + group = create(:group) + group.add_owner(@to_user) + group + end + let(:to_project) { fork_project(from_forked_project, @to_user, namespace: other_namespace) } + + it 'sets the root of the network to the root project' do + expect(to_project.fork_network.root_project).to eq(@from_project) + end + + it 'sets the forked_from_project on the membership' do + expect(to_project.fork_network_member.forked_from_project).to eq(from_forked_project) + end end end context 'project already exists' do it "fails due to validation, not transaction failure" do @existing_project = create(:project, :repository, creator_id: @to_user.id, name: @from_project.name, namespace: @to_namespace) - @to_project = fork_project(@from_project, @to_user) + @to_project = fork_project(@from_project, @to_user, namespace: @to_namespace) expect(@existing_project).to be_persisted expect(@to_project).not_to be_persisted @@ -88,7 +116,7 @@ describe Projects::ForkService do end it 'does not allow creation' do - to_project = fork_project(@from_project, @to_user) + to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace) expect(to_project).not_to be_persisted expect(to_project.errors.messages).to have_key(:base) @@ -182,9 +210,4 @@ describe Projects::ForkService do end end end - - def fork_project(from_project, user, params = {}) - allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) - Projects::ForkService.new(from_project, user, params).execute - end end diff --git a/spec/services/projects/hashed_storage_migration_service_spec.rb b/spec/services/projects/hashed_storage_migration_service_spec.rb index 1b61207b550..aa1988d29d6 100644 --- a/spec/services/projects/hashed_storage_migration_service_spec.rb +++ b/spec/services/projects/hashed_storage_migration_service_spec.rb @@ -20,7 +20,7 @@ describe Projects::HashedStorageMigrationService do expect(gitlab_shell.exists?(project.repository_storage_path, "#{hashed_storage.disk_path}.wiki.git")).to be_truthy end - it 'updates project to be hashed and not readonly' do + it 'updates project to be hashed and not read-only' do service.execute expect(project.hashed_storage?).to be_truthy diff --git a/spec/services/projects/unlink_fork_service_spec.rb b/spec/services/projects/unlink_fork_service_spec.rb index 4f1ab697460..50d3a4ec982 100644 --- a/spec/services/projects/unlink_fork_service_spec.rb +++ b/spec/services/projects/unlink_fork_service_spec.rb @@ -1,19 +1,22 @@ require 'spec_helper' describe Projects::UnlinkForkService do - subject { described_class.new(fork_project, user) } + include ProjectForksHelper - let(:fork_link) { create(:forked_project_link) } - let(:fork_project) { fork_link.forked_to_project } + subject { described_class.new(forked_project, user) } + + let(:fork_link) { forked_project.forked_project_link } + let(:project) { create(:project, :public) } + let(:forked_project) { fork_project(project, user) } let(:user) { create(:user) } context 'with opened merge request on the source project' do - let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: fork_link.forked_from_project) } - let(:mr_close_service) { MergeRequests::CloseService.new(fork_project, user) } + let(:merge_request) { create(:merge_request, source_project: forked_project, target_project: fork_link.forked_from_project) } + let(:mr_close_service) { MergeRequests::CloseService.new(forked_project, user) } before do allow(MergeRequests::CloseService).to receive(:new) - .with(fork_project, user) + .with(forked_project, user) .and_return(mr_close_service) end @@ -25,13 +28,24 @@ describe Projects::UnlinkForkService do end it 'remove fork relation' do - expect(fork_project.forked_project_link).to receive(:destroy) + expect(forked_project.forked_project_link).to receive(:destroy) + + subject.execute + end + + it 'removes the link to the fork network' do + expect(forked_project.fork_network_member).to be_present + expect(forked_project.fork_network).to be_present subject.execute + forked_project.reload + + expect(forked_project.fork_network_member).to be_nil + expect(forked_project.reload.fork_network).to be_nil end it 'refreshes the forks count cache of the source project' do - source = fork_project.forked_from_project + source = forked_project.forked_from_project expect(source.forks_count).to eq(1) diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index d400304622e..3da222e2ed8 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::UpdateService, '#execute' do + include ProjectForksHelper + let(:gitlab_shell) { Gitlab::Shell.new } let(:user) { create(:user) } let(:admin) { create(:admin) } @@ -76,13 +78,7 @@ describe Projects::UpdateService, '#execute' do describe 'when updating project that has forks' do let(:project) { create(:project, :internal) } - let(:forked_project) { create(:forked_project_with_submodules, :internal) } - - before do - forked_project.build_forked_project_link(forked_to_project_id: forked_project.id, - forked_from_project_id: project.id) - forked_project.save - end + let(:forked_project) { fork_project(project) } it 'updates forks visibility level when parent set to more restrictive' do opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE } diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb index fef4da0c76e..17eabad73be 100644 --- a/spec/services/users/activity_service_spec.rb +++ b/spec/services/users/activity_service_spec.rb @@ -38,6 +38,18 @@ describe Users::ActivityService do end end end + + context 'when in GitLab read-only instance' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + end + + it 'does not update last_activity_at' do + service.execute + + expect(last_hour_user_ids).to eq([]) + end + end end def last_hour_user_ids diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index 81cb94ab8c4..9f05cabf7ae 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -71,7 +71,7 @@ shared_examples 'discussion comments' do |resource_name| expect(page).not_to have_selector menu_selector find(toggle_selector).click - find('body').click + find('body').trigger 'click' expect(page).not_to have_selector menu_selector end diff --git a/spec/support/ldap_shared_examples.rb b/spec/support/ldap_shared_examples.rb new file mode 100644 index 00000000000..52c34e78965 --- /dev/null +++ b/spec/support/ldap_shared_examples.rb @@ -0,0 +1,69 @@ +shared_examples_for 'normalizes a DN' do + using RSpec::Parameterized::TableSyntax + + where(:test_description, :given, :expected) do + 'strips extraneous whitespace' | 'uid =John Smith , ou = People, dc= example,dc =com' | 'uid=john smith,ou=people,dc=example,dc=com' + 'strips extraneous whitespace for a DN with a single RDN' | 'uid = John Smith' | 'uid=john smith' + 'unescapes non-reserved, non-special Unicode characters' | 'uid = Sebasti\\c3\\a1n\\ C.\\20Smith, ou=People (aka. \\22humans\\") ,dc=example, dc=com' | 'uid=sebastián c. smith,ou=people (aka. \\"humans\\"),dc=example,dc=com' + 'downcases the whole string' | 'UID=John Smith,ou=People,dc=example,dc=com' | 'uid=john smith,ou=people,dc=example,dc=com' + 'for a null DN (empty string), returns empty string and does not error' | '' | '' + 'does not strip an escaped leading space in an attribute value' | 'uid=\\ John Smith,ou=People,dc=example,dc=com' | 'uid=\\ john smith,ou=people,dc=example,dc=com' + 'does not strip an escaped leading space in the last attribute value' | 'uid=\\ John Smith' | 'uid=\\ john smith' + 'does not strip an escaped trailing space in an attribute value' | 'uid=John Smith\\ ,ou=People,dc=example,dc=com' | 'uid=john smith\\ ,ou=people,dc=example,dc=com' + 'strips extraneous spaces after an escaped trailing space' | 'uid=John Smith\\ ,ou=People,dc=example,dc=com' | 'uid=john smith\\ ,ou=people,dc=example,dc=com' + 'strips extraneous spaces after an escaped trailing space at the end of the DN' | 'uid=John Smith,ou=People,dc=example,dc=com\\ ' | 'uid=john smith,ou=people,dc=example,dc=com\\ ' + 'properly preserves escaped trailing space after unescaped trailing spaces' | 'uid=John Smith \\ ,ou=People,dc=example,dc=com' | 'uid=john smith \\ ,ou=people,dc=example,dc=com' + 'preserves multiple inner spaces in an attribute value' | 'uid=John Smith,ou=People,dc=example,dc=com' | 'uid=john smith,ou=people,dc=example,dc=com' + 'preserves inner spaces after an escaped space' | 'uid=John\\ Smith,ou=People,dc=example,dc=com' | 'uid=john smith,ou=people,dc=example,dc=com' + 'hex-escapes an escaped leading newline in an attribute value' | "uid=\\\nJohn Smith,ou=People,dc=example,dc=com" | "uid=\\0ajohn smith,ou=people,dc=example,dc=com" + 'hex-escapes and does not strip an escaped trailing newline in an attribute value' | "uid=John Smith\\\n,ou=People,dc=example,dc=com" | "uid=john smith\\0a,ou=people,dc=example,dc=com" + 'hex-escapes an unescaped leading newline (actually an invalid DN?)' | "uid=\nJohn Smith,ou=People,dc=example,dc=com" | "uid=\\0ajohn smith,ou=people,dc=example,dc=com" + 'strips an unescaped trailing newline (actually an invalid DN?)' | "uid=John Smith\n,ou=People,dc=example,dc=com" | "uid=john smith,ou=people,dc=example,dc=com" + 'does not strip if no extraneous whitespace' | 'uid=John Smith,ou=People,dc=example,dc=com' | 'uid=john smith,ou=people,dc=example,dc=com' + 'does not modify an escaped equal sign in an attribute value' | 'uid= foo \\= bar' | 'uid=foo \\= bar' + 'converts an escaped hex equal sign to an escaped equal sign in an attribute value' | 'uid= foo \\3D bar' | 'uid=foo \\= bar' + 'does not modify an escaped comma in an attribute value' | 'uid= John C. Smith, ou=San Francisco\\, CA' | 'uid=john c. smith,ou=san francisco\\, ca' + 'converts an escaped hex comma to an escaped comma in an attribute value' | 'uid= John C. Smith, ou=San Francisco\\2C CA' | 'uid=john c. smith,ou=san francisco\\, ca' + 'does not modify an escaped hex carriage return character in an attribute value' | 'uid= John C. Smith, ou=San Francisco\\,\\0DCA' | 'uid=john c. smith,ou=san francisco\\,\\0dca' + 'does not modify an escaped hex line feed character in an attribute value' | 'uid= John C. Smith, ou=San Francisco\\,\\0ACA' | 'uid=john c. smith,ou=san francisco\\,\\0aca' + 'does not modify an escaped hex CRLF in an attribute value' | 'uid= John C. Smith, ou=San Francisco\\,\\0D\\0ACA' | 'uid=john c. smith,ou=san francisco\\,\\0d\\0aca' + 'allows attribute type name OIDs' | '0.9.2342.19200300.100.1.25=Example,0.9.2342.19200300.100.1.25=Com' | '0.9.2342.19200300.100.1.25=example,0.9.2342.19200300.100.1.25=com' + 'strips extraneous whitespace from attribute type name OIDs' | '0.9.2342.19200300.100.1.25 = Example, 0.9.2342.19200300.100.1.25 = Com' | '0.9.2342.19200300.100.1.25=example,0.9.2342.19200300.100.1.25=com' + end + + with_them do + it 'normalizes the DN' do + assert_generic_test(test_description, subject, expected) + end + end +end + +shared_examples_for 'normalizes a DN attribute value' do + using RSpec::Parameterized::TableSyntax + + where(:test_description, :given, :expected) do + 'strips extraneous whitespace' | ' John Smith ' | 'john smith' + 'unescapes non-reserved, non-special Unicode characters' | 'Sebasti\\c3\\a1n\\ C.\\20Smith' | 'sebastián c. smith' + 'downcases the whole string' | 'JoHn C. Smith' | 'john c. smith' + 'does not strip an escaped leading space in an attribute value' | '\\ John Smith' | '\\ john smith' + 'does not strip an escaped trailing space in an attribute value' | 'John Smith\\ ' | 'john smith\\ ' + 'hex-escapes an escaped leading newline in an attribute value' | "\\\nJohn Smith" | "\\0ajohn smith" + 'hex-escapes and does not strip an escaped trailing newline in an attribute value' | "John Smith\\\n" | "john smith\\0a" + 'hex-escapes an unescaped leading newline (actually an invalid DN value?)' | "\nJohn Smith" | "\\0ajohn smith" + 'strips an unescaped trailing newline (actually an invalid DN value?)' | "John Smith\n" | "john smith" + 'does not strip if no extraneous whitespace' | 'John Smith' | 'john smith' + 'does not modify an escaped equal sign in an attribute value' | ' foo \\= bar' | 'foo \\= bar' + 'converts an escaped hex equal sign to an escaped equal sign in an attribute value' | ' foo \\3D bar' | 'foo \\= bar' + 'does not modify an escaped comma in an attribute value' | 'San Francisco\\, CA' | 'san francisco\\, ca' + 'converts an escaped hex comma to an escaped comma in an attribute value' | 'San Francisco\\2C CA' | 'san francisco\\, ca' + 'does not modify an escaped hex carriage return character in an attribute value' | 'San Francisco\\,\\0DCA' | 'san francisco\\,\\0dca' + 'does not modify an escaped hex line feed character in an attribute value' | 'San Francisco\\,\\0ACA' | 'san francisco\\,\\0aca' + 'does not modify an escaped hex CRLF in an attribute value' | 'San Francisco\\,\\0D\\0ACA' | 'san francisco\\,\\0d\\0aca' + end + + with_them do + it 'normalizes the DN attribute value' do + assert_generic_test(test_description, subject, expected) + end + end +end diff --git a/spec/support/project_forks_helper.rb b/spec/support/project_forks_helper.rb new file mode 100644 index 00000000000..0d1c6792d13 --- /dev/null +++ b/spec/support/project_forks_helper.rb @@ -0,0 +1,58 @@ +module ProjectForksHelper + def fork_project(project, user = nil, params = {}) + # Load the `fork_network` for the project to fork as there might be one that + # wasn't loaded yet. + project.reload unless project.fork_network + + unless user + user = create(:user) + project.add_developer(user) + end + + unless params[:namespace] || params[:namespace_id] + params[:namespace] = create(:group) + params[:namespace].add_owner(user) + end + + service = Projects::ForkService.new(project, user, params) + + create_repository = params.delete(:repository) + # Avoid creating a repository + unless create_repository + allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) + shell = double('gitlab_shell', fork_repository: true) + allow(service).to receive(:gitlab_shell).and_return(shell) + end + + forked_project = service.execute + + # Reload the both projects so they know about their newly created fork_network + if forked_project.persisted? + project.reload + forked_project.reload + end + + if create_repository + # The call to project.repository.after_import in RepositoryForkWorker does + # not reset the @exists variable of this forked_project.repository + # so we have to explicitely call this method to clear the @exists variable. + # of the instance we're returning here. + forked_project.repository.after_import + + # We can't leave the hooks in place after a fork, as those would fail in tests + # The "internal" API is not available + FileUtils.rm_rf("#{forked_project.repository.path}/hooks") + end + + forked_project + end + + def fork_project_with_submodules(project, user = nil, params = {}) + forked_project = fork_project(project, user, params) + TestEnv.copy_repo(forked_project, + bare_repo: TestEnv.forked_repo_path_bare, + refs: TestEnv::FORKED_BRANCH_SHA) + + forked_project + end +end diff --git a/spec/support/shared_examples/position_formatters.rb b/spec/support/shared_examples/position_formatters.rb new file mode 100644 index 00000000000..ffc9456dbc7 --- /dev/null +++ b/spec/support/shared_examples/position_formatters.rb @@ -0,0 +1,43 @@ +shared_examples_for "position formatter" do + let(:formatter) { described_class.new(attrs) } + + describe '#key' do + let(:key) { [123, 456, 789, Digest::SHA1.hexdigest(formatter.old_path), Digest::SHA1.hexdigest(formatter.new_path), 1, 2] } + + subject { formatter.key } + + it { is_expected.to eq(key) } + end + + describe '#complete?' do + subject { formatter.complete? } + + context 'when there are missing key attributes' do + it { is_expected.to be_truthy } + end + + context 'when old_line and new_line are nil' do + let(:attrs) { base_attrs } + + it { is_expected.to be_falsy } + end + end + + describe '#to_h' do + let(:formatter_hash) do + attrs.merge(position_type: base_attrs[:position_type] || 'text' ) + end + + subject { formatter.to_h } + + it { is_expected.to eq(formatter_hash) } + end + + describe '#==' do + subject { formatter } + + let(:other_formatter) { described_class.new(attrs) } + + it { is_expected.to eq(other_formatter) } + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 79395f4c564..a27bfdee3d2 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -46,7 +46,8 @@ module TestEnv 'v1.1.0' => 'b83d6e3', 'add-ipython-files' => '93ee732', 'add-pdf-file' => 'e774ebd', - 'add-pdf-text-binary' => '79faa7b' + 'add-pdf-text-binary' => '79faa7b', + 'add_images_and_changes' => '010d106' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb index 98c7de9b709..efed2e02a1b 100644 --- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' describe 'projects/merge_requests/_commits.html.haml' do include Devise::Test::ControllerHelpers + include ProjectForksHelper let(:user) { create(:user) } - let(:target_project) { create(:project, :repository) } - let(:source_project) { create(:project, :repository, forked_from_project: target_project) } + let(:target_project) { create(:project, :public, :repository) } + let(:source_project) { fork_project(target_project, user, repository: true) } let(:merge_request) do create(:merge_request, :simple, diff --git a/spec/views/projects/merge_requests/edit.html.haml_spec.rb b/spec/views/projects/merge_requests/edit.html.haml_spec.rb index 69c7d0cbf28..9b74a7e1946 100644 --- a/spec/views/projects/merge_requests/edit.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/edit.html.haml_spec.rb @@ -2,16 +2,19 @@ require 'spec_helper' describe 'projects/merge_requests/edit.html.haml' do include Devise::Test::ControllerHelpers + include ProjectForksHelper let(:user) { create(:user) } let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, forked_from_project: project) } - let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + let(:forked_project) { fork_project(project, user, repository: true) } + let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) } let(:milestone) { create(:milestone, project: project) } let(:closed_merge_request) do + project.add_developer(user) + create(:closed_merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project, author: user, assignee: user, diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb index 6f29d12373a..28d54c2fb77 100644 --- a/spec/views/projects/merge_requests/show.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -2,16 +2,17 @@ require 'spec_helper' describe 'projects/merge_requests/show.html.haml' do include Devise::Test::ControllerHelpers + include ProjectForksHelper let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, forked_from_project: project) } - let(:unlink_project) { Projects::UnlinkForkService.new(fork_project, user) } + let(:project) { create(:project, :public, :repository) } + let(:forked_project) { fork_project(project, user, repository: true) } + let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) } let(:note) { create(:note_on_merge_request, project: project, noteable: closed_merge_request) } let(:closed_merge_request) do create(:closed_merge_request, - source_project: fork_project, + source_project: forked_project, target_project: project, author: user) end @@ -52,7 +53,7 @@ describe 'projects/merge_requests/show.html.haml' do context 'when the merge request is open' do it 'closes the merge request if the source project does not exist' do closed_merge_request.update_attributes(state: 'open') - fork_project.destroy + forked_project.destroy render diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb index 8cc3f37ebe8..1a7ffd5cdbf 100644 --- a/spec/workers/build_finished_worker_spec.rb +++ b/spec/workers/build_finished_worker_spec.rb @@ -11,6 +11,8 @@ describe BuildFinishedWorker do expect(BuildHooksWorker) .to receive(:new).ordered.and_call_original + expect(BuildTraceSectionsWorker) + .to receive(:perform_async) expect_any_instance_of(BuildCoverageWorker) .to receive(:perform) expect_any_instance_of(BuildHooksWorker) diff --git a/spec/workers/build_trace_sections_worker_spec.rb b/spec/workers/build_trace_sections_worker_spec.rb new file mode 100644 index 00000000000..45243f45547 --- /dev/null +++ b/spec/workers/build_trace_sections_worker_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe BuildTraceSectionsWorker do + describe '#perform' do + context 'when build exists' do + let!(:build) { create(:ci_build) } + + it 'updates trace sections' do + expect_any_instance_of(Ci::Build) + .to receive(:parse_trace_sections!) + + described_class.new.perform(build.id) + end + end + + context 'when build does not exist' do + it 'does not raise exception' do + expect { described_class.new.perform(123) } + .not_to raise_error + end + end + end +end diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb index 20cf580af8a..ed8cedc0079 100644 --- a/spec/workers/namespaceless_project_destroy_worker_spec.rb +++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe NamespacelessProjectDestroyWorker do + include ProjectForksHelper + subject { described_class.new } before do @@ -55,9 +57,11 @@ describe NamespacelessProjectDestroyWorker do context 'project forked from another' do let!(:parent_project) { create(:project) } - - before do - create(:forked_project_link, forked_to_project: project, forked_from_project: parent_project) + let(:project) do + namespaceless_project = fork_project(parent_project) + namespaceless_project.namespace_id = nil + namespaceless_project.save(validate: false) + namespaceless_project end it 'closes open merge requests' do |