Commit 4ddf2089 authored by Luke "Jared" Bennett's avatar Luke "Jared" Bennett

Merge branch 'new-resolvable-discussion' of gitlab.com:gitlab-org/gitlab-ce...

Merge branch 'new-resolvable-discussion' of gitlab.com:gitlab-org/gitlab-ce into new-resolvable-discussion
parents a33aacd5 792f6ed1
...@@ -30,6 +30,7 @@ eslint-report.html ...@@ -30,6 +30,7 @@ eslint-report.html
/config/unicorn.rb /config/unicorn.rb
/config/secrets.yml /config/secrets.yml
/config/sidekiq.yml /config/sidekiq.yml
/config/registry.key
/coverage/* /coverage/*
/coverage-javascript/ /coverage-javascript/
/db/*.sqlite3 /db/*.sqlite3
......
...@@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4' ...@@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
gem 'redis-namespace', '~> 1.5.2' gem 'redis-namespace', '~> 1.5.2'
gem 'sidekiq-limit_fetch', '~> 3.4' gem 'sidekiq-limit_fetch', '~> 3.4'
# Cron Parser
gem 'rufus-scheduler', '~> 3.1.10'
# HTTP requests # HTTP requests
gem 'httparty', '~> 0.13.3' gem 'httparty', '~> 0.13.3'
......
...@@ -987,6 +987,7 @@ DEPENDENCIES ...@@ -987,6 +987,7 @@ DEPENDENCIES
rubocop-rspec (~> 1.12.0) rubocop-rspec (~> 1.12.0)
ruby-fogbugz (~> 0.2.1) ruby-fogbugz (~> 0.2.1)
ruby-prof (~> 0.16.2) ruby-prof (~> 0.16.2)
rufus-scheduler (~> 3.1.10)
rugged (~> 0.25.1.1) rugged (~> 0.25.1.1)
sanitize (~> 2.0) sanitize (~> 2.0)
sass-rails (~> 5.0.6) sass-rails (~> 5.0.6)
......
...@@ -33,7 +33,7 @@ core team members will mention this person. ...@@ -33,7 +33,7 @@ core team members will mention this person.
### Merge request coaching ### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get Several people from the [GitLab team][team] are helping community members to get
their contributions accepted by meeting our [Definition of done](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done). their contributions accepted by meeting our [Definition of done][done].
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
...@@ -64,6 +64,49 @@ Merge requests may still be merged into master during this period, ...@@ -64,6 +64,49 @@ Merge requests may still be merged into master during this period,
but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch. but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch.
By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things. By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things.
### Between the 1st and the 7th
These types of merge requests need special consideration:
* **Large features**: a large feature is one that is highlighted in the kick-off
and the release blogpost; typically this will have its own channel in Slack
and a dedicated team with front-end, back-end, and UX.
* **Small features**: any other feature request.
**Large features** must be with a maintainer **by the 1st**. It's OK if they
aren't completely done, but this allows the maintainer enough time to make the
decision about whether this can make it in before the freeze. If the maintainer
doesn't think it will make it, they should inform the developers working on it
and the Product Manager responsible for the feature.
**Small features** must be with a reviewer (not necessarily maintainer) **by the
3rd**.
Most merge requests from the community do not have a specific release
target. However, if one does and falls into either of the above categories, it's
the reviewer's responsibility to manage the above communication and assignment
on behalf of the community member.
### On the 7th
Merge requests should still be complete, following the
[definition of done][done]. The single exception is documentation, and this can
only be left until after the freeze if:
* There is a follow-up issue to add documentation.
* It is assigned to the person writing documentation for this feature, and they
are aware of it.
* It is in the correct milestone, with the ~Deliverable label.
All Community Edition merge requests from GitLab team members merged on the
freeze date (the 7th) should have a corresponding Enterprise Edition merge
request, even if there are no conflicts. This is to reduce the size of the
subsequent EE merge, as we often merge a lot to CE on the release date. For more
information, see
[limit conflicts with EE when developing on CE][limit_ee_conflicts].
### Between the 7th and the 22nd
Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release) Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release)
and security issues will be cherry-picked into the stable branch. and security issues will be cherry-picked into the stable branch.
Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch. Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch.
...@@ -158,3 +201,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http ...@@ -158,3 +201,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http
[contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria [contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria
["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements ["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements
[Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review [Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review
[done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done
[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html
...@@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward( ...@@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward(
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined; return typeof callback === 'function' ? callback() : undefined;
}); });
return $('.emoji-menu').removeClass('is-visible'); $('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
}; };
AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar( AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
......
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() { ...@@ -84,10 +84,10 @@ window.Build = (function() {
var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
return $.ajax({ return $.ajax({
url: this.buildUrl, url: this.pageUrl + "/trace.json",
dataType: 'json', dataType: 'json',
success: function(buildData) { success: function(buildData) {
$('.js-build-output').html(buildData.trace_html); $('.js-build-output').html(buildData.html);
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
if (window.location.hash === DOWN_BUILD_TRACE) { if (window.location.hash === DOWN_BUILD_TRACE) {
$("html,body").scrollTop(this.$buildTrace.height()); $("html,body").scrollTop(this.$buildTrace.height());
......
...@@ -43,6 +43,7 @@ import GroupsList from './groups_list'; ...@@ -43,6 +43,7 @@ import GroupsList from './groups_list';
import ProjectsList from './projects_list'; import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout'; import UserCallout from './user_callout';
const ShortcutsBlob = require('./shortcuts_blob'); const ShortcutsBlob = require('./shortcuts_blob');
...@@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
skipResetBindings: true, skipResetBindings: true,
fileBlobPermalinkUrl, 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) { switch (page) {
......
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>
`,
};
...@@ -55,7 +55,7 @@ require('./filtered_search_dropdown'); ...@@ -55,7 +55,7 @@ require('./filtered_search_dropdown');
renderContent() { renderContent() {
const dropdownData = []; 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; const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) { if (icon && hint && tag) {
dropdownData.push( dropdownData.push(
......
...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; ...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
} }
}); });
return values.join(' '); return values
.map(value => value.trim())
.join(' ');
} }
static getSearchInput(filteredSearchInput) { static getSearchInput(filteredSearchInput) {
......
import Vue from 'vue';
export default new Vue();
/* global Flash */
import FilteredSearchContainer from './container'; 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 { class FilteredSearchManager {
constructor(page) { constructor(page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search'); this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container'); this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; 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) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); 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.bindEvents();
this.loadSearchParamsFromURL(); this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown(); this.dropdownManager.setDropdown();
...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; ...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() { cleanup() {
this.unbindEvents(); this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper); document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
} }
bindEvents() { bindEvents() {
...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; ...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.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.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; ...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this); this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.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.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; ...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
unbindEvents() { unbindEvents() {
...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; ...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
checkForBackspace(e) { checkForBackspace(e) {
...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; ...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
} }
addInputContainerFocus() { addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) { if (inputContainer) {
inputContainer.classList.add('focus'); inputContainer.classList.add('focus');
...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; ...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
} }
removeInputContainerFocus(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; ...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
} }
unselectEditTokens(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container'); const isElementTokensContainer = e.target.classList.contains('tokens-container');
...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; ...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
} }
} }
clearSearch(e) { onClearSearch(e) {
e.preventDefault(); e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = ''; this.filteredSearchInput.value = '';
const removeElements = []; const removeElements = [];
...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; ...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search(); 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() { loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray(); const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams(); const usernameParams = this.getUsernameParams();
...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; ...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
} }
}); });
this.saveCurrentSearchQuery();
if (hasFilteredSearch) { if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden'); this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder(); this.handleInputPlaceholder();
...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; ...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() { search() {
const paths = []; const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); = this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; ...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent(); currentDropdownRef.dispatchInputEvent();
} }
} }
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
} }
window.gl = window.gl || {}; window.gl = window.gl || {};
......
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;
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;
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;
...@@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status'; ...@@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status';
import { formatRelevantDigits } from '~/lib/utils/number_utils'; import { formatRelevantDigits } from '~/lib/utils/number_utils';
import '../flash'; import '../flash';
const prometheusContainer = '.prometheus-container';
const prometheusParentGraphContainer = '.prometheus-graphs';
const prometheusGraphsContainer = '.prometheus-graph'; const prometheusGraphsContainer = '.prometheus-graph';
const prometheusStatesContainer = '.prometheus-state';
const metricsEndpoint = 'metrics.json'; const metricsEndpoint = 'metrics.json';
const timeFormat = d3.time.format('%H:%M'); const timeFormat = d3.time.format('%H:%M');
const dayFormat = d3.time.format('%b %e, %a'); const dayFormat = d3.time.format('%b %e, %a');
...@@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left; ...@@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left;
const extraAddedWidthParent = 100; const extraAddedWidthParent = 100;
class PrometheusGraph { class PrometheusGraph {
constructor() { constructor() {
this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; const $prometheusContainer = $(prometheusContainer);
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; const hasMetrics = $prometheusContainer.data('has-metrics');
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + this.docLink = $prometheusContainer.data('doc-link');
extraAddedWidthParent; this.integrationLink = $prometheusContainer.data('prometheus-integration');
this.originalWidth = parentContainerWidth;
this.originalHeight = 330; $(document).ajaxError(() => {});
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom; if (hasMetrics) {
this.backOffRequestCounter = 0; this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
this.configureGraph(); this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
this.init(); const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
extraAddedWidthParent;
this.originalWidth = parentContainerWidth;
this.originalHeight = 330;
this.width = parentContainerWidth - this.margin.left - this.margin.right;
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
this.backOffRequestCounter = 0;
this.configureGraph();
this.init();
} else {
this.state = '.js-getting-started';
this.updateState();
}
} }
createGraph() { createGraph() {
...@@ -40,8 +54,19 @@ class PrometheusGraph { ...@@ -40,8 +54,19 @@ class PrometheusGraph {
init() { init() {
this.getData().then((metricsResponse) => { this.getData().then((metricsResponse) => {
if (Object.keys(metricsResponse).length === 0) { let enoughData = true;
new Flash('Empty metrics', 'alert'); Object.keys(metricsResponse.metrics).forEach((key) => {
let currentKey;
if (key === 'cpu_values' || key === 'memory_values') {
currentKey = metricsResponse.metrics[key];
if (Object.keys(currentKey).length === 0) {
enoughData = false;
}
}
});
if (!enoughData) {
this.state = '.js-loading';
this.updateState();
} else { } else {
this.transformData(metricsResponse); this.transformData(metricsResponse);
this.createGraph(); this.createGraph();
...@@ -345,14 +370,17 @@ class PrometheusGraph { ...@@ -345,14 +370,17 @@ class PrometheusGraph {
} }
return resp.metrics; return resp.metrics;
}) })
.catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); .catch(() => {
this.state = '.js-unable-to-connect';
this.updateState();
});
} }
transformData(metricsResponse) { transformData(metricsResponse) {
Object.keys(metricsResponse.metrics).forEach((key) => { Object.keys(metricsResponse.metrics).forEach((key) => {
if (key === 'cpu_values' || key === 'memory_values') { if (key === 'cpu_values' || key === 'memory_values') {
const metricValues = (metricsResponse.metrics[key])[0]; const metricValues = (metricsResponse.metrics[key])[0];
if (typeof metricValues !== 'undefined') { if (metricValues !== undefined) {
this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
time: new Date(metric[0] * 1000), time: new Date(metric[0] * 1000),
value: metric[1], value: metric[1],
...@@ -361,6 +389,13 @@ class PrometheusGraph { ...@@ -361,6 +389,13 @@ class PrometheusGraph {
} }
}); });
} }
updateState() {
const $statesContainer = $(prometheusStatesContainer);
$(prometheusParentGraphContainer).hide();
$(`${this.state}`, $statesContainer).removeClass('hidden');
$(prometheusStatesContainer).show();
}
} }
export default PrometheusGraph; export default PrometheusGraph;
...@@ -91,7 +91,7 @@ ...@@ -91,7 +91,7 @@
.award-menu-holder { .award-menu-holder {
display: inline-block; display: inline-block;
position: relative; position: absolute;
.tooltip { .tooltip {
white-space: nowrap; white-space: nowrap;
...@@ -117,11 +117,41 @@ ...@@ -117,11 +117,41 @@
&.active, &.active,
&:hover, &:hover,
&:active { &:active,
&.is-active {
background-color: $row-hover; background-color: $row-hover;
border-color: $row-hover-border; border-color: $row-hover-border;
box-shadow: none; box-shadow: none;
outline: 0; outline: 0;
.award-control-icon svg {
background: $award-emoji-positive-add-bg;
path {
fill: $award-emoji-positive-add-lines;
}
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
transform: scale(1.15);
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
transform: scale(1);
}
.award-control-icon-super-positive {
opacity: 1;
transform: scale(1);
}
} }
&.btn { &.btn {
...@@ -162,9 +192,33 @@ ...@@ -162,9 +192,33 @@
color: $border-gray-normal; color: $border-gray-normal;
margin-top: 1px; margin-top: 1px;
padding: 0 2px; padding: 0 2px;
svg {
margin-bottom: 1px;
height: 18px;
width: 18px;
border-radius: 50%;
path {
fill: $border-gray-normal;
}
}
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
left: 7px;
bottom: 9px;
opacity: 0;
@include transition(opacity, transform);
} }
.award-control-text { .award-control-text {
vertical-align: middle; vertical-align: middle;
} }
} }
.note-awards .award-control-icon-positive {
left: 6px;
}
...@@ -177,10 +177,6 @@ ...@@ -177,10 +177,6 @@
border-radius: $border-radius-base; border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color; box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading { &.is-loading {
.dropdown-content { .dropdown-content {
display: none; display: none;
...@@ -467,6 +463,11 @@ ...@@ -467,6 +463,11 @@
overflow-y: auto; overflow-y: auto;
} }
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer { .dropdown-footer {
padding-top: 10px; padding-top: 10px;
margin-top: 10px; margin-top: 10px;
......
...@@ -281,3 +281,16 @@ span.idiff { ...@@ -281,3 +281,16 @@ span.idiff {
display: none; 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 @@ ...@@ -22,7 +22,6 @@
} }
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update { .issues_bulk_update {
.dropdown-menu-toggle { .dropdown-menu-toggle {
width: 132px; width: 132px;
...@@ -56,7 +55,7 @@ ...@@ -56,7 +55,7 @@
} }
} }
.filtered-search-container { .filtered-search-wrapper {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -151,11 +150,13 @@ ...@@ -151,11 +150,13 @@
width: 100%; width: 100%;
} }
.filtered-search-input-container { .filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
position: relative;
width: 100%; width: 100%;
min-width: 0;
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: $white-light; background-color: $white-light;
...@@ -163,14 +164,6 @@ ...@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto; -webkit-flex: 1 1 auto;
flex: 1 1 auto; flex: 1 1 auto;
margin-bottom: 10px; margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
} }
&:hover { &:hover {
...@@ -229,6 +222,118 @@ ...@@ -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 { .filter-dropdown-container {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -248,10 +353,8 @@ ...@@ -248,10 +353,8 @@
} }
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters { .issue-bulk-update-dropdown-toggle {
.dropdown-menu-toggle { width: 100px;
width: 100px;
}
} }
} }
......
...@@ -16,6 +16,8 @@ body.modal-open { ...@@ -16,6 +16,8 @@ body.modal-open {
overflow: hidden; overflow: hidden;
} }
.modal .modal-dialog { @media (min-width: $screen-md-min) {
width: 860px; .modal-dialog {
width: 860px;
}
} }
...@@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary; ...@@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary;
* Award emoji * Award emoji
*/ */
$award-emoji-menu-shadow: rgba(0,0,0,.175); $award-emoji-menu-shadow: rgba(0,0,0,.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
/* /*
* Search Box * Search Box
......
/**
* Container Registry
*/
.container-image {
border-bottom: 1px solid $white-normal;
}
.container-image-head {
padding: 0 16px;
line-height: 4em;
}
.table.tags {
margin-bottom: 0;
}
...@@ -233,6 +233,15 @@ ...@@ -233,6 +233,15 @@
stroke-width: 1; stroke-width: 1;
} }
.prometheus-state {
margin-top: 10px;
display: none;
.state-button-section {
margin-top: 10px;
}
}
.environments-actions { .environments-actions {
.external-url, .external-url,
.monitoring-url, .monitoring-url,
......
...@@ -4,14 +4,14 @@ ...@@ -4,14 +4,14 @@
*/ */
.event-item { .event-item {
font-size: $gl-font-size; 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; border-bottom: 1px solid $white-normal;
color: $list-text-color; color: $list-text-color;
position: relative;
&.event-inline { &.event-inline {
.avatar { .profile-icon {
position: relative; top: 20px;
top: -2px;
} }
.event-title, .event-title,
...@@ -24,8 +24,28 @@ ...@@ -24,8 +24,28 @@
color: $gl-text-color; color: $gl-text-color;
} }
.avatar { .profile-icon {
margin-left: -($gl-avatar-size + $gl-padding-top); 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 { .event-title {
...@@ -163,7 +183,7 @@ ...@@ -163,7 +183,7 @@
max-width: 100%; max-width: 100%;
} }
.avatar { .profile-icon {
display: none; display: none;
} }
......
...@@ -329,8 +329,6 @@ ...@@ -329,8 +329,6 @@
} }
#modal_merge_info .modal-dialog { #modal_merge_info .modal-dialog {
width: 600px;
.dark { .dark {
margin-right: 40px; margin-right: 40px;
} }
......
...@@ -410,13 +410,50 @@ ul.notes { ...@@ -410,13 +410,50 @@ ul.notes {
font-size: 17px; font-size: 17px;
} }
&:hover { svg {
height: 16px;
width: 16px;
fill: $gray-darkest;
vertical-align: text-top;
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
margin-left: -20px;
opacity: 0;
}
&:hover,
&.is-active {
.danger-highlight { .danger-highlight {
color: $gl-text-red; color: $gl-text-red;
} }
.link-highlight { .link-highlight {
color: $gl-link-color; color: $gl-link-color;
svg {
fill: $gl-link-color;
}
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
}
.award-control-icon-super-positive {
opacity: 1;
} }
} }
} }
...@@ -520,7 +557,6 @@ ul.notes { ...@@ -520,7 +557,6 @@ ul.notes {
} }
.line-resolve-all-container { .line-resolve-all-container {
.btn-group { .btn-group {
margin-left: -4px; margin-left: -4px;
} }
...@@ -549,7 +585,6 @@ ul.notes { ...@@ -549,7 +585,6 @@ ul.notes {
fill: $gray-darkest; fill: $gray-darkest;
} }
} }
} }
.line-resolve-all { .line-resolve-all {
......
...@@ -230,6 +230,14 @@ ...@@ -230,6 +230,14 @@
font-size: 0; font-size: 0;
} }
.fade-right {
right: 0;
}
.fade-left {
left: 0;
}
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
.cover-block { .cover-block {
padding-top: 20px; padding-top: 20px;
......
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
} }
.trigger-actions { .trigger-actions {
white-space: nowrap;
.btn { .btn {
margin-left: 10px; margin-left: 10px;
} }
......
...@@ -145,8 +145,6 @@ ...@@ -145,8 +145,6 @@
margin: 0; margin: 0;
} }
#modal-remove-blob > .modal-dialog { width: 850px; }
.blob-upload-dropzone-previews { .blob-upload-dropzone-previews {
text-align: center; text-align: center;
border: 2px; border: 2px;
......
...@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
:name, :name,
:path, :path,
:request_access_enabled, :request_access_enabled,
:visibility_level :visibility_level,
:require_two_factor_authentication,
:two_factor_grace_period
] ]
end end
end end
...@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base ...@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
include PageLayoutHelper include PageLayoutHelper
include SentryHelper include SentryHelper
include WorkhorseHelper include WorkhorseHelper
include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token! before_action :authenticate_user_from_private_token!
before_action :authenticate_user! before_action :authenticate_user!
before_action :validate_user_service_ticket! before_action :validate_user_service_ticket!
before_action :check_password_expiration before_action :check_password_expiration
before_action :check_2fa_requirement
before_action :ldap_security_check before_action :ldap_security_check
before_action :sentry_context before_action :sentry_context
before_action :default_headers before_action :default_headers
...@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base ...@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
end end
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 def ldap_security_check
if current_user && current_user.requires_ldap_check? if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease return unless current_user.try_obtain_ldap_lease
...@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base ...@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('gitlab_project') current_application_settings.import_sources.include?('gitlab_project')
end 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 # U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication. # to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html # https://developers.yubico.com/U2F/App_ID.html
......
# == 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 ...@@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController
:visibility_level, :visibility_level,
:parent_id, :parent_id,
:create_chat_team, :create_chat_team,
:chat_team_name :chat_team_name,
:require_two_factor_authentication,
:two_factor_grace_period
] ]
end end
......
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_2fa_requirement skip_before_action :check_two_factor_requirement
def show def show
unless current_user.otp_secret unless current_user.otp_secret
...@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed? current_user.save! if current_user.changed?
if two_factor_authentication_required? && !current_user.two_factor_enabled? if two_factor_authentication_required? && !current_user.two_factor_enabled?
if two_factor_grace_period_expired? two_factor_authentication_reason(
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.' global: lambda do
else 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 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
end end
...@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if two_factor_grace_period_expired? if two_factor_grace_period_expired?
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup' redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
else 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 redirect_to root_path
end end
end end
......
...@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
# Raised when given an invalid file path # Raised when given an invalid file path
InvalidPathError = Class.new(StandardError) InvalidPathError = Class.new(StandardError)
prepend_before_action :authenticate_user!, only: [:edit]
before_action :require_non_empty_project, except: [:new, :create] before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code! 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 :assign_blob_vars
before_action :commit, except: [:new, :create] before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create] before_action :blob, except: [:new, :create]
...@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
end end
def edit def edit
blob.load_all_data!(@repository) if can_collaborate_with_project?
blob.load_all_data!(@repository)
else
redirect_to action: 'show'
end
end end
def update def update
......
...@@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController ...@@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id) @builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline @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 end
def trace def trace
respond_to do |format| build.trace.read do |stream|
format.json do respond_to do |format|
state = params[:state].presence format.json do
render json: @build.trace_with_state(state: state). result = {
merge!(id: @build.id, status: @build.status) 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 end
end end
...@@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController ...@@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController
end end
def raw def raw
if @build.has_trace_file? build.trace.read do |stream|
send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline' if stream.file?
else send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
render_404 else
render_404
end
end end
end end
......
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 ...@@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
if pipeline if pipeline
status = pipeline.status status = pipeline.status
coverage = pipeline.try(:coverage) coverage = pipeline.coverage
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
......
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
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
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 ...@@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper 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 :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor, prepend_before_action :authenticate_with_two_factor,
......
...@@ -64,18 +64,6 @@ module AuthHelper ...@@ -64,18 +64,6 @@ module AuthHelper
current_user.identities.exists?(provider: provider.to_s) current_user.identities.exists?(provider: provider.to_s)
end 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) def unlink_allowed?(provider)
%w(saml cas3).exclude?(provider.to_s) %w(saml cas3).exclude?(provider.to_s)
end end
......
...@@ -8,31 +8,36 @@ module BlobHelper ...@@ -8,31 +8,36 @@ module BlobHelper
%w(credits changelog news copying copyright license authors) %w(credits changelog news copying copyright license authors)
end end
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) def edit_path(project = @project, ref = @ref, path = @path, options = {})
return unless current_user 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 = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob return unless blob
edit_path = namespace_project_edit_blob_path(project.namespace, project, common_classes = "btn js-edit-blob #{options[:extra_class]}"
tree_join(ref, path),
options[:link_opts])
if !on_top_of_branch?(project, ref) 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' } button_tag 'Edit', class: "#{common_classes} 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) # This condition applies to anonymous or users who can edit directly
link_to "Edit", edit_path, class: 'btn btn-sm' elsif !current_user || (current_user && can_edit_blob?(blob, project, ref))
elsif can?(current_user, :fork_project, project) link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
continue_params = { elsif current_user && can?(current_user, :fork_project, project)
to: edit_path, button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler"
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
end end
end end
......
module DropdownsHelper module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block) 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" } data_attr = { toggle: "dropdown" }
if options.has_key?(:data) if options.has_key?(:data)
...@@ -20,7 +20,7 @@ module DropdownsHelper ...@@ -20,7 +20,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder]) output << dropdown_filter(options[:placeholder])
end 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) capture(&block) if block && !options.has_key?(:footer_content)
end end
......
...@@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base ...@@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
UPVOTE_NAME = "thumbsup".freeze UPVOTE_NAME = "thumbsup".freeze
include Participable include Participable
include GhostUser
belongs_to :awardable, polymorphic: true belongs_to :awardable, polymorphic: true
belongs_to :user belongs_to :user
validates :awardable, :user, presence: true validates :awardable, :user, presence: true
validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } 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 participant :user
......
...@@ -171,19 +171,6 @@ module Ci ...@@ -171,19 +171,6 @@ module Ci
latest_builds.where('stage_idx < ?', stage_idx) latest_builds.where('stage_idx < ?', stage_idx)
end 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 def timeout
project.build_timeout project.build_timeout
end end
...@@ -244,136 +231,35 @@ module Ci ...@@ -244,136 +231,35 @@ module Ci
end end
def update_coverage def update_coverage
coverage = extract_coverage(trace, coverage_regex) coverage = trace.extract_coverage(coverage_regex)
update_attributes(coverage: coverage) if coverage.present? update_attributes(coverage: coverage) if coverage.present?
end end
def extract_coverage(text, regex) def trace
return unless regex Gitlab::Ci::Trace.new(self)
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?
end end
def has_trace? def has_trace?
raw_trace.present? trace.exist?
end end
def raw_trace(last_lines: nil) def trace=(data)
if File.exist?(trace_file_path) raise NotImplementedError
Gitlab::Ci::TraceReader.new(trace_file_path).
read(last_lines: last_lines)
else
# backward compatibility
read_attribute :trace
end
end end
## def old_trace
# Deprecated read_attribute(:trace)
#
# 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)
end
def trace(last_lines: nil)
hide_secrets(raw_trace(last_lines: last_lines))
end end
def trace_length def erase_old_trace!
if raw_trace write_attribute(:trace, nil)
raw_trace.bytesize save
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
end end
def needs_touch? def needs_touch?
Time.now - updated_at > 15.minutes.to_i Time.now - updated_at > 15.minutes.to_i
end 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 # Deprecated
# #
...@@ -555,6 +441,15 @@ module Ci ...@@ -555,6 +441,15 @@ module Ci
options[:dependencies]&.empty? options[:dependencies]&.empty?
end 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 private
def update_artifacts_size def update_artifacts_size
...@@ -566,7 +461,7 @@ module Ci ...@@ -566,7 +461,7 @@ module Ci
end end
def erase_trace! def erase_trace!
self.trace = nil trace.erase!
end end
def update_erased!(user = nil) def update_erased!(user = nil)
...@@ -628,15 +523,6 @@ module Ci ...@@ -628,15 +523,6 @@ module Ci
pipeline.config_processor.build_attributes(name) pipeline.config_processor.build_attributes(name)
end 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 def update_project_statistics
return unless project return unless project
......
...@@ -8,6 +8,7 @@ module Ci ...@@ -8,6 +8,7 @@ module Ci
belongs_to :owner, class_name: "User" belongs_to :owner, class_name: "User"
has_many :trigger_requests, dependent: :destroy has_many :trigger_requests, dependent: :destroy
has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true validates :token, presence: true, uniqueness: true
......
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
module GhostUser
extend ActiveSupport::Concern
def ghost_user?
user && user.ghost?
end
end
...@@ -83,6 +83,74 @@ module Routable ...@@ -83,6 +83,74 @@ module Routable
AND members.source_type = r2.source_type"). AND members.source_type = r2.source_type").
where('members.user_id = ?', user_id) where('members.user_id = ?', user_id)
end 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 end
def full_name def full_name
......
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 ...@@ -27,11 +27,14 @@ class Group < Namespace
validates :avatar, file_size: { maximum: 200.kilobytes.to_i } 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 mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy has_many :uploads, as: :model, dependent: :destroy
after_create :post_create_hook after_create :post_create_hook
after_destroy :post_destroy_hook after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
class << self class << self
# Searches for groups matching the given query. # Searches for groups matching the given query.
...@@ -223,4 +226,12 @@ class Group < Namespace ...@@ -223,4 +226,12 @@ class Group < Namespace
type: public? ? 'O' : 'I' # Open vs Invite-only type: public? ? 'O' : 'I' # Open vs Invite-only
} }
end 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 end
...@@ -3,11 +3,16 @@ class GroupMember < Member ...@@ -3,11 +3,16 @@ class GroupMember < Member
belongs_to :group, foreign_key: 'source_id' 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 # Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\ANamespace\z/ } validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) } 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 def self.access_level_roles
Gitlab::Access.options_with_owner Gitlab::Access.options_with_owner
end end
......
...@@ -116,6 +116,7 @@ class Project < ActiveRecord::Base ...@@ -116,6 +116,7 @@ class Project < ActiveRecord::Base
has_one :mock_ci_service, dependent: :destroy has_one :mock_ci_service, dependent: :destroy
has_one :mock_deployment_service, dependent: :destroy has_one :mock_deployment_service, dependent: :destroy
has_one :mock_monitoring_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_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
has_one :forked_from_project, through: :forked_project_link has_one :forked_from_project, through: :forked_project_link
...@@ -159,6 +160,7 @@ class Project < ActiveRecord::Base ...@@ -159,6 +160,7 @@ class Project < ActiveRecord::Base
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
has_one :project_feature, dependent: :destroy has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
has_many :container_repositories, dependent: :destroy
has_many :commit_statuses, dependent: :destroy has_many :commit_statuses, dependent: :destroy
has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline' has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
...@@ -406,32 +408,15 @@ class Project < ActiveRecord::Base ...@@ -406,32 +408,15 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(path_with_namespace, self) @repository ||= Repository.new(path_with_namespace, self)
end end
def container_registry_path_with_namespace def container_registry_url
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
if Gitlab.config.registry.enabled 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
end end
def has_container_registry_tags? def has_container_registry_tags?
return unless container_registry_repository container_repositories.to_a.any?(&:has_tags?) ||
has_root_container_repository_tags?
container_registry_repository.tags.any?
end end
def commit(ref = 'HEAD') def commit(ref = 'HEAD')
...@@ -922,10 +907,10 @@ class Project < ActiveRecord::Base ...@@ -922,10 +907,10 @@ class Project < ActiveRecord::Base
expire_caches_before_rename(old_path_with_namespace) expire_caches_before_rename(old_path_with_namespace)
if has_container_registry_tags? 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 # we currently doesn't support renaming repository if it contains images in container registry
raise StandardError.new('Project cannot be renamed, because tags are present in its container registry') raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
end end
if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace) if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
...@@ -1115,10 +1100,6 @@ class Project < ActiveRecord::Base ...@@ -1115,10 +1100,6 @@ class Project < ActiveRecord::Base
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
end end
def build_coverage_enabled?
build_coverage_regex.present?
end
def build_timeout_in_minutes def build_timeout_in_minutes
build_timeout / 60 build_timeout / 60
end end
...@@ -1272,7 +1253,7 @@ class Project < ActiveRecord::Base ...@@ -1272,7 +1253,7 @@ class Project < ActiveRecord::Base
] ]
if container_registry_enabled? 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 end
variables variables
...@@ -1405,4 +1386,15 @@ class Project < ActiveRecord::Base ...@@ -1405,4 +1386,15 @@ class Project < ActiveRecord::Base
Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace) Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
end 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 end
...@@ -2,11 +2,23 @@ require 'slack-notifier' ...@@ -2,11 +2,23 @@ require 'slack-notifier'
module ChatMessage module ChatMessage
class BaseMessage class BaseMessage
attr_reader :markdown
attr_reader :user_name
attr_reader :user_avatar
attr_reader :project_name
attr_reader :project_url
def initialize(params) 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 end
def pretext def pretext
return message if markdown
format(message) format(message)
end end
...@@ -17,6 +29,10 @@ module ChatMessage ...@@ -17,6 +29,10 @@ module ChatMessage
raise NotImplementedError raise NotImplementedError
end end
def activity
raise NotImplementedError
end
private private
def message def message
......
module ChatMessage module ChatMessage
class IssueMessage < BaseMessage class IssueMessage < BaseMessage
attr_reader :user_name
attr_reader :title attr_reader :title
attr_reader :project_name
attr_reader :project_url
attr_reader :issue_iid attr_reader :issue_iid
attr_reader :issue_url attr_reader :issue_url
attr_reader :action attr_reader :action
...@@ -11,9 +8,7 @@ module ChatMessage ...@@ -11,9 +8,7 @@ module ChatMessage
attr_reader :description attr_reader :description
def initialize(params) def initialize(params)
@user_name = params[:user][:username] super
@project_name = params[:project_name]
@project_url = params[:project_url]
obj_attr = params[:object_attributes] obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr) obj_attr = HashWithIndifferentAccess.new(obj_attr)
...@@ -27,15 +22,24 @@ module ChatMessage ...@@ -27,15 +22,24 @@ module ChatMessage
def attachments def attachments
return [] unless opened_issue? return [] unless opened_issue?
return description if markdown
description_message description_message
end end
def activity
{
title: "Issue #{state} by #{user_name}",
subtitle: "in #{project_link}",
text: issue_link,
image: user_avatar
}
end
private private
def message def message
case state if state == 'opened'
when "opened"
"[#{project_link}] Issue #{state} by #{user_name}" "[#{project_link}] Issue #{state} by #{user_name}"
else else
"[#{project_link}] Issue #{issue_link} #{state} by #{user_name}" "[#{project_link}] Issue #{issue_link} #{state} by #{user_name}"
...@@ -64,7 +68,7 @@ module ChatMessage ...@@ -64,7 +68,7 @@ module ChatMessage
end end
def issue_title def issue_title
"##{issue_iid} #{title}" "#{Issue.reference_prefix}#{issue_iid} #{title}"
end end
end end
end end
module ChatMessage module ChatMessage
class MergeMessage < BaseMessage class MergeMessage < BaseMessage
attr_reader :user_name attr_reader :merge_request_iid
attr_reader :project_name
attr_reader :project_url
attr_reader :merge_request_id
attr_reader :source_branch attr_reader :source_branch
attr_reader :target_branch attr_reader :target_branch
attr_reader :state attr_reader :state
attr_reader :title attr_reader :title
def initialize(params) def initialize(params)
@user_name = params[:user][:username] super
@project_name = params[:project_name]
@project_url = params[:project_url]
obj_attr = params[:object_attributes] obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr) obj_attr = HashWithIndifferentAccess.new(obj_attr)
@merge_request_id = obj_attr[:iid] @merge_request_iid = obj_attr[:iid]
@source_branch = obj_attr[:source_branch] @source_branch = obj_attr[:source_branch]
@target_branch = obj_attr[:target_branch] @target_branch = obj_attr[:target_branch]
@state = obj_attr[:state] @state = obj_attr[:state]
@title = format_title(obj_attr[:title]) @title = format_title(obj_attr[:title])
end end
def pretext
format(message)
end
def attachments def attachments
[] []
end end
def activity
{
title: "Merge Request #{state} by #{user_name}",
subtitle: "in #{project_link}",
text: merge_request_link,
image: user_avatar
}
end
private private
def format_title(title) def format_title(title)
...@@ -50,11 +50,15 @@ module ChatMessage ...@@ -50,11 +50,15 @@ module ChatMessage
end end
def merge_request_link 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 end
def merge_request_url def merge_request_url
"#{project_url}/merge_requests/#{merge_request_id}" "#{project_url}/merge_requests/#{merge_request_iid}"
end end
end end
end end
module ChatMessage module ChatMessage
class NoteMessage < BaseMessage class NoteMessage < BaseMessage
attr_reader :message
attr_reader :user_name
attr_reader :project_name
attr_reader :project_url
attr_reader :note attr_reader :note
attr_reader :note_url attr_reader :note_url
attr_reader :title
attr_reader :target
def initialize(params) def initialize(params)
params = HashWithIndifferentAccess.new(params) super
@user_name = params[:user][:username]
@project_name = params[:project_name]
@project_url = params[:project_url]
params = HashWithIndifferentAccess.new(params)
obj_attr = params[:object_attributes] obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@note = obj_attr[:note] @note = obj_attr[:note]
@note_url = obj_attr[:url] @note_url = obj_attr[:url]
noteable_type = obj_attr[:noteable_type] @target, @title = case obj_attr[:noteable_type]
when "Commit"
case noteable_type create_commit_note(params[:commit])
when "Commit" when "Issue"
create_commit_note(HashWithIndifferentAccess.new(params[:commit])) create_issue_note(params[:issue])
when "Issue" when "MergeRequest"
create_issue_note(HashWithIndifferentAccess.new(params[:issue])) create_merge_note(params[:merge_request])
when "MergeRequest" when "Snippet"
create_merge_note(HashWithIndifferentAccess.new(params[:merge_request])) create_snippet_note(params[:snippet])
when "Snippet" end
create_snippet_note(HashWithIndifferentAccess.new(params[:snippet]))
end
end end
def attachments def attachments
return note if markdown
description_message description_message
end end
def activity
{
title: "#{user_name} #{link('commented on ' + target, note_url)}",
subtitle: "in #{project_link}",
text: formatted_title,
image: user_avatar
}
end
private private
def message
"#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
end
def format_title(title) def format_title(title)
title.lines.first.chomp title.lines.first.chomp
end end
def create_commit_note(commit) def formatted_title
commit_sha = commit[:id] format_title(title)
commit_sha = Commit.truncate_sha(commit_sha)
commented_on_message(
"commit #{commit_sha}",
format_title(commit[:message]))
end end
def create_issue_note(issue) def create_issue_note(issue)
commented_on_message( ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]]
"issue ##{issue[:iid]}", end
format_title(issue[:title]))
def create_commit_note(commit)
commit_sha = Commit.truncate_sha(commit[:id])
["commit #{commit_sha}", commit[:message]]
end end
def create_merge_note(merge_request) def create_merge_note(merge_request)
commented_on_message( ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]]
"merge request !#{merge_request[:iid]}",
format_title(merge_request[:title]))
end end
def create_snippet_note(snippet) def create_snippet_note(snippet)
commented_on_message( ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]]
"snippet ##{snippet[:id]}",
format_title(snippet[:title]))
end end
def description_message def description_message
...@@ -74,9 +78,5 @@ module ChatMessage ...@@ -74,9 +78,5 @@ module ChatMessage
def project_link def project_link
link(project_name, project_url) link(project_name, project_url)
end end
def commented_on_message(target, title)
@message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*"
end
end end
end end
module ChatMessage module ChatMessage
class PipelineMessage < BaseMessage class PipelineMessage < BaseMessage
attr_reader :ref_type, :ref, :status, :project_name, :project_url, attr_reader :ref_type
:user_name, :duration, :pipeline_id attr_reader :ref
attr_reader :status
attr_reader :duration
attr_reader :pipeline_id
def initialize(data) def initialize(data)
super
@user_name = data.dig(:user, :name) || 'API'
pipeline_attributes = data[:object_attributes] pipeline_attributes = data[:object_attributes]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref] @ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status] @status = pipeline_attributes[:status]
@duration = pipeline_attributes[:duration] @duration = pipeline_attributes[:duration]
@pipeline_id = pipeline_attributes[:id] @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 end
def pretext def pretext
...@@ -25,17 +28,24 @@ module ChatMessage ...@@ -25,17 +28,24 @@ module ChatMessage
end end
def attachments def attachments
return message if markdown
[{ text: format(message), color: attachment_color }] [{ text: format(message), color: attachment_color }]
end 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 private
def message def message
"#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
end
def format(string)
Slack::Notifier::LinkFormatter.format(string)
end end
def humanized_status def humanized_status
...@@ -74,5 +84,9 @@ module ChatMessage ...@@ -74,5 +84,9 @@ module ChatMessage
def pipeline_link def pipeline_link
"[##{pipeline_id}](#{pipeline_url})" "[##{pipeline_id}](#{pipeline_url})"
end end
def time_measure
'second'.pluralize(duration)
end
end end
end end
...@@ -3,33 +3,43 @@ module ChatMessage ...@@ -3,33 +3,43 @@ module ChatMessage
attr_reader :after attr_reader :after
attr_reader :before attr_reader :before
attr_reader :commits attr_reader :commits
attr_reader :project_name
attr_reader :project_url
attr_reader :ref attr_reader :ref
attr_reader :ref_type attr_reader :ref_type
attr_reader :user_name
def initialize(params) def initialize(params)
super
@after = params[:after] @after = params[:after]
@before = params[:before] @before = params[:before]
@commits = params.fetch(:commits, []) @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_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch'
@ref = Gitlab::Git.ref_name(params[:ref]) @ref = Gitlab::Git.ref_name(params[:ref])
@user_name = params[:user_name]
end
def pretext
format(message)
end end
def attachments def attachments
return [] if new_branch? || removed_branch? return [] if new_branch? || removed_branch?
return commit_messages if markdown
commit_message_attachments commit_message_attachments
end 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 private
def message def message
...@@ -59,7 +69,7 @@ module ChatMessage ...@@ -59,7 +69,7 @@ module ChatMessage
end end
def commit_messages def commit_messages
commits.map { |commit| compose_commit_message(commit) }.join("\n") commits.map { |commit| compose_commit_message(commit) }.join("\n\n")
end end
def commit_message_attachments def commit_message_attachments
......
module ChatMessage module ChatMessage
class WikiPageMessage < BaseMessage class WikiPageMessage < BaseMessage
attr_reader :user_name
attr_reader :title attr_reader :title
attr_reader :project_name
attr_reader :project_url
attr_reader :wiki_page_url attr_reader :wiki_page_url
attr_reader :action attr_reader :action
attr_reader :description attr_reader :description
def initialize(params) def initialize(params)
@user_name = params[:user][:username] super
@project_name = params[:project_name]
@project_url = params[:project_url]
obj_attr = params[:object_attributes] obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr) obj_attr = HashWithIndifferentAccess.new(obj_attr)
...@@ -29,9 +24,20 @@ module ChatMessage ...@@ -29,9 +24,20 @@ module ChatMessage
end end
def attachments def attachments
return description if markdown
description_message description_message
end end
def activity
{
title: "#{user_name} #{action} #{wiki_page_link}",
subtitle: "in #{project_link}",
text: title,
image: user_avatar
}
end
private private
def message def message
......
...@@ -49,10 +49,7 @@ class ChatNotificationService < Service ...@@ -49,10 +49,7 @@ class ChatNotificationService < Service
object_kind = data[:object_kind] object_kind = data[:object_kind]
data = data.merge( data = custom_data(data)
project_url: project_url,
project_name: project_name
)
# WebHook events often have an 'update' event that follows a 'open' or # WebHook events often have an 'update' event that follows a 'open' or
# 'close' action. Ignore update events for now to prevent duplicate # 'close' action. Ignore update events for now to prevent duplicate
...@@ -68,8 +65,7 @@ class ChatNotificationService < Service ...@@ -68,8 +65,7 @@ class ChatNotificationService < Service
opts[:channel] = channel_name if channel_name opts[:channel] = channel_name if channel_name
opts[:username] = username if username opts[:username] = username if username
notifier = Slack::Notifier.new(webhook, opts) return false unless notify(message, opts)
notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback)
true true
end end
...@@ -92,6 +88,18 @@ class ChatNotificationService < Service ...@@ -92,6 +88,18 @@ class ChatNotificationService < Service
private 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) def get_message(object_kind, data)
case object_kind case object_kind
when "push", "tag_push" when "push", "tag_push"
......
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 ...@@ -6,6 +6,8 @@ class Repository
attr_accessor :path_with_namespace, :project attr_accessor :path_with_namespace, :project
delegate :ref_name_for_sha, to: :raw_repository
CommitError = Class.new(StandardError) CommitError = Class.new(StandardError)
CreateTreeError = Class.new(StandardError) CreateTreeError = Class.new(StandardError)
...@@ -700,14 +702,6 @@ class Repository ...@@ -700,14 +702,6 @@ class Repository
end end
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) def refs_contains_sha(ref_type, sha)
args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha}) args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
names = Gitlab::Popen.popen(args, path_to_repo).first names = Gitlab::Popen.popen(args, path_to_repo).first
......
...@@ -237,6 +237,7 @@ class Service < ActiveRecord::Base ...@@ -237,6 +237,7 @@ class Service < ActiveRecord::Base
slack_slash_commands slack_slash_commands
slack slack
teamcity teamcity
microsoft_teams
] ]
if Rails.env.development? if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring] service_names += %w[mock_ci mock_deployment mock_monitoring]
......
...@@ -89,7 +89,8 @@ class User < ActiveRecord::Base ...@@ -89,7 +89,8 @@ class User < ActiveRecord::Base
has_many :subscriptions, dependent: :destroy has_many :subscriptions, dependent: :destroy
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" 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_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 :spam_logs, dependent: :destroy
has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
...@@ -484,6 +485,14 @@ class User < ActiveRecord::Base ...@@ -484,6 +485,14 @@ class User < ActiveRecord::Base
Group.member_descendants(id) Group.member_descendants(id)
end 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 def nested_groups_projects
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL'). Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
member_descendants(id) member_descendants(id)
...@@ -955,6 +964,15 @@ class User < ActiveRecord::Base ...@@ -955,6 +964,15 @@ class User < ActiveRecord::Base
self.admin = (new_level == 'admin') self.admin = (new_level == 'admin')
end 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 protected
# override, from Devise::Validatable # override, from Devise::Validatable
......
...@@ -17,6 +17,7 @@ module Auth ...@@ -17,6 +17,7 @@ module Auth
end end
def self.full_access_token(*names) def self.full_access_token(*names)
names = names.flatten
registry = Gitlab.config.registry registry = Gitlab.config.registry
token = JSONWebToken::RSAToken.new(registry.key) token = JSONWebToken::RSAToken.new(registry.key)
token.issuer = registry.issuer token.issuer = registry.issuer
...@@ -37,13 +38,13 @@ module Auth ...@@ -37,13 +38,13 @@ module Auth
private private
def authorized_token(*accesses) def authorized_token(*accesses)
token = JSONWebToken::RSAToken.new(registry.key) JSONWebToken::RSAToken.new(registry.key).tap do |token|
token.issuer = registry.issuer token.issuer = registry.issuer
token.audience = params[:service] token.audience = params[:service]
token.subject = current_user.try(:username) token.subject = current_user.try(:username)
token.expire_time = self.class.token_expire_at token.expire_time = self.class.token_expire_at
token[:access] = accesses.compact token[:access] = accesses.compact
token end
end end
def scope def scope
...@@ -55,20 +56,43 @@ module Auth ...@@ -55,20 +56,43 @@ module Auth
def process_scope(scope) def process_scope(scope)
type, name, actions = scope.split(':', 3) type, name, actions = scope.split(':', 3)
actions = actions.split(',') actions = actions.split(',')
path = ContainerRegistry::Path.new(name)
return unless type == 'repository' return unless type == 'repository'
process_repository_access(type, name, actions) process_repository_access(type, path, actions)
end end
def process_repository_access(type, name, actions) def process_repository_access(type, path, actions)
requested_project = Project.find_by_full_path(name) return unless path.valid?
requested_project = path.repository_project
return unless requested_project return unless requested_project
actions = actions.select do |action| actions = actions.select do |action|
can_access?(requested_project, action) can_access?(requested_project, action)
end 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 end
def can_access?(requested_project, requested_action) def can_access?(requested_project, requested_action)
...@@ -101,6 +125,11 @@ module Auth ...@@ -101,6 +125,11 @@ module Auth
can?(current_user, :read_container_image, requested_project) can?(current_user, :read_container_image, requested_project)
end 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) def build_can_push?(requested_project)
# Build can push only to the project from which it originates # Build can push only to the project from which it originates
has_authentication_ability?(:build_create_container_image) && has_authentication_ability?(:build_create_container_image) &&
...@@ -113,14 +142,11 @@ module Auth ...@@ -113,14 +142,11 @@ module Auth
end end
def error(code, status:, message: '') def error(code, status:, message: '')
{ { errors: [{ code: code, message: message }], http_status: status }
errors: [{ code: code, message: message }],
http_status: status
}
end end
def has_authentication_ability?(capability) def has_authentication_ability?(capability)
(@authentication_abilities || []).include?(capability) @authentication_abilities.to_a.include?(capability)
end end
end end
end end
...@@ -31,16 +31,16 @@ module Projects ...@@ -31,16 +31,16 @@ module Projects
project.team.truncate project.team.truncate
project.destroy! project.destroy!
unless remove_registry_tags unless remove_legacy_registry_tags
raise_error('Failed to remove project container registry. Please try again or contact administrator') raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
end end
unless remove_repository(repo_path) 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 end
unless remove_repository(wiki_path) 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
end end
...@@ -68,10 +68,16 @@ module Projects ...@@ -68,10 +68,16 @@ module Projects
end end
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 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 end
def raise_error(message) def raise_error(message)
......
...@@ -26,7 +26,7 @@ module Users ...@@ -26,7 +26,7 @@ module Users
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
end 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 # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
namespace = user.namespace namespace = user.namespace
...@@ -35,22 +35,5 @@ module Users ...@@ -35,22 +35,5 @@ module Users
user_data user_data
end 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
end end
# 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
# 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
# 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 @@ ...@@ -13,7 +13,7 @@
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f = 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? - if @group.new_record?
.form-group .form-group
......
...@@ -13,5 +13,7 @@ ...@@ -13,5 +13,7 @@
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button', %button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': 'Add emoji', 'aria-label': 'Add emoji',
data: { title: 'Add emoji', placement: "bottom" } } data: { title: 'Add emoji', placement: "bottom" } }
= icon('smile-o', class: "award-control-icon award-control-icon-normal") %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
= icon('spinner spin', class: "award-control-icon award-control-icon-loading") = icon('spinner spin', class: "award-control-icon award-control-icon-loading")
...@@ -3,8 +3,6 @@ ...@@ -3,8 +3,6 @@
.event-item-timestamp .event-item-timestamp
#{time_ago_with_tooltip(event.created_at)} #{time_ago_with_tooltip(event.created_at)}
= author_avatar(event, size: 40)
- if event.created_project? - if event.created_project?
= render "events/event/created_project", event: event = render "events/event/created_project", event: event
- elsif event.push? - elsif event.push?
......
- 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 .event-title
%span.author_name= link_to_author event
%span{ class: event.action_name } %span{ class: event.action_name }
- if event.target - if event.target
= event.action_name = event.action_name
......
.profile-icon.open-icon
= custom_icon("icon_status_open")
.event-title .event-title
%span.author_name= link_to_author event
%span{ class: event.action_name } %span{ class: event.action_name }
= event_action_name(event) = event_action_name(event)
......
.profile-icon
= custom_icon("comment_o")
.event-title .event-title
%span.author_name= link_to_author event
= event.action_name = event.action_name
= event_note_title_html(event) = event_note_title_html(event)
......
- project = event.project - project = event.project
.profile-icon
- if event.action_name == "deleted"
= custom_icon("trash_o")
- else
= custom_icon("icon_commit")
.event-title .event-title
%span.author_name= link_to_author event
%span.pushed #{event.action_name} #{event.ref_type} %span.pushed #{event.action_name} #{event.ref_type}
%strong %strong
- commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name) - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
...@@ -48,4 +53,3 @@ ...@@ -48,4 +53,3 @@
.event-body .event-body
%ul.well-list.event_commits %ul.well-list.event_commits
= render "events/commit", commit: last_commit, project: project, event: event = render "events/commit", commit: last_commit, project: project, event: event
- if current_user.admin? - if current_user.admin?
.form-group .form-group
.col-sm-offset-2.col-sm-10 = f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
.col-sm-10
.checkbox .checkbox
= f.label :lfs_enabled do = f.label :lfs_enabled do
= f.check_box :lfs_enabled, checked: @group.lfs_enabled? = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
...@@ -9,3 +10,19 @@ ...@@ -9,3 +10,19 @@
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
%br/ %br/
%span.descr This setting can be overridden in each project. %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
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
.col-sm-offset-2.col-sm-10 .col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f = render 'shared/allow_request_access', form: f
= render 'group_lfs_settings', f: f = render 'group_admin_settings', f: f
.form-group .form-group
%hr %hr
......
...@@ -137,6 +137,6 @@ ...@@ -137,6 +137,6 @@
- if build.has_trace? - if build.has_trace?
%td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" } %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;" } %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 - else
%td{ colspan: "2" } %td{ colspan: "2" }
...@@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. ...@@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
Stage: <%= build.stage %> Stage: <%= build.stage %>
Name: <%= build.name %> Name: <%= build.name %>
<% if build.has_trace? -%> <% if build.has_trace? -%>
Trace: <%= build.trace_with_state(last_lines: 10)[:text] %> Trace: <%= build.trace.raw(last_lines: 10) %>
<% end -%> <% end -%>
<% end -%> <% end -%>
...@@ -25,4 +25,11 @@ ...@@ -25,4 +25,11 @@
#blob-content-holder.blob-content-holder #blob-content-holder.blob-content-holder
%article.file-holder %article.file-holder
= render "projects/blob/header", blob: blob = 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 = render blob.to_partial_path(@project), blob: blob
...@@ -32,8 +32,8 @@ ...@@ -32,8 +32,8 @@
= link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, = 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' tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
- if current_user .btn-group{ role: "group" }<
.btn-group{ role: "group" }< = edit_blob_link if blob_text_viewable?(blob)
= edit_blob_link if blob_text_viewable?(blob) - if current_user
= replace_blob_link = replace_blob_link
= delete_blob_link = delete_blob_link
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
- elsif @build.runner - elsif @build.runner
\##{@build.runner.id} \##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group } .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' = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- if @build.active? - if @build.active?
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
......
...@@ -20,6 +20,6 @@ ...@@ -20,6 +20,6 @@
%th Coverage %th Coverage
%th %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' = paginate builds, theme: 'gitlab'
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
- retried = local_assigns.fetch(:retried, false) - retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false) - stage = local_assigns.fetch(:stage, false)
- coverage = local_assigns.fetch(:coverage, false)
- allow_retry = local_assigns.fetch(:allow_retry, false) - allow_retry = local_assigns.fetch(:allow_retry, false)
%tr.build.commit{ class: ('retried' if retried) } %tr.build.commit{ class: ('retried' if retried) }
...@@ -88,7 +87,7 @@ ...@@ -88,7 +87,7 @@
%span= time_ago_with_tooltip(build.finished_at) %span= time_ago_with_tooltip(build.finished_at)
%td.coverage %td.coverage
- if coverage && build.try(:coverage) - if build.try(:coverage)
#{build.coverage}% #{build.coverage}%
%td %td
......
...@@ -47,7 +47,6 @@ ...@@ -47,7 +47,6 @@
%th Job ID %th Job ID
%th Name %th Name
%th %th
- if pipeline.project.build_coverage_enabled? %th Coverage
%th Coverage
%th %th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
- environment = local_assigns.fetch(:environment) - environment = local_assigns.fetch(:environment)
- return unless environment.has_metrics? && can?(current_user, :read_environment, environment) - return unless can?(current_user, :read_environment, environment)
= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
= icon('area-chart') = icon('area-chart')
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
= page_specific_javascript_bundle_tag('monitoring') = page_specific_javascript_bundle_tag('monitoring')
= render "projects/pipelines/head" = render "projects/pipelines/head"
%div{ class: container_class } .prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" }
.top-area .top-area
.row .row
.col-sm-6 .col-sm-6
...@@ -16,13 +16,68 @@ ...@@ -16,13 +16,68 @@
.col-sm-6 .col-sm-6
.nav-controls .nav-controls
= render 'projects/deployments/actions', deployment: @environment.last_deployment = render 'projects/deployments/actions', deployment: @environment.last_deployment
.row .prometheus-state
.col-sm-12 .js-getting-started.hidden
%h4 .row
CPU utilization .col-md-4.col-md-offset-4.state-svg
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' } = render "shared/empty_states/monitoring/getting_started.svg"
.row .row
.col-sm-12 .col-md-6.col-md-offset-3
%h4 %h4.text-center.state-title
Memory usage Get started with performance monitoring
%svg.prometheus-graph{ 'graph-type' => 'memory_values' } .row
.col-md-6.col-md-offset-3
.description-text.text-center.state-description
Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.
= link_to help_page_path('administration/monitoring/prometheus/index.md') do
Learn more about performance monitoring
.row.state-button-section
.col-md-4.col-md-offset-4.text-center.state-button
= link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do
Configure Prometheus
.js-loading.hidden
.row
.col-md-4.col-md-offset-4.state-svg
= render "shared/empty_states/monitoring/loading.svg"
.row
.col-md-6.col-md-offset-3
%h4.text-center.state-title
Waiting for performance data
.row
.col-md-6.col-md-offset-3
.description-text.text-center.state-description
Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.
.row.state-button-section
.col-md-4.col-md-offset-4.text-center.state-button
= link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do
View documentation
.js-unable-to-connect.hidden
.row
.col-md-4.col-md-offset-4.state-svg
= render "shared/empty_states/monitoring/unable_to_connect.svg"
.row
.col-md-6.col-md-offset-3
%h4.text-center.state-title
Unable to connect to Prometheus server
.row
.col-md-6.col-md-offset-3
.description-text.text-center.state-description
Ensure connectivity is available from the GitLab server to the
= link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do
Prometheus server
.row.state-button-section
.col-md-4.col-md-offset-4.text-center.state-button
= link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do
View documentation
.prometheus-graphs
.row
.col-sm-12
%h4
CPU utilization
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
.row
.col-sm-12
%h4
Memory usage
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
- retried = local_assigns.fetch(:retried, false) - retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false) - stage = local_assigns.fetch(:stage, false)
- coverage = local_assigns.fetch(:coverage, false)
%tr.generic_commit_status{ class: ('retried' if retried) } %tr.generic_commit_status{ class: ('retried' if retried) }
%td.status %td.status
...@@ -80,7 +79,7 @@ ...@@ -80,7 +79,7 @@
%span= time_ago_with_tooltip(generic_commit_status.finished_at) %span= time_ago_with_tooltip(generic_commit_status.finished_at)
%td.coverage %td.coverage
- if coverage && generic_commit_status.try(:coverage) - if generic_commit_status.try(:coverage)
#{generic_commit_status.coverage}% #{generic_commit_status.coverage}%
%td %td
......
...@@ -59,7 +59,9 @@ ...@@ -59,7 +59,9 @@
- if note.emoji_awardable? - if note.emoji_awardable?
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
= icon('spinner spin') = icon('spinner spin')
= icon('smile-o', class: 'link-highlight') %span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile')
- if note_editable - if note_editable
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
......
...@@ -36,7 +36,6 @@ ...@@ -36,7 +36,6 @@
%th Job ID %th Job ID
%th Name %th Name
%th %th
- if pipeline.project.build_coverage_enabled? %th Coverage
%th Coverage
%th %th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
.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.
...@@ -25,5 +25,9 @@ ...@@ -25,5 +25,9 @@
- if can?(current_user, :update_container_image, @project) - if can?(current_user, :update_container_image, @project)
%td.content %td.content
.controls.hidden-xs.pull-right .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 = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
= icon("trash cred") 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')
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment