Merge branch 'master' into fl-38869-linked-tabs

* master: (136 commits)
  Ensure we set SUITE_FLAKY_RSPEC_REPORT_PATH to nil in RspecFlaky::Listener spec
  Gitaly feature flag metadata
  Add delete issue docs
  Adjust tooltips to adhere to 8px grid and make them more readable
  Fix JS lock issue specs
  Improve redirect uri state and fix all remaining tests
  Add 1000+ counters (instead of inifnite) to jobs controller
  Use short path project_clusters_url
  Change Clusters to Cluster in sidebar
  Security fix: redirection in google_api/authorizations_controller
  UX review
  Fix sidebar title Fix fixture
  Fix margins in edit form
  Use utc for time comparision
  Good old dangling comma
  Fix wording in the js messages
  Resetting of active Line + setting it for the async display functions
  Add some empty spaces
  Fix failing spec
  Refactor discussion lock docs
  ...
This commit is contained in:
Filipa Lacerda 2017-10-06 20:28:49 +01:00
commit 32ad31a16a
195 changed files with 5187 additions and 1163 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,112 @@
/* globals Flash */
import Visibility from 'visibilityjs';
import axios from 'axios';
import Poll from './lib/utils/poll';
import { s__ } from './locale';
import './flash';
/**
* Cluster page has 2 separate parts:
* Toggle button
*
* - Polling status while creating or scheduled
* -- Update status area with the response result
*/
class ClusterService {
constructor(options = {}) {
this.options = options;
}
fetchData() {
return axios.get(this.options.endpoint);
}
}
export default class Clusters {
constructor() {
const dataset = document.querySelector('.js-edit-cluster-form').dataset;
this.state = {
statusPath: dataset.statusPath,
clusterStatus: dataset.clusterStatus,
clusterStatusReason: dataset.clusterStatusReason,
toggleStatus: dataset.toggleStatus,
};
this.service = new ClusterService({ endpoint: this.state.statusPath });
this.toggleButton = document.querySelector('.js-toggle-cluster');
this.toggleInput = document.querySelector('.js-toggle-input');
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.toggleButton.addEventListener('click', this.toggle.bind(this));
if (this.state.clusterStatus !== 'created') {
this.updateContainer(this.state.clusterStatus, this.state.clusterStatusReason);
}
if (this.state.statusPath) {
this.initPolling();
}
}
toggle() {
this.toggleButton.classList.toggle('checked');
this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString());
}
initPolling() {
this.poll = new Poll({
resource: this.service,
method: 'fetchData',
successCallback: (data) => {
const { status, status_reason } = data.data;
this.updateContainer(status, status_reason);
},
errorCallback: () => {
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
},
});
if (!Visibility.hidden()) {
this.poll.makeRequest();
} else {
this.service.fetchData();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}
hideAll() {
this.errorContainer.classList.add('hidden');
this.successContainer.classList.add('hidden');
this.creatingContainer.classList.add('hidden');
}
updateContainer(status, error) {
this.hideAll();
switch (status) {
case 'created':
this.successContainer.classList.remove('hidden');
break;
case 'errored':
this.errorContainer.classList.remove('hidden');
this.errorReasonContainer.textContent = error;
break;
case 'scheduled':
case 'creating':
this.creatingContainer.classList.remove('hidden');
break;
default:
this.hideAll();
}
}
}

View File

@ -17,7 +17,8 @@ class Diff {
}
});
FilesCommentButton.init($diffFile);
const tab = document.getElementById('diffs');
if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile);
$diffFile.each((index, file) => new gl.ImageFile(file));

View File

@ -525,6 +525,11 @@ import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
case 'admin:impersonation_tokens:index':
new gl.DueDateSelectors();
break;
case 'projects:clusters:show':
import(/* webpackChunkName: "clusters" */ './clusters')
.then(cluster => new cluster.default()) // eslint-disable-line new-cap
.catch(() => {});
break;
}
switch (path[0]) {
case 'sessions':

View File

@ -54,12 +54,14 @@ LineHighlighter.prototype.bindEvents = function() {
$fileHolder.on('highlight:line', this.highlightHash);
};
LineHighlighter.prototype.highlightHash = function() {
var range;
LineHighlighter.prototype.highlightHash = function(newHash) {
let range;
if (newHash && typeof newHash === 'string') this._hash = newHash;
this.clearHighlight();
if (this._hash !== '') {
range = this.hashToRange(this._hash);
if (range[0]) {
this.highlightRange(range);
const lineSelector = `#L${range[0]}`;

View File

@ -7,10 +7,12 @@
import TaskList from '../../task_list';
import * as constants from '../constants';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import issueDiscussionLockedWidget from './issue_discussion_locked_widget.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issuableStateMixin from '../mixins/issuable_state';
export default {
name: 'issueCommentForm',
@ -26,8 +28,9 @@
};
},
components: {
confidentialIssue,
issueWarning,
issueNoteSignedOutWidget,
issueDiscussionLockedWidget,
markdownField,
userAvatarLink,
},
@ -55,6 +58,9 @@
isIssueOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
},
canCreateNote() {
return this.getIssueData.current_user.can_create_note;
},
issueActionButtonTitle() {
if (this.note.length) {
const actionText = this.isIssueOpen ? 'close' : 'reopen';
@ -90,9 +96,6 @@
endpoint() {
return this.getIssueData.create_note_path;
},
isConfidentialIssue() {
return this.getIssueData.confidential;
},
},
methods: {
...mapActions([
@ -220,6 +223,9 @@
});
},
},
mixins: [
issuableStateMixin,
],
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
@ -235,6 +241,7 @@
<template>
<div>
<issue-note-signed-out-widget v-if="!isLoggedIn" />
<issue-discussion-locked-widget v-else-if="!canCreateNote" />
<ul
v-else
class="notes notes-form timeline">
@ -253,15 +260,22 @@
<div class="timeline-content timeline-content-form">
<form
ref="commentForm"
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
<confidentialIssue v-if="isConfidentialIssue" />
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"
>
<div class="error-alert"></div>
<issue-warning
v-if="hasWarning(getIssueData)"
:is-locked="isLocked(getIssueData)"
:is-confidential="isConfidential(getIssueData)"
/>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:is-confidential-issue="isConfidentialIssue"
ref="markdownField">
<textarea
id="note-body"

View File

@ -0,0 +1,19 @@
<script>
export default {
computed: {
lockIcon() {
return gl.utils.spriteIcon('lock');
},
},
};
</script>
<template>
<div class="disabled-comment text-center">
<span class="issuable-note-warning">
<span class="icon" v-html="lockIcon"></span>
<span>This issue is locked. Only <b>project members</b> can comment.</span>
</span>
</div>
</template>

View File

@ -1,8 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
export default {
name: 'issueNoteForm',
@ -39,12 +40,13 @@
};
},
components: {
confidentialIssue,
issueWarning,
markdownField,
},
computed: {
...mapGetters([
'getDiscussionLastNote',
'getIssueData',
'getIssueDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
@ -67,9 +69,6 @@
isDisabled() {
return !this.note.length || this.isSubmitting;
},
isConfidentialIssue() {
return this.getIssueDataByProp('confidential');
},
},
methods: {
handleUpdate() {
@ -95,6 +94,9 @@
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
},
},
mixins: [
issuableStateMixin,
],
mounted() {
this.$refs.textarea.focus();
},
@ -125,7 +127,13 @@
<div class="flash-container timeline-content"></div>
<form
class="edit-note common-note-form js-quick-submit gfm-form">
<confidentialIssue v-if="isConfidentialIssue" />
<issue-warning
v-if="hasWarning(getIssueData)"
:is-locked="isLocked(getIssueData)"
:is-confidential="isConfidential(getIssueData)"
/>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"

View File

@ -0,0 +1,15 @@
export default {
methods: {
isConfidential(issue) {
return !!issue.confidential;
},
isLocked(issue) {
return !!issue.discussion_locked;
},
hasWarning(issue) {
return this.isConfidential(issue) || this.isLocked(issue);
},
},
};

View File

@ -63,12 +63,7 @@ const RepoEditor = {
const lineNumber = e.target.position.lineNumber;
if (e.target.element.classList.contains('line-numbers')) {
location.hash = `L${lineNumber}`;
Store.activeLine = lineNumber;
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
Store.setActiveLine(lineNumber);
}
},
},
@ -101,6 +96,15 @@ const RepoEditor = {
this.setupEditor();
}
},
activeLine() {
if (Helper.monacoInstance) {
Helper.monacoInstance.setPosition({
lineNumber: this.activeLine,
column: 1,
});
}
},
},
computed: {
shouldHideEditor() {

View File

@ -14,6 +14,11 @@ export default {
highlightFile() {
$(this.$el).find('.file-content').syntaxHighlight();
},
highlightLine() {
if (Store.activeLine > -1) {
this.lineHighlighter.highlightHash(`#L${Store.activeLine}`);
}
},
},
mounted() {
this.highlightFile();
@ -26,8 +31,12 @@ export default {
html() {
this.$nextTick(() => {
this.highlightFile();
this.highlightLine();
});
},
activeLine() {
this.highlightLine();
},
},
};
</script>

View File

@ -18,22 +18,40 @@ export default {
},
created() {
this.addPopEventListener();
window.addEventListener('popstate', this.checkHistory);
},
destroyed() {
window.removeEventListener('popstate', this.checkHistory);
},
data: () => Store,
methods: {
addPopEventListener() {
window.addEventListener('popstate', () => {
if (location.href.indexOf('#') > -1) return;
this.linkClicked({
checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
if (!selectedFile) {
// Maybe it is not in the current tree but in the opened tabs
selectedFile = Helper.getFileFromPath(location.pathname);
}
let lineNumber = null;
if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2));
if (selectedFile) {
if (selectedFile.url !== this.activeFile.url) {
this.fileClicked(selectedFile, lineNumber);
} else {
Store.setActiveLine(lineNumber);
}
} else {
// Not opened at all lets open new tab
this.fileClicked({
url: location.href,
});
});
}, lineNumber);
}
},
fileClicked(clickedFile) {
fileClicked(clickedFile, lineNumber) {
let file = clickedFile;
if (file.loading) return;
file.loading = true;
@ -41,17 +59,20 @@ export default {
if (file.type === 'tree' && file.opened) {
file = Store.removeChildFilesOfTree(file);
file.loading = false;
Store.setActiveLine(lineNumber);
} else {
const openFile = Helper.getFileFromPath(file.url);
if (openFile) {
file.loading = false;
Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber);
} else {
Service.url = file.url;
Helper.getContent(file)
.then(() => {
file.loading = false;
Helper.scrollTabsRight();
Store.setActiveLine(lineNumber);
})
.catch(Helper.loadingError);
}

View File

@ -254,7 +254,9 @@ const RepoHelper = {
RepoHelper.key = RepoHelper.genKey();
history.pushState({ key: RepoHelper.key }, '', url);
if (document.location.pathname !== url) {
history.pushState({ key: RepoHelper.key }, '', url);
}
if (title) {
document.title = title;

View File

@ -26,7 +26,7 @@ const RepoStore = {
},
activeFile: Helper.getDefaultActiveFile(),
activeFileIndex: 0,
activeLine: 0,
activeLine: -1,
activeFileLabel: 'Raw',
files: [],
isCommitable: false,
@ -85,6 +85,7 @@ const RepoStore = {
if (!file.loading) Helper.updateHistoryEntry(file.url, file.pageTitle || file.name);
RepoStore.binary = file.binary;
RepoStore.setActiveLine(-1);
},
setFileActivity(file, openedFile, i) {
@ -101,6 +102,10 @@ const RepoStore = {
RepoStore.activeFileIndex = i;
},
setActiveLine(activeLine) {
if (!isNaN(activeLine)) RepoStore.activeLine = activeLine;
},
setActiveToRaw() {
RepoStore.activeFile.raw = false;
// can't get vue to listen to raw for some reason so RepoStore for now.

View File

@ -47,9 +47,9 @@ export default {
</script>
<template>
<div class="block confidentiality">
<div class="block issuable-sidebar-item confidentiality">
<div class="sidebar-collapsed-icon">
<i class="fa" :class="faEye" aria-hidden="true" data-hidden="true"></i>
<i class="fa" :class="faEye" aria-hidden="true"></i>
</div>
<div class="title hide-collapsed">
Confidentiality
@ -62,19 +62,19 @@ export default {
Edit
</a>
</div>
<div class="value confidential-value hide-collapsed">
<div class="value sidebar-item-value hide-collapsed">
<editForm
v-if="edit"
:toggle-form="toggleForm"
:is-confidential="isConfidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
<div v-if="!isConfidential" class="no-value confidential-value">
<i class="fa fa-eye is-not-confidential"></i>
<div v-if="!isConfidential" class="no-value sidebar-item-value">
<i class="fa fa-eye sidebar-item-icon"></i>
Not confidential
</div>
<div v-else class="value confidential-value hide-collapsed">
<i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i>
<div v-else class="value sidebar-item-value hide-collapsed">
<i aria-hidden="true" class="fa fa-eye-slash sidebar-item-icon is-active"></i>
This issue is confidential
</div>
</div>

View File

@ -2,9 +2,6 @@
import editFormButtons from './edit_form_buttons.vue';
export default {
components: {
editFormButtons,
},
props: {
isConfidential: {
required: true,
@ -19,12 +16,16 @@ export default {
type: Function,
},
},
components: {
editFormButtons,
},
};
</script>
<template>
<div class="dropdown open">
<div class="dropdown-menu confidential-warning-message">
<div class="dropdown-menu sidebar-item-warning-message">
<div>
<p v-if="!isConfidential">
You are going to turn on the confidentiality. This means that only team members with

View File

@ -15,7 +15,7 @@ export default {
},
},
computed: {
onOrOff() {
toggleButtonText() {
return this.isConfidential ? 'Turn Off' : 'Turn On';
},
updateConfidentialBool() {
@ -26,7 +26,7 @@ export default {
</script>
<template>
<div class="confidential-warning-message-actions">
<div class="sidebar-item-warning-message-actions">
<button
type="button"
class="btn btn-default append-right-10"
@ -39,7 +39,7 @@ export default {
class="btn btn-close"
@click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
>
{{ onOrOff }}
{{ toggleButtonText }}
</button>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script>
import editFormButtons from './edit_form_buttons.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
export default {
props: {
isLocked: {
required: true,
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateLockedAttribute: {
required: true,
type: Function,
},
issuableType: {
required: true,
type: String,
},
},
mixins: [
issuableMixin,
],
components: {
editFormButtons,
},
};
</script>
<template>
<div class="dropdown open">
<div class="dropdown-menu sidebar-item-warning-message">
<p class="text" v-if="isLocked">
Unlock this {{ issuableDisplayName(issuableType) }}?
<strong>Everyone</strong>
will be able to comment.
</p>
<p class="text" v-else>
Lock this {{ issuableDisplayName(issuableType) }}?
Only
<strong>project members</strong>
will be able to comment.
</p>
<edit-form-buttons
:is-locked="isLocked"
:toggle-form="toggleForm"
:update-locked-attribute="updateLockedAttribute"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script>
export default {
props: {
isLocked: {
required: true,
type: Boolean,
},
toggleForm: {
required: true,
type: Function,
},
updateLockedAttribute: {
required: true,
type: Function,
},
},
computed: {
buttonText() {
return this.isLocked ? this.__('Unlock') : this.__('Lock');
},
toggleLock() {
return !this.isLocked;
},
},
};
</script>
<template>
<div class="sidebar-item-warning-message-actions">
<button
type="button"
class="btn btn-default append-right-10"
@click="toggleForm"
>
{{ __('Cancel') }}
</button>
<button
type="button"
class="btn btn-close"
@click.prevent="updateLockedAttribute(toggleLock)"
>
{{ buttonText }}
</button>
</div>
</template>

View File

@ -0,0 +1,120 @@
<script>
/* global Flash */
import editForm from './edit_form.vue';
import issuableMixin from '../../../vue_shared/mixins/issuable';
export default {
props: {
isLocked: {
required: true,
type: Boolean,
},
isEditable: {
required: true,
type: Boolean,
},
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return mediatorObject.service && mediatorObject.service.update && mediatorObject.store;
},
},
issuableType: {
required: true,
type: String,
},
},
mixins: [
issuableMixin,
],
components: {
editForm,
},
computed: {
lockIconClass() {
return this.isLocked ? 'fa-lock' : 'fa-unlock';
},
isLockDialogOpen() {
return this.mediator.store.isLockDialogOpen;
},
},
methods: {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
},
updateLockedAttribute(locked) {
this.mediator.service.update(this.issuableType, {
discussion_locked: locked,
})
.then(() => location.reload())
.catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}`)));
},
},
};
</script>
<template>
<div class="block issuable-sidebar-item lock">
<div class="sidebar-collapsed-icon">
<i
class="fa"
:class="lockIconClass"
aria-hidden="true"
></i>
</div>
<div class="title hide-collapsed">
Lock {{issuableDisplayName(issuableType) }}
<button
v-if="isEditable"
class="pull-right lock-edit btn btn-blank"
type="button"
@click.prevent="toggleForm"
>
{{ __('Edit') }}
</button>
</div>
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="isLockDialogOpen"
:toggle-form="toggleForm"
:is-locked="isLocked"
:update-locked-attribute="updateLockedAttribute"
:issuable-type="issuableType"
/>
<div
v-if="isLocked"
class="value sidebar-item-value"
>
<i
aria-hidden="true"
class="fa fa-lock sidebar-item-icon is-active"
></i>
{{ __('Locked') }}
</div>
<div
v-else
class="no-value sidebar-item-value hide-collapsed"
>
<i
aria-hidden="true"
class="fa fa-unlock sidebar-item-icon"
></i>
{{ __('Unlocked') }}
</div>
</div>
</div>
</template>

View File

@ -1,46 +1,76 @@
import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees';
import confidential from './components/confidential/confidential_issue_sidebar.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import SidebarAssignees from './components/assignees/sidebar_assignees';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import Translate from '../vue_shared/translate';
import Mediator from './sidebar_mediator';
Vue.use(Translate);
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
propsData: {
isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
}).$mount(el);
}
function mountLockComponent(mediator) {
const el = document.getElementById('js-lock-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const LockComp = Vue.extend(LockIssueSidebar);
new LockComp({
propsData: {
isLocked: initialData.is_locked,
isEditable: initialData.is_editable,
mediator,
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
},
}).$mount(el);
}
function domContentLoaded() {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
const mediator = new Mediator(sidebarOptions);
mediator.fetch();
const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
const confidentialEl = document.querySelector('#js-confidential-entry-point');
const sidebarAssigneesEl = document.getElementById('js-vue-sidebar-assignees');
// Only create the sidebarAssignees vue app if it is found in the DOM
// We currently do not use sidebarAssignees for the MR page
if (sidebarAssigneesEl) {
new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
new Vue(SidebarAssignees).$mount(sidebarAssigneesEl);
}
if (confidentialEl) {
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
mountConfidentialComponent(mediator);
mountLockComponent(mediator);
const ConfidentialComp = Vue.extend(confidential);
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
new ConfidentialComp({
propsData: {
isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
}).$mount(confidentialEl);
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
}
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker');
}
document.addEventListener('DOMContentLoaded', domContentLoaded);

View File

@ -15,6 +15,7 @@ export default class SidebarStore {
};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
SidebarStore.singleton = this;
}

View File

@ -1,16 +0,0 @@
<script>
export default {
name: 'confidentialIssueWarning',
};
</script>
<template>
<div class="confidential-issue-warning">
<i
aria-hidden="true"
class="fa fa-eye-slash">
</i>
<span>
This is a confidential issue. Your comment will not be visible to the public.
</span>
</div>
</template>

View File

@ -0,0 +1,55 @@
<script>
export default {
props: {
isLocked: {
type: Boolean,
default: false,
required: false,
},
isConfidential: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
iconClass() {
return {
'fa-eye-slash': this.isConfidential,
'fa-lock': this.isLocked,
};
},
isLockedAndConfidential() {
return this.isConfidential && this.isLocked;
},
},
};
</script>
<template>
<div class="issuable-note-warning">
<i
aria-hidden="true"
class="fa icon"
:class="iconClass"
v-if="!isLockedAndConfidential"
></i>
<span v-if="isLockedAndConfidential">
{{ __('This issue is confidential and locked.') }}
{{ __('People without permission will never get a notification and won\'t be able to comment.') }}
</span>
<span v-else-if="isConfidential">
{{ __('This is a confidential issue.') }}
{{ __('Your comment will not be visible to the public.') }}
</span>
<span v-else-if="isLocked">
{{ __('This issue is locked.') }}
{{ __('Only project members can comment.') }}
</span>
</div>
</template>

View File

@ -0,0 +1,9 @@
export default {
methods: {
issuableDisplayName(issuableType) {
const displayName = issuableType.replace(/_/, ' ');
return this.__ ? this.__(displayName) : displayName;
},
},
};

View File

@ -40,6 +40,7 @@
@import "framework/tables";
@import "framework/notes";
@import "framework/timeline";
@import "framework/tooltips";
@import "framework/typography";
@import "framework/zen";
@import "framework/blank";

View File

@ -381,7 +381,11 @@
background: transparent;
border: 0;
&:hover,
&:active,
&:focus {
outline: 0;
background: transparent;
box-shadow: none;
}
}

View File

@ -95,7 +95,7 @@
}
}
.title {
.navbar .title {
> a {
&:hover,
&:focus {

View File

@ -0,0 +1,7 @@
.tooltip-inner {
font-size: $tooltip-font-size;
border-radius: $border-radius-default;
line-height: 16px;
font-weight: $gl-font-weight-normal;
padding: $gl-btn-padding;
}

View File

@ -202,6 +202,11 @@ $md-area-border: #ddd;
$code_font_size: 12px;
$code_line_height: 1.6;
/*
* Tooltips
*/
$tooltip-font-size: 12px;
/*
* Padding
*/
@ -700,3 +705,9 @@ Project Templates Icons
$rails: #c00;
$node: #353535;
$java: #70ad51;
/*
Issuable warning
*/
$issuable-warning-size: 24px;
$issuable-warning-icon-margin: 4px;

View File

@ -0,0 +1,9 @@
.edit-cluster-form {
.clipboard-addon {
background-color: $white-light;
}
.alert-block {
margin-bottom: 20px;
}
}

View File

@ -5,27 +5,29 @@
margin-right: auto;
}
.is-confidential {
.issuable-warning-icon {
color: $orange-600;
background-color: $orange-100;
border-radius: $border-radius-default;
padding: 5px;
margin: 0 3px 0 -4px;
margin: 0 $btn-side-margin 0 0;
width: $issuable-warning-size;
height: $issuable-warning-size;
text-align: center;
&:first-of-type {
margin-right: $issuable-warning-icon-margin;
}
}
.is-not-confidential {
.sidebar-item-icon {
border-radius: $border-radius-default;
padding: 5px;
margin: 0 3px 0 -4px;
}
.confidentiality {
.is-not-confidential {
margin: auto;
}
.is-confidential {
margin: auto;
&.is-active {
color: $orange-600;
background-color: $orange-50;
}
}

View File

@ -101,7 +101,7 @@
}
}
.confidential-issue-warning {
.issuable-note-warning {
color: $orange-600;
background-color: $orange-100;
border-radius: $border-radius-default $border-radius-default 0 0;
@ -110,28 +110,52 @@
padding: 3px 12px;
margin: auto;
align-items: center;
.icon {
margin-right: $issuable-warning-icon-margin;
}
}
.confidential-value {
.disabled-comment .issuable-note-warning {
border: none;
border-radius: $label-border-radius;
padding-top: $gl-vert-padding;
padding-bottom: $gl-vert-padding;
.icon svg {
position: relative;
top: 2px;
margin-right: $btn-xs-side-margin;
width: $gl-font-size;
height: $gl-font-size;
fill: $orange-600;
}
}
.sidebar-item-value {
.fa {
background-color: inherit;
}
}
.confidential-warning-message {
.sidebar-item-warning-message {
line-height: 1.5;
padding: 16px;
.confidential-warning-message-actions {
.text {
color: $text-color;
}
.sidebar-item-warning-message-actions {
display: flex;
button {
.btn {
flex-grow: 1;
}
}
}
.confidential-issue-warning + .md-area {
.issuable-note-warning + .md-area {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@ -703,6 +703,12 @@ ul.notes {
color: $note-disabled-comment-color;
padding: 90px 0;
&.discussion-locked {
border: none;
background-color: $white-light;
}
a {
color: $gl-link-color;
}

View File

@ -0,0 +1,29 @@
module GoogleApi
class AuthorizationsController < ApplicationController
def callback
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
.get_token(params[:code])
session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
expires_at.to_s
state_redirect_uri = redirect_uri_from_session_key(params[:state])
if state_redirect_uri
redirect_to state_redirect_uri
else
redirect_to root_path
end
end
private
def redirect_uri_from_session_key(state)
key = GoogleApi::CloudPlatform::Client
.session_key_for_redirect_uri(params[:state])
session[key] if key
end
end
end

View File

@ -0,0 +1,136 @@
class Projects::ClustersController < Projects::ApplicationController
before_action :cluster, except: [:login, :index, :new, :create]
before_action :authorize_read_cluster!
before_action :authorize_create_cluster!, only: [:new, :create]
before_action :authorize_google_api, only: [:new, :create]
before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy]
def index
if project.cluster
redirect_to project_cluster_path(project, project.cluster)
else
redirect_to new_project_cluster_path(project)
end
end
def login
begin
state = generate_session_key_redirect(namespace_project_clusters_url.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
end
def new
@cluster = project.build_cluster
end
def create
@cluster = Ci::CreateClusterService
.new(project, current_user, create_params)
.execute(token_in_session)
if @cluster.persisted?
redirect_to project_cluster_path(project, @cluster)
else
render :new
end
end
def status
respond_to do |format|
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
render json: ClusterSerializer
.new(project: @project, current_user: @current_user)
.represent_status(@cluster)
end
end
end
def show
end
def update
Ci::UpdateClusterService
.new(project, current_user, update_params)
.execute(cluster)
if cluster.valid?
flash[:notice] = "Cluster was successfully updated."
redirect_to project_cluster_path(project, project.cluster)
else
render :show
end
end
def destroy
if cluster.destroy
flash[:notice] = "Cluster integration was successfully removed."
redirect_to project_clusters_path(project), status: 302
else
flash[:notice] = "Cluster integration was not removed."
render :show
end
end
private
def cluster
@cluster ||= project.cluster.present(current_user: current_user)
end
def create_params
params.require(:cluster).permit(
:gcp_project_id,
:gcp_cluster_zone,
:gcp_cluster_name,
:gcp_cluster_size,
:gcp_machine_type,
:project_namespace,
:enabled)
end
def update_params
params.require(:cluster).permit(
:project_namespace,
:enabled)
end
def authorize_google_api
unless GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
redirect_to action: 'login'
end
end
def token_in_session
@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
def authorize_admin_cluster!
access_denied! unless can?(current_user, :admin_cluster, cluster)
end
end

View File

@ -278,6 +278,7 @@ class Projects::IssuesController < Projects::ApplicationController
state_event
task_num
lock_version
discussion_locked
] + [{ label_ids: [], assignee_ids: [] }]
end

View File

@ -11,7 +11,7 @@ class Projects::JobsController < Projects::ApplicationController
def index
@scope = params[:scope]
@all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds = @all_builds.order('ci_builds.id DESC')
@builds =
case @scope
when 'pending'

View File

@ -34,6 +34,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:target_project_id,
:task_num,
:title,
:discussion_locked,
label_ids: []
]

View File

@ -66,7 +66,16 @@ class Projects::NotesController < Projects::ApplicationController
params.merge(last_fetched_at: last_fetched_at)
end
def authorize_admin_note!
return access_denied! unless can?(current_user, :admin_note, note)
end
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
def authorize_create_note!
return unless noteable.lockable?
access_denied! unless can?(current_user, :create_note, noteable)
end
end

View File

@ -130,8 +130,12 @@ module NotesHelper
end
def can_create_note?
issuable = @issue || @merge_request
if @snippet.is_a?(PersonalSnippet)
can?(current_user, :comment_personal_snippet, @snippet)
elsif issuable
can?(current_user, :create_note, issuable)
else
can?(current_user, :create_note, @project)
end

View File

@ -0,0 +1,11 @@
module NumbersHelper
def limited_counter_with_delimiter(resource, **options)
limit = options.fetch(:limit, 1000).to_i
count = resource.limit(limit + 1).count(:all)
if count > limit
number_with_delimiter(count - 1, options) + '+'
else
number_with_delimiter(count, options)
end
end
end

View File

@ -293,6 +293,7 @@ module ProjectsHelper
snippets: :read_project_snippet,
settings: :admin_project,
builds: :read_build,
clusters: :read_cluster,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,

View File

@ -19,7 +19,9 @@ module SystemNoteHelper
'discussion' => 'comment',
'moved' => 'arrow-right',
'outdated' => 'pencil',
'duplicate' => 'issue-duplicate'
'duplicate' => 'issue-duplicate',
'locked' => 'lock',
'unlocked' => 'lock-open'
}.freeze
def system_note_icon_name(note)

View File

@ -74,4 +74,8 @@ module Noteable
def discussions_can_be_resolved_by?(user)
discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
end
def lockable?
[MergeRequest, Issue].include?(self.class)
end
end

113
app/models/gcp/cluster.rb Normal file
View File

@ -0,0 +1,113 @@
module Gcp
class Cluster < ActiveRecord::Base
extend Gitlab::Gcp::Model
include Presentable
belongs_to :project, inverse_of: :cluster
belongs_to :user
belongs_to :service
default_value_for :gcp_cluster_zone, 'us-central1-a'
default_value_for :gcp_cluster_size, 3
default_value_for :gcp_machine_type, 'n1-standard-4'
attr_encrypted :password,
mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
attr_encrypted :kubernetes_token,
mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
attr_encrypted :gcp_token,
mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
validates :gcp_project_id,
length: 1..63,
format: {
with: Gitlab::Regex.kubernetes_namespace_regex,
message: Gitlab::Regex.kubernetes_namespace_regex_message
}
validates :gcp_cluster_name,
length: 1..63,
format: {
with: Gitlab::Regex.kubernetes_namespace_regex,
message: Gitlab::Regex.kubernetes_namespace_regex_message
}
validates :gcp_cluster_zone, presence: true
validates :gcp_cluster_size,
presence: true,
numericality: {
only_integer: true,
greater_than: 0
}
validates :project_namespace,
allow_blank: true,
length: 1..63,
format: {
with: Gitlab::Regex.kubernetes_namespace_regex,
message: Gitlab::Regex.kubernetes_namespace_regex_message
}
# if we do not do status transition we prevent change
validate :restrict_modification, on: :update, unless: :status_changed?
state_machine :status, initial: :scheduled do
state :scheduled, value: 1
state :creating, value: 2
state :created, value: 3
state :errored, value: 4
event :make_creating do
transition any - [:creating] => :creating
end
event :make_created do
transition any - [:created] => :created
end
event :make_errored do
transition any - [:errored] => :errored
end
before_transition any => [:errored, :created] do |cluster|
cluster.gcp_token = nil
cluster.gcp_operation_id = nil
end
before_transition any => [:errored] do |cluster, transition|
status_reason = transition.args.first
cluster.status_reason = status_reason if status_reason
end
end
def project_namespace_placeholder
"#{project.path}-#{project.id}"
end
def on_creation?
scheduled? || creating?
end
def api_url
'https://' + endpoint if endpoint
end
def restrict_modification
if on_creation?
errors.add(:base, "cannot modify during creation")
return false
end
true
end
end
end

View File

@ -165,6 +165,7 @@ class Project < ActiveRecord::Base
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
has_one :project_feature, inverse_of: :project
has_one :statistics, class_name: 'ProjectStatistics'
has_one :cluster, class_name: 'Gcp::Cluster', inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy

View File

@ -2,7 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved
opened closed merged duplicate
opened closed merged duplicate locked unlocked
outdated
].freeze

View File

@ -0,0 +1,12 @@
module Gcp
class ClusterPolicy < BasePolicy
alias_method :cluster, :subject
delegate { @subject.project }
rule { can?(:master_access) }.policy do
enable :update_cluster
enable :admin_cluster
end
end
end

View File

@ -1,6 +1,10 @@
class IssuablePolicy < BasePolicy
delegate { @subject.project }
condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? }
condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) }
desc "User is the assignee or author"
condition(:assignee_or_author) do
@user && @subject.assignee_or_author?(@user)
@ -12,4 +16,12 @@ class IssuablePolicy < BasePolicy
enable :read_merge_request
enable :update_merge_request
end
rule { locked & ~is_project_member }.policy do
prevent :create_note
prevent :update_note
prevent :admin_note
prevent :resolve_note
prevent :edit_note
end
end

View File

@ -1,5 +1,6 @@
class NotePolicy < BasePolicy
delegate { @subject.project }
delegate { @subject.noteable if @subject.noteable.lockable? }
condition(:is_author) { @user && @subject.author == @user }
condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
@ -8,6 +9,7 @@ class NotePolicy < BasePolicy
condition(:editable, scope: :subject) { @subject.editable? }
rule { ~editable | anonymous }.prevent :edit_note
rule { is_author | admin }.enable :edit_note
rule { can?(:master_access) }.enable :edit_note

View File

@ -193,6 +193,8 @@ class ProjectPolicy < BasePolicy
enable :admin_pages
enable :read_pages
enable :update_pages
enable :read_cluster
enable :create_cluster
end
rule { can?(:public_user_access) }.policy do

View File

@ -0,0 +1,9 @@
module Gcp
class ClusterPresenter < Gitlab::View::Presenter::Delegated
presents :cluster
def gke_cluster_url
"https://console.cloud.google.com/kubernetes/clusters/details/#{gcp_cluster_zone}/#{gcp_cluster_name}"
end
end
end

View File

@ -0,0 +1,6 @@
class ClusterEntity < Grape::Entity
include RequestAwareEntity
expose :status_name, as: :status
expose :status_reason
end

View File

@ -0,0 +1,7 @@
class ClusterSerializer < BaseSerializer
entity ClusterEntity
def represent_status(resource)
represent(resource, { only: [:status, :status_reason] })
end
end

View File

@ -3,6 +3,7 @@ class IssueEntity < IssuableEntity
expose :branch_name
expose :confidential
expose :discussion_locked
expose :assignees, using: API::Entities::UserBasic
expose :due_date
expose :moved_to_id
@ -14,7 +15,7 @@ class IssueEntity < IssuableEntity
expose :current_user do
expose :can_create_note do |issue|
can?(request.current_user, :create_note, issue.project)
can?(request.current_user, :create_note, issue)
end
expose :can_update do |issue|

View File

@ -0,0 +1,15 @@
module Ci
class CreateClusterService < BaseService
def execute(access_token)
params['gcp_machine_type'] ||= GoogleApi::CloudPlatform::Client::DEFAULT_MACHINE_TYPE
cluster_params =
params.merge(user: current_user,
gcp_token: access_token)
project.create_cluster(cluster_params).tap do |cluster|
ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted?
end
end
end
end

View File

@ -0,0 +1,17 @@
module Ci
class FetchGcpOperationService
def execute(cluster)
api_client =
GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
operation = api_client.projects_zones_operations(
cluster.gcp_project_id,
cluster.gcp_cluster_zone,
cluster.gcp_operation_id)
yield(operation) if block_given?
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
end
end
end

View File

@ -0,0 +1,72 @@
##
# TODO:
# Almost components in this class were copied from app/models/project_services/kubernetes_service.rb
# We should dry up those classes not to repeat the same code.
# Maybe we should have a special facility (e.g. lib/kubernetes_api) to maintain all Kubernetes API caller.
module Ci
class FetchKubernetesTokenService
attr_reader :api_url, :ca_pem, :username, :password
def initialize(api_url, ca_pem, username, password)
@api_url = api_url
@ca_pem = ca_pem
@username = username
@password = password
end
def execute
read_secrets.each do |secret|
name = secret.dig('metadata', 'name')
if /default-token/ =~ name
token_base64 = secret.dig('data', 'token')
return Base64.decode64(token_base64) if token_base64
end
end
nil
end
private
def read_secrets
kubeclient = build_kubeclient!
kubeclient.get_secrets.as_json
rescue KubeException => err
raise err unless err.error_code == 404
[]
end
def build_kubeclient!(api_path: 'api', api_version: 'v1')
raise "Incomplete settings" unless api_url && username && password
::Kubeclient::Client.new(
join_api_url(api_path),
api_version,
auth_options: { username: username, password: password },
ssl_options: kubeclient_ssl_options,
http_proxy_uri: ENV['http_proxy']
)
end
def join_api_url(api_path)
url = URI.parse(api_url)
prefix = url.path.sub(%r{/+\z}, '')
url.path = [prefix, api_path].join("/")
url.to_s
end
def kubeclient_ssl_options
opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
if ca_pem.present?
opts[:cert_store] = OpenSSL::X509::Store.new
opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
end
opts
end
end
end

View File

@ -0,0 +1,33 @@
module Ci
class FinalizeClusterCreationService
def execute(cluster)
api_client =
GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
begin
gke_cluster = api_client.projects_zones_clusters_get(
cluster.gcp_project_id,
cluster.gcp_cluster_zone,
cluster.gcp_cluster_name)
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
end
endpoint = gke_cluster.endpoint
api_url = 'https://' + endpoint
ca_cert = Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate)
username = gke_cluster.master_auth.username
password = gke_cluster.master_auth.password
kubernetes_token = Ci::FetchKubernetesTokenService.new(
api_url, ca_cert, username, password).execute
unless kubernetes_token
return cluster.make_errored!('Failed to get a default token of kubernetes')
end
Ci::IntegrateClusterService.new.execute(
cluster, endpoint, ca_cert, kubernetes_token, username, password)
end
end
end

View File

@ -0,0 +1,26 @@
module Ci
class IntegrateClusterService
def execute(cluster, endpoint, ca_cert, token, username, password)
Gcp::Cluster.transaction do
cluster.update!(
enabled: true,
endpoint: endpoint,
ca_cert: ca_cert,
kubernetes_token: token,
username: username,
password: password,
service: cluster.project.find_or_initialize_service('kubernetes'),
status_event: :make_created)
cluster.service.update!(
active: true,
api_url: cluster.api_url,
ca_pem: ca_cert,
namespace: cluster.project_namespace,
token: token)
end
rescue ActiveRecord::RecordInvalid => e
cluster.make_errored!("Failed to integrate cluster into kubernetes_service: #{e.message}")
end
end
end

View File

@ -0,0 +1,36 @@
module Ci
class ProvisionClusterService
def execute(cluster)
api_client =
GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
begin
operation = api_client.projects_zones_clusters_create(
cluster.gcp_project_id,
cluster.gcp_cluster_zone,
cluster.gcp_cluster_name,
cluster.gcp_cluster_size,
machine_type: cluster.gcp_machine_type)
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
end
unless operation.status == 'RUNNING' || operation.status == 'PENDING'
return cluster.make_errored!("Operation status is unexpected; #{operation.status_message}")
end
cluster.gcp_operation_id = api_client.parse_operation_id(operation.self_link)
unless cluster.gcp_operation_id
return cluster.make_errored!('Can not find operation_id from self_link')
end
if cluster.make_creating
WaitForClusterCreationWorker.perform_in(
WaitForClusterCreationWorker::INITIAL_INTERVAL, cluster.id)
else
return cluster.make_errored!("Failed to update cluster record; #{cluster.errors}")
end
end
end
end

View File

@ -0,0 +1,22 @@
module Ci
class UpdateClusterService < BaseService
def execute(cluster)
Gcp::Cluster.transaction do
cluster.update!(params)
if params['enabled'] == 'true'
cluster.service.update!(
active: true,
api_url: cluster.api_url,
ca_pem: cluster.ca_cert,
namespace: cluster.project_namespace,
token: cluster.kubernetes_token)
else
cluster.service.update!(active: false)
end
end
rescue ActiveRecord::RecordInvalid => e
cluster.errors.add(:base, e.message)
end
end
end

View File

@ -43,6 +43,10 @@ class IssuableBaseService < BaseService
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
end
def create_discussion_lock_note(issuable)
SystemNoteService.discussion_lock(issuable, current_user)
end
def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
@ -57,6 +61,7 @@ class IssuableBaseService < BaseService
params.delete(:due_date)
params.delete(:canonical_issue_id)
params.delete(:project)
params.delete(:discussion_locked)
end
filter_assignee(issuable)
@ -236,6 +241,7 @@ class IssuableBaseService < BaseService
handle_common_system_notes(issuable, old_labels: old_labels)
end
change_discussion_lock(issuable)
handle_changes(
issuable,
old_labels: old_labels,
@ -294,6 +300,12 @@ class IssuableBaseService < BaseService
end
end
def change_discussion_lock(issuable)
if issuable.previous_changes.include?('discussion_locked')
create_discussion_lock_note(issuable)
end
end
def toggle_award(issuable)
award = params.delete(:emoji_award)
if award

View File

@ -591,6 +591,13 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
def discussion_lock(issuable, author)
action = issuable.discussion_locked? ? 'locked' : 'unlocked'
body = "#{action} this issue"
create_note(NoteSummary.new(issuable, issuable.project, author, body, action: action))
end
private
def notes_for_mentioner(mentioner, noteable, notes)

View File

@ -3,7 +3,7 @@
%div{ class: container_class }
.top-area
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope

View File

@ -4,7 +4,7 @@
%div{ class: container_class }
.top-area
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.prepend-top-default
.search-holder
= render 'shared/projects/search_form', autofocus: true, icon: true

View File

@ -146,7 +146,7 @@
= number_with_delimiter(@project.open_merge_requests_count)
- if project_nav_tab? :pipelines
= nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do
= nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters]) do
= link_to project_pipelines_path(@project), class: 'shortcuts-pipelines' do
.nav-icon-container
= sprite_icon('pipeline')
@ -189,6 +189,12 @@
%span
Charts
- if project_nav_tab? :clusters
= nav_link(controller: :clusters) do
= link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do
%span
Cluster
- if project_nav_tab? :wiki
= nav_link(controller: :wikis) do
= link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do

View File

@ -1,5 +1,12 @@
- referenced_users = local_assigns.fetch(:referenced_users, nil)
- if defined?(@merge_request) && @merge_request.discussion_locked?
.issuable-note-warning
= icon('lock', class: 'icon')
%span
= _('This merge request is locked.')
= _('Only project members can comment.')
.md-area
.md-header
%ul.nav-links.clearfix

View File

@ -0,0 +1,37 @@
.row
.col-sm-8.col-sm-offset-4
%p
- link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Read our %{link_to_help_page} on cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
= form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
= form_errors(@cluster)
.form-group
= field.label :gcp_cluster_name, s_('ClusterIntegration|Cluster name')
= field.text_field :gcp_cluster_name, class: 'form-control'
.form-group
= field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID')
= link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer')
= field.text_field :gcp_project_id, class: 'form-control'
.form-group
= field.label :gcp_cluster_zone, s_('ClusterIntegration|Zone')
= link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
= field.text_field :gcp_cluster_zone, class: 'form-control', placeholder: 'us-central1-a'
.form-group
= field.label :gcp_cluster_size, s_('ClusterIntegration|Number of nodes')
= field.text_field :gcp_cluster_size, class: 'form-control', placeholder: '3'
.form-group
= field.label :gcp_machine_type, s_('ClusterIntegration|Machine type')
= link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer')
= field.text_field :gcp_machine_type, class: 'form-control', placeholder: 'n1-standard-4'
.form-group
= field.label :project_namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
= field.text_field :project_namespace, class: 'form-control', placeholder: @cluster.project_namespace_placeholder
.form-group
= field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save'

View File

@ -0,0 +1,14 @@
%h4.prepend-top-0
= s_('ClusterIntegration|Create new cluster on Google Container Engine')
%p
= s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:')
%ul
%li
- link_to_container_engine = link_to(s_('ClusterIntegration|access to Google Container Engine'), 'https://console.cloud.google.com', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Your account must have %{link_to_container_engine}').html_safe % { link_to_container_engine: link_to_container_engine }
%li
- link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/container-engine/docs/quickstart', target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements }
%li
- link_to_container_project = link_to(s_('ClusterIntegration|Google Container Engine project'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|A %{link_to_container_project} must have been created under this account').html_safe % { link_to_container_project: link_to_container_project }

View File

@ -0,0 +1,7 @@
%h4.prepend-top-0
= s_('ClusterIntegration|Cluster integration')
%p
= s_('ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
%p
- link = link_to(s_('ClusterIntegration|cluster'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Learn more about %{link_to_documentation}').html_safe % { link_to_documentation: link }

View File

@ -0,0 +1,16 @@
- breadcrumb_title "Cluster"
- page_title _("Login")
.row.prepend-top-default
.col-sm-4
= render 'sidebar'
.col-sm-8
= render 'header'
.row
.col-sm-8.col-sm-offset-4.signin-with-google
- if @authorize_url
= link_to @authorize_url do
= image_tag('auth_buttons/signin_with_google.png')
- else
- link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer')
= s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link }

View File

@ -0,0 +1,9 @@
- breadcrumb_title "Cluster"
- page_title _("New Cluster")
.row.prepend-top-default
.col-sm-4
= render 'sidebar'
.col-sm-8
= render 'header'
= render 'form'

View File

@ -0,0 +1,70 @@
- breadcrumb_title "Cluster"
- page_title _("Cluster")
- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason } }
.col-sm-4
= render 'sidebar'
.col-sm-8
%label.append-bottom-10{ for: 'enable-cluster-integration' }
= s_('ClusterIntegration|Enable cluster integration')
%p
- if @cluster.enabled?
- if can?(current_user, :update_cluster, @cluster)
= s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
- else
= s_('ClusterIntegration|Cluster integration is enabled for this project.')
- else
= s_('ClusterIntegration|Cluster integration is disabled for this project.')
= form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
= form_errors(@cluster)
.form-group.append-bottom-20
%label.append-bottom-10
= field.hidden_field :enabled, { class: 'js-toggle-input'}
%button{ type: 'button',
class: "js-toggle-cluster project-feature-toggle #{'checked' unless !@cluster.enabled?} #{'disabled' unless can?(current_user, :update_cluster, @cluster)}",
'aria-label': s_('ClusterIntegration|Toggle Cluster'),
disabled: !can?(current_user, :update_cluster, @cluster),
data: { 'enabled-text': 'Enabled', 'disabled-text': 'Disabled' } }
- if can?(current_user, :update_cluster, @cluster)
.form-group
= field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
- if can?(current_user, :admin_cluster, @cluster)
%label.append-bottom-10{ for: 'google-container-engine' }
= s_('ClusterIntegration|Google Container Engine')
%p
- link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
.hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
= s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
%p.js-error-reason
.hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
= s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
.hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
= s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
.form_group.append-bottom-20
%label.append-bottom-10{ for: 'cluter-name' }
= s_('ClusterIntegration|Cluster name')
.input-group
%input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
%span.input-group-addon.clipboard-addon
= clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
- if can?(current_user, :admin_cluster, @cluster)
.well.form_group
%label.text-danger
= s_('ClusterIntegration|Remove cluster integration')
%p
= s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
= link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})

View File

@ -27,7 +27,9 @@
.issuable-meta
- if @issue.confidential
= icon('eye-slash', class: 'is-confidential')
= icon('eye-slash', class: 'issuable-warning-icon')
- if @issue.discussion_locked?
= icon('lock', class: 'issuable-warning-icon')
= issuable_meta(@issue, @project, "Issue")
.issuable-actions.js-issuable-actions

View File

@ -8,7 +8,7 @@
.nav-controls
- if can?(current_user, :update_build, @project)
- if @all_builds.running_or_pending.any?
- if @all_builds.running_or_pending.limit(1).any?
= link_to 'Cancel running', cancel_all_project_jobs_path(@project),
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post

View File

@ -15,6 +15,8 @@
= icon('angle-double-left')
.issuable-meta
- if @merge_request.discussion_locked?
= icon('lock', class: 'issuable-warning-icon')
= issuable_meta(@merge_request, @project, "Merge request")
.issuable-actions.js-issuable-actions

View File

@ -83,7 +83,7 @@
#pipelines.pipelines.tab-pane
- if @pipelines.any?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
#diffs.diffs.tab-pane
#diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } }
-# This tab is always loaded via AJAX
.mr-loading-status

View File

@ -3,22 +3,22 @@
= link_to build_path_proc.call(nil) do
All
%span.badge.js-totalbuilds-count
= number_with_delimiter(all_builds.count(:id))
= limited_counter_with_delimiter(all_builds)
%li{ class: active_when(scope == 'pending') }>
= link_to build_path_proc.call('pending') do
Pending
%span.badge
= number_with_delimiter(all_builds.pending.count(:id))
= limited_counter_with_delimiter(all_builds.pending)
%li{ class: active_when(scope == 'running') }>
= link_to build_path_proc.call('running') do
Running
%span.badge
= number_with_delimiter(all_builds.running.count(:id))
= limited_counter_with_delimiter(all_builds.running)
%li{ class: active_when(scope == 'finished') }>
= link_to build_path_proc.call('finished') do
Finished
%span.badge
= number_with_delimiter(all_builds.finished.count(:id))
= limited_counter_with_delimiter(all_builds.finished)

View File

@ -119,6 +119,10 @@
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe
#js-confidential-entry-point
- if issuable.has_attribute?(:discussion_locked)
%script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe
#js-lock-entry-point
= render "shared/issuable/participants", participants: issuable.participants(current_user)
- if current_user
- subscribed = issuable.subscribed?(current_user, @project)

View File

@ -1,3 +1,6 @@
- issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked?
%ul#notes-list.notes.main-notes-list.timeline
= render "shared/notes/notes"
@ -21,5 +24,14 @@
or
= link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment
- elsif discussion_locked
.disabled-comment.text-center.prepend-top-default
%span.issuable-note-warning
%span.icon= sprite_icon('lock', size: 14)
%span
This
= issuable.class.to_s.titleize.downcase
is locked. Only
%b project members
can comment.
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe

View File

@ -0,0 +1,10 @@
class ClusterProvisionWorker
include Sidekiq::Worker
include ClusterQueue
def perform(cluster_id)
Gcp::Cluster.find_by_id(cluster_id).try do |cluster|
Ci::ProvisionClusterService.new.execute(cluster)
end
end
end

View File

@ -0,0 +1,10 @@
##
# Concern for setting Sidekiq settings for the various Gcp clusters workers.
#
module ClusterQueue
extend ActiveSupport::Concern
included do
sidekiq_options queue: :gcp_cluster
end
end

View File

@ -0,0 +1,27 @@
class WaitForClusterCreationWorker
include Sidekiq::Worker
include ClusterQueue
INITIAL_INTERVAL = 2.minutes
EAGER_INTERVAL = 10.seconds
TIMEOUT = 20.minutes
def perform(cluster_id)
Gcp::Cluster.find_by_id(cluster_id).try do |cluster|
Ci::FetchGcpOperationService.new.execute(cluster) do |operation|
case operation.status
when 'RUNNING'
if TIMEOUT < Time.now.utc - operation.start_time.to_time.utc
return cluster.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}")
end
WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, cluster.id)
when 'DONE'
Ci::FinalizeClusterCreationService.new.execute(cluster)
else
return cluster.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
end
end
end
end
end

View File

@ -0,0 +1,4 @@
title: Discussion lock for issues and merge requests
merge_request:
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Make tabs on top scrollable on admin dashboard
merge_request: 14685
author: Takuya Noguchi
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add 1000+ counters to job page
merge_request:
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Adjust tooltips to adhere to 8px grid and make them more readable
merge_request:
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fixed navbar title colors leaking out of the navbar
merge_request:
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Create Kubernetes cluster on GKE from k8s service
merge_request: 14470
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add client and call site metadata to Gitaly calls for better traceability
merge_request: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14332
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Sort JobsController by id, not created_at
merge_request:
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Replace the 'project/merge_requests.feature' spinach test with an rspec analog
merge_request: 14621
author: Vitaliy @blackst0ne Klachkov
type: other

View File

@ -87,6 +87,7 @@ Rails.application.routes.draw do
resources :issues, module: :boards, only: [:index, :update]
end
draw :google_api
draw :import
draw :uploads
draw :explore

View File

@ -0,0 +1,5 @@
namespace :google_api do
resource :auth, only: [], controller: :authorizations do
match :callback, via: [:get, :post]
end
end

View File

@ -183,6 +183,16 @@ constraints(ProjectUrlConstrainer.new) do
end
end
resources :clusters, except: [:edit] do
collection do
get :login
end
member do
get :status, format: :json
end
end
resources :environments, except: [:destroy] do
member do
post :stop

View File

@ -62,5 +62,6 @@
- [update_user_activity, 1]
- [propagate_service_template, 1]
- [background_migration, 1]
- [gcp_cluster, 1]
- [project_migrate_hashed_storage, 1]
- [storage_migrator, 1]

View File

@ -0,0 +1,13 @@
class AddDiscussionLockedToIssuable < ActiveRecord::Migration
DOWNTIME = false
def up
add_column(:merge_requests, :discussion_locked, :boolean)
add_column(:issues, :discussion_locked, :boolean)
end
def down
remove_column(:merge_requests, :discussion_locked)
remove_column(:issues, :discussion_locked)
end
end

View File

@ -0,0 +1,45 @@
class CreateGcpClusters < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :gcp_clusters do |t|
# Order columns by best align scheme
t.references :project, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade }
t.references :user, foreign_key: { on_delete: :nullify }
t.references :service, foreign_key: { on_delete: :nullify }
t.integer :status
t.integer :gcp_cluster_size, null: false
# Timestamps
t.datetime_with_timezone :created_at, null: false
t.datetime_with_timezone :updated_at, null: false
# Enable/disable
t.boolean :enabled, default: true
# General
t.text :status_reason
# k8s integration specific
t.string :project_namespace
# Cluster details
t.string :endpoint
t.text :ca_cert
t.text :encrypted_kubernetes_token
t.string :encrypted_kubernetes_token_iv
t.string :username
t.text :encrypted_password
t.string :encrypted_password_iv
# GKE
t.string :gcp_project_id, null: false
t.string :gcp_cluster_zone, null: false
t.string :gcp_cluster_name, null: false
t.string :gcp_machine_type
t.string :gcp_operation_id
t.text :encrypted_gcp_token
t.string :encrypted_gcp_token_iv
end
end
end

Some files were not shown because too many files have changed in this diff Show More