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