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,10 +353,8 @@
|
|||
}
|
||||
|
||||
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
|
||||
.issues-details-filters {
|
||||
.dropdown-menu-toggle {
|
||||
width: 100px;
|
||||
}
|
||||
.issue-bulk-update-dropdown-toggle {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
blob.load_all_data!(@repository)
|
||||
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
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
state = params[:state].presence
|
||||
render json: @build.trace_with_state(state: state).
|
||||
merge!(id: @build.id, status: @build.status)
|
||||
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
|
||||
trace = stream.html_with_state(state)
|
||||
result.merge!(trace.to_h)
|
||||
end
|
||||
|
||||
render json: result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -86,10 +86,12 @@ 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'
|
||||
else
|
||||
render_404
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
edit_path = namespace_project_edit_blob_path(project.namespace, project,
|
||||
tree_join(ref, path),
|
||||
options[:link_opts])
|
||||
common_classes = "btn js-edit-blob #{options[:extra_class]}"
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
link_to "Edit", fork_path, class: 'btn', method: :post
|
||||
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
|
||||
when "Commit"
|
||||
create_commit_note(HashWithIndifferentAccess.new(params[:commit]))
|
||||
when "Issue"
|
||||
create_issue_note(HashWithIndifferentAccess.new(params[:issue]))
|
||||
when "MergeRequest"
|
||||
create_merge_note(HashWithIndifferentAccess.new(params[:merge_request]))
|
||||
when "Snippet"
|
||||
create_snippet_note(HashWithIndifferentAccess.new(params[:snippet]))
|
||||
end
|
||||
@target, @title = case obj_attr[:noteable_type]
|
||||
when "Commit"
|
||||
create_commit_note(params[:commit])
|
||||
when "Issue"
|
||||
create_issue_note(params[:issue])
|
||||
when "MergeRequest"
|
||||
create_merge_note(params[:merge_request])
|
||||
when "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)
|
||||
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
|
||||
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
|
||||
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)
|
||||
.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 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 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