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:
commit
32ad31a16a
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
$diffFile.each((index, file) => new gl.ImageFile(file));
|
||||||
|
|
||||||
|
|
|
@ -525,6 +525,11 @@ import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
|
||||||
case 'admin:impersonation_tokens:index':
|
case 'admin:impersonation_tokens:index':
|
||||||
new gl.DueDateSelectors();
|
new gl.DueDateSelectors();
|
||||||
break;
|
break;
|
||||||
|
case 'projects:clusters:show':
|
||||||
|
import(/* webpackChunkName: "clusters" */ './clusters')
|
||||||
|
.then(cluster => new cluster.default()) // eslint-disable-line new-cap
|
||||||
|
.catch(() => {});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
switch (path[0]) {
|
switch (path[0]) {
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
|
|
|
@ -54,12 +54,14 @@ LineHighlighter.prototype.bindEvents = function() {
|
||||||
$fileHolder.on('highlight:line', this.highlightHash);
|
$fileHolder.on('highlight:line', this.highlightHash);
|
||||||
};
|
};
|
||||||
|
|
||||||
LineHighlighter.prototype.highlightHash = function() {
|
LineHighlighter.prototype.highlightHash = function(newHash) {
|
||||||
var range;
|
let range;
|
||||||
|
if (newHash && typeof newHash === 'string') this._hash = newHash;
|
||||||
|
|
||||||
|
this.clearHighlight();
|
||||||
|
|
||||||
if (this._hash !== '') {
|
if (this._hash !== '') {
|
||||||
range = this.hashToRange(this._hash);
|
range = this.hashToRange(this._hash);
|
||||||
|
|
||||||
if (range[0]) {
|
if (range[0]) {
|
||||||
this.highlightRange(range);
|
this.highlightRange(range);
|
||||||
const lineSelector = `#L${range[0]}`;
|
const lineSelector = `#L${range[0]}`;
|
||||||
|
|
|
@ -7,10 +7,12 @@
|
||||||
import TaskList from '../../task_list';
|
import TaskList from '../../task_list';
|
||||||
import * as constants from '../constants';
|
import * as constants from '../constants';
|
||||||
import eventHub from '../event_hub';
|
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 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 markdownField from '../../vue_shared/components/markdown/field.vue';
|
||||||
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||||
|
import issuableStateMixin from '../mixins/issuable_state';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'issueCommentForm',
|
name: 'issueCommentForm',
|
||||||
|
@ -26,8 +28,9 @@
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
confidentialIssue,
|
issueWarning,
|
||||||
issueNoteSignedOutWidget,
|
issueNoteSignedOutWidget,
|
||||||
|
issueDiscussionLockedWidget,
|
||||||
markdownField,
|
markdownField,
|
||||||
userAvatarLink,
|
userAvatarLink,
|
||||||
},
|
},
|
||||||
|
@ -55,6 +58,9 @@
|
||||||
isIssueOpen() {
|
isIssueOpen() {
|
||||||
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
|
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
|
||||||
},
|
},
|
||||||
|
canCreateNote() {
|
||||||
|
return this.getIssueData.current_user.can_create_note;
|
||||||
|
},
|
||||||
issueActionButtonTitle() {
|
issueActionButtonTitle() {
|
||||||
if (this.note.length) {
|
if (this.note.length) {
|
||||||
const actionText = this.isIssueOpen ? 'close' : 'reopen';
|
const actionText = this.isIssueOpen ? 'close' : 'reopen';
|
||||||
|
@ -90,9 +96,6 @@
|
||||||
endpoint() {
|
endpoint() {
|
||||||
return this.getIssueData.create_note_path;
|
return this.getIssueData.create_note_path;
|
||||||
},
|
},
|
||||||
isConfidentialIssue() {
|
|
||||||
return this.getIssueData.confidential;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions([
|
...mapActions([
|
||||||
|
@ -220,6 +223,9 @@
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mixins: [
|
||||||
|
issuableStateMixin,
|
||||||
|
],
|
||||||
mounted() {
|
mounted() {
|
||||||
// jQuery is needed here because it is a custom event being dispatched with jQuery.
|
// jQuery is needed here because it is a custom event being dispatched with jQuery.
|
||||||
$(document).on('issuable:change', (e, isClosed) => {
|
$(document).on('issuable:change', (e, isClosed) => {
|
||||||
|
@ -235,6 +241,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<issue-note-signed-out-widget v-if="!isLoggedIn" />
|
<issue-note-signed-out-widget v-if="!isLoggedIn" />
|
||||||
|
<issue-discussion-locked-widget v-else-if="!canCreateNote" />
|
||||||
<ul
|
<ul
|
||||||
v-else
|
v-else
|
||||||
class="notes notes-form timeline">
|
class="notes notes-form timeline">
|
||||||
|
@ -253,15 +260,22 @@
|
||||||
<div class="timeline-content timeline-content-form">
|
<div class="timeline-content timeline-content-form">
|
||||||
<form
|
<form
|
||||||
ref="commentForm"
|
ref="commentForm"
|
||||||
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
|
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"
|
||||||
<confidentialIssue v-if="isConfidentialIssue" />
|
>
|
||||||
|
|
||||||
<div class="error-alert"></div>
|
<div class="error-alert"></div>
|
||||||
|
|
||||||
|
<issue-warning
|
||||||
|
v-if="hasWarning(getIssueData)"
|
||||||
|
:is-locked="isLocked(getIssueData)"
|
||||||
|
:is-confidential="isConfidential(getIssueData)"
|
||||||
|
/>
|
||||||
|
|
||||||
<markdown-field
|
<markdown-field
|
||||||
:markdown-preview-path="markdownPreviewPath"
|
:markdown-preview-path="markdownPreviewPath"
|
||||||
:markdown-docs-path="markdownDocsPath"
|
:markdown-docs-path="markdownDocsPath"
|
||||||
:quick-actions-docs-path="quickActionsDocsPath"
|
:quick-actions-docs-path="quickActionsDocsPath"
|
||||||
:add-spacing-classes="false"
|
:add-spacing-classes="false"
|
||||||
:is-confidential-issue="isConfidentialIssue"
|
|
||||||
ref="markdownField">
|
ref="markdownField">
|
||||||
<textarea
|
<textarea
|
||||||
id="note-body"
|
id="note-body"
|
||||||
|
|
|
@ -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>
|
|
@ -1,8 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import eventHub from '../event_hub';
|
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 markdownField from '../../vue_shared/components/markdown/field.vue';
|
||||||
|
import issuableStateMixin from '../mixins/issuable_state';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'issueNoteForm',
|
name: 'issueNoteForm',
|
||||||
|
@ -39,12 +40,13 @@
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
confidentialIssue,
|
issueWarning,
|
||||||
markdownField,
|
markdownField,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters([
|
...mapGetters([
|
||||||
'getDiscussionLastNote',
|
'getDiscussionLastNote',
|
||||||
|
'getIssueData',
|
||||||
'getIssueDataByProp',
|
'getIssueDataByProp',
|
||||||
'getNotesDataByProp',
|
'getNotesDataByProp',
|
||||||
'getUserDataByProp',
|
'getUserDataByProp',
|
||||||
|
@ -67,9 +69,6 @@
|
||||||
isDisabled() {
|
isDisabled() {
|
||||||
return !this.note.length || this.isSubmitting;
|
return !this.note.length || this.isSubmitting;
|
||||||
},
|
},
|
||||||
isConfidentialIssue() {
|
|
||||||
return this.getIssueDataByProp('confidential');
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleUpdate() {
|
handleUpdate() {
|
||||||
|
@ -95,6 +94,9 @@
|
||||||
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
|
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mixins: [
|
||||||
|
issuableStateMixin,
|
||||||
|
],
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$refs.textarea.focus();
|
this.$refs.textarea.focus();
|
||||||
},
|
},
|
||||||
|
@ -125,7 +127,13 @@
|
||||||
<div class="flash-container timeline-content"></div>
|
<div class="flash-container timeline-content"></div>
|
||||||
<form
|
<form
|
||||||
class="edit-note common-note-form js-quick-submit gfm-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-field
|
||||||
:markdown-preview-path="markdownPreviewPath"
|
:markdown-preview-path="markdownPreviewPath"
|
||||||
:markdown-docs-path="markdownDocsPath"
|
:markdown-docs-path="markdownDocsPath"
|
||||||
|
|
|
@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -63,12 +63,7 @@ const RepoEditor = {
|
||||||
const lineNumber = e.target.position.lineNumber;
|
const lineNumber = e.target.position.lineNumber;
|
||||||
if (e.target.element.classList.contains('line-numbers')) {
|
if (e.target.element.classList.contains('line-numbers')) {
|
||||||
location.hash = `L${lineNumber}`;
|
location.hash = `L${lineNumber}`;
|
||||||
Store.activeLine = lineNumber;
|
Store.setActiveLine(lineNumber);
|
||||||
|
|
||||||
Helper.monacoInstance.setPosition({
|
|
||||||
lineNumber: this.activeLine,
|
|
||||||
column: 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -101,6 +96,15 @@ const RepoEditor = {
|
||||||
this.setupEditor();
|
this.setupEditor();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
activeLine() {
|
||||||
|
if (Helper.monacoInstance) {
|
||||||
|
Helper.monacoInstance.setPosition({
|
||||||
|
lineNumber: this.activeLine,
|
||||||
|
column: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
shouldHideEditor() {
|
shouldHideEditor() {
|
||||||
|
|
|
@ -14,6 +14,11 @@ export default {
|
||||||
highlightFile() {
|
highlightFile() {
|
||||||
$(this.$el).find('.file-content').syntaxHighlight();
|
$(this.$el).find('.file-content').syntaxHighlight();
|
||||||
},
|
},
|
||||||
|
highlightLine() {
|
||||||
|
if (Store.activeLine > -1) {
|
||||||
|
this.lineHighlighter.highlightHash(`#L${Store.activeLine}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.highlightFile();
|
this.highlightFile();
|
||||||
|
@ -26,8 +31,12 @@ export default {
|
||||||
html() {
|
html() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.highlightFile();
|
this.highlightFile();
|
||||||
|
this.highlightLine();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
activeLine() {
|
||||||
|
this.highlightLine();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -18,22 +18,40 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.addPopEventListener();
|
window.addEventListener('popstate', this.checkHistory);
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
window.removeEventListener('popstate', this.checkHistory);
|
||||||
},
|
},
|
||||||
|
|
||||||
data: () => Store,
|
data: () => Store,
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
addPopEventListener() {
|
checkHistory() {
|
||||||
window.addEventListener('popstate', () => {
|
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
|
||||||
if (location.href.indexOf('#') > -1) return;
|
if (!selectedFile) {
|
||||||
this.linkClicked({
|
// 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,
|
url: location.href,
|
||||||
});
|
}, lineNumber);
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fileClicked(clickedFile) {
|
fileClicked(clickedFile, lineNumber) {
|
||||||
let file = clickedFile;
|
let file = clickedFile;
|
||||||
if (file.loading) return;
|
if (file.loading) return;
|
||||||
file.loading = true;
|
file.loading = true;
|
||||||
|
@ -41,17 +59,20 @@ export default {
|
||||||
if (file.type === 'tree' && file.opened) {
|
if (file.type === 'tree' && file.opened) {
|
||||||
file = Store.removeChildFilesOfTree(file);
|
file = Store.removeChildFilesOfTree(file);
|
||||||
file.loading = false;
|
file.loading = false;
|
||||||
|
Store.setActiveLine(lineNumber);
|
||||||
} else {
|
} else {
|
||||||
const openFile = Helper.getFileFromPath(file.url);
|
const openFile = Helper.getFileFromPath(file.url);
|
||||||
if (openFile) {
|
if (openFile) {
|
||||||
file.loading = false;
|
file.loading = false;
|
||||||
Store.setActiveFiles(openFile);
|
Store.setActiveFiles(openFile);
|
||||||
|
Store.setActiveLine(lineNumber);
|
||||||
} else {
|
} else {
|
||||||
Service.url = file.url;
|
Service.url = file.url;
|
||||||
Helper.getContent(file)
|
Helper.getContent(file)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
file.loading = false;
|
file.loading = false;
|
||||||
Helper.scrollTabsRight();
|
Helper.scrollTabsRight();
|
||||||
|
Store.setActiveLine(lineNumber);
|
||||||
})
|
})
|
||||||
.catch(Helper.loadingError);
|
.catch(Helper.loadingError);
|
||||||
}
|
}
|
||||||
|
|
|
@ -254,7 +254,9 @@ const RepoHelper = {
|
||||||
|
|
||||||
RepoHelper.key = RepoHelper.genKey();
|
RepoHelper.key = RepoHelper.genKey();
|
||||||
|
|
||||||
history.pushState({ key: RepoHelper.key }, '', url);
|
if (document.location.pathname !== url) {
|
||||||
|
history.pushState({ key: RepoHelper.key }, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
|
|
@ -26,7 +26,7 @@ const RepoStore = {
|
||||||
},
|
},
|
||||||
activeFile: Helper.getDefaultActiveFile(),
|
activeFile: Helper.getDefaultActiveFile(),
|
||||||
activeFileIndex: 0,
|
activeFileIndex: 0,
|
||||||
activeLine: 0,
|
activeLine: -1,
|
||||||
activeFileLabel: 'Raw',
|
activeFileLabel: 'Raw',
|
||||||
files: [],
|
files: [],
|
||||||
isCommitable: false,
|
isCommitable: false,
|
||||||
|
@ -85,6 +85,7 @@ const RepoStore = {
|
||||||
|
|
||||||
if (!file.loading) Helper.updateHistoryEntry(file.url, file.pageTitle || file.name);
|
if (!file.loading) Helper.updateHistoryEntry(file.url, file.pageTitle || file.name);
|
||||||
RepoStore.binary = file.binary;
|
RepoStore.binary = file.binary;
|
||||||
|
RepoStore.setActiveLine(-1);
|
||||||
},
|
},
|
||||||
|
|
||||||
setFileActivity(file, openedFile, i) {
|
setFileActivity(file, openedFile, i) {
|
||||||
|
@ -101,6 +102,10 @@ const RepoStore = {
|
||||||
RepoStore.activeFileIndex = i;
|
RepoStore.activeFileIndex = i;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setActiveLine(activeLine) {
|
||||||
|
if (!isNaN(activeLine)) RepoStore.activeLine = activeLine;
|
||||||
|
},
|
||||||
|
|
||||||
setActiveToRaw() {
|
setActiveToRaw() {
|
||||||
RepoStore.activeFile.raw = false;
|
RepoStore.activeFile.raw = false;
|
||||||
// can't get vue to listen to raw for some reason so RepoStore for now.
|
// can't get vue to listen to raw for some reason so RepoStore for now.
|
||||||
|
|
|
@ -47,9 +47,9 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="block confidentiality">
|
<div class="block issuable-sidebar-item confidentiality">
|
||||||
<div class="sidebar-collapsed-icon">
|
<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>
|
||||||
<div class="title hide-collapsed">
|
<div class="title hide-collapsed">
|
||||||
Confidentiality
|
Confidentiality
|
||||||
|
@ -62,19 +62,19 @@ export default {
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="value confidential-value hide-collapsed">
|
<div class="value sidebar-item-value hide-collapsed">
|
||||||
<editForm
|
<editForm
|
||||||
v-if="edit"
|
v-if="edit"
|
||||||
:toggle-form="toggleForm"
|
:toggle-form="toggleForm"
|
||||||
:is-confidential="isConfidential"
|
:is-confidential="isConfidential"
|
||||||
:update-confidential-attribute="updateConfidentialAttribute"
|
:update-confidential-attribute="updateConfidentialAttribute"
|
||||||
/>
|
/>
|
||||||
<div v-if="!isConfidential" class="no-value confidential-value">
|
<div v-if="!isConfidential" class="no-value sidebar-item-value">
|
||||||
<i class="fa fa-eye is-not-confidential"></i>
|
<i class="fa fa-eye sidebar-item-icon"></i>
|
||||||
Not confidential
|
Not confidential
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="value confidential-value hide-collapsed">
|
<div v-else class="value sidebar-item-value hide-collapsed">
|
||||||
<i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i>
|
<i aria-hidden="true" class="fa fa-eye-slash sidebar-item-icon is-active"></i>
|
||||||
This issue is confidential
|
This issue is confidential
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,9 +2,6 @@
|
||||||
import editFormButtons from './edit_form_buttons.vue';
|
import editFormButtons from './edit_form_buttons.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
|
||||||
editFormButtons,
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
isConfidential: {
|
isConfidential: {
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -19,12 +16,16 @@ export default {
|
||||||
type: Function,
|
type: Function,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
editFormButtons,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown open">
|
<div class="dropdown open">
|
||||||
<div class="dropdown-menu confidential-warning-message">
|
<div class="dropdown-menu sidebar-item-warning-message">
|
||||||
<div>
|
<div>
|
||||||
<p v-if="!isConfidential">
|
<p v-if="!isConfidential">
|
||||||
You are going to turn on the confidentiality. This means that only team members with
|
You are going to turn on the confidentiality. This means that only team members with
|
||||||
|
|
|
@ -15,7 +15,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
onOrOff() {
|
toggleButtonText() {
|
||||||
return this.isConfidential ? 'Turn Off' : 'Turn On';
|
return this.isConfidential ? 'Turn Off' : 'Turn On';
|
||||||
},
|
},
|
||||||
updateConfidentialBool() {
|
updateConfidentialBool() {
|
||||||
|
@ -26,7 +26,7 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="confidential-warning-message-actions">
|
<div class="sidebar-item-warning-message-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-default append-right-10"
|
class="btn btn-default append-right-10"
|
||||||
|
@ -39,7 +39,7 @@ export default {
|
||||||
class="btn btn-close"
|
class="btn btn-close"
|
||||||
@click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
|
@click.prevent="updateConfidentialAttribute(updateConfidentialBool)"
|
||||||
>
|
>
|
||||||
{{ onOrOff }}
|
{{ toggleButtonText }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,46 +1,76 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
|
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
|
||||||
import sidebarAssignees from './components/assignees/sidebar_assignees';
|
import SidebarAssignees from './components/assignees/sidebar_assignees';
|
||||||
import confidential from './components/confidential/confidential_issue_sidebar.vue';
|
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
|
||||||
import SidebarMoveIssue from './lib/sidebar_move_issue';
|
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';
|
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() {
|
function domContentLoaded() {
|
||||||
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
|
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
|
||||||
const mediator = new Mediator(sidebarOptions);
|
const mediator = new Mediator(sidebarOptions);
|
||||||
mediator.fetch();
|
mediator.fetch();
|
||||||
|
|
||||||
const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
|
const sidebarAssigneesEl = document.getElementById('js-vue-sidebar-assignees');
|
||||||
const confidentialEl = document.querySelector('#js-confidential-entry-point');
|
|
||||||
// Only create the sidebarAssignees vue app if it is found in the DOM
|
// Only create the sidebarAssignees vue app if it is found in the DOM
|
||||||
// We currently do not use sidebarAssignees for the MR page
|
// We currently do not use sidebarAssignees for the MR page
|
||||||
if (sidebarAssigneesEl) {
|
if (sidebarAssigneesEl) {
|
||||||
new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
|
new Vue(SidebarAssignees).$mount(sidebarAssigneesEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confidentialEl) {
|
mountConfidentialComponent(mediator);
|
||||||
const dataNode = document.getElementById('js-confidential-issue-data');
|
mountLockComponent(mediator);
|
||||||
const initialData = JSON.parse(dataNode.innerHTML);
|
|
||||||
|
|
||||||
const ConfidentialComp = Vue.extend(confidential);
|
new SidebarMoveIssue(
|
||||||
|
mediator,
|
||||||
|
$('.js-move-issue'),
|
||||||
|
$('.js-move-issue-confirmation-button'),
|
||||||
|
).init();
|
||||||
|
|
||||||
new ConfidentialComp({
|
new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', domContentLoaded);
|
document.addEventListener('DOMContentLoaded', domContentLoaded);
|
||||||
|
|
|
@ -15,6 +15,7 @@ export default class SidebarStore {
|
||||||
};
|
};
|
||||||
this.autocompleteProjects = [];
|
this.autocompleteProjects = [];
|
||||||
this.moveToProjectId = 0;
|
this.moveToProjectId = 0;
|
||||||
|
this.isLockDialogOpen = false;
|
||||||
|
|
||||||
SidebarStore.singleton = this;
|
SidebarStore.singleton = this;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
issuableDisplayName(issuableType) {
|
||||||
|
const displayName = issuableType.replace(/_/, ' ');
|
||||||
|
|
||||||
|
return this.__ ? this.__(displayName) : displayName;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -40,6 +40,7 @@
|
||||||
@import "framework/tables";
|
@import "framework/tables";
|
||||||
@import "framework/notes";
|
@import "framework/notes";
|
||||||
@import "framework/timeline";
|
@import "framework/timeline";
|
||||||
|
@import "framework/tooltips";
|
||||||
@import "framework/typography";
|
@import "framework/typography";
|
||||||
@import "framework/zen";
|
@import "framework/zen";
|
||||||
@import "framework/blank";
|
@import "framework/blank";
|
||||||
|
|
|
@ -381,7 +381,11 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,7 +95,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.navbar .title {
|
||||||
> a {
|
> a {
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -202,6 +202,11 @@ $md-area-border: #ddd;
|
||||||
$code_font_size: 12px;
|
$code_font_size: 12px;
|
||||||
$code_line_height: 1.6;
|
$code_line_height: 1.6;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tooltips
|
||||||
|
*/
|
||||||
|
$tooltip-font-size: 12px;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Padding
|
* Padding
|
||||||
*/
|
*/
|
||||||
|
@ -700,3 +705,9 @@ Project Templates Icons
|
||||||
$rails: #c00;
|
$rails: #c00;
|
||||||
$node: #353535;
|
$node: #353535;
|
||||||
$java: #70ad51;
|
$java: #70ad51;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Issuable warning
|
||||||
|
*/
|
||||||
|
$issuable-warning-size: 24px;
|
||||||
|
$issuable-warning-icon-margin: 4px;
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.edit-cluster-form {
|
||||||
|
.clipboard-addon {
|
||||||
|
background-color: $white-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-block {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,27 +5,29 @@
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-confidential {
|
.issuable-warning-icon {
|
||||||
color: $orange-600;
|
color: $orange-600;
|
||||||
background-color: $orange-100;
|
background-color: $orange-100;
|
||||||
border-radius: $border-radius-default;
|
border-radius: $border-radius-default;
|
||||||
padding: 5px;
|
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;
|
border-radius: $border-radius-default;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
margin: 0 3px 0 -4px;
|
margin: 0 3px 0 -4px;
|
||||||
}
|
|
||||||
|
|
||||||
.confidentiality {
|
&.is-active {
|
||||||
.is-not-confidential {
|
color: $orange-600;
|
||||||
margin: auto;
|
background-color: $orange-50;
|
||||||
}
|
|
||||||
|
|
||||||
.is-confidential {
|
|
||||||
margin: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.confidential-issue-warning {
|
.issuable-note-warning {
|
||||||
color: $orange-600;
|
color: $orange-600;
|
||||||
background-color: $orange-100;
|
background-color: $orange-100;
|
||||||
border-radius: $border-radius-default $border-radius-default 0 0;
|
border-radius: $border-radius-default $border-radius-default 0 0;
|
||||||
|
@ -110,28 +110,52 @@
|
||||||
padding: 3px 12px;
|
padding: 3px 12px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
align-items: center;
|
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 {
|
.fa {
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.confidential-warning-message {
|
.sidebar-item-warning-message {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
.confidential-warning-message-actions {
|
.text {
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-warning-message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
button {
|
.btn {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.confidential-issue-warning + .md-area {
|
.issuable-note-warning + .md-area {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -703,6 +703,12 @@ ul.notes {
|
||||||
color: $note-disabled-comment-color;
|
color: $note-disabled-comment-color;
|
||||||
padding: 90px 0;
|
padding: 90px 0;
|
||||||
|
|
||||||
|
&.discussion-locked {
|
||||||
|
border: none;
|
||||||
|
background-color: $white-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: $gl-link-color;
|
color: $gl-link-color;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -278,6 +278,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
||||||
state_event
|
state_event
|
||||||
task_num
|
task_num
|
||||||
lock_version
|
lock_version
|
||||||
|
discussion_locked
|
||||||
] + [{ label_ids: [], assignee_ids: [] }]
|
] + [{ label_ids: [], assignee_ids: [] }]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ class Projects::JobsController < Projects::ApplicationController
|
||||||
def index
|
def index
|
||||||
@scope = params[:scope]
|
@scope = params[:scope]
|
||||||
@all_builds = project.builds.relevant
|
@all_builds = project.builds.relevant
|
||||||
@builds = @all_builds.order('created_at DESC')
|
@builds = @all_builds.order('ci_builds.id DESC')
|
||||||
@builds =
|
@builds =
|
||||||
case @scope
|
case @scope
|
||||||
when 'pending'
|
when 'pending'
|
||||||
|
|
|
@ -34,6 +34,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
|
||||||
:target_project_id,
|
:target_project_id,
|
||||||
:task_num,
|
:task_num,
|
||||||
:title,
|
:title,
|
||||||
|
:discussion_locked,
|
||||||
|
|
||||||
label_ids: []
|
label_ids: []
|
||||||
]
|
]
|
||||||
|
|
|
@ -66,7 +66,16 @@ class Projects::NotesController < Projects::ApplicationController
|
||||||
params.merge(last_fetched_at: last_fetched_at)
|
params.merge(last_fetched_at: last_fetched_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authorize_admin_note!
|
||||||
|
return access_denied! unless can?(current_user, :admin_note, note)
|
||||||
|
end
|
||||||
|
|
||||||
def authorize_resolve_note!
|
def authorize_resolve_note!
|
||||||
return access_denied! unless can?(current_user, :resolve_note, note)
|
return access_denied! unless can?(current_user, :resolve_note, note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authorize_create_note!
|
||||||
|
return unless noteable.lockable?
|
||||||
|
access_denied! unless can?(current_user, :create_note, noteable)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -130,8 +130,12 @@ module NotesHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def can_create_note?
|
def can_create_note?
|
||||||
|
issuable = @issue || @merge_request
|
||||||
|
|
||||||
if @snippet.is_a?(PersonalSnippet)
|
if @snippet.is_a?(PersonalSnippet)
|
||||||
can?(current_user, :comment_personal_snippet, @snippet)
|
can?(current_user, :comment_personal_snippet, @snippet)
|
||||||
|
elsif issuable
|
||||||
|
can?(current_user, :create_note, issuable)
|
||||||
else
|
else
|
||||||
can?(current_user, :create_note, @project)
|
can?(current_user, :create_note, @project)
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -293,6 +293,7 @@ module ProjectsHelper
|
||||||
snippets: :read_project_snippet,
|
snippets: :read_project_snippet,
|
||||||
settings: :admin_project,
|
settings: :admin_project,
|
||||||
builds: :read_build,
|
builds: :read_build,
|
||||||
|
clusters: :read_cluster,
|
||||||
labels: :read_label,
|
labels: :read_label,
|
||||||
issues: :read_issue,
|
issues: :read_issue,
|
||||||
project_members: :read_project_member,
|
project_members: :read_project_member,
|
||||||
|
|
|
@ -19,7 +19,9 @@ module SystemNoteHelper
|
||||||
'discussion' => 'comment',
|
'discussion' => 'comment',
|
||||||
'moved' => 'arrow-right',
|
'moved' => 'arrow-right',
|
||||||
'outdated' => 'pencil',
|
'outdated' => 'pencil',
|
||||||
'duplicate' => 'issue-duplicate'
|
'duplicate' => 'issue-duplicate',
|
||||||
|
'locked' => 'lock',
|
||||||
|
'unlocked' => 'lock-open'
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def system_note_icon_name(note)
|
def system_note_icon_name(note)
|
||||||
|
|
|
@ -74,4 +74,8 @@ module Noteable
|
||||||
def discussions_can_be_resolved_by?(user)
|
def discussions_can_be_resolved_by?(user)
|
||||||
discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
|
discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def lockable?
|
||||||
|
[MergeRequest, Issue].include?(self.class)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -165,6 +165,7 @@ class Project < ActiveRecord::Base
|
||||||
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
|
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
|
||||||
has_one :project_feature, inverse_of: :project
|
has_one :project_feature, inverse_of: :project
|
||||||
has_one :statistics, class_name: 'ProjectStatistics'
|
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,
|
# Container repositories need to remove data from the container registry,
|
||||||
# which is not managed by the DB. Hence we're still using dependent: :destroy
|
# which is not managed by the DB. Hence we're still using dependent: :destroy
|
||||||
|
|
|
@ -2,7 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base
|
||||||
ICON_TYPES = %w[
|
ICON_TYPES = %w[
|
||||||
commit description merge confidential visible label assignee cross_reference
|
commit description merge confidential visible label assignee cross_reference
|
||||||
title time_tracking branch milestone discussion task moved
|
title time_tracking branch milestone discussion task moved
|
||||||
opened closed merged duplicate
|
opened closed merged duplicate locked unlocked
|
||||||
outdated
|
outdated
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -1,6 +1,10 @@
|
||||||
class IssuablePolicy < BasePolicy
|
class IssuablePolicy < BasePolicy
|
||||||
delegate { @subject.project }
|
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"
|
desc "User is the assignee or author"
|
||||||
condition(:assignee_or_author) do
|
condition(:assignee_or_author) do
|
||||||
@user && @subject.assignee_or_author?(@user)
|
@user && @subject.assignee_or_author?(@user)
|
||||||
|
@ -12,4 +16,12 @@ class IssuablePolicy < BasePolicy
|
||||||
enable :read_merge_request
|
enable :read_merge_request
|
||||||
enable :update_merge_request
|
enable :update_merge_request
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
class NotePolicy < BasePolicy
|
class NotePolicy < BasePolicy
|
||||||
delegate { @subject.project }
|
delegate { @subject.project }
|
||||||
|
delegate { @subject.noteable if @subject.noteable.lockable? }
|
||||||
|
|
||||||
condition(:is_author) { @user && @subject.author == @user }
|
condition(:is_author) { @user && @subject.author == @user }
|
||||||
condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
|
condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? }
|
||||||
|
@ -8,6 +9,7 @@ class NotePolicy < BasePolicy
|
||||||
condition(:editable, scope: :subject) { @subject.editable? }
|
condition(:editable, scope: :subject) { @subject.editable? }
|
||||||
|
|
||||||
rule { ~editable | anonymous }.prevent :edit_note
|
rule { ~editable | anonymous }.prevent :edit_note
|
||||||
|
|
||||||
rule { is_author | admin }.enable :edit_note
|
rule { is_author | admin }.enable :edit_note
|
||||||
rule { can?(:master_access) }.enable :edit_note
|
rule { can?(:master_access) }.enable :edit_note
|
||||||
|
|
||||||
|
|
|
@ -193,6 +193,8 @@ class ProjectPolicy < BasePolicy
|
||||||
enable :admin_pages
|
enable :admin_pages
|
||||||
enable :read_pages
|
enable :read_pages
|
||||||
enable :update_pages
|
enable :update_pages
|
||||||
|
enable :read_cluster
|
||||||
|
enable :create_cluster
|
||||||
end
|
end
|
||||||
|
|
||||||
rule { can?(:public_user_access) }.policy do
|
rule { can?(:public_user_access) }.policy do
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
class ClusterEntity < Grape::Entity
|
||||||
|
include RequestAwareEntity
|
||||||
|
|
||||||
|
expose :status_name, as: :status
|
||||||
|
expose :status_reason
|
||||||
|
end
|
|
@ -0,0 +1,7 @@
|
||||||
|
class ClusterSerializer < BaseSerializer
|
||||||
|
entity ClusterEntity
|
||||||
|
|
||||||
|
def represent_status(resource)
|
||||||
|
represent(resource, { only: [:status, :status_reason] })
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,6 +3,7 @@ class IssueEntity < IssuableEntity
|
||||||
|
|
||||||
expose :branch_name
|
expose :branch_name
|
||||||
expose :confidential
|
expose :confidential
|
||||||
|
expose :discussion_locked
|
||||||
expose :assignees, using: API::Entities::UserBasic
|
expose :assignees, using: API::Entities::UserBasic
|
||||||
expose :due_date
|
expose :due_date
|
||||||
expose :moved_to_id
|
expose :moved_to_id
|
||||||
|
@ -14,7 +15,7 @@ class IssueEntity < IssuableEntity
|
||||||
|
|
||||||
expose :current_user do
|
expose :current_user do
|
||||||
expose :can_create_note do |issue|
|
expose :can_create_note do |issue|
|
||||||
can?(request.current_user, :create_note, issue.project)
|
can?(request.current_user, :create_note, issue)
|
||||||
end
|
end
|
||||||
|
|
||||||
expose :can_update do |issue|
|
expose :can_update do |issue|
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -43,6 +43,10 @@ class IssuableBaseService < BaseService
|
||||||
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
|
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_discussion_lock_note(issuable)
|
||||||
|
SystemNoteService.discussion_lock(issuable, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
def filter_params(issuable)
|
def filter_params(issuable)
|
||||||
ability_name = :"admin_#{issuable.to_ability_name}"
|
ability_name = :"admin_#{issuable.to_ability_name}"
|
||||||
|
|
||||||
|
@ -57,6 +61,7 @@ class IssuableBaseService < BaseService
|
||||||
params.delete(:due_date)
|
params.delete(:due_date)
|
||||||
params.delete(:canonical_issue_id)
|
params.delete(:canonical_issue_id)
|
||||||
params.delete(:project)
|
params.delete(:project)
|
||||||
|
params.delete(:discussion_locked)
|
||||||
end
|
end
|
||||||
|
|
||||||
filter_assignee(issuable)
|
filter_assignee(issuable)
|
||||||
|
@ -236,6 +241,7 @@ class IssuableBaseService < BaseService
|
||||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
change_discussion_lock(issuable)
|
||||||
handle_changes(
|
handle_changes(
|
||||||
issuable,
|
issuable,
|
||||||
old_labels: old_labels,
|
old_labels: old_labels,
|
||||||
|
@ -294,6 +300,12 @@ class IssuableBaseService < BaseService
|
||||||
end
|
end
|
||||||
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)
|
def toggle_award(issuable)
|
||||||
award = params.delete(:emoji_award)
|
award = params.delete(:emoji_award)
|
||||||
if award
|
if award
|
||||||
|
|
|
@ -591,6 +591,13 @@ module SystemNoteService
|
||||||
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
|
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def notes_for_mentioner(mentioner, noteable, notes)
|
def notes_for_mentioner(mentioner, noteable, notes)
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
%div{ class: container_class }
|
%div{ class: container_class }
|
||||||
|
|
||||||
.top-area
|
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
|
||||||
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
|
- 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
|
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
|
|
||||||
%div{ class: container_class }
|
%div{ class: container_class }
|
||||||
.top-area
|
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
|
||||||
.prepend-top-default
|
.prepend-top-default
|
||||||
.search-holder
|
.search-holder
|
||||||
= render 'shared/projects/search_form', autofocus: true, icon: true
|
= render 'shared/projects/search_form', autofocus: true, icon: true
|
||||||
|
|
|
@ -146,7 +146,7 @@
|
||||||
= number_with_delimiter(@project.open_merge_requests_count)
|
= number_with_delimiter(@project.open_merge_requests_count)
|
||||||
|
|
||||||
- if project_nav_tab? :pipelines
|
- 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
|
= link_to project_pipelines_path(@project), class: 'shortcuts-pipelines' do
|
||||||
.nav-icon-container
|
.nav-icon-container
|
||||||
= sprite_icon('pipeline')
|
= sprite_icon('pipeline')
|
||||||
|
@ -189,6 +189,12 @@
|
||||||
%span
|
%span
|
||||||
Charts
|
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
|
- if project_nav_tab? :wiki
|
||||||
= nav_link(controller: :wikis) do
|
= nav_link(controller: :wikis) do
|
||||||
= link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
|
= link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
- referenced_users = local_assigns.fetch(:referenced_users, nil)
|
- 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-area
|
||||||
.md-header
|
.md-header
|
||||||
%ul.nav-links.clearfix
|
%ul.nav-links.clearfix
|
||||||
|
|
|
@ -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'
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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'
|
|
@ -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"})
|
|
@ -27,7 +27,9 @@
|
||||||
|
|
||||||
.issuable-meta
|
.issuable-meta
|
||||||
- if @issue.confidential
|
- 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_meta(@issue, @project, "Issue")
|
||||||
|
|
||||||
.issuable-actions.js-issuable-actions
|
.issuable-actions.js-issuable-actions
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
.nav-controls
|
.nav-controls
|
||||||
- if can?(current_user, :update_build, @project)
|
- 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),
|
= link_to 'Cancel running', cancel_all_project_jobs_path(@project),
|
||||||
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
|
data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
= icon('angle-double-left')
|
= icon('angle-double-left')
|
||||||
|
|
||||||
.issuable-meta
|
.issuable-meta
|
||||||
|
- if @merge_request.discussion_locked?
|
||||||
|
= icon('lock', class: 'issuable-warning-icon')
|
||||||
= issuable_meta(@merge_request, @project, "Merge request")
|
= issuable_meta(@merge_request, @project, "Merge request")
|
||||||
|
|
||||||
.issuable-actions.js-issuable-actions
|
.issuable-actions.js-issuable-actions
|
||||||
|
|
|
@ -83,7 +83,7 @@
|
||||||
#pipelines.pipelines.tab-pane
|
#pipelines.pipelines.tab-pane
|
||||||
- if @pipelines.any?
|
- if @pipelines.any?
|
||||||
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
|
= 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
|
-# This tab is always loaded via AJAX
|
||||||
|
|
||||||
.mr-loading-status
|
.mr-loading-status
|
||||||
|
|
|
@ -3,22 +3,22 @@
|
||||||
= link_to build_path_proc.call(nil) do
|
= link_to build_path_proc.call(nil) do
|
||||||
All
|
All
|
||||||
%span.badge.js-totalbuilds-count
|
%span.badge.js-totalbuilds-count
|
||||||
= number_with_delimiter(all_builds.count(:id))
|
= limited_counter_with_delimiter(all_builds)
|
||||||
|
|
||||||
%li{ class: active_when(scope == 'pending') }>
|
%li{ class: active_when(scope == 'pending') }>
|
||||||
= link_to build_path_proc.call('pending') do
|
= link_to build_path_proc.call('pending') do
|
||||||
Pending
|
Pending
|
||||||
%span.badge
|
%span.badge
|
||||||
= number_with_delimiter(all_builds.pending.count(:id))
|
= limited_counter_with_delimiter(all_builds.pending)
|
||||||
|
|
||||||
%li{ class: active_when(scope == 'running') }>
|
%li{ class: active_when(scope == 'running') }>
|
||||||
= link_to build_path_proc.call('running') do
|
= link_to build_path_proc.call('running') do
|
||||||
Running
|
Running
|
||||||
%span.badge
|
%span.badge
|
||||||
= number_with_delimiter(all_builds.running.count(:id))
|
= limited_counter_with_delimiter(all_builds.running)
|
||||||
|
|
||||||
%li{ class: active_when(scope == 'finished') }>
|
%li{ class: active_when(scope == 'finished') }>
|
||||||
= link_to build_path_proc.call('finished') do
|
= link_to build_path_proc.call('finished') do
|
||||||
Finished
|
Finished
|
||||||
%span.badge
|
%span.badge
|
||||||
= number_with_delimiter(all_builds.finished.count(:id))
|
= limited_counter_with_delimiter(all_builds.finished)
|
||||||
|
|
|
@ -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
|
%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
|
#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)
|
= render "shared/issuable/participants", participants: issuable.participants(current_user)
|
||||||
- if current_user
|
- if current_user
|
||||||
- subscribed = issuable.subscribed?(current_user, @project)
|
- subscribed = issuable.subscribed?(current_user, @project)
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
- issuable = @issue || @merge_request
|
||||||
|
- discussion_locked = issuable&.discussion_locked?
|
||||||
|
|
||||||
%ul#notes-list.notes.main-notes-list.timeline
|
%ul#notes-list.notes.main-notes-list.timeline
|
||||||
= render "shared/notes/notes"
|
= render "shared/notes/notes"
|
||||||
|
|
||||||
|
@ -21,5 +24,14 @@
|
||||||
or
|
or
|
||||||
= link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
|
= link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
|
||||||
to comment
|
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
|
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
||||||
|
title: Discussion lock for issues and merge requests
|
||||||
|
merge_request:
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Make tabs on top scrollable on admin dashboard
|
||||||
|
merge_request: 14685
|
||||||
|
author: Takuya Noguchi
|
||||||
|
type: fixed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add 1000+ counters to job page
|
||||||
|
merge_request:
|
||||||
|
author:
|
||||||
|
type: fixed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Adjust tooltips to adhere to 8px grid and make them more readable
|
||||||
|
merge_request:
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Fixed navbar title colors leaking out of the navbar
|
||||||
|
merge_request:
|
||||||
|
author:
|
||||||
|
type: fixed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Create Kubernetes cluster on GKE from k8s service
|
||||||
|
merge_request: 14470
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Sort JobsController by id, not created_at
|
||||||
|
merge_request:
|
||||||
|
author:
|
||||||
|
type: fixed
|
|
@ -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
|
|
@ -87,6 +87,7 @@ Rails.application.routes.draw do
|
||||||
resources :issues, module: :boards, only: [:index, :update]
|
resources :issues, module: :boards, only: [:index, :update]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
draw :google_api
|
||||||
draw :import
|
draw :import
|
||||||
draw :uploads
|
draw :uploads
|
||||||
draw :explore
|
draw :explore
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
namespace :google_api do
|
||||||
|
resource :auth, only: [], controller: :authorizations do
|
||||||
|
match :callback, via: [:get, :post]
|
||||||
|
end
|
||||||
|
end
|
|
@ -183,6 +183,16 @@ constraints(ProjectUrlConstrainer.new) do
|
||||||
end
|
end
|
||||||
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
|
resources :environments, except: [:destroy] do
|
||||||
member do
|
member do
|
||||||
post :stop
|
post :stop
|
||||||
|
|
|
@ -62,5 +62,6 @@
|
||||||
- [update_user_activity, 1]
|
- [update_user_activity, 1]
|
||||||
- [propagate_service_template, 1]
|
- [propagate_service_template, 1]
|
||||||
- [background_migration, 1]
|
- [background_migration, 1]
|
||||||
|
- [gcp_cluster, 1]
|
||||||
- [project_migrate_hashed_storage, 1]
|
- [project_migrate_hashed_storage, 1]
|
||||||
- [storage_migrator, 1]
|
- [storage_migrator, 1]
|
||||||
|
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue