Commit c814446b authored by Fernando Arias's avatar Fernando Arias

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce into move-job-cancel-btn

parents a536fe28 fe4f8cad
...@@ -42,22 +42,35 @@ export function mergeUrlParams(params, url) { ...@@ -42,22 +42,35 @@ export function mergeUrlParams(params, url) {
return `${urlparts[1]}?${query}${urlparts[3]}`; return `${urlparts[1]}?${query}${urlparts[3]}`;
} }
export function removeParamQueryString(url, param) { /**
const decodedUrl = decodeURIComponent(url); * Removes specified query params from the url by returning a new url string that no longer
const urlVariables = decodedUrl.split('&'); * includes the param/value pair. If no url is provided, `window.location.href` is used as
* the default value.
return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); *
} * @param {string[]} params - the query param names to remove
* @param {string} [url=windowLocation().href] - url from which the query param will be removed
export function removeParams(params, source = window.location.href) { * @returns {string} A copy of the original url but without the query param
const url = document.createElement('a'); */
url.href = source; export function removeParams(params, url = window.location.href) {
const [rootAndQuery, fragment] = url.split('#');
const [root, query] = rootAndQuery.split('?');
if (!query) {
return url;
}
params.forEach(param => { const encodedParams = params.map(param => encodeURIComponent(param));
url.search = removeParamQueryString(url.search, param); const updatedQuery = query
}); .split('&')
.filter(paramPair => {
const [foundParam] = paramPair.split('=');
return encodedParams.indexOf(foundParam) < 0;
})
.join('&');
return url.href; const writableQuery = updatedQuery.length > 0 ? `?${updatedQuery}` : '';
const writableFragment = fragment ? `#${fragment}` : '';
return `${root}${writableQuery}${writableFragment}`;
} }
export function getLocationHash(url = window.location.href) { export function getLocationHash(url = window.location.href) {
...@@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) { ...@@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) {
return hashIndex === -1 ? null : url.substring(hashIndex + 1); return hashIndex === -1 ? null : url.substring(hashIndex + 1);
} }
/**
* Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment
* will be removed.
*
* @param {string} url - url to which the fragment will be applied
* @param {string} fragment - fragment to append
*/
export const setUrlFragment = (url, fragment) => {
const [rootUrl] = url.split('#');
const encodedFragment = encodeURIComponent(fragment.replace(/^#/, ''));
return `${rootUrl}#${encodedFragment}`;
};
export function visitUrl(url, external = false) { export function visitUrl(url, external = false) {
if (external) { if (external) {
// Simulate `target="blank" rel="noopener noreferrer"` // Simulate `target="blank" rel="noopener noreferrer"`
......
<script> <script>
import CodeCell from './code/index.vue'; import CodeOutput from './code/index.vue';
import OutputCell from './output/index.vue'; import OutputCell from './output/index.vue';
export default { export default {
name: 'CodeCell',
components: { components: {
'code-cell': CodeCell, CodeOutput,
'output-cell': OutputCell, OutputCell,
}, },
props: { props: {
cell: { cell: {
...@@ -29,8 +30,8 @@ export default { ...@@ -29,8 +30,8 @@ export default {
hasOutput() { hasOutput() {
return this.cell.outputs.length; return this.cell.outputs.length;
}, },
output() { outputs() {
return this.cell.outputs[0]; return this.cell.outputs;
}, },
}, },
}; };
...@@ -38,7 +39,7 @@ export default { ...@@ -38,7 +39,7 @@ export default {
<template> <template>
<div class="cell"> <div class="cell">
<code-cell <code-output
:raw-code="rawInputCode" :raw-code="rawInputCode"
:count="cell.execution_count" :count="cell.execution_count"
:code-css-class="codeCssClass" :code-css-class="codeCssClass"
...@@ -47,7 +48,7 @@ export default { ...@@ -47,7 +48,7 @@ export default {
<output-cell <output-cell
v-if="hasOutput" v-if="hasOutput"
:count="cell.execution_count" :count="cell.execution_count"
:output="output" :outputs="outputs"
:code-css-class="codeCssClass" :code-css-class="codeCssClass"
/> />
</div> </div>
......
...@@ -3,8 +3,9 @@ import Prism from '../../lib/highlight'; ...@@ -3,8 +3,9 @@ import Prism from '../../lib/highlight';
import Prompt from '../prompt.vue'; import Prompt from '../prompt.vue';
export default { export default {
name: 'CodeOutput',
components: { components: {
prompt: Prompt, Prompt,
}, },
props: { props: {
count: { count: {
......
...@@ -4,13 +4,21 @@ import Prompt from '../prompt.vue'; ...@@ -4,13 +4,21 @@ import Prompt from '../prompt.vue';
export default { export default {
components: { components: {
prompt: Prompt, Prompt,
}, },
props: { props: {
count: {
type: Number,
required: true,
},
rawCode: { rawCode: {
type: String, type: String,
required: true, required: true,
}, },
index: {
type: Number,
required: true,
},
}, },
computed: { computed: {
sanitizedOutput() { sanitizedOutput() {
...@@ -21,13 +29,16 @@ export default { ...@@ -21,13 +29,16 @@ export default {
}, },
}); });
}, },
showOutput() {
return this.index === 0;
},
}, },
}; };
</script> </script>
<template> <template>
<div class="output"> <div class="output">
<prompt /> <prompt type="Out" :count="count" :show-output="showOutput" />
<div v-html="sanitizedOutput"></div> <div v-html="sanitizedOutput"></div>
</div> </div>
</template> </template>
...@@ -6,6 +6,10 @@ export default { ...@@ -6,6 +6,10 @@ export default {
prompt: Prompt, prompt: Prompt,
}, },
props: { props: {
count: {
type: Number,
required: true,
},
outputType: { outputType: {
type: String, type: String,
required: true, required: true,
...@@ -14,10 +18,24 @@ export default { ...@@ -14,10 +18,24 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
index: {
type: Number,
required: true,
},
},
computed: {
imgSrc() {
return `data:${this.outputType};base64,${this.rawCode}`;
},
showOutput() {
return this.index === 0;
},
}, },
}; };
</script> </script>
<template> <template>
<div class="output"><prompt /> <img :src="'data:' + outputType + ';base64,' + rawCode" /></div> <div class="output">
<prompt type="out" :count="count" :show-output="showOutput" /> <img :src="imgSrc" />
</div>
</template> </template>
<script> <script>
import CodeCell from '../code/index.vue'; import CodeOutput from '../code/index.vue';
import Html from './html.vue'; import HtmlOutput from './html.vue';
import Image from './image.vue'; import ImageOutput from './image.vue';
export default { export default {
components: {
'code-cell': CodeCell,
'html-output': Html,
'image-output': Image,
},
props: { props: {
codeCssClass: { codeCssClass: {
type: String, type: String,
...@@ -20,68 +15,69 @@ export default { ...@@ -20,68 +15,69 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
output: { outputs: {
type: Object, type: Array,
required: true, required: true,
default: () => ({}),
}, },
}, },
computed: { data() {
componentName() { return {
if (this.output.text) { outputType: '',
return 'code-cell'; };
} else if (this.output.data['image/png']) {
return 'image-output';
} else if (this.output.data['text/html']) {
return 'html-output';
} else if (this.output.data['image/svg+xml']) {
return 'html-output';
}
return 'code-cell';
}, },
rawCode() { methods: {
if (this.output.text) { dataForType(output, type) {
return this.output.text.join(''); let data = output.data[type];
if (typeof data === 'object') {
data = data.join('');
} }
return this.dataForType(this.outputType); return data;
}, },
outputType() { getComponent(output) {
if (this.output.text) { if (output.text) {
return ''; return CodeOutput;
} else if (this.output.data['image/png']) { } else if (output.data['image/png']) {
return 'image/png'; this.outputType = 'image/png';
} else if (this.output.data['text/html']) {
return 'text/html'; return ImageOutput;
} else if (this.output.data['image/svg+xml']) { } else if (output.data['text/html']) {
return 'image/svg+xml'; this.outputType = 'text/html';
return HtmlOutput;
} else if (output.data['image/svg+xml']) {
this.outputType = 'image/svg+xml';
return HtmlOutput;
} }
return 'text/plain'; this.outputType = 'text/plain';
}, return CodeOutput;
}, },
methods: { rawCode(output) {
dataForType(type) { if (output.text) {
let data = this.output.data[type]; return output.text.join('');
if (typeof data === 'object') {
data = data.join('');
} }
return data; return this.dataForType(output, this.outputType);
}, },
}, },
}; };
</script> </script>
<template> <template>
<div>
<component <component
:is="componentName" :is="getComponent(output)"
v-for="(output, index) in outputs"
:key="index"
type="output"
:output-type="outputType" :output-type="outputType"
:count="count" :count="count"
:raw-code="rawCode" :index="index"
:raw-code="rawCode(output)"
:code-css-class="codeCssClass" :code-css-class="codeCssClass"
type="output"
/> />
</div>
</template> </template>
...@@ -11,18 +11,26 @@ export default { ...@@ -11,18 +11,26 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
showOutput: {
type: Boolean,
required: false,
default: true,
},
}, },
computed: { computed: {
hasKeys() { hasKeys() {
return this.type !== '' && this.count; return this.type !== '' && this.count;
}, },
showTypeText() {
return this.type && this.count && this.showOutput;
},
}, },
}; };
</script> </script>
<template> <template>
<div class="prompt"> <div class="prompt">
<span v-if="hasKeys"> {{ type }} [{{ count }}]: </span> <span v-if="showTypeText"> {{ type }} [{{ count }}]: </span>
</div> </div>
</template> </template>
......
...@@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells'; ...@@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells';
export default { export default {
components: { components: {
'code-cell': CodeCell, CodeCell,
'markdown-cell': MarkdownCell, MarkdownCell,
}, },
props: { props: {
notebook: { notebook: {
......
...@@ -370,7 +370,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -370,7 +370,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
> >
<button <button
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button class="btn btn-success js-comment-button js-comment-submit-button
qa-comment-button" qa-comment-button"
type="submit" type="submit"
@click.prevent="handleSave();" @click.prevent="handleSave();"
...@@ -381,7 +381,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" ...@@ -381,7 +381,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
name="button" name="button"
type="button" type="button"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown" class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static" data-display="static"
data-toggle="dropdown" data-toggle="dropdown"
aria-label="Open comment type dropdown" aria-label="Open comment type dropdown"
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { getLocationHash } from '../../lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { import {
DISCUSSION_FILTERS_DEFAULT_VALUE, DISCUSSION_FILTERS_DEFAULT_VALUE,
...@@ -44,29 +45,47 @@ export default { ...@@ -44,29 +45,47 @@ export default {
eventHub.$on('MergeRequestTabChange', this.toggleFilters); eventHub.$on('MergeRequestTabChange', this.toggleFilters);
this.toggleFilters(currentTab); this.toggleFilters(currentTab);
} }
window.addEventListener('hashchange', this.handleLocationHash);
this.handleLocationHash();
}, },
mounted() { mounted() {
this.toggleCommentsForm(); this.toggleCommentsForm();
}, },
destroyed() {
window.removeEventListener('hashchange', this.handleLocationHash);
},
methods: { methods: {
...mapActions(['filterDiscussion', 'setCommentsDisabled']), ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']),
selectFilter(value) { selectFilter(value) {
const filter = parseInt(value, 10); const filter = parseInt(value, 10);
// close dropdown // close dropdown
$(this.$refs.dropdownToggle).dropdown('toggle'); this.toggleDropdown();
if (filter === this.currentValue) return; if (filter === this.currentValue) return;
this.currentValue = filter; this.currentValue = filter;
this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter }); this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter });
this.toggleCommentsForm(); this.toggleCommentsForm();
}, },
toggleDropdown() {
$(this.$refs.dropdownToggle).dropdown('toggle');
},
toggleCommentsForm() { toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
}, },
toggleFilters(tab) { toggleFilters(tab) {
this.displayFilters = tab === DISCUSSION_TAB_LABEL; this.displayFilters = tab === DISCUSSION_TAB_LABEL;
}, },
handleLocationHash() {
const hash = getLocationHash();
if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) {
this.selectFilter(this.defaultValue);
this.toggleDropdown(); // close dropdown
this.setTargetNoteHash(hash);
}
},
}, },
}; };
</script> </script>
......
...@@ -2,6 +2,7 @@ import $ from 'jquery'; ...@@ -2,6 +2,7 @@ import $ from 'jquery';
import UsernameValidator from './username_validator'; import UsernameValidator from './username_validator';
import SigninTabsMemoizer from './signin_tabs_memoizer'; import SigninTabsMemoizer from './signin_tabs_memoizer';
import OAuthRememberMe from './oauth_remember_me'; import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
new UsernameValidator(); // eslint-disable-line no-new new UsernameValidator(); // eslint-disable-line no-new
...@@ -10,4 +11,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,4 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
new OAuthRememberMe({ new OAuthRememberMe({
container: $('.omniauth-container'), container: $('.omniauth-container'),
}).bindEvents(); }).bindEvents();
// Save the URL fragment from the current window location. This will be present if the user was
// redirected to sign-in after attempting to access a protected URL that included a fragment.
preserveUrlFragment(window.location.hash);
}); });
import $ from 'jquery'; import $ from 'jquery';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
/** /**
* OAuth-based login buttons have a separate "remember me" checkbox. * OAuth-based login buttons have a separate "remember me" checkbox.
...@@ -24,9 +25,9 @@ export default class OAuthRememberMe { ...@@ -24,9 +25,9 @@ export default class OAuthRememberMe {
const href = $(element).attr('href'); const href = $(element).attr('href');
if (rememberMe) { if (rememberMe) {
$(element).attr('href', `${href}?remember_me=1`); $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href));
} else { } else {
$(element).attr('href', href.replace('?remember_me=1', '')); $(element).attr('href', removeParams(['remember_me'], href));
} }
}); });
} }
......
import { mergeUrlParams, setUrlFragment } from '~/lib/utils/url_utility';
/**
* Ensure the given URL fragment is preserved by appending it to sign-in/sign-up form actions and
* OAuth/SAML login links.
*
* @param fragment {string} - url fragment to be preserved
*/
export default function preserveUrlFragment(fragment = '') {
if (fragment) {
const normalFragment = fragment.replace(/^#/, '');
// Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is
// eventually redirected back to the originally requested URL.
const forms = document.querySelectorAll('#signin-container form');
Array.prototype.forEach.call(forms, form => {
const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`);
form.setAttribute('action', actionWithFragment);
});
// Append a redirect_fragment query param to all oauth provider links. The redirect_fragment
// query param will be available in the omniauth callback upon successful authentication
const anchors = document.querySelectorAll('#signin-container a.oauth-login');
Array.prototype.forEach.call(anchors, anchor => {
const newHref = mergeUrlParams(
{ redirect_fragment: normalFragment },
anchor.getAttribute('href'),
);
anchor.setAttribute('href', newHref);
});
}
}
<script>
import PodBox from './pod_box.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
PodBox,
ClipboardButton,
},
props: {
func: {
type: Object,
required: true,
},
},
computed: {
name() {
return this.func.name;
},
description() {
return this.func.description;
},
funcUrl() {
return this.func.url;
},
podCount() {
return this.func.podcount || 0;
},
},
};
</script>
<template>
<section id="serverless-function-details">
<h3>{{ name }}</h3>
<div class="append-bottom-default">
<div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div>
</div>
<div class="clipboard-group append-bottom-default">
<div class="label label-monospace">{{ funcUrl }}</div>
<clipboard-button
:text="String(funcUrl)"
:title="s__('ServerlessDetails|Copy URL to clipboard')"
class="input-group-text js-clipboard-btn"
/>
<a
:href="funcUrl"
target="_blank"
rel="noopener noreferrer nofollow"
class="input-group-text btn btn-default"
>
<icon name="external-link" />
</a>
</div>
<h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
<div v-if="podCount > 0">
<p>
<b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b>
<b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b>
</p>
<pod-box :count="podCount" />
<p>
{{
s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.')
}}
</p>
</div>
<div v-else><p>No pods loaded at this time.</p></div>
</section>
</template>
...@@ -15,8 +15,14 @@ export default { ...@@ -15,8 +15,14 @@ export default {
name() { name() {
return this.func.name; return this.func.name;
}, },
url() { description() {
return this.func.url; return this.func.description;
},
detailUrl() {
return this.func.detail_url;
},
environment() {
return this.func.environment_scope;
}, },
image() { image() {
return this.func.image; return this.func.image;
...@@ -30,11 +36,20 @@ export default { ...@@ -30,11 +36,20 @@ export default {
<template> <template>
<div class="gl-responsive-table-row"> <div class="gl-responsive-table-row">
<div class="table-section section-20">{{ name }}</div> <div class="table-section section-20 section-wrap">
<div class="table-section section-50"> <a :href="detailUrl">{{ name }}</a>
<a :href="url">{{ url }}</a> </div>
<div class="table-section section-10">{{ environment }}</div>
<div class="table-section section-40 section-wrap">
<span class="line-break">{{ description }}</span>
</div> </div>
<div class="table-section section-20">{{ image }}</div> <div class="table-section section-20">{{ image }}</div>
<div class="table-section section-10"><timeago :time="timestamp" /></div> <div class="table-section section-10"><timeago :time="timestamp" /></div>
</div> </div>
</template> </template>
<style>
.line-break {
white-space: pre;
}
</style>
...@@ -50,8 +50,11 @@ export default { ...@@ -50,8 +50,11 @@ export default {
<div class="table-section section-20" role="rowheader"> <div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Function') }} {{ s__('Serverless|Function') }}
</div> </div>
<div class="table-section section-50" role="rowheader"> <div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Domain') }} {{ s__('Serverless|Cluster Env') }}
</div>
<div class="table-section section-40" role="rowheader">
{{ s__('Serverless|Description') }}
</div> </div>
<div class="table-section section-20" role="rowheader"> <div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Runtime') }} {{ s__('Serverless|Runtime') }}
......
<script>
export default {
props: {
count: {
type: Number,
required: true,
},
color: {
type: String,
required: false,
default: 'green',
},
},
methods: {
boxOffset(i) {
return 20 * (i - 1);
},
},
};
</script>
<template>
<svg :width="boxOffset(count + 1)" :height="20">
<rect
v-for="i in count"
:key="i"
width="15"
height="15"
rx="5"
ry="5"
:fill="color"
:x="boxOffset(i)"
y="0"
/>
</svg>
</template>
...@@ -4,11 +4,52 @@ import { s__ } from '../locale'; ...@@ -4,11 +4,52 @@ import { s__ } from '../locale';
import Flash from '../flash'; import Flash from '../flash';
import Poll from '../lib/utils/poll'; import Poll from '../lib/utils/poll';
import ServerlessStore from './stores/serverless_store'; import ServerlessStore from './stores/serverless_store';
import ServerlessDetailsStore from './stores/serverless_details_store';
import GetFunctionsService from './services/get_functions_service'; import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue'; import Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue';
export default class Serverless { export default class Serverless {
constructor() { constructor() {
if (document.querySelector('.js-serverless-function-details-page') != null) {
const {
serviceName,
serviceDescription,
serviceEnvironment,
serviceUrl,
serviceNamespace,
servicePodcount,
} = document.querySelector('.js-serverless-function-details-page').dataset;
const el = document.querySelector('#js-serverless-function-details');
this.store = new ServerlessDetailsStore();
const { store } = this;
const service = {
name: serviceName,
description: serviceDescription,
environment: serviceEnvironment,
url: serviceUrl,
namespace: serviceNamespace,
podcount: servicePodcount,
};
this.store.updateDetailedFunction(service);
this.functionDetails = new Vue({
el,
data() {
return {
state: store.state,
};
},
render(createElement) {
return createElement(FunctionDetails, {
props: {
func: this.state.functionDetail,
},
});
},
});
} else {
const { statusPath, clustersPath, helpPath, installed } = document.querySelector( const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
'.js-serverless-functions-page', '.js-serverless-functions-page',
).dataset; ).dataset;
...@@ -23,6 +64,7 @@ export default class Serverless { ...@@ -23,6 +64,7 @@ export default class Serverless {
this.initPolling(); this.initPolling();
} }
} }
}
initServerless() { initServerless() {
const { store } = this; const { store } = this;
...@@ -55,7 +97,7 @@ export default class Serverless { ...@@ -55,7 +97,7 @@ export default class Serverless {
resource: this.service, resource: this.service,
method: 'fetchData', method: 'fetchData',
successCallback: data => this.handleSuccess(data), successCallback: data => this.handleSuccess(data),
errorCallback: () => this.handleError(), errorCallback: () => Serverless.handleError(),
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
...@@ -64,7 +106,7 @@ export default class Serverless { ...@@ -64,7 +106,7 @@ export default class Serverless {
this.service this.service
.fetchData() .fetchData()
.then(data => this.handleSuccess(data)) .then(data => this.handleSuccess(data))
.catch(() => this.handleError()); .catch(() => Serverless.handleError());
} }
Visibility.change(() => { Visibility.change(() => {
...@@ -102,5 +144,6 @@ export default class Serverless { ...@@ -102,5 +144,6 @@ export default class Serverless {
} }
this.functions.$destroy(); this.functions.$destroy();
this.functionDetails.$destroy();
} }
} }
export default class ServerlessDetailsStore {
constructor() {
this.state = {
functionDetail: {},
};
}
updateDetailedFunction(func) {
this.state.functionDetail = func;
}
}
...@@ -13,6 +13,8 @@ export default function deviseState(data) { ...@@ -13,6 +13,8 @@ export default function deviseState(data) {
return stateKey.conflicts; return stateKey.conflicts;
} else if (data.work_in_progress) { } else if (data.work_in_progress) {
return stateKey.workInProgress; return stateKey.workInProgress;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed; return stateKey.pipelineFailed;
} else if (this.hasMergeableDiscussionsState) { } else if (this.hasMergeableDiscussionsState) {
...@@ -25,8 +27,6 @@ export default function deviseState(data) { ...@@ -25,8 +27,6 @@ export default function deviseState(data) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
} else if (!this.canMerge) { } else if (!this.canMerge) {
return stateKey.notAllowedToMerge; return stateKey.notAllowedToMerge;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.canBeMerged) { } else if (this.canBeMerged) {
return stateKey.readyToMerge; return stateKey.readyToMerge;
} }
......
/** /**
* Note Form * Note Form
*/ */
.comment-btn {
@extend .btn-success;
}
.diff-file .diff-content { .diff-file .diff-content {
tr.line_holder:hover > td .line_note_link { tr.line_holder:hover > td .line_note_link {
opacity: 1; opacity: 1;
...@@ -386,7 +382,7 @@ table { ...@@ -386,7 +382,7 @@ table {
} }
.comment-type-dropdown { .comment-type-dropdown {
.comment-btn { .btn-success {
width: auto; width: auto;
} }
...@@ -417,7 +413,7 @@ table { ...@@ -417,7 +413,7 @@ table {
width: 100%; width: 100%;
margin-bottom: 10px; margin-bottom: 10px;
.comment-btn { .btn-success {
flex-grow: 1; flex-grow: 1;
flex-shrink: 0; flex-shrink: 0;
width: auto; width: auto;
......
...@@ -219,7 +219,7 @@ ...@@ -219,7 +219,7 @@
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
.project-tag-list { .project-topic-list {
font-size: $gl-font-size; font-size: $gl-font-size;
font-weight: $gl-font-weight-normal; font-weight: $gl-font-weight-normal;
...@@ -251,7 +251,7 @@ ...@@ -251,7 +251,7 @@
line-height: $gl-font-size-large; line-height: $gl-font-size-large;
} }
.project-tag-list, .project-topic-list,
.project-metadata { .project-metadata {
font-size: $gl-font-size-small; font-size: $gl-font-size-small;
} }
...@@ -273,7 +273,7 @@ ...@@ -273,7 +273,7 @@
} }
.access-request-link, .access-request-link,
.project-tag-list { .project-topic-list {
padding-left: $gl-padding-8; padding-left: $gl-padding-8;
border-left: 1px solid $gl-text-color-secondary; border-left: 1px solid $gl-text-color-secondary;
} }
......
...@@ -75,6 +75,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -75,6 +75,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private private
def omniauth_flow(auth_module, identity_linker: nil) def omniauth_flow(auth_module, identity_linker: nil)
if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence
store_redirect_fragment(fragment)
end
if current_user if current_user
log_audit_event(current_user, with: oauth['provider']) log_audit_event(current_user, with: oauth['provider'])
...@@ -189,4 +193,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -189,4 +193,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
request_params = request.env['omniauth.params'] request_params = request.env['omniauth.params']
(request_params['remember_me'] == '1') if request_params.present? (request_params['remember_me'] == '1') if request_params.present?
end end
def store_redirect_fragment(redirect_fragment)
key = stored_location_key_for(:user)
location = session[key]
if uri = parse_uri(location)
uri.fragment = redirect_fragment
store_location_for(:user, uri.to_s)
end
end
end end
...@@ -86,7 +86,7 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -86,7 +86,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end end
def build_from_id def build_from_id
project.get_build(params[:job_id]) if params[:job_id] project.builds.find_by_id(params[:job_id]) if params[:job_id]
end end
def build_from_ref def build_from_ref
......
...@@ -45,7 +45,7 @@ class Projects::BuildArtifactsController < Projects::ApplicationController ...@@ -45,7 +45,7 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
end end
def job_from_id def job_from_id
project.get_build(params[:build_id]) if params[:build_id] project.builds.find_by_id(params[:build_id]) if params[:build_id]
end end
def job_from_ref def job_from_ref
......
...@@ -4,16 +4,7 @@ class Projects::ReleasesController < Projects::ApplicationController ...@@ -4,16 +4,7 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize # Authorize
before_action :require_non_empty_project before_action :require_non_empty_project
before_action :authorize_read_release! before_action :authorize_read_release!
before_action :check_releases_page_feature_flag
def index def index
end end
private
def check_releases_page_feature_flag
return render_404 unless Feature.enabled?(:releases_page, @project)
push_frontend_feature_flag(:releases_page, @project)
end
end end
...@@ -7,19 +7,17 @@ module Projects ...@@ -7,19 +7,17 @@ module Projects
before_action :authorize_read_cluster! before_action :authorize_read_cluster!
INDEX_PRIMING_INTERVAL = 10_000 INDEX_PRIMING_INTERVAL = 15_000
INDEX_POLLING_INTERVAL = 30_000 INDEX_POLLING_INTERVAL = 60_000
def index def index
finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
respond_to do |format| respond_to do |format|
format.json do format.json do
functions = finder.execute functions = finder.execute
if functions.any? if functions.any?
Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions) render json: serialize_function(functions)
else else
Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content head :no_content
...@@ -32,6 +30,29 @@ module Projects ...@@ -32,6 +30,29 @@ module Projects
end end
end end
end end
def show
@service = serialize_function(finder.service(params[:environment_id], params[:id]))
return not_found if @service.nil?
respond_to do |format|
format.json do
render json: @service
end
format.html
end
end
private
def finder
Projects::Serverless::FunctionsFinder.new(project.clusters)
end
def serialize_function(function)
Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function)
end
end end
end end
end end
...@@ -149,6 +149,18 @@ class IssuableFinder ...@@ -149,6 +149,18 @@ class IssuableFinder
end end
end end
def related_groups
if project? && project && project.group && Ability.allowed?(current_user, :read_group, project.group)
project.group.self_and_ancestors
elsif group
[group]
elsif current_user
Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects
else
[]
end
end
def project? def project?
params[:project_id].present? params[:project_id].present?
end end
...@@ -163,8 +175,10 @@ class IssuableFinder ...@@ -163,8 +175,10 @@ class IssuableFinder
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def projects(items = nil) def projects
return @projects = project if project? return @projects if defined?(@projects)
return @projects = [project] if project?
projects = projects =
if current_user && params[:authorized_only].presence && !current_user_related? if current_user && params[:authorized_only].presence && !current_user_related?
...@@ -459,7 +473,7 @@ class IssuableFinder ...@@ -459,7 +473,7 @@ class IssuableFinder
elsif filter_by_any_milestone? elsif filter_by_any_milestone?
items = items.any_milestone items = items.any_milestone
elsif filter_by_upcoming_milestone? elsif filter_by_upcoming_milestone?
upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items)) upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
items = items.left_joins_milestones.where(milestone_id: upcoming_ids) items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
elsif filter_by_started_milestone? elsif filter_by_started_milestone?
items = items.left_joins_milestones.where('milestones.start_date <= NOW()') items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
......
...@@ -15,11 +15,40 @@ module Projects ...@@ -15,11 +15,40 @@ module Projects
clusters_with_knative_installed.exists? clusters_with_knative_installed.exists?
end end
def service(environment_scope, name)
knative_service(environment_scope, name)&.first
end
private private
def knative_service(environment_scope, name)
clusters_with_knative_installed.preload_knative.map do |cluster|
next if environment_scope != cluster.environment_scope
services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
.select { |svc| svc["metadata"]["name"] == name }
add_metadata(cluster, services).first unless services.nil?
end
end
def knative_services def knative_services
clusters_with_knative_installed.preload_knative.map do |cluster| clusters_with_knative_installed.preload_knative.map do |cluster|
cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace) services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
add_metadata(cluster, services) unless services.nil?
end
end
def add_metadata(cluster, services)
services.each do |s|
s["environment_scope"] = cluster.environment_scope
s["cluster_id"] = cluster.id
if services.length == 1
s["podcount"] = cluster.application_knative.service_pod_details(
cluster.platform_kubernetes&.actual_namespace,
s["metadata"]["name"]).length
end
end end
end end
......
...@@ -41,6 +41,8 @@ module Clusters ...@@ -41,6 +41,8 @@ module Clusters
scope :for_cluster, -> (cluster) { where(cluster: cluster) } scope :for_cluster, -> (cluster) { where(cluster: cluster) }
after_save :clear_reactive_cache!
def chart def chart
'knative/knative' 'knative/knative'
end end
...@@ -79,7 +81,7 @@ module Clusters ...@@ -79,7 +81,7 @@ module Clusters
end end
def calculate_reactive_cache def calculate_reactive_cache
{ services: read_services } { services: read_services, pods: read_pods }
end end
def ingress_service def ingress_service
...@@ -87,7 +89,7 @@ module Clusters ...@@ -87,7 +89,7 @@ module Clusters
end end
def services_for(ns: namespace) def services_for(ns: namespace)
return unless services return [] unless services
return [] unless ns return [] unless ns
services.select do |service| services.select do |service|
...@@ -95,8 +97,22 @@ module Clusters ...@@ -95,8 +97,22 @@ module Clusters
end end
end end
def service_pod_details(ns, service)
with_reactive_cache do |data|
data[:pods].select { |pod| filter_pods(pod, ns, service) }
end
end
private private
def read_pods
cluster.kubeclient.core_client.get_pods.as_json
end
def filter_pods(pod, namespace, service)
pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service
end
def read_services def read_services
client.get_services.as_json client.get_services.as_json
rescue Kubeclient::ResourceNotFoundError rescue Kubeclient::ResourceNotFoundError
......
...@@ -1108,10 +1108,11 @@ class MergeRequest < ActiveRecord::Base ...@@ -1108,10 +1108,11 @@ class MergeRequest < ActiveRecord::Base
end end
def update_head_pipeline def update_head_pipeline
self.head_pipeline = find_actual_head_pipeline find_actual_head_pipeline.try do |pipeline|
self.head_pipeline = pipeline
update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed? update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed?
end end
end
def merge_request_pipeline_exists? def merge_request_pipeline_exists?
merge_request_pipelines.exists?(sha: diff_head_sha) merge_request_pipelines.exists?(sha: diff_head_sha)
......
...@@ -38,12 +38,14 @@ class Milestone < ActiveRecord::Base ...@@ -38,12 +38,14 @@ class Milestone < ActiveRecord::Base
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) } scope :for_projects, -> { where(group: nil).includes(:project) }
scope :for_projects_and_groups, -> (project_ids, group_ids) do scope :for_projects_and_groups, -> (projects, groups) do
conditions = [] projects = projects.compact if projects.is_a? Array
conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any? projects = [] if projects.nil?
conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any?
where(conditions.reduce(:or)) groups = groups.compact if groups.is_a? Array
groups = [] if groups.nil?
where(project: projects).or(where(group: groups))
end end
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) } scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
...@@ -133,18 +135,29 @@ class Milestone < ActiveRecord::Base ...@@ -133,18 +135,29 @@ class Milestone < ActiveRecord::Base
@link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/) @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
end end
def self.upcoming_ids_by_projects(projects) def self.upcoming_ids(projects, groups)
rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now) rel = unscoped
.for_projects_and_groups(projects, groups)
.active.where('milestones.due_date > NOW()')
if Gitlab::Database.postgresql? if Gitlab::Database.postgresql?
rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id') rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id')
else else
# We need to use MySQL's NULL-safe comparison operator `<=>` here
# because one of `project_id` or `group_id` is always NULL
join_clause = <<~HEREDOC
LEFT OUTER JOIN milestones earlier_milestones
ON milestones.project_id <=> earlier_milestones.project_id
AND milestones.group_id <=> earlier_milestones.group_id
AND milestones.due_date > earlier_milestones.due_date
AND earlier_milestones.due_date > NOW()
AND earlier_milestones.state = 'active'
HEREDOC
rel rel
.group(:project_id, :due_date, :id) .joins(join_clause)
.having('due_date = MIN(due_date)') .where('earlier_milestones.id IS NULL')
.pluck(:id, :project_id, :due_date) .select(:id)
.uniq(&:second)
.map(&:first)
end end
end end
......
...@@ -85,7 +85,11 @@ class PoolRepository < ActiveRecord::Base ...@@ -85,7 +85,11 @@ class PoolRepository < ActiveRecord::Base
def unlink_repository(repository) def unlink_repository(repository)
object_pool.unlink_repository(repository.raw) object_pool.unlink_repository(repository.raw)
mark_obsolete unless member_projects.where.not(id: repository.project.id).exists? if member_projects.where.not(id: repository.project.id).exists?
true
else
mark_obsolete
end
end end
def object_pool def object_pool
......
...@@ -73,7 +73,7 @@ class Project < ActiveRecord::Base ...@@ -73,7 +73,7 @@ class Project < ActiveRecord::Base
delegate :no_import?, to: :import_state, allow_nil: true delegate :no_import?, to: :import_state, allow_nil: true
default_value_for :archived, false default_value_for :archived, false
default_value_for :visibility_level, gitlab_config_features.visibility_level default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_project_visibility }
default_value_for :resolve_outdated_diff_discussions, false default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for :container_registry_enabled, gitlab_config_features.container_registry
default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage } default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage }
...@@ -658,10 +658,6 @@ class Project < ActiveRecord::Base ...@@ -658,10 +658,6 @@ class Project < ActiveRecord::Base
latest_successful_build_for(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}")) latest_successful_build_for(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}"))
end end
def get_build(id)
builds.find_by(id: id)
end
def merge_base_commit(first_commit_id, second_commit_id) def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id) sha = repository.merge_base(first_commit_id, second_commit_id)
commit_by(oid: sha) if sha commit_by(oid: sha) if sha
...@@ -2040,7 +2036,7 @@ class Project < ActiveRecord::Base ...@@ -2040,7 +2036,7 @@ class Project < ActiveRecord::Base
end end
def leave_pool_repository def leave_pool_repository
pool_repository&.unlink_repository(repository) pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil)
end end
private private
......
...@@ -39,9 +39,7 @@ class TeamcityService < CiService ...@@ -39,9 +39,7 @@ class TeamcityService < CiService
end end
def help def help
'The build configuration in Teamcity must use the build format '\ 'You will want to configure monitoring of all branches so merge '\
'number %build.vcs.number% '\
'you will also want to configure monitoring of all branches so merge '\
'requests build, that setting is in the vsc root advanced settings.' 'requests build, that setting is in the vsc root advanced settings.'
end end
...@@ -70,7 +68,7 @@ class TeamcityService < CiService ...@@ -70,7 +68,7 @@ class TeamcityService < CiService
end end
def calculate_reactive_cache(sha, ref) def calculate_reactive_cache(sha, ref)
response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}")
{ build_page: read_build_page(response), commit_status: read_commit_status(response) } { build_page: read_build_page(response), commit_status: read_commit_status(response) }
end end
......
...@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated ...@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
presents :project presents :project
AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon)
MAX_TAGS_TO_SHOW = 3 MAX_TOPICS_TO_SHOW = 3
def statistic_icon(icon_name = 'plus-square-o') def statistic_icon(icon_name = 'plus-square-o')
sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4') sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4')
...@@ -310,20 +310,20 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated ...@@ -310,20 +310,20 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end end
end end
def tags_to_show def topics_to_show
project.tag_list.take(MAX_TAGS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
end end
def count_of_extra_tags_not_shown def count_of_extra_topics_not_shown
if project.tag_list.count > MAX_TAGS_TO_SHOW if project.tag_list.count > MAX_TOPICS_TO_SHOW
project.tag_list.count - MAX_TAGS_TO_SHOW project.tag_list.count - MAX_TOPICS_TO_SHOW
else else
0 0
end end
end end
def has_extra_tags? def has_extra_topics?
count_of_extra_tags_not_shown > 0 count_of_extra_topics_not_shown > 0
end end
private private
......
...@@ -13,6 +13,25 @@ module Projects ...@@ -13,6 +13,25 @@ module Projects
service.dig('metadata', 'namespace') service.dig('metadata', 'namespace')
end end
expose :environment_scope do |service|
service.dig('environment_scope')
end
expose :cluster_id do |service|
service.dig('cluster_id')
end
expose :detail_url do |service|
project_serverless_path(
request.project,
service.dig('environment_scope'),
service.dig('metadata', 'name'))
end
expose :podcount do |service|
service.dig('podcount')
end
expose :created_at do |service| expose :created_at do |service|
service.dig('metadata', 'creationTimestamp') service.dig('metadata', 'creationTimestamp')
end end
...@@ -22,11 +41,24 @@ module Projects ...@@ -22,11 +41,24 @@ module Projects
end end
expose :description do |service| expose :description do |service|
service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description') service.dig(
'spec',
'runLatest',
'configuration',
'revisionTemplate',
'metadata',
'annotations',
'Description')
end end
expose :image do |service| expose :image do |service|
service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name') service.dig(
'spec',
'runLatest',
'configuration',
'build',
'template',
'name')
end end
end end
end end
......
- page_title "Sign in" - page_title "Sign in"
%div #signin-container
- if form_based_providers.any? - if form_based_providers.any?
= render 'devise/shared/tabs_ldap' = render 'devise/shared/tabs_ldap'
- else - else
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
= link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
%span= _('Activity') %span= _('Activity')
- if project_nav_tab?(:releases) && Feature.enabled?(:releases_page, @project) - if project_nav_tab?(:releases)
= nav_link(controller: :releases) do = nav_link(controller: :releases) do
= link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
%span= _('Releases') %span= _('Releases')
......
...@@ -19,12 +19,13 @@ ...@@ -19,12 +19,13 @@
%span.access-request-links.prepend-left-8 %span.access-request-links.prepend-left-8
= render 'shared/members/access_request_links', source: @project = render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present? - if @project.tag_list.present?
%span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil } %span.project-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil }
= sprite_icon('tag', size: 16, css_class: 'icon append-right-4') = sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
= @project.tags_to_show = @project.topics_to_show
- if @project.has_extra_tags? - if @project.has_extra_topics?
= _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown }
.project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
- if current_user - if current_user
.d-inline-flex .d-inline-flex
......
...@@ -39,9 +39,9 @@ ...@@ -39,9 +39,9 @@
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
.form-group .form-group
= f.label :tag_list, "Tags", class: 'label-bold' = f.label :tag_list, "Topics", class: 'label-bold'
= f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
%p.form-text.text-muted Separate tags with commas. %p.form-text.text-muted Separate topics with commas.
%fieldset.features %fieldset.features
%h5.prepend-top-0= _("Project avatar") %h5.prepend-top-0= _("Project avatar")
.form-group .form-group
......
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
- page_title @service[:name]
.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.top-area.adjust
.serverless-function-details#js-serverless-function-details
.js-serverless-function-notice
.flash-container
.function-holder.js-function-holder.input-group
- noteable_name = @note.noteable.human_class_name - noteable_name = @note.noteable.human_class_name
.float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown .float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
%input.btn.btn-nr.btn-success.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') } %input.btn.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment') }
- if @note.can_be_discussion_note? - if @note.can_be_discussion_note?
= button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do
= icon('caret-down', class: 'toggle-icon') = icon('caret-down', class: 'toggle-icon')
%ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } } %ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } }
......
---
title: Fix default visibility_level for new projects
merge_request: 24120
author: Fabian Schneider @fabsrc
type: fixed
---
title: Fix upcoming milestones filter not including group milestones
merge_request: 23098
author: Heinrich Lee Yu
type: fixed
---
title: Rename project tags to project topics
merge_request: 24219
author:
type: other
---
title: Ensured links to a comment or system note anchor resolves to the right note if a user has a discussion filter.
merge_request: 24228
author:
type: changed
---
title: Build number does not need to be tweaked anymore for the TeamCity integration to work properly.
merge_request: 23898
author:
type: changed
---
title: Improves restriction of multiple Kubernetes clusters through API
merge_request: 24251
author:
type: fixed
---
title: Update CI YAML param table with include
merge_request: !24309
author:
type: fixed
---
title: Fix unexpected exception by failure of finding an actual head pipeline
merge_request: 24257
author:
type: fixed
---
title: Remove unused button classes `btn-create` and `comment-btn`
merge_request: 23232
author: George Tsiolis
type: performance
---
title: Fix lost line number when navigating to a specific line in a protected file
before authenticating.
merge_request: 19165
author: Scott Escue
type: fixed
---
title: Add Knative detailed view
merge_request: 23863
author: Chris Baumbauer
type: added
---
title: Fixed rebase button not showing in merge request widget
merge_request:
author:
type: fixed
---
title: Support multiple outputs in jupyter notebooks
merge_request:
author:
type: changed
---
title: Remove migration to backfill project_repositories for legacy storage projects
merge_request: 24299
author:
type: removed
...@@ -247,6 +247,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -247,6 +247,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
namespace :serverless do namespace :serverless do
get '/functions/:environment_id/:id', to: 'functions#show'
resources :functions, only: [:index] resources :functions, only: [:index]
end end
......
...@@ -14,6 +14,9 @@ class EmojiChecker ...@@ -14,6 +14,9 @@ class EmojiChecker
DIGESTS = File.expand_path('../../fixtures/emojis/digests.json', __dir__) DIGESTS = File.expand_path('../../fixtures/emojis/digests.json', __dir__)
ALIASES = File.expand_path('../../fixtures/emojis/aliases.json', __dir__) ALIASES = File.expand_path('../../fixtures/emojis/aliases.json', __dir__)
URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50"
URL_GIT_COMMIT = "https://chris.beams.io/posts/git-commit/"
# A regex that indicates a piece of text _might_ include an Emoji. The regex # A regex that indicates a piece of text _might_ include an Emoji. The regex
# alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this # alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this
# regex to save us from having to check for all possible emoji names when we # regex to save us from having to check for all possible emoji names when we
...@@ -101,10 +104,7 @@ def lint_commits(commits) ...@@ -101,10 +104,7 @@ def lint_commits(commits)
elsif subject.length > 50 elsif subject.length > 50
warn_commit( warn_commit(
commit, commit,
"This commit's subject line could be improved. " \ "This commit's subject line is acceptable, but please try to [reduce it to 50 characters](#{URL_LIMIT_SUBJECT})."
'Commit subjects are ideally no longer than roughly 50 characters, ' \
'though we allow up to 72 characters in the subject. ' \
'If possible, try to reduce the length of the subject to roughly 50 characters.'
) )
end end
...@@ -196,7 +196,7 @@ def lint_commits(commits) ...@@ -196,7 +196,7 @@ def lint_commits(commits)
One or more commit messages do not meet our Git commit message standards. One or more commit messages do not meet our Git commit message standards.
For more information on how to write a good commit message, take a look at For more information on how to write a good commit message, take a look at
[How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/). [How to Write a Git Commit Message](#{URL_GIT_COMMIT}).
Here is an example of a good commit message: Here is an example of a good commit message:
......
# frozen_string_literal: true
class BackfillProjectRepositoriesForLegacyStorageProjects < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
BATCH_SIZE = 1_000
DELAY_INTERVAL = 5.minutes
MIGRATION = 'BackfillLegacyProjectRepositories'
disable_ddl_transaction!
class Project < ActiveRecord::Base
include EachBatch
self.table_name = 'projects'
end
def up
queue_background_migration_jobs_by_range_at_intervals(Project, MIGRATION, DELAY_INTERVAL)
end
def down
# no-op: since there could have been existing rows before the migration do not remove anything
end
end
...@@ -69,6 +69,9 @@ The following API resources are available: ...@@ -69,6 +69,9 @@ The following API resources are available:
- [Sidekiq metrics](sidekiq_metrics.md) - [Sidekiq metrics](sidekiq_metrics.md)
- [System hooks](system_hooks.md) - [System hooks](system_hooks.md)
- [Tags](tags.md) - [Tags](tags.md)
- [Releases](releases/index.md)
- Release Assets
- [Links](releases/links.md)
- [Todos](todos.md) - [Todos](todos.md)
- [Users](users.md) - [Users](users.md)
- [Validate CI configuration](lint.md) (linting) - [Validate CI configuration](lint.md) (linting)
......
# Applications API # Applications API
> [Introduced][ce-8160] in GitLab 10.5 > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8160) in GitLab 10.5.
[ce-8160]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8160 Applications API operates on OAuth applications for:
Only admin user can use the Applications API. - [Using GitLab as an authentication provider](../integration/oauth_provider.md).
- [Allowing access to GitLab resources on a user's behalf](oauth2.md).
## Create a application NOTE: **Note:**
Only admin users can use the Applications API.
Create a application by posting a JSON payload. ## Create an application
Create an application by posting a JSON payload.
Returns `200` if the request succeeds. Returns `200` if the request succeeds.
``` ```text
POST /applications POST /applications
``` ```
Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | |:---------------|:-------|:---------|:---------------------------------|
| `name` | string | yes | The name of the application | | `name` | string | yes | Name of the application. |
| `redirect_uri` | string | yes | The redirect URI of the application | | `redirect_uri` | string | yes | Redirect URI of the application. |
| `scopes` | string | yes | The scopes of the application | | `scopes` | string | yes | Scopes of the application. |
Example request:
```bash ```sh
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "name=MyApplication&redirect_uri=http://redirect.uri&scopes=" https://gitlab.example.com/api/v4/applications curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "name=MyApplication&redirect_uri=http://redirect.uri&scopes=" https://gitlab.example.com/api/v4/applications
``` ```
...@@ -42,11 +50,13 @@ Example response: ...@@ -42,11 +50,13 @@ Example response:
List all registered applications. List all registered applications.
``` ```text
GET /applications GET /applications
``` ```
```bash Example request:
```sh
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications
``` ```
...@@ -63,7 +73,8 @@ Example response: ...@@ -63,7 +73,8 @@ Example response:
] ]
``` ```
> Note: the `secret` value will not be exposed by this API. NOTE: **Note:**
The `secret` value will not be exposed by this API.
## Delete an application ## Delete an application
...@@ -71,7 +82,7 @@ Delete a specific application. ...@@ -71,7 +82,7 @@ Delete a specific application.
Returns `204` if the request succeeds. Returns `204` if the request succeeds.
``` ```text
DELETE /applications/:id DELETE /applications/:id
``` ```
...@@ -79,6 +90,8 @@ Parameters: ...@@ -79,6 +90,8 @@ Parameters:
- `id` (required) - The id of the application (not the application_id) - `id` (required) - The id of the application (not the application_id)
```bash Example request:
```sh
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications/:id curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications/:id
``` ```
# Releases API # Releases API
> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7. > - [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7.
> - Using this API you can manipulate GitLab's [Release](../user/project/releases/index.md) entries. > - Using this API you can manipulate GitLab's [Release](../../user/project/releases/index.md) entries.
> - For manipulating links as a release asset, see [Release Links API](links.md)
## List Releases ## List Releases
...@@ -241,7 +242,7 @@ POST /projects/:id/releases ...@@ -241,7 +242,7 @@ POST /projects/:id/releases
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `name` | string | yes | The release name. | | `name` | string | yes | The release name. |
| `tag_name` | string | yes | The tag where the release will be created from. | | `tag_name` | string | yes | The tag where the release will be created from. |
| `description` | string | no | The description of the release. You can use [markdown](../user/markdown.md). | | `description` | string | yes | The description of the release. You can use [markdown](../user/markdown.md). |
| `ref` | string | no | If `tag_name` doesn't exist, the release will be created from `ref`. It can be a commit SHA, another tag name, or a branch name. | | `ref` | string | no | If `tag_name` doesn't exist, the release will be created from `ref`. It can be a commit SHA, another tag name, or a branch name. |
| `assets:links`| array of hash | no | An array of assets links. | | `assets:links`| array of hash | no | An array of assets links. |
| `assets:links:name`| string | no (if `assets:links` specified, it's required) | The name of the link. | | `assets:links:name`| string | no (if `assets:links` specified, it's required) | The name of the link. |
...@@ -331,8 +332,8 @@ PUT /projects/:id/releases/:tag_name ...@@ -331,8 +332,8 @@ PUT /projects/:id/releases/:tag_name
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- | | ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag where the release will be created from. |
| `name` | string | no | The release name. | | `name` | string | no | The release name. |
| `tag_name` | string | no | The tag where the release will be created from. |
| `description` | string | no | The description of the release. You can use [markdown](../user/markdown.md). | | `description` | string | no | The description of the release. You can use [markdown](../user/markdown.md). |
Example request: Example request:
......
# Release links API
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7.
Using this API you can manipulate GitLab's [Release](../../user/project/releases/index.md) links. For manipulating other Release assets, see [Release API](index.md).
## Get links
Get assets as links from a Release.
```
GET /projects/:id/releases/:tag_name/assets/links
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag associated with the Release. |
Example request:
```sh
curl --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links"
```
Example response:
```json
[
{
"id":2,
"name":"awesome-v0.2.msi",
"url":"http://192.168.10.15:3000/msi",
"external":true
},
{
"id":1,
"name":"awesome-v0.2.dmg",
"url":"http://192.168.10.15:3000",
"external":true
}
]
```
## Get a link
Get an asset as a link from a Release.
```
GET /projects/:id/releases/:tag_name/assets/links/:link_id
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag associated with the Release. |
| `link_id` | integer | yes | The id of the link. |
Example request:
```sh
curl --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1"
```
Example response:
```json
{
"id":1,
"name":"awesome-v0.2.dmg",
"url":"http://192.168.10.15:3000",
"external":true
}
```
## Create a link
Create an asset as a link from a Release.
```
POST /projects/:id/releases/:tag_name/assets/links
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag associated with the Release. |
| `name` | string | yes | The name of the link. |
| `url` | string | yes | The URL of the link. |
Example request:
```sh
curl --request POST \
--header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" \
--data name="awesome-v0.2.dmg" \
--data url="http://192.168.10.15:3000" \
"http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links"
```
Example response:
```json
{
"id":1,
"name":"awesome-v0.2.dmg",
"url":"http://192.168.10.15:3000",
"external":true
}
```
## Update a link
Update an asset as a link from a Release.
```
PUT /projects/:id/releases/:tag_name/assets/links/:link_id
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag associated with the Release. |
| `link_id` | integer | yes | The id of the link. |
| `name` | string | no | The name of the link. |
| `url` | string | no | The URL of the link. |
NOTE: **NOTE**
You have to specify at least one of `name` or `url`
Example request:
```sh
curl --request PUT --data name="new name" --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1"
```
Example response:
```json
{
"id":1,
"name":"new name",
"url":"http://192.168.10.15:3000",
"external":true
}
```
## Delete a link
Delete an asset as a link from a Release.
```
DELETE /projects/:id/releases/:tag_name/assets/links/:link_id
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | --------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The tag associated with the Release. |
| `link_id` | integer | yes | The id of the link. |
Example request:
```sh
curl --request DELETE --header "PRIVATE-TOKEN: gDybLx3yrUK_HLp3qPjS" "http://localhost:3000/api/v4/projects/24/releases/v0.1/assets/links/1"
```
Example response:
```json
{
"id":1,
"name":"new name",
"url":"http://192.168.10.15:3000",
"external":true
}
```
...@@ -57,6 +57,7 @@ A job is defined by a list of parameters that define the job behavior. ...@@ -57,6 +57,7 @@ A job is defined by a list of parameters that define the job behavior.
|---------------|----------|-------------| |---------------|----------|-------------|
| [script](#script) | yes | Defines a shell script which is executed by Runner | | [script](#script) | yes | Defines a shell script which is executed by Runner |
| [extends](#extends) | no | Defines a configuration entry that this job is going to inherit from | | [extends](#extends) | no | Defines a configuration entry that this job is going to inherit from |
| [include](#include) | no | Defines a configuration entry that allows this job to include external YAML files |
| [image](#image-and-services) | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | | [image](#image-and-services) | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
| [services](#image-and-services) | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | | [services](#image-and-services) | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
| [stage](#stage) | no | Defines a job stage (default: `test`) | | [stage](#stage) | no | Defines a job stage (default: `test`) |
......
...@@ -3,8 +3,11 @@ ...@@ -3,8 +3,11 @@
This document is about using GitLab as an OAuth authentication service provider This document is about using GitLab as an OAuth authentication service provider
to sign in to other services. to sign in to other services.
If you want to use other OAuth authentication service providers to sign in to If you want to use:
GitLab, please see the [OAuth2 client documentation](../api/oauth2.md).
- Other OAuth authentication service providers to sign in to
GitLab, see the [OAuth2 client documentation](omniauth.md).
- The related API, see [Applications API](../api/applications.md).
## Introduction to OAuth ## Introduction to OAuth
......
...@@ -103,7 +103,7 @@ In order to deploy functions to your Knative instance, the following files must ...@@ -103,7 +103,7 @@ In order to deploy functions to your Knative instance, the following files must
The `gitlab-ci.yml` template creates a `Deploy` stage with a `functions` job that invokes the `tm` CLI with the required parameters. The `gitlab-ci.yml` template creates a `Deploy` stage with a `functions` job that invokes the `tm` CLI with the required parameters.
2. `serverless.yml`: This file contains the metadata for your functions, 2. `serverless.yml`: This file contains the metadata for your functions,
such as name, runtime, and environment. It must be included at the root of your repository. The following is a sample `echo` function which shows the required structure for the file. such as name, runtime, and environment. It must be included at the root of your repository. The following is a sample `echo` function which shows the required structure for the file. You can find the relevant files for this project in the [functions example project](https://gitlab.com/knative-examples/functions).
```yaml ```yaml
service: my-functions service: my-functions
...@@ -127,7 +127,7 @@ In order to deploy functions to your Knative instance, the following files must ...@@ -127,7 +127,7 @@ In order to deploy functions to your Knative instance, the following files must
``` ```
The `serverless.yml` file contains three sections with distinct parameters: The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it contains three sections with distinct parameters:
### `service` ### `service`
...@@ -167,8 +167,8 @@ appear under **Operations > Serverless**. ...@@ -167,8 +167,8 @@ appear under **Operations > Serverless**.
![serverless page](img/serverless-page.png) ![serverless page](img/serverless-page.png)
This page contains all functions available for the project, the URL for This page contains all functions available for the project, the description for
accessing the function, and if available, the function's runtime information. accessing the function, and, if available, the function's runtime information.
The details are derived from the Knative installation inside each of the project's The details are derived from the Knative installation inside each of the project's
Kubernetes cluster. Kubernetes cluster.
...@@ -184,6 +184,12 @@ The sample function can now be triggered from any HTTP client using a simple `PO ...@@ -184,6 +184,12 @@ The sample function can now be triggered from any HTTP client using a simple `PO
Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed. Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed.
Clicking on the function name will provide additional details such as the
function's URL as well as runtime statistics such as the number of active pods
available to service the request based on load.
![serverless function details](img/serverless-details.png)
## Deploying Serverless applications ## Deploying Serverless applications
> Introduced in GitLab 11.5. > Introduced in GitLab 11.5.
......
...@@ -12,7 +12,7 @@ GitLab's **Releases** are a way to track deliverables in your project. Consider ...@@ -12,7 +12,7 @@ GitLab's **Releases** are a way to track deliverables in your project. Consider
a snapshot in time of the source, build output, and other metadata or artifacts a snapshot in time of the source, build output, and other metadata or artifacts
associated with a released version of your code. associated with a released version of your code.
At the moment, you can create Release entries via the [Releases API](../../../api/releases.md); At the moment, you can create Release entries via the [Releases API](../../../api/releases/index.md);
we recommend doing this as one of the last steps in your CI/CD release pipeline. we recommend doing this as one of the last steps in your CI/CD release pipeline.
## Getting started with Releases ## Getting started with Releases
...@@ -51,6 +51,9 @@ A link is any URL which can point to whatever you like; documentation, built ...@@ -51,6 +51,9 @@ A link is any URL which can point to whatever you like; documentation, built
binaries, or other related materials. These can be both internal or external binaries, or other related materials. These can be both internal or external
links from your GitLab instance. links from your GitLab instance.
NOTE: **NOTE**
You can manipulate links of each release entry with [Release Links API](../../../api/releases/links.md)
## Releases list ## Releases list
Navigate to **Project > Releases** in order to see the list of releases for a given Navigate to **Project > Releases** in order to see the list of releases for a given
......
...@@ -14,7 +14,7 @@ functionality of a project. ...@@ -14,7 +14,7 @@ functionality of a project.
### General project settings ### General project settings
Adjust your project's name, description, avatar, [default branch](../repository/branches/index.md#default-branch), and tags: Adjust your project's name, description, avatar, [default branch](../repository/branches/index.md#default-branch), and topics:
![general project settings](img/general_settings.png) ![general project settings](img/general_settings.png)
......
...@@ -235,8 +235,8 @@ module API ...@@ -235,8 +235,8 @@ module API
forbidden! unless current_user.admin? forbidden! unless current_user.admin?
end end
def authorize!(action, subject = :global) def authorize!(action, subject = :global, reason = nil)
forbidden! unless can?(current_user, action, subject) forbidden!(reason) unless can?(current_user, action, subject)
end end
def authorize_push_project def authorize_push_project
......
...@@ -63,7 +63,7 @@ module API ...@@ -63,7 +63,7 @@ module API
use :create_params_ee use :create_params_ee
end end
post ':id/clusters/user' do post ':id/clusters/user' do
authorize! :create_cluster, user_project authorize! :add_cluster, user_project, 'Instance does not support multiple Kubernetes clusters'
user_cluster = ::Clusters::CreateService user_cluster = ::Clusters::CreateService
.new(current_user, create_cluster_user_params) .new(current_user, create_cluster_user_params)
......
...@@ -8,8 +8,6 @@ module API ...@@ -8,8 +8,6 @@ module API
RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) .merge(tag_name: API::NO_SLASH_URL_PART_REGEX)
before { error!('404 Not Found', 404) unless Feature.enabled?(:releases_page, user_project) }
params do params do
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
end end
......
...@@ -7,7 +7,6 @@ module API ...@@ -7,7 +7,6 @@ module API
RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) .merge(tag_name: API::NO_SLASH_URL_PART_REGEX)
before { error!('404 Not Found', 404) unless Feature.enabled?(:releases_page, user_project) }
before { authorize_read_releases! } before { authorize_read_releases! }
params do params do
......
...@@ -6060,13 +6060,31 @@ msgstr "" ...@@ -6060,13 +6060,31 @@ msgstr ""
msgid "Serverless" msgid "Serverless"
msgstr "" msgstr ""
msgid "ServerlessDetails|Copy URL to clipboard"
msgstr ""
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
msgstr ""
msgid "ServerlessDetails|pod in use"
msgstr ""
msgid "ServerlessDetails|pods in use"
msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr "" msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components" msgid "Serverless|An error occurred while retrieving serverless components"
msgstr "" msgstr ""
msgid "Serverless|Domain" msgid "Serverless|Cluster Env"
msgstr ""
msgid "Serverless|Description"
msgstr "" msgstr ""
msgid "Serverless|Function" msgid "Serverless|Function"
......
...@@ -126,10 +126,6 @@ module QA ...@@ -126,10 +126,6 @@ module QA
mod mod
end end
def self.attributes_names
dynamic_attributes.instance_methods(false).sort.grep_v(/=$/)
end
class DSL class DSL
def initialize(base) def initialize(base)
@base = base @base = base
......
...@@ -6,9 +6,12 @@ module QA ...@@ -6,9 +6,12 @@ module QA
module Resource module Resource
class User < Base class User < Base
attr_reader :unique_id attr_reader :unique_id
attr_writer :username, :password, :name, :email attr_writer :username, :password
attr_accessor :provider, :extern_uid attr_accessor :provider, :extern_uid
attribute :name
attribute :email
def initialize def initialize
@unique_id = SecureRandom.hex(8) @unique_id = SecureRandom.hex(8)
end end
...@@ -22,11 +25,11 @@ module QA ...@@ -22,11 +25,11 @@ module QA
end end
def name def name
@name ||= username @name ||= api_resource&.dig(:name) || username
end end
def email def email
@email ||= "#{username}@example.com" @email ||= api_resource&.dig(:email) || "#{username}@example.com"
end end
def credentials_given? def credentials_given?
......
...@@ -39,11 +39,15 @@ module QA ...@@ -39,11 +39,15 @@ module QA
end end
it 'user views raw email patch' do it 'user views raw email patch' do
user = Resource::User.fabricate_via_api! do |user|
user.username = Runtime::User.username
end
view_commit view_commit
Page::Project::Commit::Show.perform(&:select_email_patches) Page::Project::Commit::Show.perform(&:select_email_patches)
expect(page).to have_content('From: Administrator <admin@example.com>') expect(page).to have_content("From: #{user.name} <#{user.email}>")
expect(page).to have_content('Subject: [PATCH] Add second file') expect(page).to have_content('Subject: [PATCH] Add second file')
expect(page).to have_content('diff --git a/second b/second') expect(page).to have_content('diff --git a/second b/second')
end end
......
...@@ -138,10 +138,6 @@ describe QA::Resource::Base do ...@@ -138,10 +138,6 @@ describe QA::Resource::Base do
describe '.attribute' do describe '.attribute' do
include_context 'simple resource' include_context 'simple resource'
it 'appends new attribute' do
expect(subject.attributes_names).to eq([:no_block, :test, :web_url])
end
context 'when the attribute is populated via a block' do context 'when the attribute is populated via a block' do
it 'returns value from the block' do it 'returns value from the block' do
result = subject.fabricate!(resource: resource) result = subject.fabricate!(resource: resource)
......
...@@ -45,6 +45,40 @@ describe OmniauthCallbacksController, type: :controller do ...@@ -45,6 +45,40 @@ describe OmniauthCallbacksController, type: :controller do
end end
end end
context 'when a redirect fragment is provided' do
let(:provider) { :jwt }
let(:extern_uid) { 'my-uid' }
before do
request.env['omniauth.params'] = { 'redirect_fragment' => 'L101' }
end
context 'when a redirect url is stored' do
it 'redirects with fragment' do
post provider, nil, { user_return_to: '/fake/url' }
expect(response).to redirect_to('/fake/url#L101')
end
end
context 'when a redirect url with a fragment is stored' do
it 'redirects with the new fragment' do
post provider, nil, { user_return_to: '/fake/url#replaceme' }
expect(response).to redirect_to('/fake/url#L101')
end
end
context 'when no redirect url is stored' do
it 'does not redirect with the fragment' do
post provider
expect(response.redirect?).to be true
expect(response.location).not_to include('#L101')
end
end
end
context 'strategies' do context 'strategies' do
context 'github' do context 'github' do
let(:extern_uid) { 'my-uid' } let(:extern_uid) { 'my-uid' }
......
...@@ -6,10 +6,6 @@ describe Projects::ReleasesController do ...@@ -6,10 +6,6 @@ describe Projects::ReleasesController do
let!(:project) { create(:project, :repository, :public) } let!(:project) { create(:project, :repository, :public) }
let!(:user) { create(:user) } let!(:user) { create(:user) }
before do
stub_feature_flags(releases_page: true)
end
describe 'GET #index' do describe 'GET #index' do
it 'renders a 200' do it 'renders a 200' do
get_index get_index
...@@ -43,18 +39,6 @@ describe Projects::ReleasesController do ...@@ -43,18 +39,6 @@ describe Projects::ReleasesController do
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
context 'when releases_page feature flag is disabled' do
before do
stub_feature_flags(releases_page: false)
end
it 'renders a 404' do
get_index
expect(response.status).to eq(404)
end
end
end end
private private
......
...@@ -45,9 +45,45 @@ describe Projects::Serverless::FunctionsController do ...@@ -45,9 +45,45 @@ describe Projects::Serverless::FunctionsController do
end end
end end
describe 'GET #show' do
context 'invalid data' do
it 'has a bad function name' do
get :show, params: params({ format: :json, environment_id: "*", id: "foo" })
expect(response).to have_gitlab_http_status(404)
end
end
context 'valid data', :use_clean_rails_memory_store_caching do
before do
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
end
it 'has a valid function name' do
get :show, params: params({ format: :json, environment_id: "*", id: cluster.project.name })
expect(response).to have_gitlab_http_status(200)
expect(json_response).to include(
"name" => project.name,
"url" => "http://#{project.name}.#{namespace.namespace}.example.com",
"podcount" => 1
)
end
end
end
describe 'GET #index with data', :use_clean_rails_memory_store_caching do describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do before do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
end end
it 'has data' do it 'has data' do
......
...@@ -9,13 +9,13 @@ describe 'Projects > Settings > User tags a project' do ...@@ -9,13 +9,13 @@ describe 'Projects > Settings > User tags a project' do
visit edit_project_path(project) visit edit_project_path(project)
end end
it 'sets project tags' do it 'sets project topics' do
fill_in 'Tags', with: 'tag1, tag2' fill_in 'Topics', with: 'topic1, topic2'
page.within '.general-settings' do page.within '.general-settings' do
click_button 'Save changes' click_button 'Save changes'
end end
expect(find_field('Tags').value).to eq 'tag1, tag2' expect(find_field('Topics').value).to eq 'topic1, topic2'
end end
end end
...@@ -174,9 +174,13 @@ describe IssuesFinder do ...@@ -174,9 +174,13 @@ describe IssuesFinder do
context 'filtering by upcoming milestone' do context 'filtering by upcoming milestone' do
let(:params) { { milestone_title: Milestone::Upcoming.name } } let(:params) { { milestone_title: Milestone::Upcoming.name } }
let!(:group) { create(:group, :public) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let(:project_no_upcoming_milestones) { create(:project, :public) } let(:project_no_upcoming_milestones) { create(:project, :public) }
let(:project_next_1_1) { create(:project, :public) } let(:project_next_1_1) { create(:project, :public) }
let(:project_next_8_8) { create(:project, :public) } let(:project_next_8_8) { create(:project, :public) }
let(:project_in_group) { create(:project, :public, namespace: group) }
let(:yesterday) { Date.today - 1.day } let(:yesterday) { Date.today - 1.day }
let(:tomorrow) { Date.today + 1.day } let(:tomorrow) { Date.today + 1.day }
...@@ -187,21 +191,22 @@ describe IssuesFinder do ...@@ -187,21 +191,22 @@ describe IssuesFinder do
[ [
create(:milestone, :closed, project: project_no_upcoming_milestones), create(:milestone, :closed, project: project_no_upcoming_milestones),
create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now), create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now),
create(:milestone, project: project_next_1_1, title: '8.8', due_date: ten_days_from_now), create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now),
create(:milestone, project: project_next_8_8, title: '1.1', due_date: yesterday), create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday),
create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow) create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow),
create(:milestone, group: group, title: '9.9', due_date: tomorrow)
] ]
end end
before do before do
milestones.each do |milestone| milestones.each do |milestone|
create(:issue, project: milestone.project, milestone: milestone, author: user, assignees: [user]) create(:issue, project: milestone.project || project_in_group, milestone: milestone, author: user, assignees: [user])
end end
end end
it 'returns issues in the upcoming milestone for each project' do it 'returns issues in the upcoming milestone for each project or group' do
expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8') expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8', '9.9')
expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now) expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow)
end end
end end
......
...@@ -29,15 +29,34 @@ describe Projects::Serverless::FunctionsFinder do ...@@ -29,15 +29,34 @@ describe Projects::Serverless::FunctionsFinder do
context 'has knative installed' do context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:finder) { described_class.new(project.clusters) }
it 'there are no functions' do it 'there are no functions' do
expect(described_class.new(project.clusters).execute).to be_empty expect(finder.execute).to be_empty
end end
it 'there are functions', :use_clean_rails_memory_store_caching do it 'there are functions', :use_clean_rails_memory_store_caching do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"]) stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
expect(finder.execute).not_to be_empty
end
it 'has a function', :use_clean_rails_memory_store_caching do
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"],
pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"]
})
expect(described_class.new(project.clusters).execute).not_to be_empty result = finder.service(cluster.environment_scope, cluster.project.name)
expect(result).not_to be_empty
expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
end end
end end
end end
......
...@@ -3,3 +3,4 @@ ...@@ -3,3 +3,4 @@
%a.oauth-login.twitter{ href: "http://example.com/" } %a.oauth-login.twitter{ href: "http://example.com/" }
%a.oauth-login.github{ href: "http://example.com/" } %a.oauth-login.github{ href: "http://example.com/" }
%a.oauth-login.facebook{ href: "http://example.com/?redirect_fragment=L1" }
require 'spec_helper'
describe 'Sessions (JavaScript fixtures)' do
include JavaScriptFixturesHelpers
before(:all) do
clean_frontend_fixtures('sessions/')
end
describe SessionsController, '(JavaScript fixtures)', type: :controller do
include DeviseHelpers
render_views
before do
set_devise_mapping(context: @request)
end
it 'sessions/new.html.raw' do |example|
get :new
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
end
import { webIDEUrl, mergeUrlParams } from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
describe('URL utility', () => { describe('URL utility', () => {
describe('webIDEUrl', () => { describe('webIDEUrl', () => {
...@@ -8,7 +8,7 @@ describe('URL utility', () => { ...@@ -8,7 +8,7 @@ describe('URL utility', () => {
describe('without relative_url_root', () => { describe('without relative_url_root', () => {
it('returns IDE path with route', () => { it('returns IDE path with route', () => {
expect(webIDEUrl('/gitlab-org/gitlab-ce/merge_requests/1')).toBe( expect(urlUtils.webIDEUrl('/gitlab-org/gitlab-ce/merge_requests/1')).toBe(
'/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', '/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1',
); );
}); });
...@@ -20,7 +20,7 @@ describe('URL utility', () => { ...@@ -20,7 +20,7 @@ describe('URL utility', () => {
}); });
it('returns IDE path with route', () => { it('returns IDE path with route', () => {
expect(webIDEUrl('/gitlab/gitlab-org/gitlab-ce/merge_requests/1')).toBe( expect(urlUtils.webIDEUrl('/gitlab/gitlab-org/gitlab-ce/merge_requests/1')).toBe(
'/gitlab/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', '/gitlab/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1',
); );
}); });
...@@ -29,23 +29,82 @@ describe('URL utility', () => { ...@@ -29,23 +29,82 @@ describe('URL utility', () => {
describe('mergeUrlParams', () => { describe('mergeUrlParams', () => {
it('adds w', () => { it('adds w', () => {
expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
expect(mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
expect(mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe('https://host/path?w=1#frag'); expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe(
expect(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag'); 'https://host/path?w=1#frag',
);
expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe(
'https://h/p?k1=v1&w=1#frag',
);
}); });
it('updates w', () => { it('updates w', () => {
expect(mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag');
}); });
it('adds multiple params', () => { it('adds multiple params', () => {
expect(mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag');
}); });
it('adds and updates encoded params', () => { it('adds and updates encoded params', () => {
expect(mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag');
});
});
describe('removeParams', () => {
describe('when url is passed', () => {
it('removes query param with encoded ampersand', () => {
const url = urlUtils.removeParams(['filter'], '/mail?filter=n%3Djoe%26l%3Dhome');
expect(url).toBe('/mail');
});
it('should remove param when url has no other params', () => {
const url = urlUtils.removeParams(['size'], '/feature/home?size=5');
expect(url).toBe('/feature/home');
});
it('should remove param when url has other params', () => {
const url = urlUtils.removeParams(['size'], '/feature/home?q=1&size=5&f=html');
expect(url).toBe('/feature/home?q=1&f=html');
});
it('should remove param and preserve fragment', () => {
const url = urlUtils.removeParams(['size'], '/feature/home?size=5#H2');
expect(url).toBe('/feature/home#H2');
});
it('should remove multiple params', () => {
const url = urlUtils.removeParams(['z', 'a'], '/home?z=11111&l=en_US&a=true#H2');
expect(url).toBe('/home?l=en_US#H2');
});
});
});
describe('setUrlFragment', () => {
it('should set fragment when url has no fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature', 'usage');
expect(url).toBe('/home/feature#usage');
});
it('should set fragment when url has existing fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature#overview', 'usage');
expect(url).toBe('/home/feature#usage');
});
it('should set fragment when given fragment includes #', () => {
const url = urlUtils.setUrlFragment('/home/feature#overview', '#install');
expect(url).toBe('/home/feature#install');
}); });
}); });
}); });
...@@ -9,6 +9,8 @@ describe('html output cell', () => { ...@@ -9,6 +9,8 @@ describe('html output cell', () => {
return new Component({ return new Component({
propsData: { propsData: {
rawCode, rawCode,
count: 0,
index: 0,
}, },
}).$mount(); }).$mount();
} }
......
...@@ -10,7 +10,7 @@ describe('Output component', () => { ...@@ -10,7 +10,7 @@ describe('Output component', () => {
const createComponent = output => { const createComponent = output => {
vm = new Component({ vm = new Component({
propsData: { propsData: {
output, outputs: [].concat(output),
count: 1, count: 1,
}, },
}); });
...@@ -51,28 +51,21 @@ describe('Output component', () => { ...@@ -51,28 +51,21 @@ describe('Output component', () => {
it('renders as an image', () => { it('renders as an image', () => {
expect(vm.$el.querySelector('img')).not.toBeNull(); expect(vm.$el.querySelector('img')).not.toBeNull();
}); });
it('does not render the prompt', () => {
expect(vm.$el.querySelector('.prompt span')).toBeNull();
});
}); });
describe('html output', () => { describe('html output', () => {
beforeEach(done => { it('renders raw HTML', () => {
createComponent(json.cells[4].outputs[0]); createComponent(json.cells[4].outputs[0]);
setTimeout(() => {
done();
});
});
it('renders raw HTML', () => {
expect(vm.$el.querySelector('p')).not.toBeNull(); expect(vm.$el.querySelector('p')).not.toBeNull();
expect(vm.$el.textContent.trim()).toBe('test'); expect(vm.$el.querySelectorAll('p').length).toBe(1);
expect(vm.$el.textContent.trim()).toContain('test');
}); });
it('does not render the prompt', () => { it('renders multiple raw HTML outputs', () => {
expect(vm.$el.querySelector('.prompt span')).toBeNull(); createComponent([json.cells[4].outputs[0], json.cells[4].outputs[0]]);
expect(vm.$el.querySelectorAll('p').length).toBe(2);
}); });
}); });
...@@ -88,10 +81,6 @@ describe('Output component', () => { ...@@ -88,10 +81,6 @@ describe('Output component', () => {
it('renders as an svg', () => { it('renders as an svg', () => {
expect(vm.$el.querySelector('svg')).not.toBeNull(); expect(vm.$el.querySelector('svg')).not.toBeNull();
}); });
it('does not render the prompt', () => {
expect(vm.$el.querySelector('.prompt span')).toBeNull();
});
}); });
describe('default to plain text', () => { describe('default to plain text', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import DiscussionFilter from '~/notes/components/discussion_filter.vue'; import DiscussionFilter from '~/notes/components/discussion_filter.vue';
import { DISCUSSION_FILTERS_DEFAULT_VALUE } from '~/notes/constants';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { discussionFiltersMock, discussionMock } from '../mock_data'; import { discussionFiltersMock, discussionMock } from '../mock_data';
...@@ -20,16 +21,14 @@ describe('DiscussionFilter component', () => { ...@@ -20,16 +21,14 @@ describe('DiscussionFilter component', () => {
}, },
]; ];
const Component = Vue.extend(DiscussionFilter); const Component = Vue.extend(DiscussionFilter);
const selectedValue = discussionFiltersMock[0].value; const selectedValue = DISCUSSION_FILTERS_DEFAULT_VALUE;
const props = { filters: discussionFiltersMock, selectedValue };
store.state.discussions = discussions; store.state.discussions = discussions;
return mountComponentWithStore(Component, { return mountComponentWithStore(Component, {
el: null, el: null,
store, store,
props: { props,
filters: discussionFiltersMock,
selectedValue,
},
}); });
}; };
...@@ -115,4 +114,41 @@ describe('DiscussionFilter component', () => { ...@@ -115,4 +114,41 @@ describe('DiscussionFilter component', () => {
}); });
}); });
}); });
describe('URL with Links to notes', () => {
afterEach(() => {
window.location.hash = '';
});
it('updates the filter when the URL links to a note', done => {
window.location.hash = `note_${discussionMock.notes[0].id}`;
vm.currentValue = discussionFiltersMock[2].value;
vm.handleLocationHash();
vm.$nextTick(() => {
expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE);
done();
});
});
it('does not update the filter when the current filter is "Show all activity"', done => {
window.location.hash = `note_${discussionMock.notes[0].id}`;
vm.handleLocationHash();
vm.$nextTick(() => {
expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE);
done();
});
});
it('only updates filter when the URL links to a note', done => {
window.location.hash = `testing123`;
vm.handleLocationHash();
vm.$nextTick(() => {
expect(vm.currentValue).toEqual(DISCUSSION_FILTERS_DEFAULT_VALUE);
done();
});
});
});
}); });
...@@ -20,6 +20,10 @@ describe('OAuthRememberMe', () => { ...@@ -20,6 +20,10 @@ describe('OAuthRememberMe', () => {
expect($('#oauth-container .oauth-login.github').attr('href')).toBe( expect($('#oauth-container .oauth-login.github').attr('href')).toBe(
'http://example.com/?remember_me=1', 'http://example.com/?remember_me=1',
); );
expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe(
'http://example.com/?redirect_fragment=L1&remember_me=1',
);
}); });
it('removes the "remember_me" query parameter from all OAuth login buttons', () => { it('removes the "remember_me" query parameter from all OAuth login buttons', () => {
...@@ -28,5 +32,8 @@ describe('OAuthRememberMe', () => { ...@@ -28,5 +32,8 @@ describe('OAuthRememberMe', () => {
expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/'); expect($('#oauth-container .oauth-login.twitter').attr('href')).toBe('http://example.com/');
expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/'); expect($('#oauth-container .oauth-login.github').attr('href')).toBe('http://example.com/');
expect($('#oauth-container .oauth-login.facebook').attr('href')).toBe(
'http://example.com/?redirect_fragment=L1',
);
}); });
}); });
import $ from 'jquery';
import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment';
describe('preserve_url_fragment', () => {
preloadFixtures('sessions/new.html.raw');
beforeEach(() => {
loadFixtures('sessions/new.html.raw');
});
it('adds the url fragment to all login and sign up form actions', () => {
preserveUrlFragment('#L65');
expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65');
expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65');
});
it('does not add an empty url fragment to login and sign up form actions', () => {
preserveUrlFragment();
expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in');
expect($('#new_new_user').attr('action')).toBe('http://test.host/users');
});
it('does not add an empty query parameter to OmniAuth login buttons', () => {
preserveUrlFragment();
expect($('#oauth-login-cas3').attr('href')).toBe('http://test.host/users/auth/cas3');
expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe(
'http://test.host/users/auth/auth0',
);
});
describe('adds "redirect_fragment" query parameter to OmniAuth login buttons', () => {
it('when "remember_me" is not present', () => {
preserveUrlFragment('#L65');
expect($('#oauth-login-cas3').attr('href')).toBe(
'http://test.host/users/auth/cas3?redirect_fragment=L65',
);
expect($('.omniauth-container #oauth-login-auth0').attr('href')).toBe(
'http://test.host/users/auth/auth0?redirect_fragment=L65',
);
});
it('when "remember-me" is present', () => {
$('a.omniauth-btn').attr('href', (i, href) => `${href}?remember_me=1`);
preserveUrlFragment('#L65');
expect($('#oauth-login-cas3').attr('href')).toBe(
'http://test.host/users/auth/cas3?remember_me=1&redirect_fragment=L65',
);
expect($('#oauth-login-auth0').attr('href')).toBe(
'http://test.host/users/auth/auth0?remember_me=1&redirect_fragment=L65',
);
});
});
});
...@@ -76,4 +76,28 @@ describe('getStateKey', () => { ...@@ -76,4 +76,28 @@ describe('getStateKey', () => {
expect(bound()).toEqual('archived'); expect(bound()).toEqual('archived');
}); });
it('returns rebased state key', () => {
const context = {
mergeStatus: 'checked',
mergeWhenPipelineSucceeds: false,
canMerge: true,
onlyAllowMergeIfPipelineSucceeds: true,
isPipelineFailed: true,
hasMergeableDiscussionsState: false,
isPipelineBlocked: false,
canBeMerged: false,
shouldBeRebased: true,
};
const data = {
project_archived: false,
branch_missing: false,
commits_count: 2,
has_conflicts: false,
work_in_progress: false,
};
const bound = getStateKey.bind(context, data);
expect(bound()).toEqual('rebase');
});
}); });
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::BackgroundMigration::BackfillLegacyProjectRepositories, :migration, schema: 20181218192239 do describe Gitlab::BackgroundMigration::BackfillLegacyProjectRepositories, :migration, schema: 20181212171634 do
it_behaves_like 'backfill migration for project repositories', :legacy it_behaves_like 'backfill migration for project repositories', :legacy
end end
...@@ -149,6 +149,35 @@ describe Clusters::Applications::Knative do ...@@ -149,6 +149,35 @@ describe Clusters::Applications::Knative do
it { is_expected.to validate_presence_of(:hostname) } it { is_expected.to validate_presence_of(:hostname) }
end end
describe '#service_pod_details' do
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
let(:knative) { create(:clusters_applications_knative, cluster: cluster) }
let(:namespace) do
create(:cluster_kubernetes_namespace,
cluster: cluster,
cluster_project: cluster.cluster_project,
project: cluster.cluster_project.project)
end
before do
stub_kubeclient_discover(service.api_url)
stub_kubeclient_knative_services
stub_kubeclient_service_pods
stub_reactive_cache(knative,
{
services: kube_response(kube_knative_services_body),
pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace))
})
synchronous_reactive_cache(knative)
end
it 'should be able k8s core for pod details' do
expect(knative.service_pod_details(namespace.namespace, cluster.cluster_project.project.name)).not_to be_nil
end
end
describe '#services' do describe '#services' do
let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes } let(:service) { cluster.platform_kubernetes }
...@@ -166,6 +195,7 @@ describe Clusters::Applications::Knative do ...@@ -166,6 +195,7 @@ describe Clusters::Applications::Knative do
before do before do
stub_kubeclient_discover(service.api_url) stub_kubeclient_discover(service.api_url)
stub_kubeclient_knative_services stub_kubeclient_knative_services
stub_kubeclient_service_pods
end end
it 'should have an unintialized cache' do it 'should have an unintialized cache' do
...@@ -174,7 +204,11 @@ describe Clusters::Applications::Knative do ...@@ -174,7 +204,11 @@ describe Clusters::Applications::Knative do
context 'when using synchronous reactive cache' do context 'when using synchronous reactive cache' do
before do before do
stub_reactive_cache(knative, services: kube_response(kube_knative_services_body)) stub_reactive_cache(knative,
{
services: kube_response(kube_knative_services_body),
pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace))
})
synchronous_reactive_cache(knative) synchronous_reactive_cache(knative)
end end
......
...@@ -1418,6 +1418,23 @@ describe MergeRequest do ...@@ -1418,6 +1418,23 @@ describe MergeRequest do
.to change { merge_request.reload.head_pipeline } .to change { merge_request.reload.head_pipeline }
.from(nil).to(pipeline) .from(nil).to(pipeline)
end end
context 'when merge request has already had head pipeline' do
before do
merge_request.update!(head_pipeline: pipeline)
end
context 'when failed to find an actual head pipeline' do
before do
allow(merge_request).to receive(:find_actual_head_pipeline) { }
end
it 'does not update the current head pipeline' do
expect { subject }
.not_to change { merge_request.reload.head_pipeline }
end
end
end
end end
context 'when there are no pipelines with the diff head sha' do context 'when there are no pipelines with the diff head sha' do
......
...@@ -240,7 +240,88 @@ describe Milestone do ...@@ -240,7 +240,88 @@ describe Milestone do
end end
end end
describe '.upcoming_ids_by_projects' do describe '#for_projects_and_groups' do
let(:project) { create(:project) }
let(:project_other) { create(:project) }
let(:group) { create(:group) }
let(:group_other) { create(:group) }
before do
create(:milestone, project: project)
create(:milestone, project: project_other)
create(:milestone, group: group)
create(:milestone, group: group_other)
end
subject { described_class.for_projects_and_groups(projects, groups) }
shared_examples 'filters by projects and groups' do
it 'returns milestones filtered by project' do
milestones = described_class.for_projects_and_groups(projects, [])
expect(milestones.count).to eq(1)
expect(milestones.first.project_id).to eq(project.id)
end
it 'returns milestones filtered by group' do
milestones = described_class.for_projects_and_groups([], groups)
expect(milestones.count).to eq(1)
expect(milestones.first.group_id).to eq(group.id)
end
it 'returns milestones filtered by both project and group' do
milestones = described_class.for_projects_and_groups(projects, groups)
expect(milestones.count).to eq(2)
expect(milestones).to contain_exactly(project.milestones.first, group.milestones.first)
end
end
context 'ids as params' do
let(:projects) { [project.id] }
let(:groups) { [group.id] }
it_behaves_like 'filters by projects and groups'
end
context 'relations as params' do
let(:projects) { Project.where(id: project.id) }
let(:groups) { Group.where(id: group.id) }
it_behaves_like 'filters by projects and groups'
end
context 'objects as params' do
let(:projects) { [project] }
let(:groups) { [group] }
it_behaves_like 'filters by projects and groups'
end
it 'returns no records if projects and groups are nil' do
milestones = described_class.for_projects_and_groups(nil, nil)
expect(milestones).to be_empty
end
end
describe '.upcoming_ids' do
let(:group_1) { create(:group) }
let(:group_2) { create(:group) }
let(:group_3) { create(:group) }
let(:groups) { [group_1, group_2, group_3] }
let!(:past_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now - 1.day) }
let!(:current_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now + 1.day) }
let!(:future_milestone_group_1) { create(:milestone, group: group_1, due_date: Time.now + 2.days) }
let!(:past_milestone_group_2) { create(:milestone, group: group_2, due_date: Time.now - 1.day) }
let!(:closed_milestone_group_2) { create(:milestone, :closed, group: group_2, due_date: Time.now + 1.day) }
let!(:current_milestone_group_2) { create(:milestone, group: group_2, due_date: Time.now + 2.days) }
let!(:past_milestone_group_3) { create(:milestone, group: group_3, due_date: Time.now - 1.day) }
let(:project_1) { create(:project) } let(:project_1) { create(:project) }
let(:project_2) { create(:project) } let(:project_2) { create(:project) }
let(:project_3) { create(:project) } let(:project_3) { create(:project) }
...@@ -256,16 +337,20 @@ describe Milestone do ...@@ -256,16 +337,20 @@ describe Milestone do
let!(:past_milestone_project_3) { create(:milestone, project: project_3, due_date: Time.now - 1.day) } let!(:past_milestone_project_3) { create(:milestone, project: project_3, due_date: Time.now - 1.day) }
# The call to `#try` is because this returns a relation with a Postgres DB, let(:milestone_ids) { described_class.upcoming_ids(projects, groups).map(&:id) }
# and an array of IDs with a MySQL DB.
let(:milestone_ids) { described_class.upcoming_ids_by_projects(projects).map { |id| id.try(:id) || id } }
it 'returns the next upcoming open milestone ID for each project' do it 'returns the next upcoming open milestone ID for each project and group' do
expect(milestone_ids).to contain_exactly(current_milestone_project_1.id, current_milestone_project_2.id) expect(milestone_ids).to contain_exactly(
current_milestone_project_1.id,
current_milestone_project_2.id,
current_milestone_group_1.id,
current_milestone_group_2.id
)
end end
context 'when the projects have no open upcoming milestones' do context 'when the projects and groups have no open upcoming milestones' do
let(:projects) { [project_3] } let(:projects) { [project_3] }
let(:groups) { [group_3] }
it 'returns no results' do it 'returns no results' do
expect(milestone_ids).to be_empty expect(milestone_ids).to be_empty
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment