Commit c4e46877 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'master' into pawel/prometheus-business-metrics-ee-2273

parents bc9ca388 f290e2ea
......@@ -426,7 +426,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.85.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.87.0', require: 'gitaly'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
gem 'google-protobuf', '= 3.5.1'
......
......@@ -309,7 +309,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.85.0)
gitaly-proto (0.87.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (5.3.3)
......@@ -1091,7 +1091,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly-proto (~> 0.85.0)
gitaly-proto (~> 0.87.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-license (~> 1.0)
......
......@@ -5,12 +5,12 @@ import Vue from 'vue';
import Flash from '~/flash';
import { __ } from '~/locale';
import '~/vue_shared/models/label';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first
import './models/issue';
import './models/label';
import './models/list';
import './models/milestone';
import './models/assignee';
......
......@@ -37,10 +37,11 @@ export default class Clusters {
clusterStatusReason,
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
} = document.querySelector('.js-edit-cluster-form').dataset;
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath);
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
......@@ -98,6 +99,7 @@ export default class Clusters {
helpPath: this.state.helpPath,
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
},
});
},
......
......@@ -36,10 +36,6 @@
type: String,
required: false,
},
description: {
type: String,
required: true,
},
status: {
type: String,
required: false,
......@@ -148,7 +144,7 @@
class="table-section section-wrap"
role="gridcell"
>
<div v-html="description"></div>
<slot name="description"></slot>
</div>
<div
class="table-section table-button-footer section-align-top"
......
......@@ -2,10 +2,16 @@
import _ from 'underscore';
import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import {
APPLICATION_INSTALLED,
INGRESS,
} from '../constants';
export default {
components: {
applicationRow,
clipboardButton,
},
props: {
applications: {
......@@ -23,6 +29,11 @@
required: false,
default: '',
},
ingressDnsHelpPath: {
type: String,
required: false,
default: '',
},
managePrometheusPath: {
type: String,
required: false,
......@@ -43,19 +54,16 @@
false,
);
},
helmTillerDescription() {
return _.escape(s__(
`ClusterIntegration|Helm streamlines installing and managing Kubernetes applications.
Tiller runs inside of your Kubernetes Cluster, and manages
releases of your charts.`,
));
ingressId() {
return INGRESS;
},
ingressInstalled() {
return this.applications.ingress.status === APPLICATION_INSTALLED;
},
ingressExternalIp() {
return this.applications.ingress.externalIp;
},
ingressDescription() {
const descriptionParagraph = _.escape(s__(
`ClusterIntegration|Ingress gives you a way to route requests to services based on the
request host or path, centralizing a number of services into a single entrypoint.`,
));
const extraCostParagraph = sprintf(
_.escape(s__(
`ClusterIntegration|%{boldNotice} This will add some extra resources
......@@ -83,9 +91,6 @@
);
return `
<p>
${descriptionParagraph}
</p>
<p>
${extraCostParagraph}
</p>
......@@ -94,12 +99,6 @@
</p>
`;
},
gitlabRunnerDescription() {
return _.escape(s__(
`ClusterIntegration|GitLab Runner is the open source project that is used to run your jobs
and send the results back to GitLab.`,
));
},
prometheusDescription() {
return sprintf(
_.escape(s__(
......@@ -136,33 +135,137 @@
id="helm"
:title="applications.helm.title"
title-link="https://docs.helm.sh/"
:description="helmTillerDescription"
:status="applications.helm.status"
:status-reason="applications.helm.statusReason"
:request-status="applications.helm.requestStatus"
:request-reason="applications.helm.requestReason"
/>
>
<div slot="description">
{{ s__(`ClusterIntegration|Helm streamlines installing
and managing Kubernetes applications.
Tiller runs inside of your Kubernetes Cluster,
and manages releases of your charts.`) }}
</div>
</application-row>
<application-row
id="ingress"
:id="ingressId"
:title="applications.ingress.title"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
:description="ingressDescription"
:status="applications.ingress.status"
:status-reason="applications.ingress.statusReason"
:request-status="applications.ingress.requestStatus"
:request-reason="applications.ingress.requestReason"
/>
>
<div slot="description">
<p>
{{ s__(`ClusterIntegration|Ingress gives you a way to route
requests to services based on the request host or path,
centralizing a number of services into a single entrypoint.`) }}
</p>
<template v-if="ingressInstalled">
<div class="form-group">
<label for="ingress-ip-address">
{{ s__('ClusterIntegration|Ingress IP Address') }}
</label>
<div
v-if="ingressExternalIp"
class="input-group"
>
<input
type="text"
id="ingress-ip-address"
class="form-control js-ip-address"
:value="ingressExternalIp"
readonly
/>
<span class="input-group-btn">
<clipboard-button
:text="ingressExternalIp"
:title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')"
css-class="btn btn-default js-clipboard-btn"
/>
</span>
</div>
<input
v-else
type="text"
class="form-control js-ip-address"
readonly
value="?"
/>
</div>
<p
v-if="!ingressExternalIp"
class="settings-message js-no-ip-message"
>
{{ s__(`ClusterIntegration|The IP address is in
the process of being assigned. Please check your Kubernetes
cluster or Quotas on GKE if it takes a long time.`) }}
<a
:href="ingressHelpPath"
target="_blank"
rel="noopener noreferrer"
>
{{ __('More information') }}
</a>
</p>
<p>
{{ s__(`ClusterIntegration|Point a wildcard DNS to this
generated IP address in order to access
your application after it has been deployed.`) }}
<a
:href="ingressDnsHelpPath"
target="_blank"
rel="noopener noreferrer"
>
{{ __('More information') }}
</a>
</p>
</template>
<div
v-else
v-html="ingressDescription"
>
</div>
</div>
</application-row>
<application-row
id="prometheus"
:title="applications.prometheus.title"
title-link="https://prometheus.io/docs/introduction/overview/"
:manage-link="managePrometheusPath"
:description="prometheusDescription"
:status="applications.prometheus.status"
:status-reason="applications.prometheus.statusReason"
:request-status="applications.prometheus.requestStatus"
:request-reason="applications.prometheus.requestReason"
/>
>
<div
slot="description"
v-html="prometheusDescription"
>
</div>
</application-row>
<application-row
id="runner"
:title="applications.runner.title"
title-link="https://docs.gitlab.com/runner/"
:status="applications.runner.status"
:status-reason="applications.runner.statusReason"
:request-status="applications.runner.requestStatus"
:request-reason="applications.runner.requestReason"
>
<div slot="description">
{{ s__(`ClusterIntegration|GitLab Runner connects to this
project's repository and executes CI/CD jobs,
pushing results back and deploying,
applications to production.`) }}
</div>
</application-row>
<!--
NOTE: Don't forget to update `clusters.scss`
min-height for this block and uncomment `application_spec` tests
......
......@@ -10,3 +10,4 @@ export const APPLICATION_ERROR = 'errored';
export const REQUEST_LOADING = 'request-loading';
export const REQUEST_SUCCESS = 'request-success';
export const REQUEST_FAILURE = 'request-failure';
export const INGRESS = 'ingress';
import { s__ } from '../../locale';
import { INGRESS } from '../constants';
export default class ClusterStore {
constructor() {
......@@ -21,6 +22,7 @@ export default class ClusterStore {
statusReason: null,
requestStatus: null,
requestReason: null,
externalIp: null,
},
runner: {
title: s__('ClusterIntegration|GitLab Runner'),
......@@ -40,9 +42,10 @@ export default class ClusterStore {
};
}
setHelpPaths(helpPath, ingressHelpPath) {
setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath) {
this.state.helpPath = helpPath;
this.state.ingressHelpPath = ingressHelpPath;
this.state.ingressDnsHelpPath = ingressDnsHelpPath;
}
setManagePrometheusPath(managePrometheusPath) {
......@@ -64,6 +67,7 @@ export default class ClusterStore {
updateStateFromServer(serverState = {}) {
this.state.status = serverState.status;
this.state.statusReason = serverState.status_reason;
serverState.applications.forEach((serverAppEntry) => {
const {
name: appId,
......@@ -76,6 +80,10 @@ export default class ClusterStore {
status,
statusReason,
};
if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
}
});
}
}
......@@ -16,6 +16,7 @@ export default class FilteredSearchDropdownManager {
page,
isGroup,
isGroupAncestor,
isGroupDecendent,
filteredSearchTokenKeys,
}) {
this.container = FilteredSearchContainer.container;
......@@ -26,6 +27,7 @@ export default class FilteredSearchDropdownManager {
this.page = page;
this.groupsOnly = isGroup;
this.groupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.setupMapping();
......
......@@ -22,11 +22,13 @@ export default class FilteredSearchManager {
page,
isGroup = false,
isGroupAncestor = false,
isGroupDecendent = false,
filteredSearchTokenKeys = FilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
}) {
this.isGroup = isGroup;
this.isGroupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.states = ['opened', 'closed', 'merged', 'all'];
this.page = page;
......
import _ from 'underscore';
import AjaxCache from '../lib/utils/ajax_cache';
import AjaxCache from '~/lib/utils/ajax_cache';
import { objectToQueryString } from '~/lib/utils/common_utils';
import Flash from '../flash';
import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
......@@ -16,6 +17,21 @@ export default class FilteredSearchVisualTokens {
};
}
/**
* Returns a computed API endpoint
* and query string composed of values from endpointQueryParams
* @param {String} endpoint
* @param {String} endpointQueryParams
*/
static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
if (!endpointQueryParams) {
return endpoint;
}
const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token .selectable.selected');
[].forEach.call(otherTokens, t => t.classList.remove('selected'));
......@@ -86,7 +102,10 @@ export default class FilteredSearchVisualTokens {
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
const labelsEndpoint = `${baseEndpoint}/labels.json`;
const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${baseEndpoint}/labels.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(labelsEndpoint)
.then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
......
......@@ -302,6 +302,14 @@ export const parseQueryStringIntoObject = (query = '') => {
}, {});
};
/**
* Converts object with key-value pairs
* into query-param string
*
* @param {Object} params
*/
export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&');
export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname);
/**
......
......@@ -39,8 +39,11 @@ import 'ee/main';
import initDispatcher from './dispatcher';
// eslint-disable-next-line global-require, import/no-commonjs
if (process.env.NODE_ENV !== 'production') require('./test_utils/');
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
$.fx.off = true;
import(/* webpackMode: "eager" */ './test_utils/');
}
svg4everybody();
......
......@@ -3,7 +3,7 @@ import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import store from '../notes/stores';
document.addEventListener('DOMContentLoaded', () => {
export default function initMrNotes() {
new Vue({ // eslint-disable-line
el: '#js-vue-mr-discussions',
components: {
......@@ -38,4 +38,4 @@ document.addEventListener('DOMContentLoaded', () => {
return createElement('discussion-counter');
},
});
});
}
import U2FRegister from './u2f/register';
import U2FRegister from '~/u2f/register';
document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth');
......
import initTerminal from '~/terminal/';
document.addEventListener('DOMContentLoaded', initTerminal);
import { hasVueMRDiscussionsCookie } from '~/lib/utils/common_utils';
import initMrNotes from '~/mr_notes';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initShow from '../init_merge_request_show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
initSidebarBundle();
if (hasVueMRDiscussionsCookie()) {
initMrNotes();
}
});
......@@ -5,6 +5,7 @@ export default ({
filteredSearchTokenKeys,
isGroup,
isGroupAncestor,
isGroupDecendent,
stateFiltersSelector,
}) => {
const filteredSearchEnabled = FilteredSearchManager && document.querySelector('.filtered-search');
......@@ -13,6 +14,7 @@ export default ({
page,
isGroup,
isGroupAncestor,
isGroupDecendent,
filteredSearchTokenKeys,
stateFiltersSelector,
});
......
......@@ -6,4 +6,4 @@ import './terminal';
window.Terminal = Terminal;
$(() => new gl.Terminal({ selector: '#terminal' }));
export default () => new gl.Terminal({ selector: '#terminal' });
/* eslint-disable func-names, wrap-iife */
/* global u2f */
import _ from 'underscore';
import isU2FSupported from './util';
import importU2FLibrary from './util';
import U2FError from './error';
// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
......@@ -10,6 +8,7 @@ import U2FError from './error';
// State Flow #2: setup -> in_progress -> error -> setup
export default class U2FAuthenticate {
constructor(container, form, u2fParams, fallbackButton, fallbackUI) {
this.u2fUtils = null;
this.container = container;
this.renderNotSupported = this.renderNotSupported.bind(this);
this.renderAuthenticated = this.renderAuthenticated.bind(this);
......@@ -50,22 +49,23 @@ export default class U2FAuthenticate {
}
start() {
if (isU2FSupported()) {
return this.renderInProgress();
}
return this.renderNotSupported();
return importU2FLibrary()
.then((utils) => {
this.u2fUtils = utils;
this.renderInProgress();
})
.catch(() => this.renderNotSupported());
}
authenticate() {
return u2f.sign(this.appId, this.challenge, this.signRequests, (function (_this) {
return function (response) {
return this.u2fUtils.sign(this.appId, this.challenge, this.signRequests,
(response) => {
if (response.errorCode) {
const error = new U2FError(response.errorCode, 'authenticate');
return _this.renderError(error);
return this.renderError(error);
}
return _this.renderAuthenticated(JSON.stringify(response));
};
})(this), 10);
return this.renderAuthenticated(JSON.stringify(response));
}, 10);
}
renderTemplate(name, params) {
......
/* eslint-disable func-names, wrap-iife */
/* global u2f */
import _ from 'underscore';
import isU2FSupported from './util';
import importU2FLibrary from './util';
import U2FError from './error';
// Register U2F (universal 2nd factor) devices for users to authenticate with.
......@@ -11,6 +8,7 @@ import U2FError from './error';
// State Flow #2: setup -> in_progress -> error -> setup
export default class U2FRegister {
constructor(container, u2fParams) {
this.u2fUtils = null;
this.container = container;
this.renderNotSupported = this.renderNotSupported.bind(this);
this.renderRegistered = this.renderRegistered.bind(this);
......@@ -34,22 +32,23 @@ export default class U2FRegister {
}
start() {
if (isU2FSupported()) {
return this.renderSetup();
}
return this.renderNotSupported();
return importU2FLibrary()
.then((utils) => {
this.u2fUtils = utils;
this.renderSetup();
})
.catch(() => this.renderNotSupported());
}
register() {
return u2f.register(this.appId, this.registerRequests, this.signRequests, (function (_this) {
return function (response) {
return this.u2fUtils.register(this.appId, this.registerRequests, this.signRequests,
(response) => {
if (response.errorCode) {
const error = new U2FError(response.errorCode, 'register');
return _this.renderError(error);
return this.renderError(error);
}
return _this.renderRegistered(JSON.stringify(response));
};
})(this), 10);
return this.renderRegistered(JSON.stringify(response));
}, 10);
}
renderTemplate(name, params) {
......
export default function isU2FSupported() {
return window.u2f;
function isOpera(userAgent) {
return userAgent.indexOf('Opera') >= 0 || userAgent.indexOf('OPR') >= 0;
}
function getOperaVersion(userAgent) {
const match = userAgent.match(/OPR[^0-9]*([0-9]+)[^0-9]+/);
return match ? parseInt(match[1], 10) : false;
}
function isChrome(userAgent) {
return userAgent.indexOf('Chrom') >= 0 && !isOpera(userAgent);
}
function getChromeVersion(userAgent) {
const match = userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
return match ? parseInt(match[1], 10) : false;
}
export function canInjectU2fApi(userAgent) {
const isSupportedChrome = isChrome(userAgent) && getChromeVersion(userAgent) >= 41;
const isSupportedOpera = isOpera(userAgent) && getOperaVersion(userAgent) >= 40;
const isMobile = (
userAgent.indexOf('droid') >= 0 ||
userAgent.indexOf('CriOS') >= 0 ||
/\b(iPad|iPhone|iPod)(?=;)/.test(userAgent)
);
return (isSupportedChrome || isSupportedOpera) && !isMobile;
}
export default function importU2FLibrary() {
if (window.u2f) {
return Promise.resolve(window.u2f);
}
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
if (canInjectU2fApi(userAgent) || (gon && gon.test_env)) {
return import(/* webpackMode: "eager" */ 'vendor/u2f').then(() => window.u2f);
}
return Promise.reject();
}
......@@ -28,6 +28,11 @@
required: false,
default: false,
},
cssClass: {
type: String,
required: false,
default: 'btn btn-default btn-transparent btn-clipboard',
},
},
};
</script>
......@@ -35,7 +40,7 @@
<template>
<button
type="button"
class="btn btn-transparent btn-clipboard"
:class="cssClass"
:title="title"
:data-clipboard-text="text"
v-tooltip
......
<script>
import LabelsSelect from '~/labels_select';
import LoadingIcon from '../../loading_icon.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownHiddenInput from './dropdown_hidden_input.vue';
import DropdownHeader from './dropdown_header.vue';
import DropdownSearchInput from './dropdown_search_input.vue';
import DropdownFooter from './dropdown_footer.vue';
import DropdownCreateLabel from './dropdown_create_label.vue';
export default {
components: {
LoadingIcon,
DropdownTitle,
DropdownValue,
DropdownValueCollapsed,
DropdownButton,
DropdownHiddenInput,
DropdownHeader,
DropdownSearchInput,
DropdownFooter,
DropdownCreateLabel,
},
props: {
showCreate: {
type: Boolean,
required: false,
default: false,
},
abilityName: {
type: String,
required: true,
},
context: {
type: Object,
required: true,
},
namespace: {
type: String,
required: false,
default: '',
},
updatePath: {
type: String,
required: false,
default: '',
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: false,
default: '',
},
labelFilterBasePath: {
type: String,
required: false,
default: '',
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
hiddenInputName() {
return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
},
},
mounted() {
this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
handleClick: this.handleClick,
});
},
methods: {
handleClick(label) {
this.$emit('onLabelClick', label);
},
},
};
</script>
<template>
<div class="block labels">
<dropdown-value-collapsed
v-if="showCreate"
:labels="context.labels"
/>
<dropdown-title
:can-edit="canEdit"
/>
<dropdown-value
:labels="context.labels"
:label-filter-base-path="labelFilterBasePath"
>
<slot></slot>
</dropdown-value>
<div
v-if="canEdit"
class="selectbox"
style="display: none;"
>
<dropdown-hidden-input
v-for="label in context.labels"
:key="label.id"
:name="hiddenInputName"
:label="label"
/>
<div class="dropdown">
<dropdown-button
:ability-name="abilityName"
:field-name="hiddenInputName"
:update-path="updatePath"
:labels-path="labelsPath"
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
dropdown-menu-labels dropdown-menu-selectable"
>
<div class="dropdown-page-one">
<dropdown-header v-if="showCreate" />
<dropdown-search-input/>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
<dropdown-footer
v-if="showCreate"
:labels-web-url="labelsWebUrl"
/>
</div>
<dropdown-create-label
v-if="showCreate"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { __, s__, sprintf } from '~/locale';
export default {
props: {
abilityName: {
type: String,
required: true,
},
fieldName: {
type: String,
required: true,
},
updatePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
showExtraOptions: {
type: Boolean,
required: true,
},
},
computed: {
dropdownToggleText() {
if (this.labels.length === 0) {
return __('Label');
}
if (this.labels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: this.labels[0].title,
remainingLabelCount: this.labels.length - 1,
});
}
return this.labels[0].title;
},
},
};
</script>
<template>
<button
type="button"
ref="dropdownButton"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
:class="{ 'js-extra-options': showExtraOptions }"
:data-ability-name="abilityName"
:data-field-name="fieldName"
:data-issue-update="updatePath"
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
>
<span class="dropdown-toggle-text">
{{ dropdownToggleText }}
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down"
data-hidden="true"
>
</i>
</button>
</template>
<script>
export default {
created() {
this.suggestedColors = gon.suggested_label_colors;
},
};
</script>
<template>
<div class="dropdown-page-two dropdown-new-label">
<div class="dropdown-title">
<button
type="button"
class="dropdown-title-button dropdown-menu-back"
:aria-label="__('Go back')"
>
<i
aria-hidden="true"
class="fa fa-arrow-left"
data-hidden="true"
>
</i>
</button>
{{ __('Create new label') }}
<button
type="button"
class="dropdown-title-button dropdown-menu-close"
:aria-label="__('Close')"
>
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
data-hidden="true"
>
</i>
</button>
</div>
<div class="dropdown-content">
<div class="dropdown-labels-error js-label-error"></div>
<input
id="new_label_name"
type="text"
class="default-dropdown-input"
:placeholder="__('Name new label')"
/>
<div class="suggest-colors suggest-colors-dropdown">
<a
v-for="(color, index) in suggestedColors"
href="#"
:key="index"
:data-color="color"
:style="{
backgroundColor: color,
}"
>
&nbsp;
</a>
</div>
<div class="dropdown-label-color-input">
<div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
<input
id="new_label_color"
type="text"
class="default-dropdown-input"
:placeholder="__('Assign custom color like #FF0000')"
/>
</div>
<div class="clearfix">
<button
type="button"
class="btn btn-primary pull-left js-new-label-btn disabled"
>
{{ __('Create') }}
</button>
<button
type="button"
class="btn btn-default pull-right js-cancel-label-btn"
>
{{ __('Cancel') }}
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
labelsWebUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="dropdown-footer">
<ul class="dropdown-footer-list">
<li>
<a
href="#"
class="dropdown-toggle-page"
>
{{ __('Create new label') }}
</a>
</li>
<li>
<a
data-is-link="true"
class="dropdown-external-link"
:href="labelsWebUrl"
>
{{ __('Manage labels') }}
</a>
</li>
</ul>
</div>
</template>
<script>
export default {};
</script>
<template>
<div class="dropdown-title">
<span>{{ __('Assign labels') }}</span>
<button
type="button"
class="dropdown-title-button dropdown-menu-close"
:aria-label="__('Close')"
>
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
data-hidden="true"
>
</i>
</button>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true,
},
label: {
type: Object,
required: true,
},
},
};
</script>
<template>
<input
type="hidden"
:name="name"
:value="label.id"
/>
</template>
<script>
export default {};
</script>
<template>
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
type="search"
:placeholder="__('Search')"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
data-hidden="true"
>
</i>
<i
aria-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
role="button"
>
</i>
</div>
</template>
<script>
export default {
props: {
canEdit: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<div class="title hide-collapsed append-bottom-10">
{{ __('Labels') }}
<template v-if="canEdit">
<i
aria-hidden="true"
class="fa fa-spinner fa-spin block-loading"
data-hidden="true"
>
</i>
<button
type="button"
class="edit-link btn btn-blank pull-right js-sidebar-dropdown-toggle"
>
{{ __('Edit') }}
</button>
</template>
</div>
</template>
<script>
import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
labels: {
type: Array,
required: true,
},
labelFilterBasePath: {
type: String,
required: true,
},
},
computed: {
isEmpty() {
return this.labels.length === 0;
},
},
methods: {
labelFilterUrl(label) {
return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
},
labelStyle(label) {
return {
color: label.textColor,
backgroundColor: label.color,
};
},
},
};
</script>
<template>
<div class="hide-collapsed value issuable-show-labels">
<span
v-if="isEmpty"
class="text-secondary"
>
<slot>{{ __('None') }}</slot>
</span>
<a
v-else
v-for="label in labels"
:key="label.id"
:href="labelFilterUrl(label)"
>
<span
v-tooltip
class="label color-label"
data-placement="bottom"
data-container="body"
:style="labelStyle(label)"
:title="label.description"
>
{{ label.title }}
</span>
</a>
</div>
</template>
<script>
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
labels: {
type: Array,
required: true,
},
},
computed: {
labelsList() {
const labelsString = this.labels.slice(0, 5).map(label => label.title).join(', ');
if (this.labels.length > 5) {
return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
labelsString,
remainingLabelCount: this.labels.length - 5,
});
}
return labelsString;
},
},
};
</script>
<template>
<div
v-tooltip
class="sidebar-collapsed-icon"
data-placement="left"
data-container="body"
:title="labelsList"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-tags"
>
</i>
<span>{{ labels.length }}</span>
</div>
</template>
/* eslint-disable no-unused-vars, space-before-function-paren */
class ListLabel {
constructor (obj) {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
this.type = obj.type;
......
......@@ -17,7 +17,7 @@ module IssuableCollections
set_pagination
return if redirect_out_of_range(@total_pages)
if params[:label_name].present?
if params[:label_name].present? && @project
labels_params = { project_id: @project.id, title: params[:label_name] }
@labels = LabelsFinder.new(current_user, labels_params).execute
end
......
......@@ -62,19 +62,27 @@ module UploadsActions
end
def build_uploader_from_upload
return nil unless params[:secret] && params[:filename]
return unless uploader = build_uploader
upload_path = uploader_class.upload_path(params[:secret], params[:filename])
upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_path)
upload_paths = uploader.upload_paths(params[:filename])
upload = Upload.find_by(uploader: uploader_class.to_s, path: upload_paths)
upload&.build_uploader
end
def build_uploader_from_params
return unless uploader = build_uploader
uploader.retrieve_from_store!(params[:filename])
uploader
end
def build_uploader
return unless params[:secret] && params[:filename]
uploader = uploader_class.new(model, secret: params[:secret])
return nil unless uploader.model_valid?
return unless uploader.model_valid?
uploader.retrieve_from_store!(params[:filename])
uploader
end
......
......@@ -14,12 +14,13 @@ class Groups::LabelsController < Groups::ApplicationController
end
format.json do
available_labels =
if params[:only_group_labels]
group.labels
else
LabelsFinder.new(current_user, group_id: @group.id).execute
end
available_labels = LabelsFinder.new(
current_user,
group_id: @group.id,
only_group_labels: params[:only_group_labels],
include_ancestor_groups: params[:include_ancestor_groups],
include_descendant_groups: params[:include_descendant_groups]
).execute
render json: LabelSerializer.new.represent_appearance(available_labels)
end
......
......@@ -4,6 +4,7 @@ class Projects::ClustersController < Projects::ApplicationController
before_action :authorize_create_cluster!, only: [:new]
before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy]
before_action :update_applications_status, only: [:status]
STATUS_POLLING_INTERVAL = 10_000
......@@ -101,4 +102,8 @@ class Projects::ClustersController < Projects::ApplicationController
def authorize_admin_cluster!
access_denied! unless can?(current_user, :admin_cluster, cluster)
end
def update_applications_status
@cluster.applications.each(&:schedule_status_update)
end
end
......@@ -14,37 +14,31 @@ class Projects::CommitsController < Projects::ApplicationController
@merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
.find_by(source_project: @project, source_branch: @ref, target_branch: @repository.root_ref)
# https://gitlab.com/gitlab-org/gitaly/issues/931
Gitlab::GitalyClient.allow_n_plus_1_calls do
respond_to do |format|
format.html
format.atom { render layout: 'xml.atom' }
respond_to do |format|
format.html
format.atom { render layout: 'xml.atom' }
format.json do
pager_json(
'projects/commits/_commits',
@commits.size,
project: @project,
ref: @ref)
end
format.json do
pager_json(
'projects/commits/_commits',
@commits.size,
project: @project,
ref: @ref)
end
end
end
def signatures
# https://gitlab.com/gitlab-org/gitaly/issues/931
Gitlab::GitalyClient.allow_n_plus_1_calls do
respond_to do |format|
format.json do
render json: {
signatures: @commits.select(&:has_signature?).map do |commit|
{
commit_sha: commit.sha,
html: view_to_html_string('projects/commit/_signature', signature: commit.signature)
}
end
}
end
respond_to do |format|
format.json do
render json: {
signatures: @commits.select(&:has_signature?).map do |commit|
{
commit_sha: commit.sha,
html: view_to_html_string('projects/commit/_signature', signature: commit.signature)
}
end
}
end
end
end
......
......@@ -61,12 +61,20 @@ class LabelsFinder < UnionFinder
def group_ids
strong_memoize(:group_ids) do
group = Group.find(params[:group_id])
groups = params[:include_ancestor_groups].present? ? group.self_and_ancestors : [group]
groups_user_can_read_labels(groups).map(&:id)
groups_user_can_read_labels(groups_to_include).map(&:id)
end
end
def groups_to_include
group = Group.find(params[:group_id])
groups = [group]
groups += group.ancestors if params[:include_ancestor_groups].present?
groups += group.descendants if params[:include_descendant_groups].present?
groups
end
def group?
params[:group_id].present?
end
......
......@@ -48,11 +48,23 @@ class NotesFinder
def init_collection
if target
notes_on_target
elsif target_type
notes_of_target_type
else
notes_of_any_type
end
end
def notes_of_target_type
notes = notes_for_type(target_type)
search(notes)
end
def target_type
@params[:target_type]
end
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
......
......@@ -58,11 +58,37 @@ class SnippetsFinder < UnionFinder
.public_or_visible_to_user(current_user)
end
# Returns a collection of projects that is either public or visible to the
# logged in user.
#
# A caller must pass in a block to modify individual parts of
# the query, e.g. to apply .with_feature_available_for_user on top of it.
# This is useful for performance as we can stick those additional filters
# at the bottom of e.g. the UNION.
def projects_for_user
return yield(Project.public_to_user) unless current_user
# If the current_user is allowed to see all projects,
# we can shortcut and just return.
return yield(Project.all) if current_user.full_private_access?
authorized_projects = yield(Project.where('EXISTS (?)', current_user.authorizations_for_projects))
levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
visible_projects = yield(Project.where(visibility_level: levels))
# We use a UNION here instead of OR clauses since this results in better
# performance.
union = Gitlab::SQL::Union.new([authorized_projects.select('projects.id'), visible_projects.select('projects.id')])
Project.from("(#{union.to_sql}) AS #{Project.table_name}")
end
def feature_available_projects
# Don't return any project related snippets if the user cannot read cross project
return table[:id].eq(nil) unless Ability.allowed?(current_user, :read_cross_project)
projects = Project.public_or_visible_to_user(current_user, use_where_in: false) do |part|
projects = projects_for_user do |part|
part.with_feature_available_for_user(:snippets, current_user)
end.select(:id)
......
......@@ -324,10 +324,6 @@ module ApplicationHelper
cookies["sidebar_collapsed"] == "true"
end
def show_new_ide?(project)
cookies["new_repo"] == "true" && project.feature_available?(:ide)
end
def locale_path
asset_path("locale/#{Gitlab::I18n.locale}/app.js")
end
......
......@@ -33,20 +33,6 @@ module BlobHelper
ref)
end
def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
return unless show_new_ide?(project)
return unless blob = readable_blob(options, path, project, ref)
common_classes = "btn js-edit-ide #{options[:extra_class]}"
edit_button_tag(blob,
common_classes,
_('Web IDE'),
ide_edit_path(project, ref, path, options),
project,
ref)
end
def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
return unless current_user
......
module LabelsHelper
extend self
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
......
module U2fHelper
def inject_u2f_api?
((browser.chrome? && browser.version.to_i >= 41) || (browser.opera? && browser.version.to_i >= 40)) && !browser.device.mobile?
end
end
class Badge < ActiveRecord::Base
# This structure sets the placeholders that the urls
# can have. This hash also sets which action to ask when
# the placeholder is found.
PLACEHOLDERS = {
'project_path' => :full_path,
'project_id' => :id,
'default_branch' => :default_branch,
'commit_sha' => ->(project) { project.commit&.sha }
}.freeze
# This regex is built dynamically using the keys from the PLACEHOLDER struct.
# So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
default_scope { order_created_at_asc }
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX }
validates :type, presence: true
def rendered_link_url(project = nil)
build_rendered_url(link_url, project)
end
def rendered_image_url(project = nil)
build_rendered_url(image_url, project)
end
private
def build_rendered_url(url, project = nil)
return url unless valid? && project
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
replace_placeholder_action(PLACEHOLDERS[arg], project)
end
end
# The action param represents the :symbol or Proc to call in order
# to retrieve the return value from the project.
# This method checks if it is a Proc and use the call method, and if it is
# a symbol just send the action
def replace_placeholder_action(action, project)
return unless project
action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend
end
end
class GroupBadge < Badge
belongs_to :group
validates :group, presence: true
end
class ProjectBadge < Badge
belongs_to :project
validates :project, presence: true
def rendered_link_url(project = nil)
project ||= self.project
super
end
def rendered_image_url(project = nil)
project ||= self.project
super
end
end
......@@ -15,7 +15,7 @@ module Clusters
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(name, install_helm: true)
Gitlab::Kubernetes::Helm::InitCommand.new(name)
end
end
end
......
......@@ -5,6 +5,8 @@ module Clusters
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
default_value_for :ingress_type, :nginx
default_value_for :version, :nginx
......@@ -13,16 +15,34 @@ module Clusters
nginx: 1
}
FETCH_IP_ADDRESS_DELAY = 30.seconds
state_machine :status do
before_transition any => [:installed] do |application|
application.run_after_commit do
ClusterWaitForIngressIpAddressWorker.perform_in(
FETCH_IP_ADDRESS_DELAY, application.name, application.id)
end
end
end
def chart
'stable/nginx-ingress'
end
def chart_values_file
"#{Rails.root}/vendor/#{name}/values.yaml"
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name,
chart: chart,
values: values
)
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(name, chart: chart, chart_values_file: chart_values_file)
def schedule_status_update
return unless installed?
return if external_ip
ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
end
end
......
......@@ -7,6 +7,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
default_value_for :version, VERSION
......@@ -30,12 +31,12 @@ module Clusters
80
end
def chart_values_file
"#{Rails.root}/vendor/#{name}/values.yaml"
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(name, chart: chart, chart_values_file: chart_values_file)
Gitlab::Kubernetes::Helm::InstallCommand.new(
name,
chart: chart,
values: values
)
end
def proxy_client
......
module Clusters
module Applications
class Runner < ActiveRecord::Base
VERSION = '0.1.13'.freeze
self.table_name = 'clusters_applications_runners'
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
delegate :project, to: :cluster
default_value_for :version, VERSION
def chart
"#{name}/gitlab-runner"
end
def repository
'https://charts.gitlab.io'
end
def values
content_values.to_yaml
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name,
chart: chart,
values: values,
repository: repository
)
end
private
def ensure_runner
runner || create_and_assign_runner
end
def create_and_assign_runner
transaction do
project.runners.create!(name: 'kubernetes-cluster', tag_list: %w(kubernetes cluster)).tap do |runner|
update!(runner_id: runner.id)
end
end
end
def gitlab_url
Gitlab::Routing.url_helpers.root_url(only_path: false)
end
def specification
{
"gitlabUrl" => gitlab_url,
"runnerToken" => ensure_runner.token
}
end
def content_values
specification.merge(YAML.load_file(chart_values_file))
end
end
end
end
......@@ -8,7 +8,8 @@ module Clusters
APPLICATIONS = {
Applications::Helm.application_name => Applications::Helm,
Applications::Ingress.application_name => Applications::Ingress,
Applications::Prometheus.application_name => Applications::Prometheus
Applications::Prometheus.application_name => Applications::Prometheus,
Applications::Runner.application_name => Applications::Runner
}.freeze
belongs_to :user
......@@ -24,6 +25,7 @@ module Clusters
has_one :application_helm, class_name: 'Clusters::Applications::Helm'
has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
has_one :application_runner, class_name: 'Clusters::Applications::Runner'
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
......@@ -70,7 +72,8 @@ module Clusters
[
application_helm || build_application_helm,
application_ingress || build_application_ingress,
application_prometheus || build_application_prometheus
application_prometheus || build_application_prometheus,
application_runner || build_application_runner
]
end
......
......@@ -23,6 +23,11 @@ module Clusters
def name
self.class.application_name
end
def schedule_status_update
# Override if you need extra data synchronized
# from K8s after installation
end
end
end
end
......
module Clusters
module Concerns
module ApplicationData
extend ActiveSupport::Concern
included do
def repository
nil
end
def values
File.read(chart_values_file)
end
private
def chart_values_file
"#{Rails.root}/vendor/#{name}/values.yaml"
end
end
end
end
end
......@@ -19,6 +19,7 @@ class Commit
attr_accessor :project, :author
attr_accessor :redacted_description_html
attr_accessor :redacted_title_html
attr_reader :gpg_commit
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
......@@ -110,6 +111,7 @@ class Commit
@raw = raw_commit
@project = project
@statuses = {}
@gpg_commit = Gitlab::Gpg::Commit.new(self) if project
end
def id
......@@ -452,8 +454,4 @@ class Commit
def merged_merge_request_no_cache(user)
MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit?
end
def gpg_commit
@gpg_commit ||= Gitlab::Gpg::Commit.new(self)
end
end
......@@ -141,7 +141,7 @@ class CommitStatus < ActiveRecord::Base
end
def group_name
name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip
name.to_s.gsub(%r{\d+[\.\s:/\\]+\d+\s*}, '').strip
end
def failed_but_allowed?
......
......@@ -6,6 +6,12 @@ class CycleAnalytics
@options = options
end
def all_medians_per_stage
STAGES.each_with_object({}) do |stage_name, medians_per_stage|
medians_per_stage[stage_name] = self[stage_name].median
end
end
def summary
@summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project,
from: @options[:from],
......
......@@ -39,6 +39,7 @@ class Group < Namespace
# We cannot simply set `has_many :audit_events, as: :entity, dependent: :destroy`
# here since Group inherits from Namespace, the entity_type would be set to `Namespace`.
has_many :audit_events, -> { where(entity_type: Group) }, foreign_key: 'entity_id'
has_many :badges, class_name: 'GroupBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
......
......@@ -18,4 +18,8 @@ class LfsObject < ActiveRecord::Base
.where(lfs_objects_projects: { id: nil })
.destroy_all
end
def self.calculate_oid(path)
Digest::SHA256.file(path).hexdigest
end
end
......@@ -228,6 +228,8 @@ class Project < ActiveRecord::Base
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
......@@ -324,42 +326,13 @@ class Project < ActiveRecord::Base
# Returns a collection of projects that is either public or visible to the
# logged in user.
#
# A caller may pass in a block to modify individual parts of
# the query, e.g. to apply .with_feature_available_for_user on top of it.
# This is useful for performance as we can stick those additional filters
# at the bottom of e.g. the UNION.
#
# Optionally, turning `use_where_in` off leads to returning a
# relation using #from instead of #where. This can perform much better
# but leads to trouble when used in conjunction with AR's #merge method.
def self.public_or_visible_to_user(user = nil, use_where_in: true, &block)
# If we don't get a block passed, use identity to avoid if/else repetitions
block = ->(part) { part } unless block_given?
return block.call(public_to_user) unless user
# If the user is allowed to see all projects,
# we can shortcut and just return.
return block.call(all) if user.full_private_access?
authorized = user
.project_authorizations
.select(1)
.where('project_authorizations.project_id = projects.id')
authorized_projects = block.call(where('EXISTS (?)', authorized))
levels = Gitlab::VisibilityLevel.levels_for_user(user)
visible_projects = block.call(where(visibility_level: levels))
# We use a UNION here instead of OR clauses since this results in better
# performance.
union = Gitlab::SQL::Union.new([authorized_projects.select('projects.id'), visible_projects.select('projects.id')])
if use_where_in
where("projects.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
def self.public_or_visible_to_user(user = nil)
if user
where('EXISTS (?) OR projects.visibility_level IN (?)',
user.authorizations_for_projects,
Gitlab::VisibilityLevel.levels_for_user(user))
else
from("(#{union.to_sql}) AS #{table_name}")
public_to_user
end
end
......@@ -378,14 +351,11 @@ class Project < ActiveRecord::Base
elsif user
column = ProjectFeature.quoted_access_level_column(feature)
authorized = user.project_authorizations.select(1)
.where('project_authorizations.project_id = projects.id')
with_project_feature
.where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
visible,
ProjectFeature::PRIVATE,
authorized)
user.authorizations_for_projects)
else
with_feature_access_level(feature, visible)
end
......@@ -1806,6 +1776,17 @@ class Project < ActiveRecord::Base
.set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
end
def badges
return project_badges unless group
group_badges_rel = GroupBadge.where(group: group.self_and_ancestors)
union = Gitlab::SQL::Union.new([project_badges.select(:id),
group_badges_rel.select(:id)])
Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
private
def storage
......
......@@ -99,7 +99,7 @@ class ChatNotificationService < Service
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"
ChatMessage::PushMessage.new(data)
ChatMessage::PushMessage.new(data) if notify_for_ref?(data)
when "issue"
ChatMessage::IssueMessage.new(data) unless update?(data)
when "merge_request"
......@@ -145,10 +145,16 @@ class ChatNotificationService < Service
end
def notify_for_ref?(data)
return true if data[:object_attributes][:tag]
return true if data.dig(:object_attributes, :tag)
return true unless notify_only_default_branch?
data[:object_attributes][:ref] == project.default_branch
ref = if data[:ref]
Gitlab::Git.ref_name(data[:ref])
else
data.dig(:object_attributes, :ref)
end
ref == project.default_branch
end
def notify_for_pipeline?(data)
......
......@@ -597,15 +597,7 @@ class Repository
def license_key
return unless exists?
# The licensee gem creates a Rugged object from the path:
# https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb
begin
Licensee.license(path).try(:key)
# Normally we would rescue Rugged::Error, but that is banned by lint-rugged
# and we need to migrate this endpoint to Gitaly:
# https://gitlab.com/gitlab-org/gitaly/issues/1026
rescue
end
raw_repository.license_short_name
end
cache_method :license_key
......
......@@ -619,6 +619,15 @@ class User < ActiveRecord::Base
authorized_projects(min_access_level).exists?({ id: project.id })
end
# Typically used in conjunction with projects table to get projects
# a user has been given access to.
#
# Example use:
# `Project.where('EXISTS(?)', user.authorizations_for_projects)`
def authorizations_for_projects
project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
end
# Returns the projects this user has reporter (or greater) access to, limited
# to at most the given projects.
#
......
......@@ -7,6 +7,7 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil
# median returns a BatchLoader instance which we first have to unwrap by using to_i
!stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
end
end
......@@ -2,4 +2,5 @@ class ClusterApplicationEntity < Grape::Entity
expose :name
expose :status_name, as: :status
expose :status_reason
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
end
module Badges
class BaseService
protected
attr_accessor :params
def initialize(params = {})
@params = params.dup
end
end
end
module Badges
class BuildService < Badges::BaseService
# returns the created badge
def execute(source)
if source.is_a?(Group)
GroupBadge.new(params.merge(group: source))
else
ProjectBadge.new(params.merge(project: source))
end
end
end
end
module Badges
class CreateService < Badges::BaseService
# returns the created badge
def execute(source)
badge = Badges::BuildService.new(params).execute(source)
badge.tap { |b| b.save }
end
end
end
module Badges
class UpdateService < Badges::BaseService
# returns the updated badge
def execute(badge)
if params.present?
badge.update_attributes(params)
end
badge
end
end
end
module Clusters
module Applications
class CheckIngressIpAddressService < BaseHelmService
include Gitlab::Utils::StrongMemoize
Error = Class.new(StandardError)
LEASE_TIMEOUT = 15.seconds.to_i
def execute
return if app.external_ip
return unless try_obtain_lease
app.update!(external_ip: ingress_ip) if ingress_ip
end
private
def try_obtain_lease
Gitlab::ExclusiveLease
.new("check_ingress_ip_address_service:#{app.id}", timeout: LEASE_TIMEOUT)
.try_obtain
end
def ingress_ip
service.status.loadBalancer.ingress&.first&.ip
end
def service
strong_memoize(:ingress_service) do
kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE)
end
end
end
end
end
......@@ -111,6 +111,10 @@ class IssuableBaseService < BaseService
@available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
end
def handle_quick_actions_on_create(issuable)
merge_quick_actions_into_params!(issuable)
end
def merge_quick_actions_into_params!(issuable)
original_description = params.fetch(:description, issuable.description)
......@@ -133,7 +137,7 @@ class IssuableBaseService < BaseService
end
def create(issuable)
merge_quick_actions_into_params!(issuable)
handle_quick_actions_on_create(issuable)
filter_params(issuable)
params.delete(:state_event)
......
......@@ -26,6 +26,17 @@ module MergeRequests
private
def handle_wip_event(merge_request)
if wip_event = params.delete(:wip_event)
# We update the title that is provided in the params or we use the mr title
title = params[:title] || merge_request.title
params[:title] = case wip_event
when 'wip' then MergeRequest.wip_title(title)
when 'unwip' then MergeRequest.wipless_title(title)
end
end
end
def merge_request_metrics_service(merge_request)
MergeRequestMetricsService.new(merge_request.metrics)
end
......
......@@ -34,6 +34,12 @@ module MergeRequests
super
end
# Override from IssuableBaseService
def handle_quick_actions_on_create(merge_request)
super
handle_wip_event(merge_request)
end
private
def update_merge_requests_head_pipeline(merge_request)
......
......@@ -119,17 +119,6 @@ module MergeRequests
end
end
def handle_wip_event(merge_request)
if wip_event = params.delete(:wip_event)
# We update the title that is provided in the params or we use the mr title
title = params[:title] || merge_request.title
params[:title] = case wip_event
when 'wip' then MergeRequest.wip_title(title)
when 'unwip' then MergeRequest.wipless_title(title)
end
end
end
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
......
......@@ -348,9 +348,9 @@ module QuickActions
"#{verb} this #{noun} as Work In Progress."
end
condition do
issuable.persisted? &&
issuable.respond_to?(:work_in_progress?) &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
issuable.respond_to?(:work_in_progress?) &&
# Allow it to mark as WIP on MR creation page _or_ through MR notes.
(issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable))
end
command :wip do
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
......
......@@ -32,8 +32,11 @@ class FileUploader < GitlabUploader
)
end
def self.base_dir(model)
model_path_segment(model)
def self.base_dir(model, store = Store::LOCAL)
decorated_model = model
decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE
model_path_segment(decorated_model)
end
# used in migrations and import/exports
......@@ -51,21 +54,24 @@ class FileUploader < GitlabUploader
#
# Returns a String without a trailing slash
def self.model_path_segment(model)
if model.hashed_storage?(:attachments)
model.disk_path
case model
when Storage::HashedProject then model.disk_path
else
model.full_path
model.hashed_storage?(:attachments) ? model.disk_path : model.full_path
end
end
def self.upload_path(secret, identifier)
File.join(secret, identifier)
end
def self.generate_secret
SecureRandom.hex
end
def upload_paths(filename)
[
File.join(secret, filename),
File.join(base_dir(Store::REMOTE), secret, filename)
]
end
attr_accessor :model
def initialize(model, mounted_as = nil, **uploader_context)
......@@ -75,8 +81,10 @@ class FileUploader < GitlabUploader
apply_context!(uploader_context)
end
def base_dir
self.class.base_dir(@model)
# enforce the usage of Hashed storage when storing to
# remote store as the FileMover doesn't support OS
def base_dir(store = nil)
self.class.base_dir(@model, store || object_store)
end
# we don't need to know the actual path, an uploader instance should be
......@@ -86,15 +94,19 @@ class FileUploader < GitlabUploader
end
def upload_path
self.class.upload_path(dynamic_segment, identifier)
end
def model_path_segment
self.class.model_path_segment(@model)
if file_storage?
# Legacy path relative to project.full_path
File.join(dynamic_segment, identifier)
else
File.join(store_dir, identifier)
end
end
def store_dir
File.join(base_dir, dynamic_segment)
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
Store::REMOTE => File.join(base_dir(ObjectStorage::Store::REMOTE), dynamic_segment)
}
end
def markdown_link
......
......@@ -4,7 +4,7 @@ class NamespaceFileUploader < FileUploader
options.storage_path
end
def self.base_dir(model)
def self.base_dir(model, _store = nil)
File.join(options.base_dir, 'namespace', model_path_segment(model))
end
......@@ -20,7 +20,7 @@ class NamespaceFileUploader < FileUploader
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
Store::REMOTE => File.join('namespace', model_path_segment, dynamic_segment)
Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment)
}
end
end
......@@ -4,7 +4,7 @@ class PersonalFileUploader < FileUploader
options.storage_path
end
def self.base_dir(model)
def self.base_dir(model, _store = nil)
File.join(options.base_dir, model_path_segment(model))
end
......@@ -34,7 +34,7 @@ class PersonalFileUploader < FileUploader
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
Store::REMOTE => File.join(model_path_segment, dynamic_segment)
Store::REMOTE => File.join(self.class.model_path_segment(model), dynamic_segment)
}
end
......
# UrlValidator
#
# Custom validator for URLs.
#
# By default, only URLs for the HTTP(S) protocols will be considered valid.
# Provide a `:protocols` option to configure accepted protocols.
#
# Also, this validator can help you validate urls with placeholders inside.
# Usually, if you have a url like 'http://www.example.com/%{project_path}' the
# URI parser will reject that URL format. Provide a `:placeholder_regex` option
# to configure accepted placeholders.
#
# Example:
#
# class User < ActiveRecord::Base
# validates :personal_url, url: true
#
# validates :ftp_url, url: { protocols: %w(ftp) }
#
# validates :git_url, url: { protocols: %w(http https ssh git) }
#
# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ }
# end
#
class UrlPlaceholderValidator < UrlValidator
def validate_each(record, attribute, value)
placeholder_regex = self.options[:placeholder_regex]
value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value
super(record, attribute, value)
end
end
......@@ -706,15 +706,15 @@
.checkbox
= f.label :usage_ping_enabled do
= f.check_box :usage_ping_enabled, disabled: !can_be_configured
Usage ping enabled
= link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
Enable usage ping
.help-block
- if can_be_configured
Every week GitLab will report license usage back to GitLab, Inc.
Disable this option if you do not want this to occur. To see the
JSON payload that will be sent, visit the
= succeed '.' do
= link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping')
To help improve GitLab and its user experience, GitLab will
periodically collect usage information.
= link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping")
about what information is shared with GitLab Inc. Visit
= link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping')
to see the JSON payload sent.
- else
The usage ping is disabled, and cannot be configured through this
form. For more information, see the documentation on
......
- if inject_u2f_api?
- content_for :page_specific_javascripts do
= webpack_bundle_tag('u2f')
%div
= render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
.login-box
......
......@@ -42,7 +42,6 @@
= webpack_bundle_tag "common"
= webpack_bundle_tag "main"
= webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled
= webpack_bundle_tag "test" if Rails.env.test?
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
......
......@@ -2,12 +2,6 @@
- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
- content_for :page_specific_javascripts do
- if inject_u2f_api?
= webpack_bundle_tag('u2f')
= webpack_bundle_tag('two_factor_auth')
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.prepend-top-default
.col-lg-4
......
......@@ -30,6 +30,12 @@
%br
= render "shared/mirror_status"
.project-badges
- @project.badges.each do |badge|
- badge_link_url = badge.rendered_link_url(@project)
%a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' }
%img{ src: badge.rendered_image_url(@project), alt: badge_link_url }
.project-repo-buttons
.count-buttons
= render 'projects/buttons/star'
......
......@@ -18,7 +18,14 @@
.email-modal-input-group.input-group
= text_field_tag :issuable_email, email, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-btn
= clipboard_button(target: '#issuable_email')
= clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard btn-transparent hidden-xs')
= mail_to email, class: 'btn btn-clipboard btn-transparent',
subject: _("Enter the #{name} title"),
body: _("Enter the #{name} description"),
title: _('Send email'),
data: { toggle: 'tooltip', placement: 'bottom' } do
= sprite_icon('mail')
%p
= render 'by_email_description'
%p
......
......@@ -14,7 +14,9 @@
= lock_file_link(html_options: { class: 'btn btn-sm path-lock' })
= edit_blob_button
-# EE-specific
= ide_edit_button
-# EE-specific
- if current_user
= replace_blob_link
= delete_blob_link
......
......@@ -10,11 +10,13 @@
install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm),
install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus),
install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner),
toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'),
ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'),
manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } }
.js-cluster-application-notice
......
......@@ -5,7 +5,6 @@
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('service_desk')
.project-edit-container
%section.settings.general-settings.no-animate{ class: ('expanded' if expanded) }
......
......@@ -3,7 +3,6 @@
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm/xterm"
= webpack_bundle_tag("terminal")
%div{ class: container_class }
.top-area
......
......@@ -5,9 +5,6 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
- if has_vue_discussions_cookie?
- content_for :page_specific_javascripts do
= webpack_bundle_tag('mr_notes')
.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
= render "projects/merge_requests/mr_title"
......
......@@ -78,9 +78,11 @@
= render 'projects/find_file_link'
-## EE-specific
- if show_new_ide?(@project)
= succeed " " do
= link_to ide_edit_path(@project, @id), class: 'btn btn-default' do
= _('Web IDE')
-## EE-specific
= render 'projects/buttons/download', project: @project, ref: @ref
......@@ -14,25 +14,25 @@
= link_to search_filter_path(scope: 'issues') do
Issues
%span.badge
= @search_results.issues_count
= limited_count(@search_results.limited_issues_count)
- if project_search_tabs?(:merge_requests)
%li{ class: active_when(@scope == 'merge_requests') }
= link_to search_filter_path(scope: 'merge_requests') do
Merge requests
%span.badge
= @search_results.merge_requests_count
= limited_count(@search_results.limited_merge_requests_count)
- if project_search_tabs?(:milestones)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
Milestones
%span.badge
= @search_results.milestones_count
= limited_count(@search_results.limited_milestones_count)
- if project_search_tabs?(:notes)
%li{ class: active_when(@scope == 'notes') }
= link_to search_filter_path(scope: 'notes') do
Comments
%span.badge
= @search_results.notes_count
= limited_count(@search_results.limited_notes_count)
- if project_search_tabs?(:wiki)
%li{ class: active_when(@scope == 'wiki_blobs') }
= link_to search_filter_path(scope: 'wiki_blobs') do
......
- show_create = local_assigns.fetch(:show_create, false)
- show_new_branch_form = show_new_ide?(@project) && show_create && can?(current_user, :push_code, @project)
- dropdown_toggle_text = @ref || @project.default_branch
= form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do
= hidden_field_tag :destination, destination
......@@ -16,14 +15,3 @@
= dropdown_filter _("Search branches and tags")
= dropdown_content
= dropdown_loading
- if show_new_branch_form
= dropdown_footer do
%ul.dropdown-footer-list
%li
%a.dropdown-toggle-page{ href: "#" }
Create new branch
- if show_new_branch_form
.dropdown-page-two
= dropdown_title("Create new branch", options: { back: true })
= dropdown_content do
.js-new-branch-dropdown
......@@ -24,6 +24,7 @@
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation
- gcp_cluster:check_gcp_project_billing
- gcp_cluster:cluster_wait_for_ingress_ip_address
- github_import_advance_stage
- github_importer:github_import_import_diff_note
......
class ClusterWaitForIngressIpAddressWorker
include ApplicationWorker
include ClusterQueue
include ClusterApplications
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
Clusters::Applications::CheckIngressIpAddressService.new(app).execute
end
end
end
---
title: Add email button to new issue by email
merge_request: 10942
author: Islam Wazery
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.
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