Merge branch 'master' into '18471-restrict-tag-pushes-protected-tags'
# Conflicts: # spec/lib/gitlab/import_export/all_models.yml
This commit is contained in:
		
						commit
						8a5ca1121b
					
				| 
						 | 
				
			
			@ -30,6 +30,7 @@ eslint-report.html
 | 
			
		|||
/config/unicorn.rb
 | 
			
		||||
/config/secrets.yml
 | 
			
		||||
/config/sidekiq.yml
 | 
			
		||||
/config/registry.key
 | 
			
		||||
/coverage/*
 | 
			
		||||
/coverage-javascript/
 | 
			
		||||
/db/*.sqlite3
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
0.5.0
 | 
			
		||||
0.6.0
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										3
									
								
								Gemfile
								
								
								
								
							| 
						 | 
				
			
			@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
 | 
			
		|||
gem 'redis-namespace', '~> 1.5.2'
 | 
			
		||||
gem 'sidekiq-limit_fetch', '~> 3.4'
 | 
			
		||||
 | 
			
		||||
# Cron Parser
 | 
			
		||||
gem 'rufus-scheduler', '~> 3.1.10'
 | 
			
		||||
 | 
			
		||||
# HTTP requests
 | 
			
		||||
gem 'httparty', '~> 0.13.3'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -987,6 +987,7 @@ DEPENDENCIES
 | 
			
		|||
  rubocop-rspec (~> 1.12.0)
 | 
			
		||||
  ruby-fogbugz (~> 0.2.1)
 | 
			
		||||
  ruby-prof (~> 0.16.2)
 | 
			
		||||
  rufus-scheduler (~> 3.1.10)
 | 
			
		||||
  rugged (~> 0.25.1.1)
 | 
			
		||||
  sanitize (~> 2.0)
 | 
			
		||||
  sass-rails (~> 5.0.6)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
function BlobForkSuggestion(openButton, cancelButton, suggestionSection) {
 | 
			
		||||
  if (openButton) {
 | 
			
		||||
    openButton.addEventListener('click', () => {
 | 
			
		||||
      suggestionSection.classList.remove('hidden');
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (cancelButton) {
 | 
			
		||||
    cancelButton.addEventListener('click', () => {
 | 
			
		||||
      suggestionSection.classList.add('hidden');
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default BlobForkSuggestion;
 | 
			
		||||
| 
						 | 
				
			
			@ -84,10 +84,10 @@ window.Build = (function() {
 | 
			
		|||
    var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
 | 
			
		||||
 | 
			
		||||
    return $.ajax({
 | 
			
		||||
      url: this.buildUrl,
 | 
			
		||||
      url: this.pageUrl + "/trace.json",
 | 
			
		||||
      dataType: 'json',
 | 
			
		||||
      success: function(buildData) {
 | 
			
		||||
        $('.js-build-output').html(buildData.trace_html);
 | 
			
		||||
        $('.js-build-output').html(buildData.html);
 | 
			
		||||
        gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
 | 
			
		||||
        if (window.location.hash === DOWN_BUILD_TRACE) {
 | 
			
		||||
          $("html,body").scrollTop(this.$buildTrace.height());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,9 +38,35 @@ showTooltip = function(target, title) {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
$(function() {
 | 
			
		||||
  var clipboard;
 | 
			
		||||
 | 
			
		||||
  clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
 | 
			
		||||
  const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
 | 
			
		||||
  clipboard.on('success', genericSuccess);
 | 
			
		||||
  return clipboard.on('error', genericError);
 | 
			
		||||
  clipboard.on('error', genericError);
 | 
			
		||||
 | 
			
		||||
  // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
 | 
			
		||||
  // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text`
 | 
			
		||||
  // attribute that ClipboardJS reads from.
 | 
			
		||||
  // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
 | 
			
		||||
  // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
 | 
			
		||||
  // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the
 | 
			
		||||
  // `text/plain` and `text/x-gfm` copy data types to the intended values.
 | 
			
		||||
  $(document).on('copy', 'body > textarea[readonly]', function(e) {
 | 
			
		||||
    const clipboardData = e.originalEvent.clipboardData;
 | 
			
		||||
    if (!clipboardData) return;
 | 
			
		||||
 | 
			
		||||
    const text = e.target.value;
 | 
			
		||||
 | 
			
		||||
    let json;
 | 
			
		||||
    try {
 | 
			
		||||
      json = JSON.parse(text);
 | 
			
		||||
    } catch (ex) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!json.text || !json.gfm) return;
 | 
			
		||||
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    clipboardData.setData('text/plain', json.text);
 | 
			
		||||
    clipboardData.setData('text/x-gfm', json.gfm);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,6 +43,7 @@ import GroupsList from './groups_list';
 | 
			
		|||
import ProjectsList from './projects_list';
 | 
			
		||||
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
 | 
			
		||||
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
 | 
			
		||||
import BlobForkSuggestion from './blob/blob_fork_suggestion';
 | 
			
		||||
import UserCallout from './user_callout';
 | 
			
		||||
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -87,6 +88,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
 | 
			
		|||
          skipResetBindings: true,
 | 
			
		||||
          fileBlobPermalinkUrl,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        new BlobForkSuggestion(
 | 
			
		||||
          document.querySelector('.js-edit-blob-link-fork-toggler'),
 | 
			
		||||
          document.querySelector('.js-cancel-fork-suggestion'),
 | 
			
		||||
          document.querySelector('.js-file-fork-suggestion-section'),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      switch (page) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
import eventHub from '../event_hub';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'RecentSearchesDropdownContent',
 | 
			
		||||
 | 
			
		||||
  props: {
 | 
			
		||||
    items: {
 | 
			
		||||
      type: Array,
 | 
			
		||||
      required: true,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  computed: {
 | 
			
		||||
    processedItems() {
 | 
			
		||||
      return this.items.map((item) => {
 | 
			
		||||
        const { tokens, searchToken }
 | 
			
		||||
          = gl.FilteredSearchTokenizer.processTokens(item);
 | 
			
		||||
 | 
			
		||||
        const resultantTokens = tokens.map(token => ({
 | 
			
		||||
          prefix: `${token.key}:`,
 | 
			
		||||
          suffix: `${token.symbol}${token.value}`,
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          text: item,
 | 
			
		||||
          tokens: resultantTokens,
 | 
			
		||||
          searchToken,
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    hasItems() {
 | 
			
		||||
      return this.items.length > 0;
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  methods: {
 | 
			
		||||
    onItemActivated(text) {
 | 
			
		||||
      eventHub.$emit('recentSearchesItemSelected', text);
 | 
			
		||||
    },
 | 
			
		||||
    onRequestClearRecentSearches(e) {
 | 
			
		||||
      // Stop the dropdown from closing
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
      eventHub.$emit('requestClearRecentSearches');
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  template: `
 | 
			
		||||
    <div>
 | 
			
		||||
      <ul v-if="hasItems">
 | 
			
		||||
        <li
 | 
			
		||||
          v-for="(item, index) in processedItems"
 | 
			
		||||
          :key="index">
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="filtered-search-history-dropdown-item"
 | 
			
		||||
            @click="onItemActivated(item.text)">
 | 
			
		||||
            <span>
 | 
			
		||||
              <span
 | 
			
		||||
                v-for="(token, tokenIndex) in item.tokens"
 | 
			
		||||
                class="filtered-search-history-dropdown-token">
 | 
			
		||||
                <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
 | 
			
		||||
              </span>
 | 
			
		||||
            </span>
 | 
			
		||||
            <span class="filtered-search-history-dropdown-search-token">
 | 
			
		||||
              {{ item.searchToken }}
 | 
			
		||||
            </span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="divider"></li>
 | 
			
		||||
        <li>
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="filtered-search-history-clear-button"
 | 
			
		||||
            @click="onRequestClearRecentSearches($event)">
 | 
			
		||||
            Clear recent searches
 | 
			
		||||
          </button>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      <div
 | 
			
		||||
        v-else
 | 
			
		||||
        class="dropdown-info-note">
 | 
			
		||||
        You don't have any recent searches
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  `,
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
 | 
			
		|||
    renderContent() {
 | 
			
		||||
      const dropdownData = [];
 | 
			
		||||
 | 
			
		||||
      [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
 | 
			
		||||
      [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
 | 
			
		||||
        const { icon, hint, tag, type } = dropdownMenu.dataset;
 | 
			
		||||
        if (icon && hint && tag) {
 | 
			
		||||
          dropdownData.push(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return values.join(' ');
 | 
			
		||||
      return values
 | 
			
		||||
        .map(value => value.trim())
 | 
			
		||||
        .join(' ');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static getSearchInput(filteredSearchInput) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
import Vue from 'vue';
 | 
			
		||||
 | 
			
		||||
export default new Vue();
 | 
			
		||||
| 
						 | 
				
			
			@ -1,18 +1,56 @@
 | 
			
		|||
/* global Flash */
 | 
			
		||||
 | 
			
		||||
import FilteredSearchContainer from './container';
 | 
			
		||||
import RecentSearchesRoot from './recent_searches_root';
 | 
			
		||||
import RecentSearchesStore from './stores/recent_searches_store';
 | 
			
		||||
import RecentSearchesService from './services/recent_searches_service';
 | 
			
		||||
import eventHub from './event_hub';
 | 
			
		||||
 | 
			
		||||
(() => {
 | 
			
		||||
  class FilteredSearchManager {
 | 
			
		||||
    constructor(page) {
 | 
			
		||||
      this.container = FilteredSearchContainer.container;
 | 
			
		||||
      this.filteredSearchInput = this.container.querySelector('.filtered-search');
 | 
			
		||||
      this.filteredSearchInputForm = this.filteredSearchInput.form;
 | 
			
		||||
      this.clearSearchButton = this.container.querySelector('.clear-search');
 | 
			
		||||
      this.tokensContainer = this.container.querySelector('.tokens-container');
 | 
			
		||||
      this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
 | 
			
		||||
 | 
			
		||||
      this.recentSearchesStore = new RecentSearchesStore();
 | 
			
		||||
      let recentSearchesKey = 'issue-recent-searches';
 | 
			
		||||
      if (page === 'merge_requests') {
 | 
			
		||||
        recentSearchesKey = 'merge-request-recent-searches';
 | 
			
		||||
      }
 | 
			
		||||
      this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
 | 
			
		||||
 | 
			
		||||
      // Fetch recent searches from localStorage
 | 
			
		||||
      this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
 | 
			
		||||
        .catch(() => {
 | 
			
		||||
          // eslint-disable-next-line no-new
 | 
			
		||||
          new Flash('An error occured while parsing recent searches');
 | 
			
		||||
          // Gracefully fail to empty array
 | 
			
		||||
          return [];
 | 
			
		||||
        })
 | 
			
		||||
        .then((searches) => {
 | 
			
		||||
          // Put any searches that may have come in before
 | 
			
		||||
          // we fetched the saved searches ahead of the already saved ones
 | 
			
		||||
          const resultantSearches = this.recentSearchesStore.setRecentSearches(
 | 
			
		||||
            this.recentSearchesStore.state.recentSearches.concat(searches),
 | 
			
		||||
          );
 | 
			
		||||
          this.recentSearchesService.save(resultantSearches);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      if (this.filteredSearchInput) {
 | 
			
		||||
        this.tokenizer = gl.FilteredSearchTokenizer;
 | 
			
		||||
        this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
 | 
			
		||||
 | 
			
		||||
        this.recentSearchesRoot = new RecentSearchesRoot(
 | 
			
		||||
          this.recentSearchesStore,
 | 
			
		||||
          this.recentSearchesService,
 | 
			
		||||
          document.querySelector('.js-filtered-search-history-dropdown'),
 | 
			
		||||
        );
 | 
			
		||||
        this.recentSearchesRoot.init();
 | 
			
		||||
 | 
			
		||||
        this.bindEvents();
 | 
			
		||||
        this.loadSearchParamsFromURL();
 | 
			
		||||
        this.dropdownManager.setDropdown();
 | 
			
		||||
| 
						 | 
				
			
			@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
    cleanup() {
 | 
			
		||||
      this.unbindEvents();
 | 
			
		||||
      document.removeEventListener('beforeunload', this.cleanupWrapper);
 | 
			
		||||
 | 
			
		||||
      if (this.recentSearchesRoot) {
 | 
			
		||||
        this.recentSearchesRoot.destroy();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bindEvents() {
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
 | 
			
		||||
      this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
 | 
			
		||||
      this.checkForEnterWrapper = this.checkForEnter.bind(this);
 | 
			
		||||
      this.clearSearchWrapper = this.clearSearch.bind(this);
 | 
			
		||||
      this.onClearSearchWrapper = this.onClearSearch.bind(this);
 | 
			
		||||
      this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
 | 
			
		||||
      this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
 | 
			
		||||
      this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
 | 
			
		||||
| 
						 | 
				
			
			@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      this.tokenChange = this.tokenChange.bind(this);
 | 
			
		||||
      this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
 | 
			
		||||
      this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
 | 
			
		||||
      this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
 | 
			
		||||
 | 
			
		||||
      this.filteredSearchInputForm = this.filteredSearchInput.form;
 | 
			
		||||
      this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
 | 
			
		||||
      this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
 | 
			
		||||
      this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
 | 
			
		||||
| 
						 | 
				
			
			@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
 | 
			
		||||
      this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
 | 
			
		||||
      this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
 | 
			
		||||
      this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
 | 
			
		||||
      this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
 | 
			
		||||
      document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
 | 
			
		||||
      document.addEventListener('click', this.unselectEditTokensWrapper);
 | 
			
		||||
      document.addEventListener('click', this.removeInputContainerFocusWrapper);
 | 
			
		||||
      document.addEventListener('keydown', this.removeSelectedTokenWrapper);
 | 
			
		||||
      eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    unbindEvents() {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
 | 
			
		||||
      this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
 | 
			
		||||
      this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
 | 
			
		||||
      this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
 | 
			
		||||
      this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
 | 
			
		||||
      document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
 | 
			
		||||
      document.removeEventListener('click', this.unselectEditTokensWrapper);
 | 
			
		||||
      document.removeEventListener('click', this.removeInputContainerFocusWrapper);
 | 
			
		||||
      document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
 | 
			
		||||
      eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    checkForBackspace(e) {
 | 
			
		||||
| 
						 | 
				
			
			@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    addInputContainerFocus() {
 | 
			
		||||
      const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
 | 
			
		||||
      const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
 | 
			
		||||
 | 
			
		||||
      if (inputContainer) {
 | 
			
		||||
        inputContainer.classList.add('focus');
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    removeInputContainerFocus(e) {
 | 
			
		||||
      const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
 | 
			
		||||
      const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
 | 
			
		||||
      const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
 | 
			
		||||
      const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
 | 
			
		||||
      const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
 | 
			
		||||
| 
						 | 
				
			
			@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    unselectEditTokens(e) {
 | 
			
		||||
      const inputContainer = this.container.querySelector('.filtered-search-input-container');
 | 
			
		||||
      const inputContainer = this.container.querySelector('.filtered-search-box');
 | 
			
		||||
      const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
 | 
			
		||||
      const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
 | 
			
		||||
      const isElementTokensContainer = e.target.classList.contains('tokens-container');
 | 
			
		||||
| 
						 | 
				
			
			@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    clearSearch(e) {
 | 
			
		||||
    onClearSearch(e) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      this.clearSearch();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    clearSearch() {
 | 
			
		||||
      this.filteredSearchInput.value = '';
 | 
			
		||||
 | 
			
		||||
      const removeElements = [];
 | 
			
		||||
| 
						 | 
				
			
			@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
      this.search();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    saveCurrentSearchQuery() {
 | 
			
		||||
      // Don't save before we have fetched the already saved searches
 | 
			
		||||
      this.fetchingRecentSearchesPromise.then(() => {
 | 
			
		||||
        const searchQuery = gl.DropdownUtils.getSearchQuery();
 | 
			
		||||
        if (searchQuery.length > 0) {
 | 
			
		||||
          const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
 | 
			
		||||
          this.recentSearchesService.save(resultantSearches);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    loadSearchParamsFromURL() {
 | 
			
		||||
      const params = gl.utils.getUrlParamsArray();
 | 
			
		||||
      const usernameParams = this.getUsernameParams();
 | 
			
		||||
| 
						 | 
				
			
			@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.saveCurrentSearchQuery();
 | 
			
		||||
 | 
			
		||||
      if (hasFilteredSearch) {
 | 
			
		||||
        this.clearSearchButton.classList.remove('hidden');
 | 
			
		||||
        this.handleInputPlaceholder();
 | 
			
		||||
| 
						 | 
				
			
			@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
 | 
			
		||||
    search() {
 | 
			
		||||
      const paths = [];
 | 
			
		||||
      const searchQuery = gl.DropdownUtils.getSearchQuery();
 | 
			
		||||
 | 
			
		||||
      this.saveCurrentSearchQuery();
 | 
			
		||||
 | 
			
		||||
      const { tokens, searchToken }
 | 
			
		||||
        = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery());
 | 
			
		||||
        = this.tokenizer.processTokens(searchQuery);
 | 
			
		||||
      const currentState = gl.utils.getParameterByName('state') || 'opened';
 | 
			
		||||
      paths.push(`state=${currentState}`);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
 | 
			
		|||
        currentDropdownRef.dispatchInputEvent();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    onrecentSearchesItemSelected(text) {
 | 
			
		||||
      this.clearSearch();
 | 
			
		||||
      this.filteredSearchInput.value = text;
 | 
			
		||||
      this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
 | 
			
		||||
      this.search();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  window.gl = window.gl || {};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
import Vue from 'vue';
 | 
			
		||||
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
 | 
			
		||||
import eventHub from './event_hub';
 | 
			
		||||
 | 
			
		||||
class RecentSearchesRoot {
 | 
			
		||||
  constructor(
 | 
			
		||||
    recentSearchesStore,
 | 
			
		||||
    recentSearchesService,
 | 
			
		||||
    wrapperElement,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.store = recentSearchesStore;
 | 
			
		||||
    this.service = recentSearchesService;
 | 
			
		||||
    this.wrapperElement = wrapperElement;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init() {
 | 
			
		||||
    this.bindEvents();
 | 
			
		||||
    this.render();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bindEvents() {
 | 
			
		||||
    this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
 | 
			
		||||
 | 
			
		||||
    eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  unbindEvents() {
 | 
			
		||||
    eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    this.vm = new Vue({
 | 
			
		||||
      el: this.wrapperElement,
 | 
			
		||||
      data: this.store.state,
 | 
			
		||||
      template: `
 | 
			
		||||
        <recent-searches-dropdown-content
 | 
			
		||||
          :items="recentSearches" />
 | 
			
		||||
      `,
 | 
			
		||||
      components: {
 | 
			
		||||
        'recent-searches-dropdown-content': RecentSearchesDropdownContent,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onRequestClearRecentSearches() {
 | 
			
		||||
    const resultantSearches = this.store.setRecentSearches([]);
 | 
			
		||||
    this.service.save(resultantSearches);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  destroy() {
 | 
			
		||||
    this.unbindEvents();
 | 
			
		||||
    if (this.vm) {
 | 
			
		||||
      this.vm.$destroy();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default RecentSearchesRoot;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
class RecentSearchesService {
 | 
			
		||||
  constructor(localStorageKey = 'issuable-recent-searches') {
 | 
			
		||||
    this.localStorageKey = localStorageKey;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fetch() {
 | 
			
		||||
    const input = window.localStorage.getItem(this.localStorageKey);
 | 
			
		||||
 | 
			
		||||
    let searches = [];
 | 
			
		||||
    if (input && input.length > 0) {
 | 
			
		||||
      try {
 | 
			
		||||
        searches = JSON.parse(input);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        return Promise.reject(err);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Promise.resolve(searches);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  save(searches = []) {
 | 
			
		||||
    window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default RecentSearchesService;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import _ from 'underscore';
 | 
			
		||||
 | 
			
		||||
class RecentSearchesStore {
 | 
			
		||||
  constructor(initialState = {}) {
 | 
			
		||||
    this.state = Object.assign({
 | 
			
		||||
      recentSearches: [],
 | 
			
		||||
    }, initialState);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addRecentSearch(newSearch) {
 | 
			
		||||
    this.setRecentSearches([newSearch].concat(this.state.recentSearches));
 | 
			
		||||
 | 
			
		||||
    return this.state.recentSearches;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setRecentSearches(searches = []) {
 | 
			
		||||
    const trimmedSearches = searches.map(search => search.trim());
 | 
			
		||||
    this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
 | 
			
		||||
    return this.state.recentSearches;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default RecentSearchesStore;
 | 
			
		||||
| 
						 | 
				
			
			@ -177,10 +177,6 @@
 | 
			
		|||
  border-radius: $border-radius-base;
 | 
			
		||||
  box-shadow: 0 2px 4px $dropdown-shadow-color;
 | 
			
		||||
 | 
			
		||||
  .filtered-search-input-container & {
 | 
			
		||||
    max-width: 280px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.is-loading {
 | 
			
		||||
    .dropdown-content {
 | 
			
		||||
      display: none;
 | 
			
		||||
| 
						 | 
				
			
			@ -467,6 +463,11 @@
 | 
			
		|||
  overflow-y: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-info-note {
 | 
			
		||||
  color: $gl-text-color-secondary;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dropdown-footer {
 | 
			
		||||
  padding-top: 10px;
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -281,3 +281,16 @@ span.idiff {
 | 
			
		|||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.file-fork-suggestion {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
  background-color: $gray-light;
 | 
			
		||||
  border-bottom: 1px solid $border-color;
 | 
			
		||||
  padding: 5px $gl-padding;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.file-fork-suggestion-note {
 | 
			
		||||
  margin-right: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,6 @@
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: $screen-sm-min) {
 | 
			
		||||
  .issues-filters,
 | 
			
		||||
  .issues_bulk_update {
 | 
			
		||||
    .dropdown-menu-toggle {
 | 
			
		||||
      width: 132px;
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +55,7 @@
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-container {
 | 
			
		||||
.filtered-search-wrapper {
 | 
			
		||||
  display: -webkit-flex;
 | 
			
		||||
  display: flex;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -151,11 +150,13 @@
 | 
			
		|||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-input-container {
 | 
			
		||||
.filtered-search-box {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  display: -webkit-flex;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  min-width: 0;
 | 
			
		||||
  border: 1px solid $border-color;
 | 
			
		||||
  background-color: $white-light;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -163,14 +164,6 @@
 | 
			
		|||
    -webkit-flex: 1 1 auto;
 | 
			
		||||
    flex: 1 1 auto;
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
 | 
			
		||||
    .dropdown-menu {
 | 
			
		||||
      width: auto;
 | 
			
		||||
      left: 0;
 | 
			
		||||
      right: 0;
 | 
			
		||||
      max-width: none;
 | 
			
		||||
      min-width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:hover {
 | 
			
		||||
| 
						 | 
				
			
			@ -229,6 +222,118 @@
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-box-input-container {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  // Fix PhantomJS not supporting `flex: 1;` properly.
 | 
			
		||||
  // This is important because it can change the expected `e.target` when clicking things in tests.
 | 
			
		||||
  // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
 | 
			
		||||
  // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
 | 
			
		||||
  // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  min-width: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-input-dropdown-menu {
 | 
			
		||||
  max-width: 280px;
 | 
			
		||||
 | 
			
		||||
  @media (max-width: $screen-xs-min) {
 | 
			
		||||
    width: auto;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    max-width: none;
 | 
			
		||||
    min-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown-toggle-button {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  width: auto;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  padding-top: 0;
 | 
			
		||||
  padding-left: 0.75em;
 | 
			
		||||
  padding-bottom: 0;
 | 
			
		||||
  padding-right: 0.5em;
 | 
			
		||||
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border-radius: 0;
 | 
			
		||||
  border-top: 0;
 | 
			
		||||
  border-left: 0;
 | 
			
		||||
  border-bottom: 0;
 | 
			
		||||
  border-right: 1px solid $border-color;
 | 
			
		||||
 | 
			
		||||
  color: $gl-text-color-secondary;
 | 
			
		||||
 | 
			
		||||
  transition: color 0.1s linear;
 | 
			
		||||
 | 
			
		||||
  &:hover,
 | 
			
		||||
  &:focus {
 | 
			
		||||
    color: $gl-text-color;
 | 
			
		||||
    border-color: $dropdown-input-focus-border;
 | 
			
		||||
    outline: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .dropdown-toggle-text {
 | 
			
		||||
    color: inherit;
 | 
			
		||||
 | 
			
		||||
    .fa {
 | 
			
		||||
      color: inherit;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .fa {
 | 
			
		||||
    position: initial;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown-wrapper {
 | 
			
		||||
  position: initial;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown {
 | 
			
		||||
  width: 40%;
 | 
			
		||||
 | 
			
		||||
  @media (max-width: $screen-xs-min) {
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    max-width: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown-content {
 | 
			
		||||
  max-height: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown-item,
 | 
			
		||||
.filtered-search-history-clear-button {
 | 
			
		||||
  @include dropdown-link;
 | 
			
		||||
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin: 0.5em 0;
 | 
			
		||||
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border: 0;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filtered-search-history-dropdown-token {
 | 
			
		||||
  display: inline;
 | 
			
		||||
 | 
			
		||||
  &:not(:last-child) {
 | 
			
		||||
    margin-right: 0.3em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & > .value {
 | 
			
		||||
    font-weight: 600;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filter-dropdown-container {
 | 
			
		||||
  display: -webkit-flex;
 | 
			
		||||
  display: flex;
 | 
			
		||||
| 
						 | 
				
			
			@ -248,11 +353,9 @@
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
 | 
			
		||||
  .issues-details-filters {
 | 
			
		||||
    .dropdown-menu-toggle {
 | 
			
		||||
  .issue-bulk-update-dropdown-toggle {
 | 
			
		||||
    width: 100px;
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: $screen-xs-max) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
/**
 | 
			
		||||
 * Container Registry
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
.container-image {
 | 
			
		||||
  border-bottom: 1px solid $white-normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container-image-head {
 | 
			
		||||
  padding: 0 16px;
 | 
			
		||||
  line-height: 4em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.table.tags {
 | 
			
		||||
  margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -4,14 +4,14 @@
 | 
			
		|||
 */
 | 
			
		||||
.event-item {
 | 
			
		||||
  font-size: $gl-font-size;
 | 
			
		||||
  padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top);
 | 
			
		||||
  padding: $gl-padding-top 0 $gl-padding-top 40px;
 | 
			
		||||
  border-bottom: 1px solid $white-normal;
 | 
			
		||||
  color: $list-text-color;
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
  &.event-inline {
 | 
			
		||||
    .avatar {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      top: -2px;
 | 
			
		||||
    .profile-icon {
 | 
			
		||||
      top: 20px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .event-title,
 | 
			
		||||
| 
						 | 
				
			
			@ -24,8 +24,28 @@
 | 
			
		|||
    color: $gl-text-color;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .avatar {
 | 
			
		||||
    margin-left: -($gl-avatar-size + $gl-padding-top);
 | 
			
		||||
  .profile-icon {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    top: 14px;
 | 
			
		||||
 | 
			
		||||
    svg {
 | 
			
		||||
      width: 20px;
 | 
			
		||||
      height: auto;
 | 
			
		||||
      fill: $gl-text-color-secondary;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.open-icon svg {
 | 
			
		||||
      fill: $green-300;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.closed-icon svg {
 | 
			
		||||
      fill: $red-300;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.fork-icon svg {
 | 
			
		||||
      fill: $blue-300;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .event-title {
 | 
			
		||||
| 
						 | 
				
			
			@ -163,7 +183,7 @@
 | 
			
		|||
      max-width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .avatar {
 | 
			
		||||
    .profile-icon {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
 | 
			
		|||
      :name,
 | 
			
		||||
      :path,
 | 
			
		||||
      :request_access_enabled,
 | 
			
		||||
      :visibility_level
 | 
			
		||||
      :visibility_level,
 | 
			
		||||
      :require_two_factor_authentication,
 | 
			
		||||
      :two_factor_grace_period
 | 
			
		||||
    ]
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
 | 
			
		|||
  include PageLayoutHelper
 | 
			
		||||
  include SentryHelper
 | 
			
		||||
  include WorkhorseHelper
 | 
			
		||||
  include EnforcesTwoFactorAuthentication
 | 
			
		||||
 | 
			
		||||
  before_action :authenticate_user_from_private_token!
 | 
			
		||||
  before_action :authenticate_user!
 | 
			
		||||
  before_action :validate_user_service_ticket!
 | 
			
		||||
  before_action :check_password_expiration
 | 
			
		||||
  before_action :check_2fa_requirement
 | 
			
		||||
  before_action :ldap_security_check
 | 
			
		||||
  before_action :sentry_context
 | 
			
		||||
  before_action :default_headers
 | 
			
		||||
| 
						 | 
				
			
			@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_2fa_requirement
 | 
			
		||||
    if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
 | 
			
		||||
      redirect_to profile_two_factor_auth_path
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def ldap_security_check
 | 
			
		||||
    if current_user && current_user.requires_ldap_check?
 | 
			
		||||
      return unless current_user.try_obtain_ldap_lease
 | 
			
		||||
| 
						 | 
				
			
			@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
 | 
			
		|||
    current_application_settings.import_sources.include?('gitlab_project')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_authentication_required?
 | 
			
		||||
    current_application_settings.require_two_factor_authentication
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_grace_period
 | 
			
		||||
    current_application_settings.two_factor_grace_period
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_grace_period_expired?
 | 
			
		||||
    date = current_user.otp_grace_period_started_at
 | 
			
		||||
    date && (date + two_factor_grace_period.hours) < Time.current
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def skip_two_factor?
 | 
			
		||||
    session[:skip_tfa] && session[:skip_tfa] > Time.current
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # U2F (universal 2nd factor) devices need a unique identifier for the application
 | 
			
		||||
  # to perform authentication.
 | 
			
		||||
  # https://developers.yubico.com/U2F/App_ID.html
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
# == EnforcesTwoFactorAuthentication
 | 
			
		||||
#
 | 
			
		||||
# Controller concern to enforce two-factor authentication requirements
 | 
			
		||||
#
 | 
			
		||||
# Upon inclusion, adds `check_two_factor_requirement` as a before_action,
 | 
			
		||||
# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?`
 | 
			
		||||
# available as view helpers.
 | 
			
		||||
module EnforcesTwoFactorAuthentication
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    before_action :check_two_factor_requirement
 | 
			
		||||
    helper_method :two_factor_grace_period_expired?, :two_factor_skippable?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_two_factor_requirement
 | 
			
		||||
    if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
 | 
			
		||||
      redirect_to profile_two_factor_auth_path
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_authentication_required?
 | 
			
		||||
    current_application_settings.require_two_factor_authentication? ||
 | 
			
		||||
      current_user.try(:require_two_factor_authentication_from_group?)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_authentication_reason(global: -> {}, group: -> {})
 | 
			
		||||
    if two_factor_authentication_required?
 | 
			
		||||
      if current_application_settings.require_two_factor_authentication?
 | 
			
		||||
        global.call
 | 
			
		||||
      else
 | 
			
		||||
        groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
 | 
			
		||||
        group.call(groups)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_grace_period
 | 
			
		||||
    periods = [current_application_settings.two_factor_grace_period]
 | 
			
		||||
    periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
 | 
			
		||||
    periods.min
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_grace_period_expired?
 | 
			
		||||
    date = current_user.otp_grace_period_started_at
 | 
			
		||||
    date && (date + two_factor_grace_period.hours) < Time.current
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_skippable?
 | 
			
		||||
    two_factor_authentication_required? &&
 | 
			
		||||
      !current_user.two_factor_enabled? &&
 | 
			
		||||
      !two_factor_grace_period_expired?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def skip_two_factor?
 | 
			
		||||
    session[:skip_two_factor] && session[:skip_two_factor] > Time.current
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController
 | 
			
		|||
      :visibility_level,
 | 
			
		||||
      :parent_id,
 | 
			
		||||
      :create_chat_team,
 | 
			
		||||
      :chat_team_name
 | 
			
		||||
      :chat_team_name,
 | 
			
		||||
      :require_two_factor_authentication,
 | 
			
		||||
      :two_factor_grace_period
 | 
			
		||||
    ]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
 | 
			
		||||
  skip_before_action :check_2fa_requirement
 | 
			
		||||
  skip_before_action :check_two_factor_requirement
 | 
			
		||||
 | 
			
		||||
  def show
 | 
			
		||||
    unless current_user.otp_secret
 | 
			
		||||
| 
						 | 
				
			
			@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
 | 
			
		|||
    current_user.save! if current_user.changed?
 | 
			
		||||
 | 
			
		||||
    if two_factor_authentication_required? && !current_user.two_factor_enabled?
 | 
			
		||||
      if two_factor_grace_period_expired?
 | 
			
		||||
        flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
 | 
			
		||||
      else
 | 
			
		||||
      two_factor_authentication_reason(
 | 
			
		||||
        global: lambda do
 | 
			
		||||
          flash.now[:alert] =
 | 
			
		||||
            'The global settings require you to enable Two-Factor Authentication for your account.'
 | 
			
		||||
        end,
 | 
			
		||||
        group: lambda do |groups|
 | 
			
		||||
          group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
 | 
			
		||||
 | 
			
		||||
          flash.now[:alert] = %{
 | 
			
		||||
            The group settings for #{group_links} require you to enable
 | 
			
		||||
            Two-Factor Authentication for your account.
 | 
			
		||||
          }.html_safe
 | 
			
		||||
        end
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      unless two_factor_grace_period_expired?
 | 
			
		||||
        grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
 | 
			
		||||
        flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
 | 
			
		||||
        flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}."
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
 | 
			
		|||
    if two_factor_grace_period_expired?
 | 
			
		||||
      redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
 | 
			
		||||
    else
 | 
			
		||||
      session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
 | 
			
		||||
      session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
 | 
			
		||||
      redirect_to root_path
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
 | 
			
		|||
  # Raised when given an invalid file path
 | 
			
		||||
  InvalidPathError = Class.new(StandardError)
 | 
			
		||||
 | 
			
		||||
  prepend_before_action :authenticate_user!, only: [:edit]
 | 
			
		||||
 | 
			
		||||
  before_action :require_non_empty_project, except: [:new, :create]
 | 
			
		||||
  before_action :authorize_download_code!
 | 
			
		||||
  before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy]
 | 
			
		||||
  before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
 | 
			
		||||
  before_action :assign_blob_vars
 | 
			
		||||
  before_action :commit, except: [:new, :create]
 | 
			
		||||
  before_action :blob, except: [:new, :create]
 | 
			
		||||
| 
						 | 
				
			
			@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def edit
 | 
			
		||||
    if can_collaborate_with_project?
 | 
			
		||||
      blob.load_all_data!(@repository)
 | 
			
		||||
    else
 | 
			
		||||
      redirect_to action: 'show'
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
 | 
			
		|||
    @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
 | 
			
		||||
    @builds = @builds.where("id not in (?)", @build.id)
 | 
			
		||||
    @pipeline = @build.pipeline
 | 
			
		||||
 | 
			
		||||
    respond_to do |format|
 | 
			
		||||
      format.html
 | 
			
		||||
      format.json do
 | 
			
		||||
        render json: {
 | 
			
		||||
          id: @build.id,
 | 
			
		||||
          status: @build.status,
 | 
			
		||||
          trace_html: @build.trace_html
 | 
			
		||||
        }
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def trace
 | 
			
		||||
    build.trace.read do |stream|
 | 
			
		||||
      respond_to do |format|
 | 
			
		||||
        format.json do
 | 
			
		||||
          result = {
 | 
			
		||||
            id: @build.id, status: @build.status, complete: @build.complete?
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if stream.valid?
 | 
			
		||||
            stream.limit
 | 
			
		||||
            state = params[:state].presence
 | 
			
		||||
        render json: @build.trace_with_state(state: state).
 | 
			
		||||
          merge!(id: @build.id, status: @build.status)
 | 
			
		||||
            trace = stream.html_with_state(state)
 | 
			
		||||
            result.merge!(trace.to_h)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          render json: result
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -86,12 +86,14 @@ class Projects::BuildsController < Projects::ApplicationController
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def raw
 | 
			
		||||
    if @build.has_trace_file?
 | 
			
		||||
      send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline'
 | 
			
		||||
    build.trace.read do |stream|
 | 
			
		||||
      if stream.file?
 | 
			
		||||
        send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
 | 
			
		||||
      else
 | 
			
		||||
        render_404
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,34 +0,0 @@
 | 
			
		|||
class Projects::ContainerRegistryController < Projects::ApplicationController
 | 
			
		||||
  before_action :verify_registry_enabled
 | 
			
		||||
  before_action :authorize_read_container_image!
 | 
			
		||||
  before_action :authorize_update_container_image!, only: [:destroy]
 | 
			
		||||
  layout 'project'
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    @tags = container_registry_repository.tags
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy
 | 
			
		||||
    url = namespace_project_container_registry_index_path(project.namespace, project)
 | 
			
		||||
 | 
			
		||||
    if tag.delete
 | 
			
		||||
      redirect_to url
 | 
			
		||||
    else
 | 
			
		||||
      redirect_to url, alert: 'Failed to remove tag'
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def verify_registry_enabled
 | 
			
		||||
    render_404 unless Gitlab.config.registry.enabled
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def container_registry_repository
 | 
			
		||||
    @container_registry_repository ||= project.container_registry_repository
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def tag
 | 
			
		||||
    @tag ||= container_registry_repository.tag(params[:id])
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
 | 
			
		|||
 | 
			
		||||
    if pipeline
 | 
			
		||||
      status = pipeline.status
 | 
			
		||||
      coverage = pipeline.try(:coverage)
 | 
			
		||||
      coverage = pipeline.coverage
 | 
			
		||||
 | 
			
		||||
      status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
module Projects
 | 
			
		||||
  module Registry
 | 
			
		||||
    class ApplicationController < Projects::ApplicationController
 | 
			
		||||
      layout 'project'
 | 
			
		||||
 | 
			
		||||
      before_action :verify_registry_enabled!
 | 
			
		||||
      before_action :authorize_read_container_image!
 | 
			
		||||
 | 
			
		||||
      private
 | 
			
		||||
 | 
			
		||||
      def verify_registry_enabled!
 | 
			
		||||
        render_404 unless Gitlab.config.registry.enabled
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
module Projects
 | 
			
		||||
  module Registry
 | 
			
		||||
    class RepositoriesController < ::Projects::Registry::ApplicationController
 | 
			
		||||
      before_action :authorize_update_container_image!, only: [:destroy]
 | 
			
		||||
      before_action :ensure_root_container_repository!, only: [:index]
 | 
			
		||||
 | 
			
		||||
      def index
 | 
			
		||||
        @images = project.container_repositories
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def destroy
 | 
			
		||||
        if image.destroy
 | 
			
		||||
          redirect_to project_container_registry_path(@project),
 | 
			
		||||
                      notice: 'Image repository has been removed successfully!'
 | 
			
		||||
        else
 | 
			
		||||
          redirect_to project_container_registry_path(@project),
 | 
			
		||||
                      alert: 'Failed to remove image repository!'
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      private
 | 
			
		||||
 | 
			
		||||
      def image
 | 
			
		||||
        @image ||= project.container_repositories.find(params[:id])
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      ##
 | 
			
		||||
      # Container repository object for root project path.
 | 
			
		||||
      #
 | 
			
		||||
      # Needed to maintain a backwards compatibility.
 | 
			
		||||
      #
 | 
			
		||||
      def ensure_root_container_repository!
 | 
			
		||||
        ContainerRegistry::Path.new(@project.full_path).tap do |path|
 | 
			
		||||
          break if path.has_repository?
 | 
			
		||||
 | 
			
		||||
          ContainerRepository.build_from_path(path).tap do |repository|
 | 
			
		||||
            repository.save! if repository.has_tags?
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
module Projects
 | 
			
		||||
  module Registry
 | 
			
		||||
    class TagsController < ::Projects::Registry::ApplicationController
 | 
			
		||||
      before_action :authorize_update_container_image!, only: [:destroy]
 | 
			
		||||
 | 
			
		||||
      def destroy
 | 
			
		||||
        if tag.delete
 | 
			
		||||
          redirect_to project_container_registry_path(@project),
 | 
			
		||||
                      notice: 'Registry tag has been removed successfully!'
 | 
			
		||||
        else
 | 
			
		||||
          redirect_to project_container_registry_path(@project),
 | 
			
		||||
                      alert: 'Failed to remove registry tag!'
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      private
 | 
			
		||||
 | 
			
		||||
      def image
 | 
			
		||||
        @image ||= project.container_repositories
 | 
			
		||||
          .find(params[:repository_id])
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def tag
 | 
			
		||||
        @tag ||= image.tag(params[:id])
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
 | 
			
		|||
  include Devise::Controllers::Rememberable
 | 
			
		||||
  include Recaptcha::ClientHelper
 | 
			
		||||
 | 
			
		||||
  skip_before_action :check_2fa_requirement, only: [:destroy]
 | 
			
		||||
  skip_before_action :check_two_factor_requirement, only: [:destroy]
 | 
			
		||||
 | 
			
		||||
  prepend_before_action :check_initial_setup, only: [:new]
 | 
			
		||||
  prepend_before_action :authenticate_with_two_factor,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,18 +64,6 @@ module AuthHelper
 | 
			
		|||
    current_user.identities.exists?(provider: provider.to_s)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_skippable?
 | 
			
		||||
    current_application_settings.require_two_factor_authentication &&
 | 
			
		||||
      !current_user.two_factor_enabled? &&
 | 
			
		||||
      current_application_settings.two_factor_grace_period &&
 | 
			
		||||
      !two_factor_grace_period_expired?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_grace_period_expired?
 | 
			
		||||
    current_user.otp_grace_period_started_at &&
 | 
			
		||||
      (current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def unlink_allowed?(provider)
 | 
			
		||||
    %w(saml cas3).exclude?(provider.to_s)
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,31 +8,36 @@ module BlobHelper
 | 
			
		|||
    %w(credits changelog news copying copyright license authors)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
 | 
			
		||||
    return unless current_user
 | 
			
		||||
 | 
			
		||||
    blob = options.delete(:blob)
 | 
			
		||||
    blob ||= project.repository.blob_at(ref, path) rescue nil
 | 
			
		||||
 | 
			
		||||
    return unless blob
 | 
			
		||||
 | 
			
		||||
    edit_path = namespace_project_edit_blob_path(project.namespace, project,
 | 
			
		||||
  def edit_path(project = @project, ref = @ref, path = @path, options = {})
 | 
			
		||||
    namespace_project_edit_blob_path(project.namespace, project,
 | 
			
		||||
                                     tree_join(ref, path),
 | 
			
		||||
                                     options[:link_opts])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
    if !on_top_of_branch?(project, ref)
 | 
			
		||||
      button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
 | 
			
		||||
    elsif can_edit_blob?(blob, project, ref)
 | 
			
		||||
      link_to "Edit", edit_path, class: 'btn btn-sm'
 | 
			
		||||
    elsif can?(current_user, :fork_project, project)
 | 
			
		||||
  def fork_path(project = @project, ref = @ref, path = @path, options = {})
 | 
			
		||||
    continue_params = {
 | 
			
		||||
      to: edit_path,
 | 
			
		||||
      notice: edit_in_new_fork_notice,
 | 
			
		||||
      notice_now: edit_in_new_fork_notice_now
 | 
			
		||||
    }
 | 
			
		||||
      fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
 | 
			
		||||
    namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
      link_to "Edit", fork_path, class: 'btn', method: :post
 | 
			
		||||
  def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
 | 
			
		||||
    blob = options.delete(:blob)
 | 
			
		||||
    blob ||= project.repository.blob_at(ref, path) rescue nil
 | 
			
		||||
 | 
			
		||||
    return unless blob
 | 
			
		||||
 | 
			
		||||
    common_classes = "btn js-edit-blob #{options[:extra_class]}"
 | 
			
		||||
 | 
			
		||||
    if !on_top_of_branch?(project, ref)
 | 
			
		||||
      button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
 | 
			
		||||
    # This condition applies to anonymous or users who can edit directly
 | 
			
		||||
    elsif !current_user || (current_user && can_edit_blob?(blob, project, ref))
 | 
			
		||||
      link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
 | 
			
		||||
    elsif current_user && can?(current_user, :fork_project, project)
 | 
			
		||||
      button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -205,13 +210,13 @@ module BlobHelper
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def copy_file_path_button(file_path)
 | 
			
		||||
    clipboard_button(clipboard_text: file_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
 | 
			
		||||
    clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def copy_blob_content_button(blob)
 | 
			
		||||
    return if markup?(blob.name)
 | 
			
		||||
 | 
			
		||||
    clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
 | 
			
		||||
    clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def open_raw_file_button(path)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,23 +1,42 @@
 | 
			
		|||
module ButtonHelper
 | 
			
		||||
  # Output a "Copy to Clipboard" button
 | 
			
		||||
  #
 | 
			
		||||
  # data - Data attributes passed to `content_tag`
 | 
			
		||||
  # data  - Data attributes passed to `content_tag` (default: {}):
 | 
			
		||||
  #         :text   - Text to copy (optional)
 | 
			
		||||
  #         :gfm    - GitLab Flavored Markdown to copy, if different from `text` (optional)
 | 
			
		||||
  #         :target - Selector for target element to copy from (optional)
 | 
			
		||||
  #
 | 
			
		||||
  # Examples:
 | 
			
		||||
  #
 | 
			
		||||
  #   # Define the clipboard's text
 | 
			
		||||
  #   clipboard_button(clipboard_text: "Foo")
 | 
			
		||||
  #   clipboard_button(text: "Foo")
 | 
			
		||||
  #   # => "<button class='...' data-clipboard-text='Foo'>...</button>"
 | 
			
		||||
  #
 | 
			
		||||
  #   # Define the target element
 | 
			
		||||
  #   clipboard_button(clipboard_target: "div#foo")
 | 
			
		||||
  #   clipboard_button(target: "div#foo")
 | 
			
		||||
  #   # => "<button class='...' data-clipboard-target='div#foo'>...</button>"
 | 
			
		||||
  #
 | 
			
		||||
  # See http://clipboardjs.com/#usage
 | 
			
		||||
  def clipboard_button(data = {})
 | 
			
		||||
    css_class = data[:class] || 'btn-clipboard btn-transparent'
 | 
			
		||||
    title = data[:title] || 'Copy to clipboard'
 | 
			
		||||
 | 
			
		||||
    # This supports code in app/assets/javascripts/copy_to_clipboard.js that
 | 
			
		||||
    # works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
 | 
			
		||||
    if text = data.delete(:text)
 | 
			
		||||
      data[:clipboard_text] =
 | 
			
		||||
        if gfm = data.delete(:gfm)
 | 
			
		||||
          { text: text, gfm: gfm }
 | 
			
		||||
        else
 | 
			
		||||
          text
 | 
			
		||||
        end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    target = data.delete(:target)
 | 
			
		||||
    data[:clipboard_target] = target if target
 | 
			
		||||
 | 
			
		||||
    data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
 | 
			
		||||
 | 
			
		||||
    content_tag :button,
 | 
			
		||||
      icon('clipboard', 'aria-hidden': 'true'),
 | 
			
		||||
      class: "btn #{css_class}",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
module DropdownsHelper
 | 
			
		||||
  def dropdown_tag(toggle_text, options: {}, &block)
 | 
			
		||||
    content_tag :div, class: "dropdown" do
 | 
			
		||||
    content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
 | 
			
		||||
      data_attr = { toggle: "dropdown" }
 | 
			
		||||
 | 
			
		||||
      if options.has_key?(:data)
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +20,7 @@ module DropdownsHelper
 | 
			
		|||
          output << dropdown_filter(options[:placeholder])
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        output << content_tag(:div, class: "dropdown-content") do
 | 
			
		||||
        output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
 | 
			
		||||
          capture(&block) if block && !options.has_key?(:footer_content)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
 | 
			
		|||
  UPVOTE_NAME   = "thumbsup".freeze
 | 
			
		||||
 | 
			
		||||
  include Participable
 | 
			
		||||
  include GhostUser
 | 
			
		||||
 | 
			
		||||
  belongs_to :awardable, polymorphic: true
 | 
			
		||||
  belongs_to :user
 | 
			
		||||
 | 
			
		||||
  validates :awardable, :user, presence: true
 | 
			
		||||
  validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names }
 | 
			
		||||
  validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }
 | 
			
		||||
  validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user?
 | 
			
		||||
 | 
			
		||||
  participant :user
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -171,19 +171,6 @@ module Ci
 | 
			
		|||
      latest_builds.where('stage_idx < ?', stage_idx)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace_html(**args)
 | 
			
		||||
      trace_with_state(**args)[:html] || ''
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace_with_state(state: nil, last_lines: nil)
 | 
			
		||||
      trace_ansi = trace(last_lines: last_lines)
 | 
			
		||||
      if trace_ansi.present?
 | 
			
		||||
        Ci::Ansi2html.convert(trace_ansi, state)
 | 
			
		||||
      else
 | 
			
		||||
        {}
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def timeout
 | 
			
		||||
      project.build_timeout
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			@ -244,136 +231,35 @@ module Ci
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def update_coverage
 | 
			
		||||
      coverage = extract_coverage(trace, coverage_regex)
 | 
			
		||||
      coverage = trace.extract_coverage(coverage_regex)
 | 
			
		||||
      update_attributes(coverage: coverage) if coverage.present?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def extract_coverage(text, regex)
 | 
			
		||||
      return unless regex
 | 
			
		||||
 | 
			
		||||
      matches = text.scan(Regexp.new(regex)).last
 | 
			
		||||
      matches = matches.last if matches.is_a?(Array)
 | 
			
		||||
      coverage = matches.gsub(/\d+(\.\d+)?/).first
 | 
			
		||||
 | 
			
		||||
      if coverage.present?
 | 
			
		||||
        coverage.to_f
 | 
			
		||||
      end
 | 
			
		||||
    rescue
 | 
			
		||||
      # if bad regex or something goes wrong we dont want to interrupt transition
 | 
			
		||||
      # so we just silentrly ignore error for now
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def has_trace_file?
 | 
			
		||||
      File.exist?(path_to_trace) || has_old_trace_file?
 | 
			
		||||
    def trace
 | 
			
		||||
      Gitlab::Ci::Trace.new(self)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def has_trace?
 | 
			
		||||
      raw_trace.present?
 | 
			
		||||
      trace.exist?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def raw_trace(last_lines: nil)
 | 
			
		||||
      if File.exist?(trace_file_path)
 | 
			
		||||
        Gitlab::Ci::TraceReader.new(trace_file_path).
 | 
			
		||||
          read(last_lines: last_lines)
 | 
			
		||||
      else
 | 
			
		||||
        # backward compatibility
 | 
			
		||||
        read_attribute :trace
 | 
			
		||||
      end
 | 
			
		||||
    def trace=(data)
 | 
			
		||||
      raise NotImplementedError
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # Deprecated
 | 
			
		||||
    #
 | 
			
		||||
    # This is a hotfix for CI build data integrity, see #4246
 | 
			
		||||
    def has_old_trace_file?
 | 
			
		||||
      project.ci_id && File.exist?(old_path_to_trace)
 | 
			
		||||
    def old_trace
 | 
			
		||||
      read_attribute(:trace)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace(last_lines: nil)
 | 
			
		||||
      hide_secrets(raw_trace(last_lines: last_lines))
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace_length
 | 
			
		||||
      if raw_trace
 | 
			
		||||
        raw_trace.bytesize
 | 
			
		||||
      else
 | 
			
		||||
        0
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace=(trace)
 | 
			
		||||
      recreate_trace_dir
 | 
			
		||||
      trace = hide_secrets(trace)
 | 
			
		||||
      File.write(path_to_trace, trace)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def recreate_trace_dir
 | 
			
		||||
      unless Dir.exist?(dir_to_trace)
 | 
			
		||||
        FileUtils.mkdir_p(dir_to_trace)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
    private :recreate_trace_dir
 | 
			
		||||
 | 
			
		||||
    def append_trace(trace_part, offset)
 | 
			
		||||
      recreate_trace_dir
 | 
			
		||||
      touch if needs_touch?
 | 
			
		||||
 | 
			
		||||
      trace_part = hide_secrets(trace_part)
 | 
			
		||||
 | 
			
		||||
      File.truncate(path_to_trace, offset) if File.exist?(path_to_trace)
 | 
			
		||||
      File.open(path_to_trace, 'ab') do |f|
 | 
			
		||||
        f.write(trace_part)
 | 
			
		||||
      end
 | 
			
		||||
    def erase_old_trace!
 | 
			
		||||
      write_attribute(:trace, nil)
 | 
			
		||||
      save
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def needs_touch?
 | 
			
		||||
      Time.now - updated_at > 15.minutes.to_i
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def trace_file_path
 | 
			
		||||
      if has_old_trace_file?
 | 
			
		||||
        old_path_to_trace
 | 
			
		||||
      else
 | 
			
		||||
        path_to_trace
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def dir_to_trace
 | 
			
		||||
      File.join(
 | 
			
		||||
        Settings.gitlab_ci.builds_path,
 | 
			
		||||
        created_at.utc.strftime("%Y_%m"),
 | 
			
		||||
        project.id.to_s
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def path_to_trace
 | 
			
		||||
      "#{dir_to_trace}/#{id}.log"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # Deprecated
 | 
			
		||||
    #
 | 
			
		||||
    # This is a hotfix for CI build data integrity, see #4246
 | 
			
		||||
    # Should be removed in 8.4, after CI files migration has been done.
 | 
			
		||||
    #
 | 
			
		||||
    def old_dir_to_trace
 | 
			
		||||
      File.join(
 | 
			
		||||
        Settings.gitlab_ci.builds_path,
 | 
			
		||||
        created_at.utc.strftime("%Y_%m"),
 | 
			
		||||
        project.ci_id.to_s
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # Deprecated
 | 
			
		||||
    #
 | 
			
		||||
    # This is a hotfix for CI build data integrity, see #4246
 | 
			
		||||
    # Should be removed in 8.4, after CI files migration has been done.
 | 
			
		||||
    #
 | 
			
		||||
    def old_path_to_trace
 | 
			
		||||
      "#{old_dir_to_trace}/#{id}.log"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # Deprecated
 | 
			
		||||
    #
 | 
			
		||||
| 
						 | 
				
			
			@ -555,6 +441,15 @@ module Ci
 | 
			
		|||
      options[:dependencies]&.empty?
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def hide_secrets(trace)
 | 
			
		||||
      return unless trace
 | 
			
		||||
 | 
			
		||||
      trace = trace.dup
 | 
			
		||||
      Ci::MaskSecret.mask!(trace, project.runners_token) if project
 | 
			
		||||
      Ci::MaskSecret.mask!(trace, token)
 | 
			
		||||
      trace
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def update_artifacts_size
 | 
			
		||||
| 
						 | 
				
			
			@ -566,7 +461,7 @@ module Ci
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def erase_trace!
 | 
			
		||||
      self.trace = nil
 | 
			
		||||
      trace.erase!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def update_erased!(user = nil)
 | 
			
		||||
| 
						 | 
				
			
			@ -628,15 +523,6 @@ module Ci
 | 
			
		|||
      pipeline.config_processor.build_attributes(name)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def hide_secrets(trace)
 | 
			
		||||
      return unless trace
 | 
			
		||||
 | 
			
		||||
      trace = trace.dup
 | 
			
		||||
      Ci::MaskSecret.mask!(trace, project.runners_token) if project
 | 
			
		||||
      Ci::MaskSecret.mask!(trace, token)
 | 
			
		||||
      trace
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def update_project_statistics
 | 
			
		||||
      return unless project
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,7 @@ module Ci
 | 
			
		|||
    belongs_to :owner, class_name: "User"
 | 
			
		||||
 | 
			
		||||
    has_many :trigger_requests, dependent: :destroy
 | 
			
		||||
    has_one :trigger_schedule, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
    validates :token, presence: true, uniqueness: true
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
module Ci
 | 
			
		||||
  class TriggerSchedule < ActiveRecord::Base
 | 
			
		||||
    extend Ci::Model
 | 
			
		||||
    include Importable
 | 
			
		||||
 | 
			
		||||
    acts_as_paranoid
 | 
			
		||||
 | 
			
		||||
    belongs_to :project
 | 
			
		||||
    belongs_to :trigger
 | 
			
		||||
 | 
			
		||||
    delegate :ref, to: :trigger
 | 
			
		||||
 | 
			
		||||
    validates :trigger, presence: { unless: :importing? }
 | 
			
		||||
    validates :cron, cron: true, presence: { unless: :importing? }
 | 
			
		||||
    validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
 | 
			
		||||
    validates :ref, presence: { unless: :importing? }
 | 
			
		||||
 | 
			
		||||
    before_save :set_next_run_at
 | 
			
		||||
 | 
			
		||||
    def set_next_run_at
 | 
			
		||||
      self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def schedule_next_run!
 | 
			
		||||
      save! # with set_next_run_at
 | 
			
		||||
    rescue ActiveRecord::RecordInvalid
 | 
			
		||||
      update_attribute(:next_run_at, nil) # update without validation
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
module GhostUser
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  def ghost_user?
 | 
			
		||||
    user && user.ghost?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +83,74 @@ module Routable
 | 
			
		|||
               AND members.source_type = r2.source_type").
 | 
			
		||||
        where('members.user_id = ?', user_id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Builds a relation to find multiple objects that are nested under user
 | 
			
		||||
    # membership. Includes the parent, as opposed to `#member_descendants`
 | 
			
		||||
    # which only includes the descendants.
 | 
			
		||||
    #
 | 
			
		||||
    # Usage:
 | 
			
		||||
    #
 | 
			
		||||
    #     Klass.member_self_and_descendants(1)
 | 
			
		||||
    #
 | 
			
		||||
    # Returns an ActiveRecord::Relation.
 | 
			
		||||
    def member_self_and_descendants(user_id)
 | 
			
		||||
      joins(:route).
 | 
			
		||||
        joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
 | 
			
		||||
               OR routes.path = r2.path
 | 
			
		||||
               INNER JOIN members ON members.source_id = r2.source_id
 | 
			
		||||
               AND members.source_type = r2.source_type").
 | 
			
		||||
        where('members.user_id = ?', user_id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Returns all objects in a hierarchy, where any node in the hierarchy is
 | 
			
		||||
    # under the user membership.
 | 
			
		||||
    #
 | 
			
		||||
    # Usage:
 | 
			
		||||
    #
 | 
			
		||||
    #     Klass.member_hierarchy(1)
 | 
			
		||||
    #
 | 
			
		||||
    # Examples:
 | 
			
		||||
    #
 | 
			
		||||
    #     Given the following group tree...
 | 
			
		||||
    #
 | 
			
		||||
    #            _______group_1_______
 | 
			
		||||
    #           |                     |
 | 
			
		||||
    #           |                     |
 | 
			
		||||
    #     nested_group_1        nested_group_2
 | 
			
		||||
    #           |                     |
 | 
			
		||||
    #           |                     |
 | 
			
		||||
    #     nested_group_1_1      nested_group_2_1
 | 
			
		||||
    #
 | 
			
		||||
    #
 | 
			
		||||
    #     ... the following results are returned:
 | 
			
		||||
    #
 | 
			
		||||
    #     * the user is a member of group 1
 | 
			
		||||
    #       => 'group_1',
 | 
			
		||||
    #          'nested_group_1', nested_group_1_1',
 | 
			
		||||
    #          'nested_group_2', 'nested_group_2_1'
 | 
			
		||||
    #
 | 
			
		||||
    #     * the user is a member of nested_group_2
 | 
			
		||||
    #       => 'group1',
 | 
			
		||||
    #          'nested_group_2', 'nested_group_2_1'
 | 
			
		||||
    #
 | 
			
		||||
    #     * the user is a member of nested_group_2_1
 | 
			
		||||
    #       => 'group1',
 | 
			
		||||
    #          'nested_group_2', 'nested_group_2_1'
 | 
			
		||||
    #
 | 
			
		||||
    # Returns an ActiveRecord::Relation.
 | 
			
		||||
    def member_hierarchy(user_id)
 | 
			
		||||
      paths = member_self_and_descendants(user_id).pluck('routes.path')
 | 
			
		||||
 | 
			
		||||
      return none if paths.empty?
 | 
			
		||||
 | 
			
		||||
      wheres = paths.map do |path|
 | 
			
		||||
        "#{connection.quote(path)} = routes.path
 | 
			
		||||
         OR
 | 
			
		||||
         #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      joins(:route).where(wheres.join(' OR '))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def full_name
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,77 @@
 | 
			
		|||
class ContainerRepository < ActiveRecord::Base
 | 
			
		||||
  belongs_to :project
 | 
			
		||||
 | 
			
		||||
  validates :name, length: { minimum: 0, allow_nil: false }
 | 
			
		||||
  validates :name, uniqueness: { scope: :project_id }
 | 
			
		||||
 | 
			
		||||
  delegate :client, to: :registry
 | 
			
		||||
 | 
			
		||||
  before_destroy :delete_tags!
 | 
			
		||||
 | 
			
		||||
  def registry
 | 
			
		||||
    @registry ||= begin
 | 
			
		||||
      token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
 | 
			
		||||
 | 
			
		||||
      url = Gitlab.config.registry.api_url
 | 
			
		||||
      host_port = Gitlab.config.registry.host_port
 | 
			
		||||
 | 
			
		||||
      ContainerRegistry::Registry.new(url, token: token, path: host_port)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def path
 | 
			
		||||
    @path ||= [project.full_path, name].select(&:present?).join('/')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def tag(tag)
 | 
			
		||||
    ContainerRegistry::Tag.new(self, tag)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def manifest
 | 
			
		||||
    @manifest ||= client.repository_tags(path)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def tags
 | 
			
		||||
    return @tags if defined?(@tags)
 | 
			
		||||
    return [] unless manifest && manifest['tags']
 | 
			
		||||
 | 
			
		||||
    @tags = manifest['tags'].map do |tag|
 | 
			
		||||
      ContainerRegistry::Tag.new(self, tag)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def blob(config)
 | 
			
		||||
    ContainerRegistry::Blob.new(self, config)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def has_tags?
 | 
			
		||||
    tags.any?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def root_repository?
 | 
			
		||||
    name.empty?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def delete_tags!
 | 
			
		||||
    return unless has_tags?
 | 
			
		||||
 | 
			
		||||
    digests = tags.map { |tag| tag.digest }.to_set
 | 
			
		||||
 | 
			
		||||
    digests.all? do |digest|
 | 
			
		||||
      client.delete_repository_tag(self.path, digest)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.build_from_path(path)
 | 
			
		||||
    self.new(project: path.repository_project,
 | 
			
		||||
             name: path.repository_name)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.create_from_path!(path)
 | 
			
		||||
    build_from_path(path).tap(&:save!)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.build_root_repository(project)
 | 
			
		||||
    self.new(project: project, name: '')
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -27,11 +27,14 @@ class Group < Namespace
 | 
			
		|||
 | 
			
		||||
  validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
 | 
			
		||||
 | 
			
		||||
  validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
 | 
			
		||||
 | 
			
		||||
  mount_uploader :avatar, AvatarUploader
 | 
			
		||||
  has_many :uploads, as: :model, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
  after_create :post_create_hook
 | 
			
		||||
  after_destroy :post_destroy_hook
 | 
			
		||||
  after_save :update_two_factor_requirement
 | 
			
		||||
 | 
			
		||||
  class << self
 | 
			
		||||
    # Searches for groups matching the given query.
 | 
			
		||||
| 
						 | 
				
			
			@ -223,4 +226,12 @@ class Group < Namespace
 | 
			
		|||
      type: public? ? 'O' : 'I' # Open vs Invite-only
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  protected
 | 
			
		||||
 | 
			
		||||
  def update_two_factor_requirement
 | 
			
		||||
    return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
 | 
			
		||||
 | 
			
		||||
    users.find_each(&:update_two_factor_requirement)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,11 +3,16 @@ class GroupMember < Member
 | 
			
		|||
 | 
			
		||||
  belongs_to :group, foreign_key: 'source_id'
 | 
			
		||||
 | 
			
		||||
  delegate :update_two_factor_requirement, to: :user
 | 
			
		||||
 | 
			
		||||
  # Make sure group member points only to group as it source
 | 
			
		||||
  default_value_for :source_type, SOURCE_TYPE
 | 
			
		||||
  validates :source_type, format: { with: /\ANamespace\z/ }
 | 
			
		||||
  default_scope { where(source_type: SOURCE_TYPE) }
 | 
			
		||||
 | 
			
		||||
  after_create :update_two_factor_requirement, unless: :invite?
 | 
			
		||||
  after_destroy :update_two_factor_requirement, unless: :invite?
 | 
			
		||||
 | 
			
		||||
  def self.access_level_roles
 | 
			
		||||
    Gitlab::Access.options_with_owner
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -116,6 +116,7 @@ class Project < ActiveRecord::Base
 | 
			
		|||
  has_one :mock_ci_service, dependent: :destroy
 | 
			
		||||
  has_one :mock_deployment_service, dependent: :destroy
 | 
			
		||||
  has_one :mock_monitoring_service, dependent: :destroy
 | 
			
		||||
  has_one :microsoft_teams_service, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
  has_one  :forked_project_link,  dependent: :destroy, foreign_key: "forked_to_project_id"
 | 
			
		||||
  has_one  :forked_from_project,  through:   :forked_project_link
 | 
			
		||||
| 
						 | 
				
			
			@ -160,6 +161,7 @@ class Project < ActiveRecord::Base
 | 
			
		|||
  has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
 | 
			
		||||
  has_one :project_feature, dependent: :destroy
 | 
			
		||||
  has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
 | 
			
		||||
  has_many :container_repositories, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
  has_many :commit_statuses, dependent: :destroy
 | 
			
		||||
  has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
 | 
			
		||||
| 
						 | 
				
			
			@ -407,32 +409,15 @@ class Project < ActiveRecord::Base
 | 
			
		|||
    @repository ||= Repository.new(path_with_namespace, self)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def container_registry_path_with_namespace
 | 
			
		||||
    path_with_namespace.downcase
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def container_registry_repository
 | 
			
		||||
    return unless Gitlab.config.registry.enabled
 | 
			
		||||
 | 
			
		||||
    @container_registry_repository ||= begin
 | 
			
		||||
      token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace)
 | 
			
		||||
      url = Gitlab.config.registry.api_url
 | 
			
		||||
      host_port = Gitlab.config.registry.host_port
 | 
			
		||||
      registry = ContainerRegistry::Registry.new(url, token: token, path: host_port)
 | 
			
		||||
      registry.repository(container_registry_path_with_namespace)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def container_registry_repository_url
 | 
			
		||||
  def container_registry_url
 | 
			
		||||
    if Gitlab.config.registry.enabled
 | 
			
		||||
      "#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}"
 | 
			
		||||
      "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def has_container_registry_tags?
 | 
			
		||||
    return unless container_registry_repository
 | 
			
		||||
 | 
			
		||||
    container_registry_repository.tags.any?
 | 
			
		||||
    container_repositories.to_a.any?(&:has_tags?) ||
 | 
			
		||||
      has_root_container_repository_tags?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def commit(ref = 'HEAD')
 | 
			
		||||
| 
						 | 
				
			
			@ -907,10 +892,10 @@ class Project < ActiveRecord::Base
 | 
			
		|||
    expire_caches_before_rename(old_path_with_namespace)
 | 
			
		||||
 | 
			
		||||
    if has_container_registry_tags?
 | 
			
		||||
      Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present"
 | 
			
		||||
      Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
 | 
			
		||||
 | 
			
		||||
      # we currently doesn't support renaming repository if it contains tags in container registry
 | 
			
		||||
      raise StandardError.new('Project cannot be renamed, because tags are present in its container registry')
 | 
			
		||||
      # we currently doesn't support renaming repository if it contains images in container registry
 | 
			
		||||
      raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
 | 
			
		||||
| 
						 | 
				
			
			@ -1100,10 +1085,6 @@ class Project < ActiveRecord::Base
 | 
			
		|||
    self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_coverage_enabled?
 | 
			
		||||
    build_coverage_regex.present?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_timeout_in_minutes
 | 
			
		||||
    build_timeout / 60
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -1257,7 +1238,7 @@ class Project < ActiveRecord::Base
 | 
			
		|||
    ]
 | 
			
		||||
 | 
			
		||||
    if container_registry_enabled?
 | 
			
		||||
      variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true }
 | 
			
		||||
      variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    variables
 | 
			
		||||
| 
						 | 
				
			
			@ -1385,4 +1366,15 @@ class Project < ActiveRecord::Base
 | 
			
		|||
 | 
			
		||||
    Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  ##
 | 
			
		||||
  # This method is here because of support for legacy container repository
 | 
			
		||||
  # which has exactly the same path like project does, but which might not be
 | 
			
		||||
  # persisted in `container_repositories` table.
 | 
			
		||||
  #
 | 
			
		||||
  def has_root_container_repository_tags?
 | 
			
		||||
    return false unless Gitlab.config.registry.enabled
 | 
			
		||||
 | 
			
		||||
    ContainerRepository.build_root_repository(self).has_tags?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,11 +2,23 @@ require 'slack-notifier'
 | 
			
		|||
 | 
			
		||||
module ChatMessage
 | 
			
		||||
  class BaseMessage
 | 
			
		||||
    attr_reader :markdown
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
    attr_reader :user_avatar
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      raise NotImplementedError
 | 
			
		||||
      @markdown = params[:markdown] || false
 | 
			
		||||
      @project_name = params.dig(:project, :path_with_namespace) || params[:project_name]
 | 
			
		||||
      @project_url = params.dig(:project, :web_url) || params[:project_url]
 | 
			
		||||
      @user_name = params.dig(:user, :username) || params[:user_name]
 | 
			
		||||
      @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pretext
 | 
			
		||||
      return message if markdown
 | 
			
		||||
 | 
			
		||||
      format(message)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +29,10 @@ module ChatMessage
 | 
			
		|||
      raise NotImplementedError
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      raise NotImplementedError
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,6 @@
 | 
			
		|||
module ChatMessage
 | 
			
		||||
  class IssueMessage < BaseMessage
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
    attr_reader :title
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
    attr_reader :issue_iid
 | 
			
		||||
    attr_reader :issue_url
 | 
			
		||||
    attr_reader :action
 | 
			
		||||
| 
						 | 
				
			
			@ -11,9 +8,7 @@ module ChatMessage
 | 
			
		|||
    attr_reader :description
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      @user_name = params[:user][:username]
 | 
			
		||||
      @project_name = params[:project_name]
 | 
			
		||||
      @project_url = params[:project_url]
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      obj_attr = params[:object_attributes]
 | 
			
		||||
      obj_attr = HashWithIndifferentAccess.new(obj_attr)
 | 
			
		||||
| 
						 | 
				
			
			@ -27,15 +22,24 @@ module ChatMessage
 | 
			
		|||
 | 
			
		||||
    def attachments
 | 
			
		||||
      return [] unless opened_issue?
 | 
			
		||||
      return description if markdown
 | 
			
		||||
 | 
			
		||||
      description_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      {
 | 
			
		||||
        title: "Issue #{state} by #{user_name}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: issue_link,
 | 
			
		||||
        image: user_avatar
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
      case state
 | 
			
		||||
      when "opened"
 | 
			
		||||
      if state == 'opened'
 | 
			
		||||
        "[#{project_link}] Issue #{state} by #{user_name}"
 | 
			
		||||
      else
 | 
			
		||||
        "[#{project_link}] Issue #{issue_link} #{state} by #{user_name}"
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +68,7 @@ module ChatMessage
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def issue_title
 | 
			
		||||
      "##{issue_iid} #{title}"
 | 
			
		||||
      "#{Issue.reference_prefix}#{issue_iid} #{title}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,36 +1,36 @@
 | 
			
		|||
module ChatMessage
 | 
			
		||||
  class MergeMessage < BaseMessage
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
    attr_reader :merge_request_id
 | 
			
		||||
    attr_reader :merge_request_iid
 | 
			
		||||
    attr_reader :source_branch
 | 
			
		||||
    attr_reader :target_branch
 | 
			
		||||
    attr_reader :state
 | 
			
		||||
    attr_reader :title
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      @user_name = params[:user][:username]
 | 
			
		||||
      @project_name = params[:project_name]
 | 
			
		||||
      @project_url = params[:project_url]
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      obj_attr = params[:object_attributes]
 | 
			
		||||
      obj_attr = HashWithIndifferentAccess.new(obj_attr)
 | 
			
		||||
      @merge_request_id = obj_attr[:iid]
 | 
			
		||||
      @merge_request_iid = obj_attr[:iid]
 | 
			
		||||
      @source_branch = obj_attr[:source_branch]
 | 
			
		||||
      @target_branch = obj_attr[:target_branch]
 | 
			
		||||
      @state = obj_attr[:state]
 | 
			
		||||
      @title = format_title(obj_attr[:title])
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pretext
 | 
			
		||||
      format(message)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def attachments
 | 
			
		||||
      []
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      {
 | 
			
		||||
        title: "Merge Request #{state} by #{user_name}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: merge_request_link,
 | 
			
		||||
        image: user_avatar
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def format_title(title)
 | 
			
		||||
| 
						 | 
				
			
			@ -50,11 +50,15 @@ module ChatMessage
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_request_link
 | 
			
		||||
      link("merge request !#{merge_request_id}", merge_request_url)
 | 
			
		||||
      link(merge_request_title, merge_request_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_request_title
 | 
			
		||||
      "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_request_url
 | 
			
		||||
      "#{project_url}/merge_requests/#{merge_request_id}"
 | 
			
		||||
      "#{project_url}/merge_requests/#{merge_request_iid}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,70 +1,74 @@
 | 
			
		|||
module ChatMessage
 | 
			
		||||
  class NoteMessage < BaseMessage
 | 
			
		||||
    attr_reader :message
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
    attr_reader :note
 | 
			
		||||
    attr_reader :note_url
 | 
			
		||||
    attr_reader :title
 | 
			
		||||
    attr_reader :target
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      params = HashWithIndifferentAccess.new(params)
 | 
			
		||||
      @user_name = params[:user][:username]
 | 
			
		||||
      @project_name = params[:project_name]
 | 
			
		||||
      @project_url = params[:project_url]
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      params = HashWithIndifferentAccess.new(params)
 | 
			
		||||
      obj_attr = params[:object_attributes]
 | 
			
		||||
      obj_attr = HashWithIndifferentAccess.new(obj_attr)
 | 
			
		||||
      @note = obj_attr[:note]
 | 
			
		||||
      @note_url = obj_attr[:url]
 | 
			
		||||
      noteable_type = obj_attr[:noteable_type]
 | 
			
		||||
 | 
			
		||||
      case noteable_type
 | 
			
		||||
      @target, @title = case obj_attr[:noteable_type]
 | 
			
		||||
                        when "Commit"
 | 
			
		||||
        create_commit_note(HashWithIndifferentAccess.new(params[:commit]))
 | 
			
		||||
                          create_commit_note(params[:commit])
 | 
			
		||||
                        when "Issue"
 | 
			
		||||
        create_issue_note(HashWithIndifferentAccess.new(params[:issue]))
 | 
			
		||||
                          create_issue_note(params[:issue])
 | 
			
		||||
                        when "MergeRequest"
 | 
			
		||||
        create_merge_note(HashWithIndifferentAccess.new(params[:merge_request]))
 | 
			
		||||
                          create_merge_note(params[:merge_request])
 | 
			
		||||
                        when "Snippet"
 | 
			
		||||
        create_snippet_note(HashWithIndifferentAccess.new(params[:snippet]))
 | 
			
		||||
                          create_snippet_note(params[:snippet])
 | 
			
		||||
                        end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def attachments
 | 
			
		||||
      return note if markdown
 | 
			
		||||
 | 
			
		||||
      description_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      {
 | 
			
		||||
        title: "#{user_name} #{link('commented on ' + target, note_url)}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: formatted_title,
 | 
			
		||||
        image: user_avatar
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
      "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def format_title(title)
 | 
			
		||||
      title.lines.first.chomp
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_commit_note(commit)
 | 
			
		||||
      commit_sha = commit[:id]
 | 
			
		||||
      commit_sha = Commit.truncate_sha(commit_sha)
 | 
			
		||||
      commented_on_message(
 | 
			
		||||
        "commit #{commit_sha}",
 | 
			
		||||
        format_title(commit[:message]))
 | 
			
		||||
    def formatted_title
 | 
			
		||||
      format_title(title)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_issue_note(issue)
 | 
			
		||||
      commented_on_message(
 | 
			
		||||
        "issue ##{issue[:iid]}",
 | 
			
		||||
        format_title(issue[:title]))
 | 
			
		||||
      ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_commit_note(commit)
 | 
			
		||||
      commit_sha = Commit.truncate_sha(commit[:id])
 | 
			
		||||
 | 
			
		||||
      ["commit #{commit_sha}", commit[:message]]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_merge_note(merge_request)
 | 
			
		||||
      commented_on_message(
 | 
			
		||||
        "merge request !#{merge_request[:iid]}",
 | 
			
		||||
        format_title(merge_request[:title]))
 | 
			
		||||
      ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def create_snippet_note(snippet)
 | 
			
		||||
      commented_on_message(
 | 
			
		||||
        "snippet ##{snippet[:id]}",
 | 
			
		||||
        format_title(snippet[:title]))
 | 
			
		||||
      ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def description_message
 | 
			
		||||
| 
						 | 
				
			
			@ -74,9 +78,5 @@ module ChatMessage
 | 
			
		|||
    def project_link
 | 
			
		||||
      link(project_name, project_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def commented_on_message(target, title)
 | 
			
		||||
      @message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,22 @@
 | 
			
		|||
module ChatMessage
 | 
			
		||||
  class PipelineMessage < BaseMessage
 | 
			
		||||
    attr_reader :ref_type, :ref, :status, :project_name, :project_url,
 | 
			
		||||
                :user_name, :duration, :pipeline_id
 | 
			
		||||
    attr_reader :ref_type
 | 
			
		||||
    attr_reader :ref
 | 
			
		||||
    attr_reader :status
 | 
			
		||||
    attr_reader :duration
 | 
			
		||||
    attr_reader :pipeline_id
 | 
			
		||||
 | 
			
		||||
    def initialize(data)
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      @user_name = data.dig(:user, :name) || 'API'
 | 
			
		||||
 | 
			
		||||
      pipeline_attributes = data[:object_attributes]
 | 
			
		||||
      @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
 | 
			
		||||
      @ref = pipeline_attributes[:ref]
 | 
			
		||||
      @status = pipeline_attributes[:status]
 | 
			
		||||
      @duration = pipeline_attributes[:duration]
 | 
			
		||||
      @pipeline_id = pipeline_attributes[:id]
 | 
			
		||||
 | 
			
		||||
      @project_name = data[:project][:path_with_namespace]
 | 
			
		||||
      @project_url = data[:project][:web_url]
 | 
			
		||||
      @user_name = (data[:user] && data[:user][:name]) || 'API'
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pretext
 | 
			
		||||
| 
						 | 
				
			
			@ -25,17 +28,24 @@ module ChatMessage
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def attachments
 | 
			
		||||
      return message if markdown
 | 
			
		||||
 | 
			
		||||
      [{ text: format(message), color: attachment_color }]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      {
 | 
			
		||||
        title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: "in #{duration} #{time_measure}",
 | 
			
		||||
        image: user_avatar || ''
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
      "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def format(string)
 | 
			
		||||
      Slack::Notifier::LinkFormatter.format(string)
 | 
			
		||||
      "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def humanized_status
 | 
			
		||||
| 
						 | 
				
			
			@ -74,5 +84,9 @@ module ChatMessage
 | 
			
		|||
    def pipeline_link
 | 
			
		||||
      "[##{pipeline_id}](#{pipeline_url})"
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def time_measure
 | 
			
		||||
      'second'.pluralize(duration)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,33 +3,43 @@ module ChatMessage
 | 
			
		|||
    attr_reader :after
 | 
			
		||||
    attr_reader :before
 | 
			
		||||
    attr_reader :commits
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
    attr_reader :ref
 | 
			
		||||
    attr_reader :ref_type
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      @after = params[:after]
 | 
			
		||||
      @before = params[:before]
 | 
			
		||||
      @commits = params.fetch(:commits, [])
 | 
			
		||||
      @project_name = params[:project_name]
 | 
			
		||||
      @project_url = params[:project_url]
 | 
			
		||||
      @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch'
 | 
			
		||||
      @ref = Gitlab::Git.ref_name(params[:ref])
 | 
			
		||||
      @user_name = params[:user_name]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def pretext
 | 
			
		||||
      format(message)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def attachments
 | 
			
		||||
      return [] if new_branch? || removed_branch?
 | 
			
		||||
      return commit_messages if markdown
 | 
			
		||||
 | 
			
		||||
      commit_message_attachments
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      action = if new_branch?
 | 
			
		||||
                 "created"
 | 
			
		||||
               elsif removed_branch?
 | 
			
		||||
                 "removed"
 | 
			
		||||
               else
 | 
			
		||||
                 "pushed to"
 | 
			
		||||
               end
 | 
			
		||||
 | 
			
		||||
      {
 | 
			
		||||
        title: "#{user_name} #{action} #{ref_type}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: compare_link,
 | 
			
		||||
        image: user_avatar
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +69,7 @@ module ChatMessage
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def commit_messages
 | 
			
		||||
      commits.map { |commit| compose_commit_message(commit) }.join("\n")
 | 
			
		||||
      commits.map { |commit| compose_commit_message(commit) }.join("\n\n")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def commit_message_attachments
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,12 @@
 | 
			
		|||
module ChatMessage
 | 
			
		||||
  class WikiPageMessage < BaseMessage
 | 
			
		||||
    attr_reader :user_name
 | 
			
		||||
    attr_reader :title
 | 
			
		||||
    attr_reader :project_name
 | 
			
		||||
    attr_reader :project_url
 | 
			
		||||
    attr_reader :wiki_page_url
 | 
			
		||||
    attr_reader :action
 | 
			
		||||
    attr_reader :description
 | 
			
		||||
 | 
			
		||||
    def initialize(params)
 | 
			
		||||
      @user_name = params[:user][:username]
 | 
			
		||||
      @project_name = params[:project_name]
 | 
			
		||||
      @project_url = params[:project_url]
 | 
			
		||||
      super
 | 
			
		||||
 | 
			
		||||
      obj_attr = params[:object_attributes]
 | 
			
		||||
      obj_attr = HashWithIndifferentAccess.new(obj_attr)
 | 
			
		||||
| 
						 | 
				
			
			@ -29,9 +24,20 @@ module ChatMessage
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def attachments
 | 
			
		||||
      return description if markdown
 | 
			
		||||
 | 
			
		||||
      description_message
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def activity
 | 
			
		||||
      {
 | 
			
		||||
        title: "#{user_name} #{action} #{wiki_page_link}",
 | 
			
		||||
        subtitle: "in #{project_link}",
 | 
			
		||||
        text: title,
 | 
			
		||||
        image: user_avatar
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def message
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,10 +49,7 @@ class ChatNotificationService < Service
 | 
			
		|||
 | 
			
		||||
    object_kind = data[:object_kind]
 | 
			
		||||
 | 
			
		||||
    data = data.merge(
 | 
			
		||||
      project_url: project_url,
 | 
			
		||||
      project_name: project_name
 | 
			
		||||
    )
 | 
			
		||||
    data = custom_data(data)
 | 
			
		||||
 | 
			
		||||
    # WebHook events often have an 'update' event that follows a 'open' or
 | 
			
		||||
    # 'close' action. Ignore update events for now to prevent duplicate
 | 
			
		||||
| 
						 | 
				
			
			@ -68,8 +65,7 @@ class ChatNotificationService < Service
 | 
			
		|||
    opts[:channel] = channel_name if channel_name
 | 
			
		||||
    opts[:username] = username if username
 | 
			
		||||
 | 
			
		||||
    notifier = Slack::Notifier.new(webhook, opts)
 | 
			
		||||
    notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback)
 | 
			
		||||
    return false unless notify(message, opts)
 | 
			
		||||
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -92,6 +88,18 @@ class ChatNotificationService < Service
 | 
			
		|||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def notify(message, opts)
 | 
			
		||||
    Slack::Notifier.new(webhook, opts).ping(
 | 
			
		||||
      message.pretext,
 | 
			
		||||
      attachments: message.attachments,
 | 
			
		||||
      fallback: message.fallback
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def custom_data(data)
 | 
			
		||||
    data.merge(project_url: project_url, project_name: project_name)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def get_message(object_kind, data)
 | 
			
		||||
    case object_kind
 | 
			
		||||
    when "push", "tag_push"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
class MicrosoftTeamsService < ChatNotificationService
 | 
			
		||||
  def title
 | 
			
		||||
    'Microsoft Teams Notification'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def description
 | 
			
		||||
    'Receive event notifications in Microsoft Teams'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.to_param
 | 
			
		||||
    'microsoft_teams'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def help
 | 
			
		||||
    'This service sends notifications about projects events to Microsoft Teams channels.<br />
 | 
			
		||||
    To set up this service:
 | 
			
		||||
    <ol>
 | 
			
		||||
      <li><a href="https://msdn.microsoft.com/en-us/microsoft-teams/connectors">Getting started with 365 Office Connectors For Microsoft Teams</a>.</li>
 | 
			
		||||
      <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
 | 
			
		||||
      <li>Select events below to enable notifications.</li>
 | 
			
		||||
    </ol>'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def webhook_placeholder
 | 
			
		||||
    'https://outlook.office.com/webhook/…'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def event_field(event)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def default_channel_placeholder
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def default_fields
 | 
			
		||||
    [
 | 
			
		||||
      { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
 | 
			
		||||
      { type: 'checkbox', name: 'notify_only_broken_pipelines' },
 | 
			
		||||
      { type: 'checkbox', name: 'notify_only_default_branch' },
 | 
			
		||||
    ]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def notify(message, opts)
 | 
			
		||||
    MicrosoftTeams::Notifier.new(webhook).ping(
 | 
			
		||||
      title: message.project_name,
 | 
			
		||||
      pretext: message.pretext,
 | 
			
		||||
      activity: message.activity,
 | 
			
		||||
      attachments: message.attachments
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def custom_data(data)
 | 
			
		||||
    super(data).merge(markdown: true)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ class Repository
 | 
			
		|||
 | 
			
		||||
  attr_accessor :path_with_namespace, :project
 | 
			
		||||
 | 
			
		||||
  delegate :ref_name_for_sha, to: :raw_repository
 | 
			
		||||
 | 
			
		||||
  CommitError = Class.new(StandardError)
 | 
			
		||||
  CreateTreeError = Class.new(StandardError)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -700,14 +702,6 @@ class Repository
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def ref_name_for_sha(ref_path, sha)
 | 
			
		||||
    args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
 | 
			
		||||
 | 
			
		||||
    # Not found -> ["", 0]
 | 
			
		||||
    # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
 | 
			
		||||
    Gitlab::Popen.popen(args, path_to_repo).first.split.last
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def refs_contains_sha(ref_type, sha)
 | 
			
		||||
    args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
 | 
			
		||||
    names = Gitlab::Popen.popen(args, path_to_repo).first
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -237,6 +237,7 @@ class Service < ActiveRecord::Base
 | 
			
		|||
      slack_slash_commands
 | 
			
		||||
      slack
 | 
			
		||||
      teamcity
 | 
			
		||||
      microsoft_teams
 | 
			
		||||
    ]
 | 
			
		||||
    if Rails.env.development?
 | 
			
		||||
      service_names += %w[mock_ci mock_deployment mock_monitoring]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -89,7 +89,8 @@ class User < ActiveRecord::Base
 | 
			
		|||
  has_many :subscriptions,            dependent: :destroy
 | 
			
		||||
  has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id,   class_name: "Event"
 | 
			
		||||
  has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
 | 
			
		||||
  has_one  :abuse_report,             dependent: :destroy
 | 
			
		||||
  has_one  :abuse_report,             dependent: :destroy, foreign_key: :user_id
 | 
			
		||||
  has_many :reported_abuse_reports,   dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
 | 
			
		||||
  has_many :spam_logs,                dependent: :destroy
 | 
			
		||||
  has_many :builds,                   dependent: :nullify, class_name: 'Ci::Build'
 | 
			
		||||
  has_many :pipelines,                dependent: :nullify, class_name: 'Ci::Pipeline'
 | 
			
		||||
| 
						 | 
				
			
			@ -484,6 +485,14 @@ class User < ActiveRecord::Base
 | 
			
		|||
    Group.member_descendants(id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def all_expanded_groups
 | 
			
		||||
    Group.member_hierarchy(id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def expanded_groups_requiring_two_factor_authentication
 | 
			
		||||
    all_expanded_groups.where(require_two_factor_authentication: true)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def nested_groups_projects
 | 
			
		||||
    Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
 | 
			
		||||
      member_descendants(id)
 | 
			
		||||
| 
						 | 
				
			
			@ -955,6 +964,15 @@ class User < ActiveRecord::Base
 | 
			
		|||
    self.admin = (new_level == 'admin')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update_two_factor_requirement
 | 
			
		||||
    periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
 | 
			
		||||
 | 
			
		||||
    self.require_two_factor_authentication_from_group = periods.any?
 | 
			
		||||
    self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period']
 | 
			
		||||
 | 
			
		||||
    save
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  protected
 | 
			
		||||
 | 
			
		||||
  # override, from Devise::Validatable
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ module Auth
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def self.full_access_token(*names)
 | 
			
		||||
      names = names.flatten
 | 
			
		||||
      registry = Gitlab.config.registry
 | 
			
		||||
      token = JSONWebToken::RSAToken.new(registry.key)
 | 
			
		||||
      token.issuer = registry.issuer
 | 
			
		||||
| 
						 | 
				
			
			@ -37,13 +38,13 @@ module Auth
 | 
			
		|||
    private
 | 
			
		||||
 | 
			
		||||
    def authorized_token(*accesses)
 | 
			
		||||
      token = JSONWebToken::RSAToken.new(registry.key)
 | 
			
		||||
      JSONWebToken::RSAToken.new(registry.key).tap do |token|
 | 
			
		||||
        token.issuer = registry.issuer
 | 
			
		||||
        token.audience = params[:service]
 | 
			
		||||
        token.subject = current_user.try(:username)
 | 
			
		||||
        token.expire_time = self.class.token_expire_at
 | 
			
		||||
        token[:access] = accesses.compact
 | 
			
		||||
      token
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def scope
 | 
			
		||||
| 
						 | 
				
			
			@ -55,20 +56,43 @@ module Auth
 | 
			
		|||
    def process_scope(scope)
 | 
			
		||||
      type, name, actions = scope.split(':', 3)
 | 
			
		||||
      actions = actions.split(',')
 | 
			
		||||
      path = ContainerRegistry::Path.new(name)
 | 
			
		||||
 | 
			
		||||
      return unless type == 'repository'
 | 
			
		||||
 | 
			
		||||
      process_repository_access(type, name, actions)
 | 
			
		||||
      process_repository_access(type, path, actions)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def process_repository_access(type, name, actions)
 | 
			
		||||
      requested_project = Project.find_by_full_path(name)
 | 
			
		||||
    def process_repository_access(type, path, actions)
 | 
			
		||||
      return unless path.valid?
 | 
			
		||||
 | 
			
		||||
      requested_project = path.repository_project
 | 
			
		||||
 | 
			
		||||
      return unless requested_project
 | 
			
		||||
 | 
			
		||||
      actions = actions.select do |action|
 | 
			
		||||
        can_access?(requested_project, action)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      { type: type, name: name, actions: actions } if actions.present?
 | 
			
		||||
      return unless actions.present?
 | 
			
		||||
 | 
			
		||||
      # At this point user/build is already authenticated.
 | 
			
		||||
      #
 | 
			
		||||
      ensure_container_repository!(path, actions)
 | 
			
		||||
 | 
			
		||||
      { type: type, name: path.to_s, actions: actions }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # Because we do not have two way communication with registry yet,
 | 
			
		||||
    # we create a container repository image resource when push to the
 | 
			
		||||
    # registry is successfuly authorized.
 | 
			
		||||
    #
 | 
			
		||||
    def ensure_container_repository!(path, actions)
 | 
			
		||||
      return if path.has_repository?
 | 
			
		||||
      return unless actions.include?('push')
 | 
			
		||||
 | 
			
		||||
      ContainerRepository.create_from_path!(path)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def can_access?(requested_project, requested_action)
 | 
			
		||||
| 
						 | 
				
			
			@ -101,6 +125,11 @@ module Auth
 | 
			
		|||
        can?(current_user, :read_container_image, requested_project)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    ##
 | 
			
		||||
    # We still support legacy pipeline triggers which do not have associated
 | 
			
		||||
    # actor. New permissions model and new triggers are always associated with
 | 
			
		||||
    # an actor, so this should be improved in 10.0 version of GitLab.
 | 
			
		||||
    #
 | 
			
		||||
    def build_can_push?(requested_project)
 | 
			
		||||
      # Build can push only to the project from which it originates
 | 
			
		||||
      has_authentication_ability?(:build_create_container_image) &&
 | 
			
		||||
| 
						 | 
				
			
			@ -113,14 +142,11 @@ module Auth
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    def error(code, status:, message: '')
 | 
			
		||||
      {
 | 
			
		||||
        errors: [{ code: code, message: message }],
 | 
			
		||||
        http_status: status
 | 
			
		||||
      }
 | 
			
		||||
      { errors: [{ code: code, message: message }], http_status: status }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def has_authentication_ability?(capability)
 | 
			
		||||
      (@authentication_abilities || []).include?(capability)
 | 
			
		||||
      @authentication_abilities.to_a.include?(capability)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,16 +31,16 @@ module Projects
 | 
			
		|||
        project.team.truncate
 | 
			
		||||
        project.destroy!
 | 
			
		||||
 | 
			
		||||
        unless remove_registry_tags
 | 
			
		||||
          raise_error('Failed to remove project container registry. Please try again or contact administrator')
 | 
			
		||||
        unless remove_legacy_registry_tags
 | 
			
		||||
          raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        unless remove_repository(repo_path)
 | 
			
		||||
          raise_error('Failed to remove project repository. Please try again or contact administrator')
 | 
			
		||||
          raise_error('Failed to remove project repository. Please try again or contact administrator.')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        unless remove_repository(wiki_path)
 | 
			
		||||
          raise_error('Failed to remove wiki repository. Please try again or contact administrator')
 | 
			
		||||
          raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -68,10 +68,16 @@ module Projects
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def remove_registry_tags
 | 
			
		||||
    ##
 | 
			
		||||
    # This method makes sure that we correctly remove registry tags
 | 
			
		||||
    # for legacy image repository (when repository path equals project path).
 | 
			
		||||
    #
 | 
			
		||||
    def remove_legacy_registry_tags
 | 
			
		||||
      return true unless Gitlab.config.registry.enabled
 | 
			
		||||
 | 
			
		||||
      project.container_registry_repository.delete_tags
 | 
			
		||||
      ContainerRepository.build_root_repository(project).tap do |repository|
 | 
			
		||||
        return repository.has_tags? ? repository.delete_tags! : true
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def raise_error(message)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ module Users
 | 
			
		|||
        ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      move_issues_to_ghost_user(user)
 | 
			
		||||
      MigrateToGhostUserService.new(user).execute
 | 
			
		||||
 | 
			
		||||
      # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
 | 
			
		||||
      namespace = user.namespace
 | 
			
		||||
| 
						 | 
				
			
			@ -35,22 +35,5 @@ module Users
 | 
			
		|||
 | 
			
		||||
      user_data
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def move_issues_to_ghost_user(user)
 | 
			
		||||
      # Block the user before moving issues to prevent a data race.
 | 
			
		||||
      # If the user creates an issue after `move_issues_to_ghost_user`
 | 
			
		||||
      # runs and before the user is destroyed, the destroy will fail with
 | 
			
		||||
      # an exception. We block the user so that issues can't be created
 | 
			
		||||
      # after `move_issues_to_ghost_user` runs and before the destroy happens.
 | 
			
		||||
      user.block
 | 
			
		||||
 | 
			
		||||
      ghost_user = User.ghost
 | 
			
		||||
 | 
			
		||||
      user.issues.update_all(author_id: ghost_user.id)
 | 
			
		||||
 | 
			
		||||
      user.reload
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
# When a user is destroyed, some of their associated records are
 | 
			
		||||
# moved to a "Ghost User", to prevent these associated records from
 | 
			
		||||
# being destroyed.
 | 
			
		||||
#
 | 
			
		||||
# For example, all the issues/MRs a user has created are _not_ destroyed
 | 
			
		||||
# when the user is destroyed.
 | 
			
		||||
module Users
 | 
			
		||||
  class MigrateToGhostUserService
 | 
			
		||||
    extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
    attr_reader :ghost_user, :user
 | 
			
		||||
 | 
			
		||||
    def initialize(user)
 | 
			
		||||
      @user = user
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def execute
 | 
			
		||||
      # Block the user before moving records to prevent a data race.
 | 
			
		||||
      # For example, if the user creates an issue after `migrate_issues`
 | 
			
		||||
      # runs and before the user is destroyed, the destroy will fail with
 | 
			
		||||
      # an exception.
 | 
			
		||||
      user.block
 | 
			
		||||
 | 
			
		||||
      user.transaction do
 | 
			
		||||
        @ghost_user = User.ghost
 | 
			
		||||
 | 
			
		||||
        migrate_issues
 | 
			
		||||
        migrate_merge_requests
 | 
			
		||||
        migrate_notes
 | 
			
		||||
        migrate_abuse_reports
 | 
			
		||||
        migrate_award_emoji
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      user.reload
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def migrate_issues
 | 
			
		||||
      user.issues.update_all(author_id: ghost_user.id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def migrate_merge_requests
 | 
			
		||||
      user.merge_requests.update_all(author_id: ghost_user.id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def migrate_notes
 | 
			
		||||
      user.notes.update_all(author_id: ghost_user.id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def migrate_abuse_reports
 | 
			
		||||
      user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def migrate_award_emoji
 | 
			
		||||
      user.award_emoji.update_all(user_id: ghost_user.id)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
# CronTimezoneValidator
 | 
			
		||||
#
 | 
			
		||||
# Custom validator for CronTimezone.
 | 
			
		||||
class CronTimezoneValidator < ActiveModel::EachValidator
 | 
			
		||||
  def validate_each(record, attribute, value)
 | 
			
		||||
    cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
 | 
			
		||||
    record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
# CronValidator
 | 
			
		||||
#
 | 
			
		||||
# Custom validator for Cron.
 | 
			
		||||
class CronValidator < ActiveModel::EachValidator
 | 
			
		||||
  def validate_each(record, attribute, value)
 | 
			
		||||
    cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
 | 
			
		||||
    record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +13,7 @@
 | 
			
		|||
    .col-sm-offset-2.col-sm-10
 | 
			
		||||
      = render 'shared/allow_request_access', form: f
 | 
			
		||||
 | 
			
		||||
  = render 'groups/group_lfs_settings', f: f
 | 
			
		||||
  = render 'groups/group_admin_settings', f: f
 | 
			
		||||
 | 
			
		||||
  - if @group.new_record?
 | 
			
		||||
    .form-group
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,8 +3,6 @@
 | 
			
		|||
    .event-item-timestamp
 | 
			
		||||
      #{time_ago_with_tooltip(event.created_at)}
 | 
			
		||||
 | 
			
		||||
    = author_avatar(event, size: 40)
 | 
			
		||||
 | 
			
		||||
    - if event.created_project?
 | 
			
		||||
      = render "events/event/created_project", event: event
 | 
			
		||||
    - elsif event.push?
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,15 @@
 | 
			
		|||
- if event.target
 | 
			
		||||
  - if event.action_name == "opened"
 | 
			
		||||
    .profile-icon.open-icon
 | 
			
		||||
      = custom_icon("icon_status_open")
 | 
			
		||||
  - elsif event.action_name == "closed"
 | 
			
		||||
    .profile-icon.closed-icon
 | 
			
		||||
      = custom_icon("icon_status_closed")
 | 
			
		||||
  - else
 | 
			
		||||
    .profile-icon.fork-icon
 | 
			
		||||
      = custom_icon("code_fork")
 | 
			
		||||
 | 
			
		||||
.event-title
 | 
			
		||||
  %span.author_name= link_to_author event
 | 
			
		||||
  %span{ class: event.action_name }
 | 
			
		||||
  - if event.target
 | 
			
		||||
    = event.action_name
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,7 @@
 | 
			
		|||
.profile-icon.open-icon
 | 
			
		||||
  = custom_icon("icon_status_open")
 | 
			
		||||
 | 
			
		||||
.event-title
 | 
			
		||||
  %span.author_name= link_to_author event
 | 
			
		||||
  %span{ class: event.action_name }
 | 
			
		||||
    = event_action_name(event)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,7 @@
 | 
			
		|||
.profile-icon
 | 
			
		||||
  = custom_icon("comment_o")
 | 
			
		||||
 | 
			
		||||
.event-title
 | 
			
		||||
  %span.author_name= link_to_author event
 | 
			
		||||
  = event.action_name
 | 
			
		||||
  = event_note_title_html(event)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,12 @@
 | 
			
		|||
- project = event.project
 | 
			
		||||
 | 
			
		||||
.profile-icon
 | 
			
		||||
  - if event.action_name == "deleted"
 | 
			
		||||
    = custom_icon("trash_o")
 | 
			
		||||
  - else
 | 
			
		||||
    = custom_icon("icon_commit")
 | 
			
		||||
 | 
			
		||||
.event-title
 | 
			
		||||
  %span.author_name= link_to_author event
 | 
			
		||||
  %span.pushed #{event.action_name} #{event.ref_type}
 | 
			
		||||
  %strong
 | 
			
		||||
    - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
 | 
			
		||||
| 
						 | 
				
			
			@ -48,4 +53,3 @@
 | 
			
		|||
    .event-body
 | 
			
		||||
      %ul.well-list.event_commits
 | 
			
		||||
        = render "events/commit", commit: last_commit, project: project, event: event
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
- if current_user.admin?
 | 
			
		||||
  .form-group
 | 
			
		||||
    = f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
 | 
			
		||||
    .col-sm-10
 | 
			
		||||
      .checkbox
 | 
			
		||||
        = f.label :lfs_enabled do
 | 
			
		||||
          = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
 | 
			
		||||
          %strong
 | 
			
		||||
            Allow projects within this group to use Git LFS
 | 
			
		||||
            = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
 | 
			
		||||
          %br/
 | 
			
		||||
          %span.descr This setting can be overridden in each project.
 | 
			
		||||
 | 
			
		||||
- if can? current_user, :admin_group, @group
 | 
			
		||||
  .form-group
 | 
			
		||||
    = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
 | 
			
		||||
    .col-sm-10
 | 
			
		||||
      .checkbox
 | 
			
		||||
        = f.label :require_two_factor_authentication do
 | 
			
		||||
          = f.check_box :require_two_factor_authentication
 | 
			
		||||
          %strong
 | 
			
		||||
            Require all users in this group to setup Two-factor authentication
 | 
			
		||||
            = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
 | 
			
		||||
  .form-group
 | 
			
		||||
    .col-sm-offset-2.col-sm-10
 | 
			
		||||
      .checkbox
 | 
			
		||||
        = f.text_field :two_factor_grace_period, class: 'form-control'
 | 
			
		||||
        .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +0,0 @@
 | 
			
		|||
- if current_user.admin?
 | 
			
		||||
  .form-group
 | 
			
		||||
    .col-sm-offset-2.col-sm-10
 | 
			
		||||
      .checkbox
 | 
			
		||||
        = f.label :lfs_enabled do
 | 
			
		||||
          = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
 | 
			
		||||
          %strong
 | 
			
		||||
            Allow projects within this group to use Git LFS
 | 
			
		||||
            = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
 | 
			
		||||
          %br/
 | 
			
		||||
          %span.descr This setting can be overridden in each project.
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +27,7 @@
 | 
			
		|||
        .col-sm-offset-2.col-sm-10
 | 
			
		||||
          = render 'shared/allow_request_access', form: f
 | 
			
		||||
 | 
			
		||||
      = render 'group_lfs_settings', f: f
 | 
			
		||||
      = render 'group_admin_settings', f: f
 | 
			
		||||
 | 
			
		||||
      .form-group
 | 
			
		||||
        %hr
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -137,6 +137,6 @@
 | 
			
		|||
            - if build.has_trace?
 | 
			
		||||
              %td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" }
 | 
			
		||||
                %pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" }
 | 
			
		||||
                  = build.trace_html(last_lines: 10).html_safe
 | 
			
		||||
                  = build.trace.html(last_lines: 10).html_safe
 | 
			
		||||
            - else
 | 
			
		||||
              %td{ colspan: "2" }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
 | 
			
		|||
Stage: <%= build.stage %>
 | 
			
		||||
Name: <%= build.name %>
 | 
			
		||||
<% if build.has_trace? -%>
 | 
			
		||||
Trace: <%= build.trace_with_state(last_lines: 10)[:text] %>
 | 
			
		||||
Trace: <%= build.trace.raw(last_lines: 10) %>
 | 
			
		||||
<% end -%>
 | 
			
		||||
 | 
			
		||||
<% end -%>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,7 @@
 | 
			
		|||
          Your New Personal Access Token
 | 
			
		||||
        .form-group
 | 
			
		||||
          = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block"
 | 
			
		||||
          = clipboard_button(clipboard_text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
 | 
			
		||||
          = clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
 | 
			
		||||
          %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again.
 | 
			
		||||
 | 
			
		||||
      %hr
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,7 @@
 | 
			
		|||
            - if @project && event.project != @project
 | 
			
		||||
              %span at
 | 
			
		||||
              %strong= link_to_project event.project
 | 
			
		||||
            = clipboard_button(clipboard_text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
 | 
			
		||||
            = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
 | 
			
		||||
            #{time_ago_with_tooltip(event.created_at)}
 | 
			
		||||
 | 
			
		||||
          .pull-right
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,4 +25,11 @@
 | 
			
		|||
#blob-content-holder.blob-content-holder
 | 
			
		||||
  %article.file-holder
 | 
			
		||||
    = render "projects/blob/header", blob: blob
 | 
			
		||||
    - if current_user
 | 
			
		||||
      .js-file-fork-suggestion-section.file-fork-suggestion.hidden
 | 
			
		||||
        %span.file-fork-suggestion-note
 | 
			
		||||
          You don't have permission to edit this file. Try forking this project to edit the file.
 | 
			
		||||
        = link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new'
 | 
			
		||||
        %button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' }
 | 
			
		||||
          Cancel
 | 
			
		||||
    = render blob.to_partial_path(@project), blob: blob
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,8 +32,8 @@
 | 
			
		|||
      = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
 | 
			
		||||
          tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
 | 
			
		||||
 | 
			
		||||
    - if current_user
 | 
			
		||||
    .btn-group{ role: "group" }<
 | 
			
		||||
      = edit_blob_link if blob_text_viewable?(blob)
 | 
			
		||||
      - if current_user
 | 
			
		||||
        = replace_blob_link
 | 
			
		||||
        = delete_blob_link
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -68,7 +68,7 @@
 | 
			
		|||
        - elsif @build.runner
 | 
			
		||||
          \##{@build.runner.id}
 | 
			
		||||
      .btn-group.btn-group-justified{ role: :group }
 | 
			
		||||
        - if @build.has_trace_file?
 | 
			
		||||
        - if @build.has_trace?
 | 
			
		||||
          = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
 | 
			
		||||
        - if @build.active?
 | 
			
		||||
          = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,6 @@
 | 
			
		|||
          %th Coverage
 | 
			
		||||
          %th
 | 
			
		||||
 | 
			
		||||
      = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin }
 | 
			
		||||
      = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
 | 
			
		||||
 | 
			
		||||
  = paginate builds, theme: 'gitlab'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,6 @@
 | 
			
		|||
- retried = local_assigns.fetch(:retried, false)
 | 
			
		||||
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
 | 
			
		||||
- stage = local_assigns.fetch(:stage, false)
 | 
			
		||||
- coverage = local_assigns.fetch(:coverage, false)
 | 
			
		||||
- allow_retry = local_assigns.fetch(:allow_retry, false)
 | 
			
		||||
 | 
			
		||||
%tr.build.commit{ class: ('retried' if retried) }
 | 
			
		||||
| 
						 | 
				
			
			@ -88,7 +87,7 @@
 | 
			
		|||
        %span= time_ago_with_tooltip(build.finished_at)
 | 
			
		||||
 | 
			
		||||
  %td.coverage
 | 
			
		||||
    - if coverage && build.try(:coverage)
 | 
			
		||||
    - if build.try(:coverage)
 | 
			
		||||
      #{build.coverage}%
 | 
			
		||||
 | 
			
		||||
  %td
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
.page-content-header
 | 
			
		||||
  .header-main-content
 | 
			
		||||
    %strong Commit #{@commit.short_id}
 | 
			
		||||
    = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
    = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
    %span.hidden-xs authored
 | 
			
		||||
    #{time_ago_with_tooltip(@commit.authored_date)}
 | 
			
		||||
    %span by
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -47,7 +47,6 @@
 | 
			
		|||
        %th Job ID
 | 
			
		||||
        %th Name
 | 
			
		||||
        %th
 | 
			
		||||
        - if pipeline.project.build_coverage_enabled?
 | 
			
		||||
        %th Coverage
 | 
			
		||||
        %th
 | 
			
		||||
    = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,6 +37,6 @@
 | 
			
		|||
      .commit-actions.flex-row.hidden-xs
 | 
			
		||||
        - if commit.status(ref)
 | 
			
		||||
          = render_commit_status(commit, ref: ref)
 | 
			
		||||
        = clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
        = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
        = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
 | 
			
		||||
        = link_to_browse_code(project, commit)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,6 @@
 | 
			
		|||
- retried = local_assigns.fetch(:retried, false)
 | 
			
		||||
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
 | 
			
		||||
- stage = local_assigns.fetch(:stage, false)
 | 
			
		||||
- coverage = local_assigns.fetch(:coverage, false)
 | 
			
		||||
 | 
			
		||||
%tr.generic_commit_status{ class: ('retried' if retried) }
 | 
			
		||||
  %td.status
 | 
			
		||||
| 
						 | 
				
			
			@ -80,7 +79,7 @@
 | 
			
		|||
        %span= time_ago_with_tooltip(generic_commit_status.finished_at)
 | 
			
		||||
 | 
			
		||||
  %td.coverage
 | 
			
		||||
    - if coverage && generic_commit_status.try(:coverage)
 | 
			
		||||
    - if generic_commit_status.try(:coverage)
 | 
			
		||||
      #{generic_commit_status.coverage}%
 | 
			
		||||
 | 
			
		||||
  %td
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,7 @@
 | 
			
		|||
        .email-modal-input-group.input-group
 | 
			
		||||
          = text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#issue_email')
 | 
			
		||||
            = clipboard_button(target: '#issue_email')
 | 
			
		||||
        %p
 | 
			
		||||
          The subject will be used as the title of the new issue, and the message will be the description.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,7 @@
 | 
			
		|||
        %p
 | 
			
		||||
          %strong Step 1.
 | 
			
		||||
          Fetch and check out the branch for this merge request
 | 
			
		||||
        = clipboard_button(clipboard_target: "pre#merge-info-1", title: "Copy commands to clipboard")
 | 
			
		||||
        = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
 | 
			
		||||
        %pre.dark#merge-info-1
 | 
			
		||||
          - if @merge_request.for_fork?
 | 
			
		||||
            :preserve
 | 
			
		||||
| 
						 | 
				
			
			@ -25,7 +25,7 @@
 | 
			
		|||
        %p
 | 
			
		||||
          %strong Step 3.
 | 
			
		||||
          Merge the branch and fix any conflicts that come up
 | 
			
		||||
        = clipboard_button(clipboard_target: "pre#merge-info-3", title: "Copy commands to clipboard")
 | 
			
		||||
        = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
 | 
			
		||||
        %pre.dark#merge-info-3
 | 
			
		||||
          - if @merge_request.for_fork?
 | 
			
		||||
            :preserve
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +38,7 @@
 | 
			
		|||
        %p
 | 
			
		||||
          %strong Step 4.
 | 
			
		||||
          Push the result of the merge to GitLab
 | 
			
		||||
        = clipboard_button(clipboard_target: "pre#merge-info-4", title: "Copy commands to clipboard")
 | 
			
		||||
        = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
 | 
			
		||||
        %pre.dark#merge-info-4
 | 
			
		||||
          :preserve
 | 
			
		||||
            git push origin #{h @merge_request.target_branch}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,4 +46,4 @@
 | 
			
		|||
        \...
 | 
			
		||||
    %span.js-details-content.hide
 | 
			
		||||
      = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
 | 
			
		||||
    = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard")
 | 
			
		||||
    = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,7 +36,6 @@
 | 
			
		|||
            %th Job ID
 | 
			
		||||
            %th Name
 | 
			
		||||
            %th
 | 
			
		||||
            - if pipeline.project.build_coverage_enabled?
 | 
			
		||||
            %th Coverage
 | 
			
		||||
            %th
 | 
			
		||||
        = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
.container-image.js-toggle-container
 | 
			
		||||
  .container-image-head
 | 
			
		||||
    = link_to "#", class: "js-toggle-button" do
 | 
			
		||||
      = icon('chevron-down', 'aria-hidden': 'true')
 | 
			
		||||
      = escape_once(image.path)
 | 
			
		||||
 | 
			
		||||
    = clipboard_button(clipboard_text: "docker pull #{image.path}")
 | 
			
		||||
 | 
			
		||||
    .controls.hidden-xs.pull-right
 | 
			
		||||
      = link_to namespace_project_container_registry_path(@project.namespace, @project, image),
 | 
			
		||||
                class: 'btn btn-remove has-tooltip',
 | 
			
		||||
                title: 'Remove repository',
 | 
			
		||||
                data: { confirm: 'Are you sure?' },
 | 
			
		||||
                method: :delete do
 | 
			
		||||
        = icon('trash cred', 'aria-hidden': 'true')
 | 
			
		||||
 | 
			
		||||
  .container-image-tags.js-toggle-content.hide
 | 
			
		||||
    - if image.has_tags?
 | 
			
		||||
      .table-holder
 | 
			
		||||
        %table.table.tags
 | 
			
		||||
          %thead
 | 
			
		||||
            %tr
 | 
			
		||||
              %th Tag
 | 
			
		||||
              %th Tag ID
 | 
			
		||||
              %th Size
 | 
			
		||||
              %th Created
 | 
			
		||||
              - if can?(current_user, :update_container_image, @project)
 | 
			
		||||
                %th
 | 
			
		||||
          = render partial: 'tag', collection: image.tags
 | 
			
		||||
    - else
 | 
			
		||||
      .nothing-here-block No tags in Container Registry for this container image.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
%tr.tag
 | 
			
		||||
  %td
 | 
			
		||||
    = escape_once(tag.name)
 | 
			
		||||
    = clipboard_button(clipboard_text: "docker pull #{tag.path}")
 | 
			
		||||
    = clipboard_button(text: "docker pull #{tag.path}")
 | 
			
		||||
  %td
 | 
			
		||||
    - if tag.revision
 | 
			
		||||
      %span.has-tooltip{ title: "#{tag.revision}" }
 | 
			
		||||
| 
						 | 
				
			
			@ -25,5 +25,9 @@
 | 
			
		|||
  - if can?(current_user, :update_container_image, @project)
 | 
			
		||||
    %td.content
 | 
			
		||||
      .controls.hidden-xs.pull-right
 | 
			
		||||
        = link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do
 | 
			
		||||
          = icon("trash cred")
 | 
			
		||||
        = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
 | 
			
		||||
                  method: :delete,
 | 
			
		||||
                  class: 'btn btn-remove has-tooltip',
 | 
			
		||||
                  title: 'Remove tag',
 | 
			
		||||
                  data: { confirm: 'Are you sure you want to delete this tag?' } do
 | 
			
		||||
          = icon('trash cred')
 | 
			
		||||
| 
						 | 
				
			
			@ -15,25 +15,12 @@
 | 
			
		|||
      %br
 | 
			
		||||
      Then you are free to create and upload a container image with build and push commands:
 | 
			
		||||
      %pre
 | 
			
		||||
        docker build -t #{escape_once(@project.container_registry_repository_url)} .
 | 
			
		||||
        docker build -t #{escape_once(@project.container_registry_url)}/image .
 | 
			
		||||
        %br
 | 
			
		||||
        docker push #{escape_once(@project.container_registry_repository_url)}
 | 
			
		||||
        docker push #{escape_once(@project.container_registry_url)}/image
 | 
			
		||||
 | 
			
		||||
  - if @tags.blank?
 | 
			
		||||
    %li
 | 
			
		||||
      .nothing-here-block No images in Container Registry for this project.
 | 
			
		||||
  - if @images.blank?
 | 
			
		||||
    .nothing-here-block No container image repositories in Container Registry for this project.
 | 
			
		||||
 | 
			
		||||
  - else
 | 
			
		||||
    .table-holder
 | 
			
		||||
      %table.table.tags
 | 
			
		||||
        %thead
 | 
			
		||||
          %tr
 | 
			
		||||
            %th Name
 | 
			
		||||
            %th Image ID
 | 
			
		||||
            %th Size
 | 
			
		||||
            %th Created
 | 
			
		||||
            - if can?(current_user, :update_container_image, @project)
 | 
			
		||||
              %th
 | 
			
		||||
 | 
			
		||||
        - @tags.each do |tag|
 | 
			
		||||
          = render 'tag', tag: tag
 | 
			
		||||
    = render partial: 'image', collection: @images
 | 
			
		||||
| 
						 | 
				
			
			@ -22,14 +22,14 @@
 | 
			
		|||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#display_name')
 | 
			
		||||
        = clipboard_button(target: '#display_name')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#description')
 | 
			
		||||
        = clipboard_button(target: '#description')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
| 
						 | 
				
			
			@ -46,7 +46,7 @@
 | 
			
		|||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#request_url')
 | 
			
		||||
        = clipboard_button(target: '#request_url')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
| 
						 | 
				
			
			@ -57,14 +57,14 @@
 | 
			
		|||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#response_username')
 | 
			
		||||
        = clipboard_button(target: '#response_username')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#response_icon')
 | 
			
		||||
        = clipboard_button(target: '#response_icon')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
| 
						 | 
				
			
			@ -75,14 +75,14 @@
 | 
			
		|||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#autocomplete_hint')
 | 
			
		||||
        = clipboard_button(target: '#autocomplete_hint')
 | 
			
		||||
 | 
			
		||||
  .form-group
 | 
			
		||||
    = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
    .col-sm-10.col-xs-12.input-group
 | 
			
		||||
      = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
      .input-group-btn
 | 
			
		||||
        = clipboard_button(clipboard_target: '#autocomplete_description')
 | 
			
		||||
        = clipboard_button(target: '#autocomplete_description')
 | 
			
		||||
 | 
			
		||||
%hr
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,7 +40,7 @@
 | 
			
		|||
        .col-sm-10.col-xs-12.input-group
 | 
			
		||||
          = text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#url')
 | 
			
		||||
            = clipboard_button(target: '#url')
 | 
			
		||||
 | 
			
		||||
      .form-group
 | 
			
		||||
        = label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
| 
						 | 
				
			
			@ -51,7 +51,7 @@
 | 
			
		|||
        .col-sm-10.col-xs-12.input-group
 | 
			
		||||
          = text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#customize_name')
 | 
			
		||||
            = clipboard_button(target: '#customize_name')
 | 
			
		||||
 | 
			
		||||
      .form-group
 | 
			
		||||
        = label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
| 
						 | 
				
			
			@ -68,21 +68,21 @@
 | 
			
		|||
        .col-sm-10.col-xs-12.input-group
 | 
			
		||||
          = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#autocomplete_description')
 | 
			
		||||
            = clipboard_button(target: '#autocomplete_description')
 | 
			
		||||
 | 
			
		||||
      .form-group
 | 
			
		||||
        = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
        .col-sm-10.col-xs-12.input-group
 | 
			
		||||
          = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#autocomplete_usage_hint')
 | 
			
		||||
            = clipboard_button(target: '#autocomplete_usage_hint')
 | 
			
		||||
 | 
			
		||||
      .form-group
 | 
			
		||||
        = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label'
 | 
			
		||||
        .col-sm-10.col-xs-12.input-group
 | 
			
		||||
          = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly'
 | 
			
		||||
          .input-group-btn
 | 
			
		||||
            = clipboard_button(clipboard_target: '#descriptive_label')
 | 
			
		||||
            = clipboard_button(target: '#descriptive_label')
 | 
			
		||||
 | 
			
		||||
    %hr
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,8 +6,8 @@
 | 
			
		|||
        = ci_icon_for_status(stage.status)
 | 
			
		||||
       
 | 
			
		||||
      = stage.name.titleize
 | 
			
		||||
= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true
 | 
			
		||||
= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true
 | 
			
		||||
= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true
 | 
			
		||||
= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true
 | 
			
		||||
%tr
 | 
			
		||||
  %td{ colspan: 10 }
 | 
			
		||||
     
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,7 @@
 | 
			
		|||
              %i.fa.fa-angle-right
 | 
			
		||||
              %small.light
 | 
			
		||||
                = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
 | 
			
		||||
                = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
                = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
 | 
			
		||||
                = time_ago_with_tooltip(@commit.committed_date)
 | 
			
		||||
                \-
 | 
			
		||||
                = @commit.full_title
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
  %td
 | 
			
		||||
    - if can?(current_user, :admin_trigger, trigger)
 | 
			
		||||
      %span= trigger.token
 | 
			
		||||
      = clipboard_button(clipboard_text: trigger.token, title: "Copy trigger token to clipboard")
 | 
			
		||||
      = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
 | 
			
		||||
    - else
 | 
			
		||||
      %span= trigger.short_token
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue