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) {
return `${urlparts[1]}?${query}${urlparts[3]}`;
}
export function removeParamQueryString(url, param) {
const decodedUrl = decodeURIComponent(url);
const urlVariables = decodedUrl.split('&');
return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&');
}
export function removeParams(params, source = window.location.href) {
const url = document.createElement('a');
url.href = source;
/**
* Removes specified query params from the url by returning a new url string that no longer
* includes the param/value pair. If no url is provided, `window.location.href` is used as
* the default value.
*
* @param {string[]} params - the query param names to remove
* @param {string} [url=windowLocation().href] - url from which the query param will be removed
* @returns {string} A copy of the original url but without the query param
*/
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 => {
url.search = removeParamQueryString(url.search, param);
});
const encodedParams = params.map(param => encodeURIComponent(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) {
......@@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) {
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) {
if (external) {
// Simulate `target="blank" rel="noopener noreferrer"`
......
<script>
import CodeCell from './code/index.vue';
import CodeOutput from './code/index.vue';
import OutputCell from './output/index.vue';
export default {
name: 'CodeCell',
components: {
'code-cell': CodeCell,
'output-cell': OutputCell,
CodeOutput,
OutputCell,
},
props: {
cell: {
......@@ -29,8 +30,8 @@ export default {
hasOutput() {
return this.cell.outputs.length;
},
output() {
return this.cell.outputs[0];
outputs() {
return this.cell.outputs;
},
},
};
......@@ -38,7 +39,7 @@ export default {
<template>
<div class="cell">
<code-cell
<code-output
:raw-code="rawInputCode"
:count="cell.execution_count"
:code-css-class="codeCssClass"
......@@ -47,7 +48,7 @@ export default {
<output-cell
v-if="hasOutput"
:count="cell.execution_count"
:output="output"
:outputs="outputs"
:code-css-class="codeCssClass"
/>
</div>
......
......@@ -3,8 +3,9 @@ import Prism from '../../lib/highlight';
import Prompt from '../prompt.vue';
export default {
name: 'CodeOutput',
components: {
prompt: Prompt,
Prompt,
},
props: {
count: {
......
......@@ -4,13 +4,21 @@ import Prompt from '../prompt.vue';
export default {
components: {
prompt: Prompt,
Prompt,
},
props: {
count: {
type: Number,
required: true,
},
rawCode: {
type: String,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
sanitizedOutput() {
......@@ -21,13 +29,16 @@ export default {
},
});
},
showOutput() {
return this.index === 0;
},
},
};
</script>
<template>
<div class="output">
<prompt />
<prompt type="Out" :count="count" :show-output="showOutput" />
<div v-html="sanitizedOutput"></div>
</div>
</template>
......@@ -6,6 +6,10 @@ export default {
prompt: Prompt,
},
props: {
count: {
type: Number,
required: true,
},
outputType: {
type: String,
required: true,
......@@ -14,10 +18,24 @@ export default {
type: String,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
imgSrc() {
return `data:${this.outputType};base64,${this.rawCode}`;
},
showOutput() {
return this.index === 0;
},
},
};
</script>
<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>
<script>
import CodeCell from '../code/index.vue';
import Html from './html.vue';
import Image from './image.vue';
import CodeOutput from '../code/index.vue';
import HtmlOutput from './html.vue';
import ImageOutput from './image.vue';
export default {
components: {
'code-cell': CodeCell,
'html-output': Html,
'image-output': Image,
},
props: {
codeCssClass: {
type: String,
......@@ -20,68 +15,69 @@ export default {
required: false,
default: 0,
},
output: {
type: Object,
outputs: {
type: Array,
required: true,
default: () => ({}),
},
},
computed: {
componentName() {
if (this.output.text) {
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';
}
data() {
return {
outputType: '',
};
},
methods: {
dataForType(output, type) {
let data = output.data[type];
return 'code-cell';
},
rawCode() {
if (this.output.text) {
return this.output.text.join('');
if (typeof data === 'object') {
data = data.join('');
}
return this.dataForType(this.outputType);
return data;
},
outputType() {
if (this.output.text) {
return '';
} else if (this.output.data['image/png']) {
return 'image/png';
} else if (this.output.data['text/html']) {
return 'text/html';
} else if (this.output.data['image/svg+xml']) {
return 'image/svg+xml';
getComponent(output) {
if (output.text) {
return CodeOutput;
} else if (output.data['image/png']) {
this.outputType = 'image/png';
return ImageOutput;
} else if (output.data['text/html']) {
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: {
dataForType(type) {
let data = this.output.data[type];
if (typeof data === 'object') {
data = data.join('');
rawCode(output) {
if (output.text) {
return output.text.join('');
}
return data;
return this.dataForType(output, this.outputType);
},
},
};
</script>
<template>
<component
:is="componentName"
:output-type="outputType"
:count="count"
:raw-code="rawCode"
:code-css-class="codeCssClass"
type="output"
/>
<div>
<component
:is="getComponent(output)"
v-for="(output, index) in outputs"
:key="index"
type="output"
:output-type="outputType"
:count="count"
:index="index"
:raw-code="rawCode(output)"
:code-css-class="codeCssClass"
/>
</div>
</template>
......@@ -11,18 +11,26 @@ export default {
required: false,
default: 0,
},
showOutput: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
hasKeys() {
return this.type !== '' && this.count;
},
showTypeText() {
return this.type && this.count && this.showOutput;
},
},
};
</script>
<template>
<div class="prompt">
<span v-if="hasKeys"> {{ type }} [{{ count }}]: </span>
<span v-if="showTypeText"> {{ type }} [{{ count }}]: </span>
</div>
</template>
......
......@@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells';
export default {
components: {
'code-cell': CodeCell,
'markdown-cell': MarkdownCell,
CodeCell,
MarkdownCell,
},
props: {
notebook: {
......
......@@ -370,7 +370,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
: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"
type="submit"
@click.prevent="handleSave();"
......@@ -381,7 +381,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled"
name="button"
type="button"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static"
data-toggle="dropdown"
aria-label="Open comment type dropdown"
......
<script>
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash } from '../../lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
......@@ -44,29 +45,47 @@ export default {
eventHub.$on('MergeRequestTabChange', this.toggleFilters);
this.toggleFilters(currentTab);
}
window.addEventListener('hashchange', this.handleLocationHash);
this.handleLocationHash();
},
mounted() {
this.toggleCommentsForm();
},
destroyed() {
window.removeEventListener('hashchange', this.handleLocationHash);
},
methods: {
...mapActions(['filterDiscussion', 'setCommentsDisabled']),
...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']),
selectFilter(value) {
const filter = parseInt(value, 10);
// close dropdown
$(this.$refs.dropdownToggle).dropdown('toggle');
this.toggleDropdown();
if (filter === this.currentValue) return;
this.currentValue = filter;
this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter });
this.toggleCommentsForm();
},
toggleDropdown() {
$(this.$refs.dropdownToggle).dropdown('toggle');
},
toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
},
toggleFilters(tab) {
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>
......
......@@ -2,6 +2,7 @@ import $ from 'jquery';
import UsernameValidator from './username_validator';
import SigninTabsMemoizer from './signin_tabs_memoizer';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
document.addEventListener('DOMContentLoaded', () => {
new UsernameValidator(); // eslint-disable-line no-new
......@@ -10,4 +11,8 @@ document.addEventListener('DOMContentLoaded', () => {
new OAuthRememberMe({
container: $('.omniauth-container'),
}).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 { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
/**
* OAuth-based login buttons have a separate "remember me" checkbox.
......@@ -24,9 +25,9 @@ export default class OAuthRememberMe {
const href = $(element).attr('href');
if (rememberMe) {
$(element).attr('href', `${href}?remember_me=1`);
$(element).attr('href', mergeUrlParams({ remember_me: 1 }, href));
} 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 {
name() {
return this.func.name;
},
url() {
return this.func.url;
description() {
return this.func.description;
},
detailUrl() {
return this.func.detail_url;
},
environment() {
return this.func.environment_scope;
},
image() {
return this.func.image;
......@@ -30,11 +36,20 @@ export default {
<template>
<div class="gl-responsive-table-row">
<div class="table-section section-20">{{ name }}</div>
<div class="table-section section-50">
<a :href="url">{{ url }}</a>
<div class="table-section section-20 section-wrap">
<a :href="detailUrl">{{ name }}</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 class="table-section section-20">{{ image }}</div>
<div class="table-section section-10"><timeago :time="timestamp" /></div>
</div>
</template>
<style>
.line-break {
white-space: pre;
}
</style>
......@@ -50,8 +50,11 @@ export default {
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Function') }}
</div>
<div class="table-section section-50" role="rowheader">
{{ s__('Serverless|Domain') }}
<div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Cluster Env') }}
</div>
<div class="table-section section-40" role="rowheader">
{{ s__('Serverless|Description') }}
</div>
<div class="table-section section-20" role="rowheader">
{{ 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,23 +4,65 @@ import { s__ } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import ServerlessStore from './stores/serverless_store';
import ServerlessDetailsStore from './stores/serverless_details_store';
import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue';
export default class Serverless {
constructor() {
const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
'.js-serverless-functions-page',
).dataset;
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;
this.service = new GetFunctionsService(statusPath);
this.knativeInstalled = installed !== undefined;
this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
this.initServerless();
this.functionLoadCount = 0;
const service = {
name: serviceName,
description: serviceDescription,
environment: serviceEnvironment,
url: serviceUrl,
namespace: serviceNamespace,
podcount: servicePodcount,
};
if (statusPath && this.knativeInstalled) {
this.initPolling();
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(
'.js-serverless-functions-page',
).dataset;
this.service = new GetFunctionsService(statusPath);
this.knativeInstalled = installed !== undefined;
this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
this.initServerless();
this.functionLoadCount = 0;
if (statusPath && this.knativeInstalled) {
this.initPolling();
}
}
}
......@@ -55,7 +97,7 @@ export default class Serverless {
resource: this.service,
method: 'fetchData',
successCallback: data => this.handleSuccess(data),
errorCallback: () => this.handleError(),
errorCallback: () => Serverless.handleError(),
});
if (!Visibility.hidden()) {
......@@ -64,7 +106,7 @@ export default class Serverless {
this.service
.fetchData()
.then(data => this.handleSuccess(data))
.catch(() => this.handleError());
.catch(() => Serverless.handleError());
}
Visibility.change(() => {
......@@ -102,5 +144,6 @@ export default class Serverless {
}
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) {
return stateKey.conflicts;
} else if (data.work_in_progress) {
return stateKey.workInProgress;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
} else if (this.hasMergeableDiscussionsState) {
......@@ -25,8 +27,6 @@ export default function deviseState(data) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
} else if (!this.canMerge) {
return stateKey.notAllowedToMerge;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
}
......
/**
* Note Form
*/
.comment-btn {
@extend .btn-success;
}
.diff-file .diff-content {
tr.line_holder:hover > td .line_note_link {
opacity: 1;
......@@ -386,7 +382,7 @@ table {
}
.comment-type-dropdown {
.comment-btn {
.btn-success {
width: auto;
}
......@@ -417,7 +413,7 @@ table {
width: 100%;
margin-bottom: 10px;
.comment-btn {
.btn-success {
flex-grow: 1;
flex-shrink: 0;
width: auto;
......
......@@ -219,7 +219,7 @@
color: $gl-text-color-secondary;
}
.project-tag-list {
.project-topic-list {
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
......@@ -251,7 +251,7 @@
line-height: $gl-font-size-large;
}
.project-tag-list,
.project-topic-list,
.project-metadata {
font-size: $gl-font-size-small;
}
......@@ -273,7 +273,7 @@
}
.access-request-link,
.project-tag-list {
.project-topic-list {
padding-left: $gl-padding-8;
border-left: 1px solid $gl-text-color-secondary;
}
......
......@@ -75,6 +75,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
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
log_audit_event(current_user, with: oauth['provider'])
......@@ -189,4 +193,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
request_params = request.env['omniauth.params']
(request_params['remember_me'] == '1') if request_params.present?
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
......@@ -86,7 +86,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
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
def build_from_ref
......
......@@ -45,7 +45,7 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
end
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
def job_from_ref
......
......@@ -4,16 +4,7 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_read_release!
before_action :check_releases_page_feature_flag
def index
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
......@@ -7,19 +7,17 @@ module Projects
before_action :authorize_read_cluster!
INDEX_PRIMING_INTERVAL = 10_000
INDEX_POLLING_INTERVAL = 30_000
INDEX_PRIMING_INTERVAL = 15_000
INDEX_POLLING_INTERVAL = 60_000
def index
finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
respond_to do |format|
format.json do
functions = finder.execute
if functions.any?
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
Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content
......@@ -32,6 +30,29 @@ module Projects
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
......@@ -149,6 +149,18 @@ class IssuableFinder
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?
params[:project_id].present?
end
......@@ -163,8 +175,10 @@ class IssuableFinder
end
# rubocop: disable CodeReuse/ActiveRecord
def projects(items = nil)
return @projects = project if project?
def projects
return @projects if defined?(@projects)
return @projects = [project] if project?
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
......@@ -459,7 +473,7 @@ class IssuableFinder
elsif filter_by_any_milestone?
items = items.any_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)
elsif filter_by_started_milestone?
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
......
......@@ -15,11 +15,40 @@ module Projects
clusters_with_knative_installed.exists?
end
def service(environment_scope, name)
knative_service(environment_scope, name)&.first
end
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
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
......
......@@ -41,6 +41,8 @@ module Clusters
scope :for_cluster, -> (cluster) { where(cluster: cluster) }
after_save :clear_reactive_cache!
def chart
'knative/knative'
end
......@@ -79,7 +81,7 @@ module Clusters
end
def calculate_reactive_cache
{ services: read_services }
{ services: read_services, pods: read_pods }
end
def ingress_service
......@@ -87,7 +89,7 @@ module Clusters
end
def services_for(ns: namespace)
return unless services
return [] unless services
return [] unless ns
services.select do |service|
......@@ -95,8 +97,22 @@ module Clusters
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
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
client.get_services.as_json
rescue Kubeclient::ResourceNotFoundError
......
......@@ -1108,9 +1108,10 @@ class MergeRequest < ActiveRecord::Base
end
def update_head_pipeline
self.head_pipeline = find_actual_head_pipeline
update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed?
find_actual_head_pipeline.try do |pipeline|
self.head_pipeline = pipeline
update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed?
end
end
def merge_request_pipeline_exists?
......
......@@ -38,12 +38,14 @@ class Milestone < ActiveRecord::Base
scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) }
scope :for_projects_and_groups, -> (project_ids, group_ids) do
conditions = []
conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any?
conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any?
scope :for_projects_and_groups, -> (projects, groups) do
projects = projects.compact if projects.is_a? Array
projects = [] if projects.nil?
where(conditions.reduce(:or))
groups = groups.compact if groups.is_a? Array
groups = [] if groups.nil?
where(project: projects).or(where(group: groups))
end
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
......@@ -133,18 +135,29 @@ class Milestone < ActiveRecord::Base
@link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
end
def self.upcoming_ids_by_projects(projects)
rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)
def self.upcoming_ids(projects, groups)
rel = unscoped
.for_projects_and_groups(projects, groups)
.active.where('milestones.due_date > NOW()')
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
# 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
.group(:project_id, :due_date, :id)
.having('due_date = MIN(due_date)')
.pluck(:id, :project_id, :due_date)
.uniq(&:second)
.map(&:first)
.joins(join_clause)
.where('earlier_milestones.id IS NULL')
.select(:id)
end
end
......
......@@ -85,7 +85,11 @@ class PoolRepository < ActiveRecord::Base
def unlink_repository(repository)
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
def object_pool
......
......@@ -73,7 +73,7 @@ class Project < ActiveRecord::Base
delegate :no_import?, to: :import_state, allow_nil: true
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 :container_registry_enabled, gitlab_config_features.container_registry
default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage }
......@@ -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}"))
end
def get_build(id)
builds.find_by(id: id)
end
def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id)
commit_by(oid: sha) if sha
......@@ -2040,7 +2036,7 @@ class Project < ActiveRecord::Base
end
def leave_pool_repository
pool_repository&.unlink_repository(repository)
pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil)
end
private
......
......@@ -39,9 +39,7 @@ class TeamcityService < CiService
end
def help
'The build configuration in Teamcity must use the build format '\
'number %build.vcs.number% '\
'you will also want to configure monitoring of all branches so merge '\
'You will want to configure monitoring of all branches so merge '\
'requests build, that setting is in the vsc root advanced settings.'
end
......@@ -70,7 +68,7 @@ class TeamcityService < CiService
end
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) }
end
......
......@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
presents :project
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')
sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4')
......@@ -310,20 +310,20 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
end
def tags_to_show
project.tag_list.take(MAX_TAGS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
def topics_to_show
project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
end
def count_of_extra_tags_not_shown
if project.tag_list.count > MAX_TAGS_TO_SHOW
project.tag_list.count - MAX_TAGS_TO_SHOW
def count_of_extra_topics_not_shown
if project.tag_list.count > MAX_TOPICS_TO_SHOW
project.tag_list.count - MAX_TOPICS_TO_SHOW
else
0
end
end
def has_extra_tags?
count_of_extra_tags_not_shown > 0
def has_extra_topics?
count_of_extra_topics_not_shown > 0
end
private
......
......@@ -13,6 +13,25 @@ module Projects
service.dig('metadata', 'namespace')
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|
service.dig('metadata', 'creationTimestamp')
end
......@@ -22,11 +41,24 @@ module Projects
end
expose :description do |service|
service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description')
service.dig(
'spec',
'runLatest',
'configuration',
'revisionTemplate',
'metadata',
'annotations',
'Description')
end
expose :image do |service|
service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name')
service.dig(
'spec',
'runLatest',
'configuration',
'build',
'template',
'name')
end
end
end
......
- page_title "Sign in"
%div
#signin-container
- if form_based_providers.any?
= render 'devise/shared/tabs_ldap'
- else
......
......@@ -29,7 +29,7 @@
= link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
%span= _('Activity')
- if project_nav_tab?(:releases) && Feature.enabled?(:releases_page, @project)
- if project_nav_tab?(:releases)
= nav_link(controller: :releases) do
= link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
%span= _('Releases')
......
......@@ -19,12 +19,13 @@
%span.access-request-links.prepend-left-8
= render 'shared/members/access_request_links', source: @project
- 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')
= @project.tags_to_show
- if @project.has_extra_tags?
= @project.topics_to_show
- if @project.has_extra_topics?
= _("+ %{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
- if current_user
.d-inline-flex
......
......@@ -39,9 +39,9 @@
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
.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"
%p.form-text.text-muted Separate tags with commas.
%p.form-text.text-muted Separate topics with commas.
%fieldset.features
%h5.prepend-top-0= _("Project avatar")
.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
.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?
= 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')
%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
end
namespace :serverless do
get '/functions/:environment_id/:id', to: 'functions#show'
resources :functions, only: [:index]
end
......
......@@ -14,6 +14,9 @@ class EmojiChecker
DIGESTS = File.expand_path('../../fixtures/emojis/digests.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
# 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
......@@ -101,10 +104,7 @@ def lint_commits(commits)
elsif subject.length > 50
warn_commit(
commit,
"This commit's subject line could be improved. " \
'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.'
"This commit's subject line is acceptable, but please try to [reduce it to 50 characters](#{URL_LIMIT_SUBJECT})."
)
end
......@@ -196,7 +196,7 @@ def lint_commits(commits)
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
[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:
......
# 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:
- [Sidekiq metrics](sidekiq_metrics.md)
- [System hooks](system_hooks.md)
- [Tags](tags.md)
- [Releases](releases/index.md)
- Release Assets
- [Links](releases/links.md)
- [Todos](todos.md)
- [Users](users.md)
- [Validate CI configuration](lint.md) (linting)
......
# 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.
```
```text
POST /applications
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | yes | The name of the application |
| `redirect_uri` | string | yes | The redirect URI of the application |
| `scopes` | string | yes | The scopes of the application |
Parameters:
| Attribute | Type | Required | Description |
|:---------------|:-------|:---------|:---------------------------------|
| `name` | string | yes | Name of the application. |
| `redirect_uri` | string | yes | Redirect URI 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
```
......@@ -42,11 +50,13 @@ Example response:
List all registered applications.
```
```text
GET /applications
```
```bash
Example request:
```sh
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/applications
```
......@@ -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
......@@ -71,7 +82,7 @@ Delete a specific application.
Returns `204` if the request succeeds.
```
```text
DELETE /applications/:id
```
......@@ -79,6 +90,8 @@ Parameters:
- `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
```
# Releases 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) 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
......@@ -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). |
| `name` | string | yes | The release name. |
| `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. |
| `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. |
......@@ -331,8 +332,8 @@ PUT /projects/:id/releases/:tag_name
| 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 where the release will be created from. |
| `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). |
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.
|---------------|----------|-------------|
| [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 |
| [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) |
| [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`) |
......
......@@ -3,8 +3,11 @@
This document is about using GitLab as an OAuth authentication service provider
to sign in to other services.
If you want to use other OAuth authentication service providers to sign in to
GitLab, please see the [OAuth2 client documentation](../api/oauth2.md).
If you want to use:
- 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
......@@ -28,7 +31,7 @@ GitLab supports two ways of adding a new OAuth2 application to an instance. You
can either add an application as a regular user or add it in the admin area.
What this means is that GitLab can actually have instance-wide and a user-wide
applications. There is no difference between them except for the different
permission levels they are set (user/admin). The default callback URL is
permission levels they are set (user/admin). The default callback URL is
`http://your-gitlab.example.com/users/auth/gitlab/callback`
## Adding an application through the profile
......
......@@ -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.
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
service: my-functions
......@@ -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`
......@@ -167,8 +167,8 @@ appear under **Operations > Serverless**.
![serverless page](img/serverless-page.png)
This page contains all functions available for the project, the URL for
accessing the function, and if available, the function's runtime information.
This page contains all functions available for the project, the description for
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
Kubernetes cluster.
......@@ -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.
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
> Introduced in GitLab 11.5.
......
......@@ -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
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.
## Getting started with Releases
......@@ -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
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
Navigate to **Project > Releases** in order to see the list of releases for a given
......
......@@ -14,7 +14,7 @@ functionality of a project.
### 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)
......
......@@ -235,8 +235,8 @@ module API
forbidden! unless current_user.admin?
end
def authorize!(action, subject = :global)
forbidden! unless can?(current_user, action, subject)
def authorize!(action, subject = :global, reason = nil)
forbidden!(reason) unless can?(current_user, action, subject)
end
def authorize_push_project
......
......@@ -63,7 +63,7 @@ module API
use :create_params_ee
end
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
.new(current_user, create_cluster_user_params)
......
......@@ -8,8 +8,6 @@ module API
RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
.merge(tag_name: API::NO_SLASH_URL_PART_REGEX)
before { error!('404 Not Found', 404) unless Feature.enabled?(:releases_page, user_project) }
params do
requires :id, type: String, desc: 'The ID of a project'
end
......
......@@ -7,7 +7,6 @@ module API
RELEASE_ENDPOINT_REQUIREMETS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
.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! }
params do
......
......@@ -6060,13 +6060,31 @@ msgstr ""
msgid "Serverless"
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."
msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Domain"
msgid "Serverless|Cluster Env"
msgstr ""
msgid "Serverless|Description"
msgstr ""
msgid "Serverless|Function"
......
......@@ -126,10 +126,6 @@ module QA
mod
end
def self.attributes_names
dynamic_attributes.instance_methods(false).sort.grep_v(/=$/)
end
class DSL
def initialize(base)
@base = base
......
......@@ -6,9 +6,12 @@ module QA
module Resource
class User < Base
attr_reader :unique_id
attr_writer :username, :password, :name, :email
attr_writer :username, :password
attr_accessor :provider, :extern_uid
attribute :name
attribute :email
def initialize
@unique_id = SecureRandom.hex(8)
end
......@@ -22,11 +25,11 @@ module QA
end
def name
@name ||= username
@name ||= api_resource&.dig(:name) || username
end
def email
@email ||= "#{username}@example.com"
@email ||= api_resource&.dig(:email) || "#{username}@example.com"
end
def credentials_given?
......
......@@ -39,11 +39,15 @@ module QA
end
it 'user views raw email patch' do
user = Resource::User.fabricate_via_api! do |user|
user.username = Runtime::User.username
end
view_commit
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('diff --git a/second b/second')
end
......
......@@ -138,10 +138,6 @@ describe QA::Resource::Base do
describe '.attribute' do
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
it 'returns value from the block' do
result = subject.fabricate!(resource: resource)
......
......@@ -45,6 +45,40 @@ describe OmniauthCallbacksController, type: :controller do
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 'github' do
let(:extern_uid) { 'my-uid' }
......
......@@ -6,10 +6,6 @@ describe Projects::ReleasesController do
let!(:project) { create(:project, :repository, :public) }
let!(:user) { create(:user) }
before do
stub_feature_flags(releases_page: true)
end
describe 'GET #index' do
it 'renders a 200' do
get_index
......@@ -43,18 +39,6 @@ describe Projects::ReleasesController do
expect(response.status).to eq(404)
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
private
......
......@@ -45,9 +45,45 @@ describe Projects::Serverless::FunctionsController do
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
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
it 'has data' do
......
......@@ -9,13 +9,13 @@ describe 'Projects > Settings > User tags a project' do
visit edit_project_path(project)
end
it 'sets project tags' do
fill_in 'Tags', with: 'tag1, tag2'
it 'sets project topics' do
fill_in 'Topics', with: 'topic1, topic2'
page.within '.general-settings' do
click_button 'Save changes'
end
expect(find_field('Tags').value).to eq 'tag1, tag2'
expect(find_field('Topics').value).to eq 'topic1, topic2'
end
end
......@@ -174,9 +174,13 @@ describe IssuesFinder do
context 'filtering by upcoming milestone' do
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_next_1_1) { 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(:tomorrow) { Date.today + 1.day }
......@@ -187,21 +191,22 @@ describe IssuesFinder do
[
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: '8.8', 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: '8.8', due_date: tomorrow)
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.2', due_date: yesterday),
create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow),
create(:milestone, group: group, title: '9.9', due_date: tomorrow)
]
end
before do
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
it 'returns issues in the upcoming milestone for each project' do
expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8')
expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now)
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', '9.9')
expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow)
end
end
......
......@@ -29,15 +29,34 @@ describe Projects::Serverless::FunctionsFinder do
context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:finder) { described_class.new(project.clusters) }
it 'there are no functions' do
expect(described_class.new(project.clusters).execute).to be_empty
expect(finder.execute).to be_empty
end
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(described_class.new(project.clusters).execute).not_to be_empty
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"]
})
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
......
......@@ -3,3 +3,4 @@
%a.oauth-login.twitter{ 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('webIDEUrl', () => {
......@@ -8,7 +8,7 @@ describe('URL utility', () => {
describe('without relative_url_root', () => {
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',
);
});
......@@ -20,7 +20,7 @@ describe('URL utility', () => {
});
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',
);
});
......@@ -29,23 +29,82 @@ describe('URL utility', () => {
describe('mergeUrlParams', () => {
it('adds w', () => {
expect(mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
expect(mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
expect(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(mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe('https://h/p?k1=v1&w=1#frag');
expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag');
expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag');
expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1');
expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe(
'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', () => {
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', () => {
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', () => {
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', () => {
return new Component({
propsData: {
rawCode,
count: 0,
index: 0,
},
}).$mount();
}
......
......@@ -10,7 +10,7 @@ describe('Output component', () => {
const createComponent = output => {
vm = new Component({
propsData: {
output,
outputs: [].concat(output),
count: 1,
},
});
......@@ -51,28 +51,21 @@ describe('Output component', () => {
it('renders as an image', () => {
expect(vm.$el.querySelector('img')).not.toBeNull();
});
it('does not render the prompt', () => {
expect(vm.$el.querySelector('.prompt span')).toBeNull();
});
});
describe('html output', () => {
beforeEach(done => {
it('renders raw HTML', () => {
createComponent(json.cells[4].outputs[0]);
setTimeout(() => {
done();
});
});
it('renders raw HTML', () => {
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', () => {
expect(vm.$el.querySelector('.prompt span')).toBeNull();
it('renders multiple raw HTML outputs', () => {
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', () => {
it('renders as an svg', () => {
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', () => {
......
import Vue from 'vue';
import createStore from '~/notes/stores';
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 { discussionFiltersMock, discussionMock } from '../mock_data';
......@@ -20,16 +21,14 @@ describe('DiscussionFilter component', () => {
},
];
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;
return mountComponentWithStore(Component, {
el: null,
store,
props: {
filters: discussionFiltersMock,
selectedValue,
},
props,
});
};
......@@ -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', () => {
expect($('#oauth-container .oauth-login.github').attr('href')).toBe(
'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', () => {
......@@ -28,5 +32,8 @@ describe('OAuthRememberMe', () => {
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.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', () => {
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 @@
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
end
......@@ -149,6 +149,35 @@ describe Clusters::Applications::Knative do
it { is_expected.to validate_presence_of(:hostname) }
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
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
......@@ -166,6 +195,7 @@ describe Clusters::Applications::Knative do
before do
stub_kubeclient_discover(service.api_url)
stub_kubeclient_knative_services
stub_kubeclient_service_pods
end
it 'should have an unintialized cache' do
......@@ -174,7 +204,11 @@ describe Clusters::Applications::Knative do
context 'when using synchronous reactive cache' 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)
end
......
......@@ -1418,6 +1418,23 @@ describe MergeRequest do
.to change { merge_request.reload.head_pipeline }
.from(nil).to(pipeline)
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
context 'when there are no pipelines with the diff head sha' do
......
......@@ -240,7 +240,88 @@ describe Milestone do
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_2) { create(:project) }
let(:project_3) { create(:project) }
......@@ -256,16 +337,20 @@ describe Milestone do
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,
# 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 } }
let(:milestone_ids) { described_class.upcoming_ids(projects, groups).map(&:id) }
it 'returns the next upcoming open milestone ID for each project' do
expect(milestone_ids).to contain_exactly(current_milestone_project_1.id, current_milestone_project_2.id)
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,
current_milestone_group_1.id,
current_milestone_group_2.id
)
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(:groups) { [group_3] }
it 'returns no results' do
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