Commit 1208d552 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'master' into 'refactor-snippets-finder'

# Conflicts:
#   spec/models/project_spec.rb
parents d171ff60 d0c58a97
Dangerfile gitlab-language=ruby
db/schema.rb merge=merge_db_schema
......@@ -2,6 +2,27 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 11.4.5 (2018-11-04)
### Fixed (4 changes, 1 of them is from the community)
- fix link to enable usage ping from convdev index. !22545 (Anand Capur)
- Update gitlab-ui dependency to 1.8.0-hotfix.1 to fix IE11 bug.
- Remove duplicate escape in job sidebar.
- Fixed merge request fill tree toggling not respecting fluid width preference.
### Other (1 change)
- Fix stage dropdown not rendering in different languages.
## 11.4.4 (2018-10-30)
### Security (1 change)
- Monkey kubeclient to not follow any redirects.
## 11.4.3 (2018-10-26)
- No changes.
......@@ -250,6 +271,13 @@ entry.
- Check frozen string in style builds. (gfyoung)
## 11.3.9 (2018-10-31)
### Security (1 change)
- Monkey kubeclient to not follow any redirects.
## 11.3.8 (2018-10-27)
- No changes.
......@@ -555,6 +583,13 @@ entry.
- Creates Vue component for artifacts block on job page.
## 11.2.8 (2018-10-31)
### Security (1 change)
- Monkey kubeclient to not follow any redirects.
## 11.2.7 (2018-10-27)
- No changes.
......
......@@ -30,6 +30,7 @@ class ListIssue {
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
this.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
if (obj.project) {
this.project = new IssueProject(obj.project);
......
......@@ -2,9 +2,15 @@
import PipelinesService from '../../pipelines/services/pipelines_service';
import PipelineStore from '../../pipelines/stores/pipelines_store';
import pipelinesMixin from '../../pipelines/mixins/pipelines';
import TablePagination from '../../vue_shared/components/table_pagination.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
export default {
mixins: [pipelinesMixin],
components: {
TablePagination,
},
mixins: [pipelinesMixin, CIPaginationMixin],
props: {
endpoint: {
type: String,
......@@ -35,6 +41,8 @@ export default {
return {
store,
state: store.state,
page: getParameterByName('page') || '1',
requestData: {},
};
},
......@@ -48,11 +56,14 @@ export default {
},
created() {
this.service = new PipelinesService(this.endpoint);
this.requestData = { page: this.page };
},
methods: {
successCallback(resp) {
// depending of the endpoint the response can either bring a `pipelines` key or not.
const pipelines = resp.data.pipelines || resp.data;
this.store.storePagination(resp.headers);
this.setCommonData(pipelines);
const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
......@@ -97,5 +108,11 @@ export default {
:view-type="viewType"
/>
</div>
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
:page-info="state.pageInfo"
/>
</div>
</template>
import Vue from 'vue';
import { GlProgressBar, GlLoadingIcon, GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
Vue.component('gl-progress-bar', GlProgressBar);
Vue.component('gl-loading-icon', GlLoadingIcon);
Vue.directive('gl-tooltip', GlTooltipDirective);
......@@ -3,6 +3,7 @@ import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize, truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
export default {
......@@ -10,6 +11,9 @@ export default {
Icon,
UserAvatarImage,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
discussions: {
type: Array,
......
......@@ -167,7 +167,7 @@ export default {
<button
v-if="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button"
class="add-diff-note js-add-diff-note-button qa-diff-comment"
title="Add a comment to this line"
@click="handleCommentButton"
>
......
......@@ -102,7 +102,7 @@ export default {
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
class="diff-line-num new_line"
class="diff-line-num new_line qa-new-diff-line"
/>
<td
:class="line.type"
......
......@@ -31,7 +31,7 @@ class DirtySubmitForm {
updateDirtyInput(event) {
const input = event.target;
if (!input.dataset.dirtySubmitOriginalValue) return;
if (!input.dataset.isDirtySubmitInput) return;
this.updateDirtyInputs(input);
this.toggleSubmission();
......@@ -65,6 +65,7 @@ class DirtySubmitForm {
}
static initInput(element) {
element.dataset.isDirtySubmitInput = true;
element.dataset.dirtySubmitOriginalValue = DirtySubmitForm.inputCurrentValue(element);
}
......
<script>
import { s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip';
......@@ -28,10 +30,24 @@ export default {
},
},
methods: {
onClickAction(endpoint) {
onClickAction(action) {
if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
"DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
),
{ jobName: action.name },
);
// https://gitlab.com/gitlab-org/gitlab-ce/issues/52156
// eslint-disable-next-line no-alert
if (!window.confirm(confirmationMessage)) {
return;
}
}
this.isLoading = true;
eventHub.$emit('postAction', { endpoint });
eventHub.$emit('postAction', { endpoint: action.playPath });
},
isActionDisabled(action) {
......@@ -41,6 +57,11 @@ export default {
return !action.playable;
},
remainingTime(action) {
const remainingMilliseconds = new Date(action.scheduledAt).getTime() - Date.now();
return formatTime(Math.max(0, remainingMilliseconds));
},
},
};
</script>
......@@ -54,7 +75,7 @@ export default {
:aria-label="title"
:disabled="isLoading"
type="button"
class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container"
class="dropdown btn btn-default dropdown-new js-environment-actions-dropdown"
data-container="body"
data-toggle="dropdown"
>
......@@ -75,12 +96,19 @@ export default {
:class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)"
type="button"
class="js-manual-action-link no-btn btn"
@click="onClickAction(action.play_path)"
class="js-manual-action-link no-btn btn d-flex align-items-center"
@click="onClickAction(action)"
>
<span>
<span class="flex-fill">
{{ action.name }}
</span>
<span
v-if="action.scheduledAt"
class="text-secondary"
>
<icon name="clock" />
{{ remainingTime(action) }}
</span>
</button>
</li>
</ul>
......
......@@ -13,6 +13,7 @@ import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/**
* Environment Item Component
......@@ -73,21 +74,6 @@ export default {
return false;
},
/**
* Verifies is the given environment has manual actions.
* Used to verify if we should render them or nor.
*
* @returns {Boolean|Undefined}
*/
hasManualActions() {
return (
this.model &&
this.model.last_deployment &&
this.model.last_deployment.manual_actions &&
this.model.last_deployment.manual_actions.length > 0
);
},
/**
* Checkes whether the environment is protected.
* (`is_protected` currently only set in EE)
......@@ -154,23 +140,20 @@ export default {
return '';
},
/**
* Returns the manual actions with the name parsed.
*
* @returns {Array.<Object>|Undefined}
*/
manualActions() {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map(action => {
const parsedAction = {
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
return parsedAction;
});
actions() {
if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) {
return [];
}
return [];
const { manualActions, scheduledActions } = convertObjectPropsToCamelCase(
this.model.last_deployment,
{ deep: true },
);
const combinedActions = (manualActions || []).concat(scheduledActions || []);
return combinedActions.map(action => ({
...action,
name: humanize(action.name),
}));
},
/**
......@@ -443,7 +426,7 @@ export default {
displayEnvironmentActions() {
return (
this.hasManualActions ||
this.actions.length > 0 ||
this.externalURL ||
this.monitoringUrl ||
this.canStopEnvironment ||
......@@ -619,8 +602,8 @@ export default {
/>
<actions-component
v-if="hasManualActions && canCreateDeployment"
:actions="manualActions"
v-if="actions.length > 0"
:actions="actions"
/>
<terminal-button-component
......
<script>
import { GlLink } from '@gitlab-org/gitlab-ui';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
TimeagoTooltip,
GlLink,
},
mixins: [timeagoMixin],
props: {
......@@ -53,16 +55,16 @@ export default {
class="btn-group d-flex"
role="group"
>
<a
<gl-link
v-if="artifact.keep_path"
:href="artifact.keep_path"
class="js-keep-artifacts btn btn-sm btn-default"
data-method="post"
>
{{ s__('Job|Keep') }}
</a>
</gl-link>
<a
<gl-link
v-if="artifact.download_path"
:href="artifact.download_path"
class="js-download-artifacts btn btn-sm btn-default"
......@@ -70,15 +72,15 @@ export default {
rel="nofollow"
>
{{ s__('Job|Download') }}
</a>
</gl-link>
<a
<gl-link
v-if="artifact.browse_path"
:href="artifact.browse_path"
class="js-browse-artifacts btn btn-sm btn-default"
>
{{ s__('Job|Browse') }}
</a>
</gl-link>
</div>
</div>
</template>
<script>
import { GlLink } from '@gitlab-org/gitlab-ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
components: {
ClipboardButton,
GlLink,
},
props: {
commit: {
......@@ -31,10 +33,10 @@ export default {
<p>
{{ __('Commit') }}
<a
<gl-link
:href="commit.commit_path"
class="js-commit-sha commit-sha link-commit"
>{{ commit.short_id }}</a>
>{{ commit.short_id }}</gl-link>
<clipboard-button
:text="commit.short_id"
......@@ -42,11 +44,11 @@ export default {
css-class="btn btn-clipboard btn-transparent"
/>
<a
<gl-link
v-if="mergeRequest"
:href="mergeRequest.path"
class="js-link-commit link-commit"
>!{{ mergeRequest.iid }}</a>
>!{{ mergeRequest.iid }}</gl-link>
</p>
<p class="build-light-text append-bottom-0">
......
<script>
import { GlLink } from '@gitlab-org/gitlab-ui';
export default {
components: {
GlLink,
},
props: {
illustrationPath: {
type: String,
......@@ -62,13 +67,13 @@ export default {
v-if="action"
class="text-center"
>
<a
<gl-link
:href="action.path"
:data-method="action.method"
class="js-job-empty-state-action btn btn-primary"
>
{{ action.button_title }}
</a>
</gl-link>
</div>
</div>
</div>
......
<script>
import _ from 'underscore';
import { GlLink } from '@gitlab-org/gitlab-ui';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeagoTooltip,
GlLink,
},
props: {
user: {
......@@ -29,9 +31,9 @@ export default {
<div class="erased alert alert-warning">
<template v-if="isErasedByUser">
{{ s__("Job|Job has been erased by") }}
<a :href="user.web_url">
<gl-link :href="user.web_url">
{{ user.username }}
</a>
</gl-link>
</template>
<template v-else>
{{ s__("Job|Job has been erased") }}
......
<script>
import _ from 'underscore';
import { mapGetters, mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import bp from '~/breakpoints';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
......@@ -23,6 +24,7 @@ export default {
EmptyState,
EnvironmentsBlock,
ErasedBlock,
GlLoadingIcon,
Log,
LogTopBar,
StuckBlock,
......
<script>
import { GlTooltipDirective, GlLink } from '@gitlab-org/gitlab-ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
components: {
CiIcon,
Icon,
GlLink,
},
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
props: {
job: {
......@@ -37,11 +38,10 @@ export default {
active: isActive
}"
>
<a
v-tooltip
<gl-link
v-gl-tooltip
:href="job.status.details_path"
:title="tooltipText"
data-container="body"
data-boundary="viewport"
class="js-job-link"
>
......@@ -60,6 +60,6 @@ export default {
name="retry"
class="js-retry-icon"
/>
</a>
</gl-link>
</div>
</template>
<script>
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab-org/gitlab-ui';
import { polyfillSticky } from '~/lib/utils/sticky';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { sprintf } from '~/locale';
import scrollDown from '../svg/scroll_down.svg';
......@@ -9,9 +9,11 @@ import scrollDown from '../svg/scroll_down.svg';
export default {
components: {
Icon,
GlLink,
GlButton,
},
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
scrollDown,
props: {
......@@ -73,76 +75,70 @@ export default {
<template v-if="isTraceSizeVisible">
{{ jobLogSize }}
<a
<gl-link
v-if="rawPath"
:href="rawPath"
class="js-raw-link raw-link"
>
{{ s__("Job|Complete Raw") }}
</a>
</gl-link>
</template>
</div>
<!-- eo truncate information -->
<div class="controllers float-right">
<!-- links -->
<a
<gl-link
v-if="rawPath"
v-tooltip
v-gl-tooltip.body
:title="s__('Job|Show complete raw')"
:href="rawPath"
class="js-raw-link-controller controllers-buttons"
data-container="body"
>
<icon name="doc-text" />
</a>
</gl-link>
<a
<gl-link
v-if="erasePath"
v-tooltip
v-gl-tooltip.body
:title="s__('Job|Erase job log')"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
class="js-erase-link controllers-buttons"
data-container="body"
data-method="post"
>
<icon name="remove" />
</a>
</gl-link>
<!-- eo links -->
<!-- scroll buttons -->
<div
v-tooltip
v-gl-tooltip
:title="s__('Job|Scroll to top')"
class="controllers-buttons"
data-container="body"
>
<button
<gl-button
:disabled="isScrollTopDisabled"
type="button"
class="js-scroll-top btn-scroll btn-transparent btn-blank"
@click="handleScrollToTop"
>
<icon name="scroll_up"/>
</button>
<icon name="scroll_up" />
</gl-button>
</div>
<div
v-tooltip
v-gl-tooltip
:title="s__('Job|Scroll to bottom')"
class="controllers-buttons"
data-container="body"
>
<button
<gl-button
:disabled="isScrollBottomDisabled"
type="button"
class="js-scroll-bottom btn-scroll btn-transparent btn-blank"
:class="{ animate: isScrollingDown }"
@click="handleScrollToBottom"
v-html="$options.scrollDown"
>
</button>
/>
</div>
<!-- eo scroll buttons -->
</div>
......
<script>
import { GlLink } from '@gitlab-org/gitlab-ui';
export default {
name: 'SidebarDetailRow',
components: {
GlLink,
},
props: {
title: {
type: String,
......@@ -41,7 +46,7 @@ export default {
v-if="hasHelpURL"
class="help-button float-right"
>
<a
<gl-link
:href="helpUrl"
target="_blank"
rel="noopener noreferrer nofollow"
......@@ -50,7 +55,7 @@ export default {
class="fa fa-question-circle"
aria-hidden="true"
></i>
</a>
</gl-link>
</span>
</p>
</template>
<script>
import { GlLink } from '@gitlab-org/gitlab-ui';
/**
* Renders Stuck Runners block for job's view.
*/
export default {
components: {
GlLink,
},
props: {
hasNoRunnersForProject: {
type: Boolean,
......@@ -52,12 +56,12 @@ export default {
</p>
{{ __("Go to") }}
<a
<gl-link
v-if="runnersPath"
:href="runnersPath"
class="js-runners-path"
>
{{ __("Runners page") }}
</a>
</gl-link>
</div>
</template>
......@@ -59,7 +59,6 @@ export default class LabelsSelect {
$toggleText = $dropdown.find('.dropdown-toggle-text');
namespacePath = $dropdown.data('namespacePath');
projectPath = $dropdown.data('projectPath');
labelUrl = $dropdown.data('labels');
issueUpdateURL = $dropdown.data('issueUpdate');
selectedLabel = $dropdown.data('selected');
if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) {
......@@ -168,6 +167,7 @@ export default class LabelsSelect {
$dropdown.glDropdown({
showMenuAbove: showMenuAbove,
data: function(term, callback) {
labelUrl = $dropdown.attr('data-labels');
axios
.get(labelUrl)
.then(res => {
......
......@@ -390,7 +390,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled"
name="button"
type="button"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static"
data-toggle="dropdown"
aria-label="Open comment type dropdown">
......@@ -422,7 +422,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
<button
type="button"
class="btn btn-transparent"
class="btn btn-transparent qa-discussion-option"
@click.prevent="setNoteType('discussion')">
<i
aria-hidden="true"
......
<script>
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import { mapGetters, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE } from '../constants';
export default {
components: {
......@@ -12,14 +13,17 @@ export default {
type: Array,
required: true,
},
defaultValue: {
selectedValue: {
type: Number,
default: null,
required: false,
},
},
data() {
return { currentValue: this.defaultValue };
return {
currentValue: this.selectedValue,
defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE,
};
},
computed: {
...mapGetters(['getNotesDataByProp']),
......@@ -28,8 +32,11 @@ export default {
return this.filters.find(filter => filter.value === this.currentValue);
},
},
mounted() {
this.toggleCommentsForm();
},
methods: {
...mapActions(['filterDiscussion']),
...mapActions(['filterDiscussion', 'setCommentsDisabled']),
selectFilter(value) {
const filter = parseInt(value, 10);
......@@ -39,6 +46,10 @@ export default {
if (filter === this.currentValue) return;
this.currentValue = filter;
this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter });
this.toggleCommentsForm();
},
toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
},
},
};
......@@ -73,6 +84,10 @@ export default {
>
{{ filter.title }}
</button>
<div
v-if="filter.value === defaultValue"
class="dropdown-divider"
></div>
</li>
</ul>
</div>
......
......@@ -187,7 +187,7 @@ export default {
:data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
@keydown.meta.enter="handleUpdate()"
......
......@@ -369,7 +369,7 @@ Please check your network connection and try again.`;
role="group">
<button
type="button"
class="js-vue-discussion-reply btn btn-text-field mr-2"
class="js-vue-discussion-reply btn btn-text-field mr-2 qa-discussion-reply"
title="Add a reply"
@click="showReplyForm">Reply...</button>
</div>
......
......@@ -60,6 +60,7 @@ export default {
'getNotesDataByProp',
'discussionCount',
'isLoading',
'commentsDisabled',
]),
noteableType() {
return this.noteableData.noteableType;
......@@ -206,6 +207,7 @@ export default {
</ul>
<comment-form
v-if="!commentsDisabled"
:noteable-type="noteableType"
:markdown-version="markdownVersion"
/>
......
......@@ -15,6 +15,8 @@ export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0;
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
......
......@@ -6,7 +6,7 @@ export default store => {
if (discussionFilterEl) {
const { defaultFilter, notesFilters } = discussionFilterEl.dataset;
const defaultValue = defaultFilter ? parseInt(defaultFilter, 10) : null;
const selectedValue = defaultFilter ? parseInt(defaultFilter, 10) : null;
const filterValues = notesFilters ? JSON.parse(notesFilters) : {};
const filters = Object.keys(filterValues).map(entry => ({
title: entry,
......@@ -24,7 +24,7 @@ export default store => {
return createElement('discussion-filter', {
props: {
filters,
defaultValue,
selectedValue,
},
});
},
......
......@@ -364,5 +364,9 @@ export const filterDiscussion = ({ dispatch }, { path, filter }) => {
});
};
export const setCommentsDisabled = ({ commit }, data) => {
commit(types.DISABLE_COMMENTS, data);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -192,5 +192,7 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
return getters.unresolvedDiscussionsIdsByDate[0];
};
export const commentsDisabled = state => state.commentsDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -21,6 +21,7 @@ export default () => ({
noteableData: {
current_user: {},
},
commentsDisabled: false,
},
actions,
getters,
......
......@@ -15,6 +15,7 @@ export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION';
export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES';
export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
......
......@@ -225,4 +225,8 @@ export default {
discussion.truncated_diff_lines = diffLines;
},
[types.DISABLE_COMMENTS](state, value) {
state.commentsDisabled = value;
},
};
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
document.addEventListener('DOMContentLoaded', () => {
initGkeDropdowns();
});
import Vue from 'vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
document.addEventListener('DOMContentLoaded', () => {
const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
remainingTimeElements.forEach(
el =>
new Vue({
...GlCountdown,
el,
propsData: {
endDateString: el.dateTime,
},
}),
);
});
......@@ -155,14 +155,6 @@ export default {
);
},
shouldRenderPagination() {
return (
!this.isLoading &&
this.state.pipelines.length &&
this.state.pageInfo.total > this.state.pageInfo.perPage
);
},
emptyTabMessage() {
const { scopes } = this.$options;
const possibleScopes = [scopes.pending, scopes.running, scopes.finished];
......@@ -232,36 +224,6 @@ export default {
this.setCommonData(resp.data.pipelines);
}
},
/**
* Handles URL and query parameter changes.
* When the user uses the pagination or the tabs,
* - update URL
* - Make API request to the server with new parameters
* - Update the polling function
* - Update the internal state
*/
updateContent(parameters) {
this.updateInternalState(parameters);
// fetch new data
return this.service
.getPipelines(this.requestData)
.then(response => {
this.isLoading = false;
this.successCallback(response);
// restart polling
this.poll.restart({ data: this.requestData });
})
.catch(() => {
this.isLoading = false;
this.errorCallback();
// restart polling
this.poll.restart({ data: this.requestData });
});
},
handleResetRunnersCache(endpoint) {
this.isResetCacheButtonLoading = true;
......
......@@ -29,7 +29,7 @@ export default {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
"DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes.",
"DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
),
{ jobName: action.name },
);
......
......@@ -23,6 +23,15 @@ export default {
hasMadeRequest: false,
};
},
computed: {
shouldRenderPagination() {
return (
!this.isLoading &&
this.state.pipelines.length &&
this.state.pageInfo.total > this.state.pageInfo.perPage
);
},
},
beforeMount() {
this.poll = new Poll({
resource: this.service,
......@@ -65,6 +74,35 @@ export default {
this.poll.stop();
},
methods: {
/**
* Handles URL and query parameter changes.
* When the user uses the pagination or the tabs,
* - update URL
* - Make API request to the server with new parameters
* - Update the polling function
* - Update the internal state
*/
updateContent(parameters) {
this.updateInternalState(parameters);
// fetch new data
return this.service
.getPipelines(this.requestData)
.then(response => {
this.isLoading = false;
this.successCallback(response);
// restart polling
this.poll.restart({ data: this.requestData });
})
.catch(() => {
this.isLoading = false;
this.errorCallback();
// restart polling
this.poll.restart({ data: this.requestData });
});
},
updateTable() {
// Cancel ongoing request
if (this.isMakingRequest) {
......
<script>
import IssuesBlock from '~/reports/components/report_issues.vue';
import { STATUS_SUCCESS, STATUS_FAILED, STATUS_NEUTRAL } from '~/reports/constants';
import ReportItem from '~/reports/components/report_item.vue';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
const wrapIssueWithState = (status, isNew = false) => issue => ({
status: issue.status || status,
isNew,
issue,
});
/**
* Renders block of issues
*/
export default {
components: {
IssuesBlock,
SmartVirtualList,
ReportItem,
},
success: STATUS_SUCCESS,
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
// Typical height of a report item in px
typicalReportItemHeight: 32,
/*
The maximum amount of shown issues. This is calculated by
( max-height of report-block-list / typicalReportItemHeight ) + some safety margin
We will use VirtualList if we have more items than this number.
For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly.
*/
maxShownReportItems: 20,
props: {
newIssues: {
type: Array,
......@@ -40,42 +53,34 @@ export default {
default: '',
},
},
computed: {
issuesWithState() {
return [
...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)),
...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)),
...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)),
...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)),
];
},
},
};
</script>
<template>
<div class="report-block-container">
<issues-block
v-if="newIssues.length"
:component="component"
:issues="newIssues"
class="js-mr-code-new-issues"
status="failed"
is-new
/>
<issues-block
v-if="unresolvedIssues.length"
:component="component"
:issues="unresolvedIssues"
:status="$options.failed"
class="js-mr-code-new-issues"
/>
<issues-block
v-if="neutralIssues.length"
:component="component"
:issues="neutralIssues"
:status="$options.neutral"
class="js-mr-code-non-issues"
/>
<issues-block
v-if="resolvedIssues.length"
<smart-virtual-list
:length="issuesWithState.length"
:remain="$options.maxShownReportItems"
:size="$options.typicalReportItemHeight"
class="report-block-container"
wtag="ul"
wclass="report-block-list"
>
<report-item
v-for="(wrapped, index) in issuesWithState"
:key="index"
:issue="wrapped.issue"
:status="wrapped.status"
:component="component"
:issues="resolvedIssues"
:status="$options.success"
class="js-mr-code-resolved-issues"
:is-new="wrapped.isNew"
/>
</div>
</smart-virtual-list>
</template>
......@@ -3,14 +3,14 @@ import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { components, componentNames } from '~/reports/components/issue_body';
export default {
name: 'ReportIssues',
name: 'ReportItem',
components: {
IssueStatusIcon,
...components,
},
props: {
issues: {
type: Array,
issue: {
type: Object,
required: true,
},
component: {
......@@ -33,27 +33,21 @@ export default {
};
</script>
<template>
<div>
<ul class="report-block-list">
<li
v-for="(issue, index) in issues"
:key="index"
:class="{ 'is-dismissed': issue.isDismissed }"
class="report-block-list-issue"
>
<issue-status-icon
:status="issue.status || status"
class="append-right-5"
/>
<li
:class="{ 'is-dismissed': issue.isDismissed }"
class="report-block-list-issue"
>
<issue-status-icon
:status="status"
class="append-right-5"
/>
<component
:is="component"
v-if="component"
:issue="issue"
:status="issue.status || status"
:is-new="isNew"
/>
</li>
</ul>
</div>
<component
:is="component"
v-if="component"
:issue="issue"
:status="status"
:is-new="isNew"
/>
</li>
</template>
......@@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import GfmAutoComplete from '~/gfm_auto_complete';
import { __, s__ } from '~/locale';
import Api from '~/api';
import { GlModal } from '@gitlab-org/gitlab-ui';
import { GlModal, GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import eventHub from './event_hub';
import EmojiMenuInModal from './emoji_menu_in_modal';
......@@ -16,6 +16,9 @@ export default {
Icon,
GlModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
currentEmoji: {
type: String,
......
<script>
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import tooltip from '../../../vue_shared/directives/tooltip';
import { GlProgressBar } from '@gitlab-org/gitlab-ui';
export default {
name: 'TimeTrackingComparisonPane',
components: {
GlProgressBar,
},
directives: {
tooltip,
},
......
......@@ -65,6 +65,14 @@ export default {
deployedText() {
return this.$options.deployedTextMap[this.deployment.status];
},
isDeployInProgress() {
return this.deployment.status === 'running';
},
deployInProgressTooltip() {
return this.isDeployInProgress
? __('Stopping this environment is currently not possible as a deployment is in progress')
: '';
},
shouldRenderDropdown() {
return (
this.enableCiEnvironmentsStatusChanges &&
......@@ -183,15 +191,23 @@ export default {
css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin"
/>
</template>
<loading-button
<span
v-if="deployment.stop_url"
:loading="isStopping"
container-class="btn btn-default btn-sm inline prepend-left-4"
title="Stop environment"
@click="stopEnvironment"
v-tooltip
:title="deployInProgressTooltip"
class="d-inline-block"
tabindex="0"
>
<icon name="stop" />
</loading-button>
<loading-button
:loading="isStopping"
:disabled="isDeployInProgress"
:title="__('Stop environment')"
container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4"
@click="stopEnvironment"
>
<icon name="stop" />
</loading-button>
</span>
</div>
</div>
</div>
......
......@@ -71,6 +71,7 @@ export default {
linkStart: `<a href="${this.troubleshootingDocsPath}">`,
linkEnd: '</a>',
},
false,
);
},
},
......
<script>
import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
import { GlTooltipDirective } from '@gitlab-org/gitlab-ui';
/**
* Counts down to a given end date.
*/
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
endDateString: {
type: String,
......
<script>
import $ from 'jquery';
import Tooltip from '../../directives/tooltip';
import { GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import ToolbarButton from './toolbar_button.vue';
import Icon from '../icon.vue';
export default {
directives: {
Tooltip,
},
components: {
ToolbarButton,
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
previewMarkdown: {
type: Boolean,
......@@ -147,7 +147,7 @@ export default {
icon="table"
/>
<button
v-tooltip
v-gl-tooltip
aria-label="Go full screen"
class="toolbar-btn toolbar-fullscreen-btn js-zen-enter"
data-container="body"
......
<script>
import tooltip from '../../directives/tooltip';
import icon from '../icon.vue';
import { GlTooltipDirective } from '@gitlab-org/gitlab-ui';
import Icon from '../icon.vue';
export default {
components: {
icon,
Icon,
},
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
props: {
buttonTitle: {
......@@ -43,7 +43,7 @@ export default {
<template>
<button
v-tooltip
v-gl-tooltip
:data-md-tag="tag"
:data-md-select="tagSelect"
:data-md-block="tagBlock"
......
<script>
import VirtualList from 'vue-virtual-scroll-list';
export default {
name: 'SmartVirtualList',
components: { VirtualList },
props: {
size: { type: Number, required: true },
length: { type: Number, required: true },
remain: { type: Number, required: true },
rtag: { type: String, default: 'div' },
wtag: { type: String, default: 'div' },
wclass: { type: String, default: null },
},
};
</script>
<template>
<virtual-list
v-if="length > remain"
v-bind="$attrs"
:size="remain"
:remain="remain"
:rtag="rtag"
:wtag="wtag"
:wclass="wclass"
class="js-virtual-list"
>
<slot></slot>
</virtual-list>
<component
:is="rtag"
v-else
class="js-plain-element"
>
<component
:is="wtag"
:class="wclass"
>
<slot></slot>
</component>
</component>
</template>
......@@ -14,7 +14,14 @@ export default {
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
this.updateContent({ scope: this.scope, page: Number(page).toString() });
const params = {
page: Number(page).toString(),
};
if (this.scope) {
params.scope = this.scope;
}
this.updateContent(params);
},
updateInternalState(parameters) {
......
......@@ -348,6 +348,7 @@
@include media-breakpoint-down(xs) {
width: 100%;
margin: $btn-side-margin 0;
}
}
}
......
......@@ -322,15 +322,15 @@
width: $contextual-sidebar-width - 1px;
transition: width $sidebar-transition-duration;
position: fixed;
height: $toggle-sidebar-height;
bottom: 0;
padding: $gl-padding;
padding: 0 $gl-padding;
background-color: $gray-light;
border: 0;
border-top: 1px solid $border-color;
color: $gl-text-color-secondary;
display: flex;
align-items: center;
line-height: 1;
svg {
margin-right: 8px;
......
......@@ -39,7 +39,7 @@
svg {
fill: currentColor;
$svg-sizes: 8 10 12 16 18 24 32 48 72;
$svg-sizes: 8 10 12 14 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
@include svg-size(#{$svg-size}px);
......
......@@ -291,7 +291,7 @@
/*
* Mixin that handles the position of the controls placed on the top bar
*/
@mixin build-controllers($control-font-size, $flex-direction, $with-grow, $flex-grow-size) {
@mixin build-controllers($control-font-size, $flex-direction, $with-grow, $flex-grow-size, $svg-display: 'block', $svg-top: '2px') {
display: flex;
font-size: $control-font-size;
justify-content: $flex-direction;
......@@ -304,8 +304,9 @@
svg {
width: 15px;
height: 15px;
display: block;
display: $svg-display;
fill: $gl-text-color;
top: $svg-top;
}
.controllers-buttons {
......
......@@ -147,3 +147,9 @@ table {
}
}
}
.top-area + .content-list {
th {
border-top: 0;
}
}
......@@ -10,6 +10,7 @@ $sidebar-breakpoint: 1024px;
$default-transition-duration: 0.15s;
$contextual-sidebar-width: 220px;
$contextual-sidebar-collapsed-width: 50px;
$toggle-sidebar-height: 48px;
/*
* Color schema
......@@ -268,6 +269,7 @@ $flash-height: 52px;
$context-header-height: 60px;
$breadcrumb-min-height: 48px;
$project-title-row-height: 24px;
$gl-line-height: 16px;
/*
* Common component specific colors
......
......@@ -94,7 +94,7 @@
}
.controllers {
@include build-controllers(15px, center, false, 0);
@include build-controllers(15px, center, false, 0, inline, 0);
}
}
......
......@@ -44,11 +44,6 @@
margin: 0;
}
.icon-play {
height: 13px;
width: 12px;
}
.external-url,
.dropdown-new {
color: $gl-text-color-secondary;
......@@ -366,7 +361,7 @@
}
.arrow-shadow {
content: "";
content: '';
position: absolute;
width: 7px;
height: 7px;
......
......@@ -4,41 +4,29 @@
*/
.event-item {
font-size: $gl-font-size;
padding: $gl-padding-top 0 $gl-padding-top 40px;
padding: $gl-padding 0 $gl-padding 56px;
border-bottom: 1px solid $white-normal;
color: $gl-text-color;
color: $gl-text-color-secondary;
position: relative;
&.event-inline {
.system-note-image {
top: 20px;
}
.user-avatar {
top: 14px;
}
.event-title,
.event-item-timestamp {
line-height: 40px;
}
}
a {
color: $gl-text-color;
}
line-height: $gl-line-height;
.system-note-image {
position: absolute;
left: 0;
top: 14px;
svg {
width: 20px;
height: 20px;
fill: $gl-text-color-secondary;
}
}
.system-note-image-inline {
svg {
fill: $gl-text-color-secondary;
}
}
.system-note-image,
.system-note-image-inline {
&.opened-icon,
&.created-icon {
svg {
......@@ -53,16 +41,35 @@
&.accepted-icon svg {
fill: $blue-300;
}
&.commented-on-icon svg {
fill: $blue-600;
}
}
.event-user-info {
margin-bottom: $gl-padding-8;
.author_name {
a {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
}
}
}
.event-title {
@include str-truncated(calc(100% - 174px));
font-weight: $gl-font-weight-bold;
color: $gl-text-color;
.event-type {
&::first-letter {
text-transform: capitalize;
}
}
}
.event-body {
margin-top: $gl-padding-8;
margin-right: 174px;
color: $gl-text-color;
.event-note {
word-wrap: break-word;
......@@ -92,7 +99,7 @@
}
.note-image-attach {
margin-top: 4px;
margin-top: $gl-padding-4;
margin-left: 0;
max-width: 200px;
float: none;
......@@ -107,7 +114,6 @@
color: $gl-gray-500;
float: left;
font-size: $gl-font-size;
line-height: 16px;
margin-right: 5px;
}
}
......@@ -127,7 +133,9 @@
}
}
&:last-child { border: 0; }
&:last-child {
border: 0;
}
.event_commits {
li {
......@@ -154,7 +162,6 @@
.event-item-timestamp {
float: right;
line-height: 22px;
}
}
......@@ -177,10 +184,8 @@
.event-item {
padding-left: 0;
&.event-inline {
.event-title {
line-height: 20px;
}
.event-user-info {
margin-bottom: $gl-padding-4;
}
.event-title {
......@@ -194,7 +199,8 @@
}
.event-body {
margin: 0;
margin-top: $gl-padding-4;
margin-right: 0;
padding-left: 0;
}
......
......@@ -39,10 +39,6 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
svg {
vertical-align: middle;
}
}
.next-run-cell {
......@@ -52,6 +48,10 @@
a {
color: $text-color;
}
svg {
vertical-align: middle;
}
}
.pipeline-schedules-user-callout {
......
......@@ -240,6 +240,12 @@
left: 0;
}
.activities-block {
.event-item {
padding-left: 40px;
}
}
@include media-breakpoint-down(xs) {
.cover-block {
padding-top: 20px;
......@@ -267,6 +273,12 @@
margin-right: 0;
}
}
.activities-block {
.event-item {
padding-left: 0;
}
}
}
}
......
# frozen_string_literal: true
class Clusters::ApplicationsController < Clusters::BaseController
before_action :cluster
before_action :authorize_create_cluster!, only: [:create]
def create
Clusters::Applications::CreateService
.new(@cluster, current_user, create_cluster_application_params)
.execute(request)
head :no_content
rescue Clusters::Applications::CreateService::InvalidApplicationError
render_404
rescue StandardError
head :bad_request
end
private
def cluster
@cluster ||= clusterable.clusters.find(params[:id]) || render_404
end
def create_cluster_application_params
params.permit(:application, :hostname)
end
end
# frozen_string_literal: true
class Clusters::BaseController < ApplicationController
include RoutableActions
skip_before_action :authenticate_user!
before_action :authorize_read_cluster!
helper_method :clusterable
private
def cluster
@cluster ||= clusterable.clusters.find(params[:id])
.present(current_user: current_user)
end
def authorize_update_cluster!
access_denied! unless can?(current_user, :update_cluster, cluster)
end
def authorize_admin_cluster!
access_denied! unless can?(current_user, :admin_cluster, cluster)
end
def authorize_read_cluster!
access_denied! unless can?(current_user, :read_cluster, clusterable)
end
def authorize_create_cluster!
access_denied! unless can?(current_user, :create_cluster, clusterable)
end
def clusterable
raise NotImplementedError
end
end
# frozen_string_literal: true
class Clusters::ClustersController < Clusters::BaseController
include RoutableActions
before_action :cluster, except: [:index, :new, :create_gcp, :create_user]
before_action :generate_gcp_authorize_url, only: [:new]
before_action :validate_gcp_token, only: [:new]
before_action :gcp_cluster, only: [:new]
before_action :user_cluster, only: [:new]
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: [:cluster_status]
helper_method :token_in_session
STATUS_POLLING_INTERVAL = 10_000
def index
clusters = ClustersFinder.new(clusterable, current_user, :all).execute
@clusters = clusters.page(params[:page]).per(20)
end
def new
end
# Overridding ActionController::Metal#status is NOT a good idea
def cluster_status
respond_to do |format|
format.json do
Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL)
render json: ClusterSerializer
.new(current_user: @current_user)
.represent_status(@cluster)
end
end
end
def show
end
def update
Clusters::UpdateService
.new(current_user, update_params)
.execute(cluster)
if cluster.valid?
respond_to do |format|
format.json do
head :no_content
end
format.html do
flash[:notice] = _('Kubernetes cluster was successfully updated.')
redirect_to cluster.show_path
end
end
else
respond_to do |format|
format.json { head :bad_request }
format.html { render :show }
end
end
end
def destroy
if cluster.destroy
flash[:notice] = _('Kubernetes cluster integration was successfully removed.')
redirect_to clusterable.index_path, status: :found
else
flash[:notice] = _('Kubernetes cluster integration was not removed.')
render :show
end
end
def create_gcp
@gcp_cluster = ::Clusters::CreateService
.new(current_user, create_gcp_cluster_params)
.execute(access_token: token_in_session)
.present(current_user: current_user)
if @gcp_cluster.persisted?
redirect_to @gcp_cluster.show_path
else
generate_gcp_authorize_url
validate_gcp_token
user_cluster
render :new, locals: { active_tab: 'gcp' }
end
end
def create_user
@user_cluster = ::Clusters::CreateService
.new(current_user, create_user_cluster_params)
.execute(access_token: token_in_session)
.present(current_user: current_user)
if @user_cluster.persisted?
redirect_to @user_cluster.show_path
else
generate_gcp_authorize_url
validate_gcp_token
gcp_cluster
render :new, locals: { active_tab: 'user' }
end
end
private
def update_params
if cluster.managed?
params.require(:cluster).permit(
:enabled,
:environment_scope,
platform_kubernetes_attributes: [
:namespace
]
)
else
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
platform_kubernetes_attributes: [
:api_url,
:token,
:ca_cert,
:namespace
]
)
end
end
def create_gcp_cluster_params
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
:num_nodes,
:machine_type,
:legacy_abac
]).merge(
provider_type: :gcp,
platform_type: :kubernetes,
clusterable: clusterable.subject
)
end
def create_user_cluster_params
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
platform_kubernetes_attributes: [
:namespace,
:api_url,
:token,
:ca_cert,
:authorization_type
]).merge(
provider_type: :user,
platform_type: :kubernetes,
clusterable: clusterable.subject
)
end
def generate_gcp_authorize_url
state = generate_session_key_redirect(clusterable.new_path.to_s)
@authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url,
state: state).authorize_url
rescue GoogleApi::Auth::ConfigMissingError
# no-op
end
def gcp_cluster
@gcp_cluster = ::Clusters::Cluster.new.tap do |cluster|
cluster.build_provider_gcp
end
end
def user_cluster
@user_cluster = ::Clusters::Cluster.new.tap do |cluster|
cluster.build_platform_kubernetes
end
end
def validate_gcp_token
@valid_gcp_token = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
end
def token_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
def expires_at_in_session
@expires_at_in_session ||=
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def generate_session_key_redirect(uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
end
end
def update_applications_status
@cluster.applications.each(&:schedule_status_update)
end
end
# frozen_string_literal: true
module ProjectUnauthorized
extend ActiveSupport::Concern
# EE would override this
def project_unauthorized_proc
# no-op
end
end
......@@ -3,23 +3,25 @@
module RoutableActions
extend ActiveSupport::Concern
def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil, not_found_or_authorized_proc: nil)
routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, requested_full_path)
routable
else
handle_not_found_or_authorized(routable)
if not_found_or_authorized_proc
not_found_or_authorized_proc.call(routable)
end
route_not_found unless performed?
nil
end
end
# This is overridden in gitlab-ee.
def handle_not_found_or_authorized(_routable)
route_not_found
end
def routable_authorized?(routable, extra_authorization_proc)
return false unless routable
action = :"read_#{routable.class.to_s.underscore}"
return false unless can?(current_user, action, routable)
......
......@@ -3,6 +3,7 @@
class Projects::ApplicationController < ApplicationController
include CookiesHelper
include RoutableActions
include ProjectUnauthorized
include ChecksCollaboration
skip_before_action :authenticate_user!
......@@ -21,7 +22,7 @@ class Projects::ApplicationController < ApplicationController
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
auth_proc = ->(project) { !project.pending_delete? }
@project = find_routable!(Project, path, extra_authorization_proc: auth_proc)
@project = find_routable!(Project, path, extra_authorization_proc: auth_proc, not_found_or_authorized_proc: project_unauthorized_proc)
end
def build_canonical_path(project)
......
# frozen_string_literal: true
class Projects::Clusters::ApplicationsController < Projects::ApplicationController
before_action :cluster
before_action :authorize_read_cluster!
before_action :authorize_create_cluster!, only: [:create]
class Projects::Clusters::ApplicationsController < Clusters::ApplicationsController
include ProjectUnauthorized
def create
Clusters::Applications::CreateService
.new(@cluster, current_user, create_cluster_application_params)
.execute(request)
head :no_content
rescue Clusters::Applications::CreateService::InvalidApplicationError
render_404
rescue StandardError
head :bad_request
end
prepend_before_action :project
private
def cluster
@cluster ||= project.clusters.find(params[:id]) || render_404
def clusterable
@clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user)
end
def create_cluster_application_params
params.permit(:application, :hostname)
def project
@project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
end
end
# frozen_string_literal: true
class Projects::ClustersController < Projects::ApplicationController
before_action :cluster, except: [:index, :new, :create_gcp, :create_user]
before_action :authorize_read_cluster!
before_action :generate_gcp_authorize_url, only: [:new]
before_action :validate_gcp_token, only: [:new]
before_action :gcp_cluster, only: [:new]
before_action :user_cluster, only: [:new]
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]
helper_method :token_in_session
class Projects::ClustersController < Clusters::ClustersController
include ProjectUnauthorized
STATUS_POLLING_INTERVAL = 10_000
prepend_before_action :project
before_action :repository
def index
clusters = ClustersFinder.new(project, current_user, :all).execute
@clusters = clusters.page(params[:page]).per(20)
end
def new
end
def status
respond_to do |format|
format.json do
Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL)
render json: ClusterSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@cluster)
end
end
end
def show
end
def update
Clusters::UpdateService
.new(current_user, update_params)
.execute(cluster)
if cluster.valid?
respond_to do |format|
format.json do
head :no_content
end
format.html do
flash[:notice] = _('Kubernetes cluster was successfully updated.')
redirect_to project_cluster_path(project, cluster)
end
end
else
respond_to do |format|
format.json { head :bad_request }
format.html { render :show }
end
end
end
def destroy
if cluster.destroy
flash[:notice] = _('Kubernetes cluster integration was successfully removed.')
redirect_to project_clusters_path(project), status: :found
else
flash[:notice] = _('Kubernetes cluster integration was not removed.')
render :show
end
end
def create_gcp
@gcp_cluster = ::Clusters::CreateService
.new(current_user, create_gcp_cluster_params)
.execute(project: project, access_token: token_in_session)
if @gcp_cluster.persisted?
redirect_to project_cluster_path(project, @gcp_cluster)
else
generate_gcp_authorize_url
validate_gcp_token
user_cluster
render :new, locals: { active_tab: 'gcp' }
end
end
def create_user
@user_cluster = ::Clusters::CreateService
.new(current_user, create_user_cluster_params)
.execute(project: project, access_token: token_in_session)
if @user_cluster.persisted?
redirect_to project_cluster_path(project, @user_cluster)
else
generate_gcp_authorize_url
validate_gcp_token
gcp_cluster
render :new, locals: { active_tab: 'user' }
end
end
layout 'project'
private
def cluster
@cluster ||= project.clusters.find(params[:id])
.present(current_user: current_user)
end
def update_params
if cluster.managed?
params.require(:cluster).permit(
:enabled,
:environment_scope,
platform_kubernetes_attributes: [
:namespace
]
)
else
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
platform_kubernetes_attributes: [
:api_url,
:token,
:ca_cert,
:namespace
]
)
end
end
def create_gcp_cluster_params
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
:num_nodes,
:machine_type,
:legacy_abac
]).merge(
provider_type: :gcp,
platform_type: :kubernetes
)
end
def create_user_cluster_params
params.require(:cluster).permit(
:enabled,
:name,
:environment_scope,
platform_kubernetes_attributes: [
:namespace,
:api_url,
:token,
:ca_cert,
:authorization_type
]).merge(
provider_type: :user,
platform_type: :kubernetes
)
end
def generate_gcp_authorize_url
state = generate_session_key_redirect(new_project_cluster_path(@project).to_s)
@authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url,
state: state).authorize_url
rescue GoogleApi::Auth::ConfigMissingError
# no-op
end
def gcp_cluster
@gcp_cluster = ::Clusters::Cluster.new.tap do |cluster|
cluster.build_provider_gcp
end
end
def user_cluster
@user_cluster = ::Clusters::Cluster.new.tap do |cluster|
cluster.build_platform_kubernetes
end
end
def validate_gcp_token
@valid_gcp_token = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
end
def token_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
def expires_at_in_session
@expires_at_in_session ||=
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def generate_session_key_redirect(uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
end
end
def authorize_update_cluster!
access_denied! unless can?(current_user, :update_cluster, cluster)
def clusterable
@clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user)
end
def authorize_admin_cluster!
access_denied! unless can?(current_user, :admin_cluster, cluster)
def project
@project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
end
def update_applications_status
@cluster.applications.each(&:schedule_status_update)
def repository
@repository ||= project.repository
end
end
......@@ -43,7 +43,7 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipelines
@pipelines = @commit.pipelines.order(id: :desc)
@pipelines = @pipelines.where(ref: params[:ref]) if params[:ref]
@pipelines = @pipelines.where(ref: params[:ref]).page(params[:page]).per(30) if params[:ref]
respond_to do |format|
format.html
......@@ -53,6 +53,7 @@ class Projects::CommitController < Projects::ApplicationController
render json: {
pipelines: PipelineSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.represent(@pipelines),
count: {
all: @pipelines.count
......
......@@ -84,13 +84,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def pipelines
@pipelines = @merge_request.all_pipelines
@pipelines = @merge_request.all_pipelines.page(params[:page]).per(30)
Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: {
pipelines: PipelineSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.represent(@pipelines),
count: {
all: @pipelines.count
......
# frozen_string_literal: true
class ClustersFinder
def initialize(project, user, scope)
@project = project
def initialize(clusterable, user, scope)
@clusterable = clusterable
@user = user
@scope = scope || :active
end
def execute
clusters = project.clusters
clusters = clusterable.clusters
filter_by_scope(clusters)
end
private
attr_reader :project, :user, :scope
attr_reader :clusterable, :user, :scope
def filter_by_scope(clusters)
case scope.to_sym
......
......@@ -3,7 +3,7 @@
class PersonalAccessTokensFinder
attr_accessor :params
delegate :build, :find, :find_by, :find_by_token, to: :execute
delegate :build, :find, :find_by_id, :find_by_token, to: :execute
def initialize(params = {})
@params = params
......
......@@ -115,6 +115,7 @@ module ApplicationSettingsHelper
:akismet_api_key,
:akismet_enabled,
:allow_local_requests_from_hooks_and_services,
:archive_builds_in_human_readable,
:authorized_keys_enabled,
:auto_devops_enabled,
:auto_devops_domain,
......
# frozen_string_literal: true
module ClustersHelper
def has_multiple_clusters?(project)
# EE overrides this
def has_multiple_clusters?
false
end
......@@ -10,7 +11,7 @@ module ClustersHelper
return unless show_gcp_signup_offer?
content_tag :section, class: 'no-animate expanded' do
render 'projects/clusters/gcp_signup_offer_banner'
render 'clusters/clusters/gcp_signup_offer_banner'
end
end
end
......@@ -163,14 +163,10 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
text = raw("#{event.note_target_type} ") +
if event.commit_note?
content_tag(:span, event.note_target_reference, class: 'commit-sha')
else
event.note_target_reference
end
link_to(text, event_note_target_url(event), title: event.target_title, class: 'has-tooltip')
capture do
concat content_tag(:span, event.note_target_type, class: "event-target-type append-right-4")
concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link append-right-4')
end
else
content_tag(:strong, '(deleted)')
end
......@@ -183,17 +179,9 @@ module EventsHelper
"--broken encoding"
end
def event_row_class(event)
if event.body?
"event-block"
else
"event-inline"
end
end
def icon_for_event(note)
def icon_for_event(note, size: 24)
icon_name = ICON_NAMES_BY_EVENT_TYPE[note]
sprite_icon(icon_name) if icon_name
sprite_icon(icon_name, size: size) if icon_name
end
def icon_for_profile_event(event)
......@@ -203,8 +191,24 @@ module EventsHelper
end
else
content_tag :div, class: 'system-note-image user-avatar' do
author_avatar(event, size: 32)
author_avatar(event, size: 40)
end
end
end
def inline_event_icon(event)
unless current_path?('users#show')
content_tag :span, class: "system-note-image-inline d-none d-sm-flex append-right-4 #{event.action_name.parameterize}-icon align-self-center" do
icon_for_event(event.action_name, size: 14)
end
end
end
def event_user_info(event)
content_tag(:div, class: "event-user-info") do
concat content_tag(:span, link_to_author(event), class: "author_name")
concat "&nbsp;".html_safe
concat content_tag(:span, event.author.to_reference, class: "username")
end
end
end
......@@ -109,6 +109,8 @@ module IconsHelper
def file_type_icon_class(type, mode, name)
if type == 'folder'
icon_class = 'folder'
elsif type == 'archive'
icon_class = 'archive'
elsif mode == '120000'
icon_class = 'share'
else
......
......@@ -143,7 +143,7 @@ module LabelsHelper
def labels_filter_path(options = {})
project = @target_project || @project
format = options.delete(:format) || :html
format = options.delete(:format)
if project
project_labels_path(project, format, options)
......
......@@ -31,11 +31,21 @@ module TreeHelper
# mode - File unix mode
# name - File name
def tree_icon(type, mode, name)
icon("#{file_type_icon_class(type, mode, name)} fw")
icon([file_type_icon_class(type, mode, name), 'fw'])
end
def tree_hex_class(content)
"file_#{hexdigest(content.name)}"
# Using Rails `*_path` methods can be slow, especially when generating
# many paths, as with a repository tree that has thousands of items.
def fast_project_blob_path(project, blob_path)
Addressable::URI.escape(
File.join(relative_url_root, project.path_with_namespace, 'blob', blob_path)
)
end
def fast_project_tree_path(project, tree_path)
Addressable::URI.escape(
File.join(relative_url_root, project.path_with_namespace, 'tree', tree_path)
)
end
# Simple shortcut to File.join
......@@ -142,4 +152,8 @@ module TreeHelper
def selected_branch
@branch_name || tree_edit_branch
end
def relative_url_root
Gitlab.config.gitlab.relative_url_root.presence || '/'
end
end
......@@ -3,7 +3,6 @@
module UserCalloutsHelper
GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'.freeze
GCP_SIGNUP_OFFER = 'gcp_signup_offer'.freeze
CLUSTER_SECURITY_WARNING = 'cluster_security_warning'.freeze
def show_gke_cluster_integration_callout?(project)
can?(current_user, :create_cluster, project) &&
......@@ -14,10 +13,6 @@ module UserCalloutsHelper
!user_dismissed?(GCP_SIGNUP_OFFER)
end
def show_cluster_security_warning?
!user_dismissed?(CLUSTER_SECURITY_WARNING)
end
private
def user_dismissed?(feature_name)
......
......@@ -45,6 +45,20 @@ module Emails
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
def removed_milestone_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
def changed_milestone_issue_email(recipient_id, issue_id, milestone, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@milestone = milestone
@milestone_url = milestone_url(@milestone)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
......
......@@ -40,6 +40,20 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
def removed_milestone_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
def changed_milestone_merge_request_email(recipient_id, merge_request_id, milestone, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@milestone = milestone
@milestone_url = milestone_url(@milestone)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
......
......@@ -68,6 +68,14 @@ class NotifyPreview < ActionMailer::Preview
Notify.issue_status_changed_email(user.id, issue.id, 'closed', user.id).message
end
def removed_milestone_issue_email
Notify.removed_milestone_issue_email(user.id, issue.id, user.id)
end
def changed_milestone_issue_email
Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id)
end
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
......@@ -80,6 +88,14 @@ class NotifyPreview < ActionMailer::Preview
Notify.merged_merge_request_email(user.id, merge_request.id, user.id).message
end
def removed_milestone_merge_request_email
Notify.removed_milestone_merge_request_email(user.id, merge_request.id, user.id)
end
def changed_milestone_merge_request_email
Notify.changed_milestone_merge_request_email(user.id, merge_request.id, milestone, user.id)
end
def member_access_denied_email
Notify.member_access_denied_email('project', project.id, user.id).message
end
......@@ -143,6 +159,10 @@ class NotifyPreview < ActionMailer::Preview
@merge_request ||= project.merge_requests.first
end
def milestone
@milestone ||= issue.milestone
end
def pipeline
@pipeline = Ci::Pipeline.last
end
......
......@@ -5,6 +5,7 @@ class ApplicationSetting < ActiveRecord::Base
include CacheMarkdownField
include TokenAuthenticatable
include IgnorableColumn
include ChronicDurationAttribute
add_authentication_token_field :runners_registration_token
add_authentication_token_field :health_check_access_token
......@@ -45,6 +46,8 @@ class ApplicationSetting < ActiveRecord::Base
default_value_for :id, 1
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
validates :uuid, presence: true
validates :session_expire_delay,
......@@ -184,6 +187,10 @@ class ApplicationSetting < ActiveRecord::Base
validates :user_default_internal_regex, js_regex: true, allow_nil: true
validates :archive_builds_in_seconds,
allow_nil: true,
numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds }
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
......@@ -441,6 +448,10 @@ class ApplicationSetting < ActiveRecord::Base
latest_terms
end
def archive_builds_older_than
archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
end
private
def ensure_uuid!
......
......@@ -9,19 +9,18 @@ module Ci
include Presentable
include Importable
include Gitlab::Utils::StrongMemoize
include Deployable
belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
has_many :deployments, as: :deployable
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }
}.freeze
has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment'
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id
......@@ -195,6 +194,8 @@ module Ci
end
after_transition pending: :running do |build|
build.deployment&.run
build.run_after_commit do
BuildHooksWorker.perform_async(id)
end
......@@ -207,14 +208,18 @@ module Ci
end
after_transition any => [:success] do |build|
build.deployment&.succeed
build.run_after_commit do
BuildSuccessWorker.perform_async(id)
PagesWorker.perform_async(:deploy, id) if build.pages_generator?
end
end
before_transition any => [:failed] do |build|
next unless build.project
build.deployment&.drop
next if build.retries_max.zero?
if build.retries_count < build.retries_max
......@@ -233,6 +238,10 @@ module Ci
after_transition running: any do |build|
Ci::BuildRunnerSession.where(build: build).delete_all
end
after_transition any => [:skipped, :canceled] do |build|
build.deployment&.cancel
end
end
def ensure_metadata
......@@ -245,22 +254,41 @@ module Ci
.fabricate!
end
def other_actions
def other_manual_actions
pipeline.manual_actions.where.not(name: name)
end
def other_scheduled_actions
pipeline.scheduled_actions.where.not(name: name)
end
def pages_generator?
Gitlab.config.pages.enabled &&
self.name == 'pages'
end
# degenerated build is one that cannot be run by Runner
def degenerated?
self.options.nil?
end
def degenerate!
self.update!(options: nil, yaml_variables: nil, commands: nil)
end
def archived?
return true if degenerated?
archive_builds_older_than = Gitlab::CurrentSettings.current_application_settings.archive_builds_older_than
archive_builds_older_than.present? && created_at < archive_builds_older_than
end
def playable?
action? && (manual? || scheduled? || retryable?)
action? && !archived? && (manual? || scheduled? || retryable?)
end
def schedulable?
Feature.enabled?('ci_enable_scheduled_build', default_enabled: true) &&
self.when == 'delayed' && options[:start_in].present?
self.when == 'delayed' && options[:start_in].present?
end
def options_scheduled_at
......@@ -284,7 +312,7 @@ module Ci
end
def retryable?
success? || failed? || canceled?
!archived? && (success? || failed? || canceled?)
end
def retries_count
......@@ -292,7 +320,7 @@ module Ci
end
def retries_max
self.options.fetch(:retry, 0).to_i
self.options.to_h.fetch(:retry, 0).to_i
end
def latest?
......@@ -323,8 +351,12 @@ module Ci
self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options
end
def has_deployment?
!!self.deployment
end
def outdated_deployment?
success? && !last_deployment.try(:last?)
success? && !deployment.try(:last?)
end
def depends_on_builds
......@@ -339,6 +371,10 @@ module Ci
user == current_user
end
def on_stop
options&.dig(:environment, :on_stop)
end
# A slugified version of the build ref, suitable for inclusion in URLs and
# domain names. Rules:
#
......@@ -706,7 +742,7 @@ module Ci
if success?
return successful_deployment_status
elsif complete? && !success?
elsif failed?
return :failed
end
......@@ -723,13 +759,11 @@ module Ci
end
def successful_deployment_status
if success? && last_deployment&.last?
return :last
elsif success? && last_deployment.present?
return :out_of_date
if deployment&.last?
:last
else
:out_of_date
end
:creating
end
def each_report(report_types)
......
......@@ -15,7 +15,7 @@ module Ci
metadata: nil,
trace: nil,
junit: 'junit.xml',
codequality: 'codequality.json',
codequality: 'gl-code-quality-report.json',
sast: 'gl-sast-report.json',
dependency_scanning: 'gl-dependency-scanning-report.json',
container_scanning: 'gl-container-scanning-report.json',
......
......@@ -181,22 +181,31 @@ module Ci
#
# ref - The name (or names) of the branch(es)/tag(s) to limit the list of
# pipelines to.
def self.newest_first(ref = nil)
# limit - This limits a backlog search, default to 100.
def self.newest_first(ref: nil, limit: 100)
relation = order(id: :desc)
relation = relation.where(ref: ref) if ref
if limit
ids = relation.limit(limit).select(:id)
# MySQL does not support limit in subquery
ids = ids.pluck(:id) if Gitlab::Database.mysql?
relation = relation.where(id: ids)
end
ref ? relation.where(ref: ref) : relation
relation
end
def self.latest_status(ref = nil)
newest_first(ref).pluck(:status).first
newest_first(ref: ref).pluck(:status).first
end
def self.latest_successful_for(ref)
newest_first(ref).success.take
newest_first(ref: ref).success.take
end
def self.latest_successful_for_refs(refs)
relation = newest_first(refs).success
relation = newest_first(ref: refs).success
relation.each_with_object({}) do |pipeline, hash|
hash[pipeline.ref] ||= pipeline
......@@ -238,6 +247,10 @@ module Ci
end
end
def self.latest_successful_ids_per_project
success.group(:project_id).select('max(id) as id')
end
def self.truncate_sha(sha)
sha[0...8]
end
......
......@@ -3,6 +3,7 @@
module Clusters
class Cluster < ActiveRecord::Base
include Presentable
include Gitlab::Utils::StrongMemoize
self.table_name = 'clusters'
......@@ -19,13 +20,11 @@ module Clusters
has_many :cluster_projects, class_name: 'Clusters::Project'
has_many :projects, through: :cluster_projects, class_name: '::Project'
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
has_one :cluster_group, -> { order(id: :desc) }, class_name: 'Clusters::Group'
has_one :group, through: :cluster_group, class_name: '::Group'
# we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
......@@ -118,16 +117,30 @@ module Clusters
end
def first_project
return @first_project if defined?(@first_project)
@first_project = projects.first
strong_memoize(:first_project) do
projects.first
end
end
alias_method :project, :first_project
def first_group
strong_memoize(:first_group) do
groups.first
end
end
alias_method :group, :first_group
def kubeclient
platform_kubernetes.kubeclient if kubernetes?
end
def find_or_initialize_kubernetes_namespace(cluster_project)
kubernetes_namespaces.find_or_initialize_by(
project: cluster_project.project,
cluster_project: cluster_project
)
end
private
def restrict_modification
......
......@@ -2,6 +2,8 @@
module Clusters
class KubernetesNamespace < ActiveRecord::Base
include Gitlab::Kubernetes
self.table_name = 'clusters_kubernetes_namespaces'
belongs_to :cluster_project, class_name: 'Clusters::Project'
......@@ -12,7 +14,8 @@ module Clusters
validates :namespace, presence: true
validates :namespace, uniqueness: { scope: :cluster_id }
before_validation :set_namespace_and_service_account_to_default, on: :create
delegate :ca_pem, to: :platform_kubernetes, allow_nil: true
delegate :api_url, to: :platform_kubernetes, allow_nil: true
attr_encrypted :service_account_token,
mode: :per_attribute_iv,
......@@ -23,14 +26,26 @@ module Clusters
"#{namespace}-token"
end
private
def configure_predefined_credentials
self.namespace = kubernetes_or_project_namespace
self.service_account_name = default_service_account_name
end
def predefined_variables
config = YAML.dump(kubeconfig)
def set_namespace_and_service_account_to_default
self.namespace ||= default_namespace
self.service_account_name ||= default_service_account_name
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables
.append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name)
.append(key: 'KUBE_NAMESPACE', value: namespace)
.append(key: 'KUBE_TOKEN', value: service_account_token, public: false)
.append(key: 'KUBECONFIG', value: config, public: false, file: true)
end
end
def default_namespace
private
def kubernetes_or_project_namespace
platform_kubernetes&.namespace.presence || project_namespace
end
......@@ -45,5 +60,13 @@ module Clusters
def project_slug
"#{project.path}-#{project.id}".downcase
end
def kubeconfig
to_kubeconfig(
url: api_url,
namespace: namespace,
token: service_account_token,
ca_pem: ca_pem)
end
end
end
......@@ -6,6 +6,7 @@ module Clusters
include Gitlab::Kubernetes
include ReactiveCaching
include EnumWithNil
include AfterCommitQueue
RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze
......@@ -43,6 +44,7 @@ module Clusters
validate :prevent_modification, on: :update
after_save :clear_reactive_cache!
after_update :update_kubernetes_namespace
alias_attribute :ca_pem, :ca_cert
......@@ -67,21 +69,31 @@ module Clusters
end
end
def predefined_variables
config = YAML.dump(kubeconfig)
def predefined_variables(project:)
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables
.append(key: 'KUBE_URL', value: api_url)
.append(key: 'KUBE_TOKEN', value: token, public: false)
.append(key: 'KUBE_NAMESPACE', value: actual_namespace)
.append(key: 'KUBECONFIG', value: config, public: false, file: true)
variables.append(key: 'KUBE_URL', value: api_url)
if ca_pem.present?
variables
.append(key: 'KUBE_CA_PEM', value: ca_pem)
.append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
end
if kubernetes_namespace = cluster.kubernetes_namespaces.find_by(project: project)
variables.concat(kubernetes_namespace.predefined_variables)
else
# From 11.5, every Clusters::Project should have at least one
# Clusters::KubernetesNamespace, so once migration has been completed,
# this 'else' branch will be removed. For more information, please see
# https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433
config = YAML.dump(kubeconfig)
variables
.append(key: 'KUBE_URL', value: api_url)
.append(key: 'KUBE_TOKEN', value: token, public: false)
.append(key: 'KUBE_NAMESPACE', value: actual_namespace)
.append(key: 'KUBECONFIG', value: config, public: false, file: true)
end
end
end
......@@ -199,6 +211,14 @@ module Clusters
true
end
def update_kubernetes_namespace
return unless namespace_changed?
run_after_commit do
ClusterPlatformConfigureWorker.perform_async(cluster_id)
end
end
end
end
end
......@@ -51,7 +51,8 @@ class CommitStatus < ActiveRecord::Base
missing_dependency_failure: 5,
runner_unsupported: 6,
stale_schedule: 7,
job_execution_timeout: 8
job_execution_timeout: 8,
archived_failure: 9
}
##
......@@ -167,16 +168,18 @@ class CommitStatus < ActiveRecord::Base
false
end
# To be overridden when inherrited from
def retryable?
false
end
# To be overridden when inherrited from
def cancelable?
false
end
def archived?
false
end
def stuck?
false
end
......
# frozen_string_literal: true
module Deployable
extend ActiveSupport::Concern
included do
after_create :create_deployment
def create_deployment
return unless starts_environment? && !has_deployment?
environment = project.environments.find_or_create_by(
name: expanded_environment_name
)
environment.deployments.create!(
project_id: environment.project_id,
environment: environment,
ref: ref,
tag: tag,
sha: sha,
user: user,
deployable: self,
on_stop: on_stop).tap do |_|
self.reload # Reload relationships
end
end
end
end
......@@ -10,6 +10,7 @@ module TokenAuthenticatable
def add_authentication_token_field(token_field, options = {})
@token_fields = [] unless @token_fields
unique = options.fetch(:unique, true)
if @token_fields.include?(token_field)
raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field")
......@@ -25,8 +26,10 @@ module TokenAuthenticatable
TokenAuthenticatableStrategies::Insecure.new(self, token_field, options)
end
define_singleton_method("find_by_#{token_field}") do |token|
strategy.find_token_authenticatable(token)
if unique
define_singleton_method("find_by_#{token_field}") do |token|
strategy.find_token_authenticatable(token)
end
end
define_method(token_field) do
......
......@@ -43,10 +43,14 @@ module TokenAuthenticatableStrategies
set_token(instance, new_token)
end
def unique
@options.fetch(:unique, true)
end
def generate_available_token
loop do
token = generate_token
break token unless find_token_authenticatable(token, true)
break token unless unique && find_token_authenticatable(token, true)
end
end
......
......@@ -4,6 +4,7 @@ class DeployToken < ActiveRecord::Base
include Expirable
include TokenAuthenticatable
include PolicyActor
include Gitlab::Utils::StrongMemoize
add_authentication_token_field :token
AVAILABLE_SCOPES = %i(read_repository read_registry).freeze
......@@ -49,7 +50,9 @@ class DeployToken < ActiveRecord::Base
# to a single project, later we're going to extend
# that to be for multiple projects and namespaces.
def project
projects.first
strong_memoize(:project) do
projects.first
end
end
def expires_at
......
......@@ -3,6 +3,7 @@
class Deployment < ActiveRecord::Base
include AtomicInternalId
include IidRoutes
include AfterCommitQueue
belongs_to :project, required: true
belongs_to :environment, required: true
......@@ -16,11 +17,44 @@ class Deployment < ActiveRecord::Base
delegate :name, to: :environment, prefix: true
after_create :create_ref
after_create :invalidate_cache
scope :for_environment, -> (environment) { where(environment_id: environment) }
state_machine :status, initial: :created do
event :run do
transition created: :running
end
event :succeed do
transition any - [:success] => :success
end
event :drop do
transition any - [:failed] => :failed
end
event :cancel do
transition any - [:canceled] => :canceled
end
before_transition any => [:success, :failed, :canceled] do |deployment|
deployment.finished_at = Time.now
end
after_transition any => :success do |deployment|
deployment.run_after_commit do
Deployments::SuccessWorker.perform_async(id)
end
end
end
enum status: {
created: 0,
running: 1,
success: 2,
failed: 3,
canceled: 4
}
def self.last_for_environment(environment)
ids = self
.for_environment(environment)
......@@ -55,7 +89,11 @@ class Deployment < ActiveRecord::Base
end
def manual_actions
@manual_actions ||= deployable.try(:other_actions)
@manual_actions ||= deployable.try(:other_manual_actions)
end
def scheduled_actions
@scheduled_actions ||= deployable.try(:other_scheduled_actions)
end
def includes_commit?(commit)
......@@ -65,15 +103,15 @@ class Deployment < ActiveRecord::Base
end
def update_merge_request_metrics!
return unless environment.update_merge_request_metrics?
return unless environment.update_merge_request_metrics? && success?
merge_requests = project.merge_requests
.joins(:metrics)
.where(target_branch: self.ref, merge_request_metrics: { first_deployed_to_production_at: nil })
.where("merge_request_metrics.merged_at <= ?", self.created_at)
.where("merge_request_metrics.merged_at <= ?", finished_at)
if previous_deployment
merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.created_at)
merge_requests = merge_requests.where("merge_request_metrics.merged_at >= ?", previous_deployment.finished_at)
end
# Need to use `map` instead of `select` because MySQL doesn't allow `SELECT`ing from the same table
......@@ -87,7 +125,7 @@ class Deployment < ActiveRecord::Base
MergeRequest::Metrics
.where(merge_request_id: merge_request_ids, first_deployed_to_production_at: nil)
.update_all(first_deployed_to_production_at: self.created_at)
.update_all(first_deployed_to_production_at: finished_at)
end
def previous_deployment
......@@ -105,8 +143,18 @@ class Deployment < ActiveRecord::Base
@stop_action ||= manual_actions.find_by(name: on_stop)
end
def finished_at
read_attribute(:finished_at) || legacy_finished_at
end
def deployed_at
return unless success?
finished_at
end
def formatted_deployment_time
created_at.to_time.in_time_zone.to_s(:medium)
deployed_at&.to_time&.in_time_zone&.to_s(:medium)
end
def has_metrics?
......@@ -114,21 +162,17 @@ class Deployment < ActiveRecord::Base
end
def metrics
return {} unless has_metrics?
return {} unless has_metrics? && success?
metrics = prometheus_adapter.query(:deployment, self)
metrics&.merge(deployment_time: created_at.to_i) || {}
metrics&.merge(deployment_time: finished_at.to_i) || {}
end
def additional_metrics
return {} unless has_metrics?
return {} unless has_metrics? && success?
metrics = prometheus_adapter.query(:additional_metrics_deployment, self)
metrics&.merge(deployment_time: created_at.to_i) || {}
end
def status
'success'
metrics&.merge(deployment_time: finished_at.to_i) || {}
end
private
......@@ -140,4 +184,8 @@ class Deployment < ActiveRecord::Base
def ref_path
File.join(environment.ref_path, 'deployments', iid.to_s)
end
def legacy_finished_at
self.created_at if success? && !read_attribute(:finished_at)
end
end
......@@ -8,9 +8,9 @@ class Environment < ActiveRecord::Base
belongs_to :project, required: true
has_many :deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :deployments, -> { success }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :last_deployment, -> { order('deployments.id DESC') }, class_name: 'Deployment'
has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment'
before_validation :nullify_external_url
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
......
......@@ -8,8 +8,8 @@ class EnvironmentStatus
delegate :id, to: :environment
delegate :name, to: :environment
delegate :project, to: :environment
delegate :status, to: :deployment, allow_nil: true
delegate :deployed_at, to: :deployment, allow_nil: true
delegate :status, to: :deployment
def self.for_merge_request(mr, user)
build_environments_status(mr, user, mr.head_pipeline)
......@@ -33,10 +33,6 @@ class EnvironmentStatus
end
end
def deployed_at
deployment&.created_at
end
def changes
return [] if project.route_map_for(sha).nil?
......
......@@ -240,7 +240,8 @@ class Issue < ActiveRecord::Base
reference_path: issue_reference,
real_path: url_helper.project_issue_path(project, self),
issue_sidebar_endpoint: url_helper.project_issue_path(project, self, format: :json, serializer: 'sidebar'),
toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self)
toggle_subscription_endpoint: url_helper.toggle_subscription_project_issue_path(project, self),
assignable_labels_endpoint: url_helper.project_labels_path(project, format: :json, include_ancestor_groups: true)
)
end
......
......@@ -34,6 +34,10 @@ class Key < ActiveRecord::Base
after_destroy :post_destroy_hook
after_destroy :refresh_user_cache
def self.regular_keys
where(type: ['Key', nil])
end
def key=(value)
write_attribute(:key, value.present? ? Gitlab::SSHPublicKey.sanitize(value) : nil)
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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