Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-05-15 12:07:19 +00:00
parent 45a8c43afe
commit e6fed37d94
51 changed files with 763 additions and 381 deletions

View File

@ -1 +1 @@
14.19.0
14.20.0

View File

@ -0,0 +1,63 @@
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
import ServiceTable from './service_table.vue';
export default {
components: {
IncubationBanner,
GoogleCloudMenu,
ServiceTable,
},
props: {
configurationUrl: {
type: String,
required: true,
},
deploymentsUrl: {
type: String,
required: true,
},
databasesUrl: {
type: String,
required: true,
},
aimlUrl: {
type: String,
required: true,
},
visionAiUrl: {
type: String,
required: true,
},
translationAiUrl: {
type: String,
required: true,
},
languageAiUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<div>
<incubation-banner />
<google-cloud-menu
active="aiml"
:configuration-url="configurationUrl"
:deployments-url="deploymentsUrl"
:databases-url="databasesUrl"
:aiml-url="aimlUrl"
/>
<service-table
:language-ai-url="languageAiUrl"
:translation-ai-url="translationAiUrl"
:vision-ai-url="visionAiUrl"
/>
</div>
</template>

View File

@ -4,11 +4,13 @@ import { s__ } from '~/locale';
const CONFIGURATION_KEY = 'configuration';
const DEPLOYMENTS_KEY = 'deployments';
const DATABASES_KEY = 'databases';
const AIML_KEY = 'aiml';
const i18n = {
configuration: { title: s__('CloudSeed|Configuration') },
deployments: { title: s__('CloudSeed|Deployments') },
databases: { title: s__('CloudSeed|Databases') },
aiml: { title: s__('CloudSeed|AI / ML') },
};
export default {
@ -29,6 +31,11 @@ export default {
type: String,
required: true,
},
aimlUrl: {
type: String,
required: false,
default: '',
},
},
computed: {
isConfigurationActive() {
@ -40,6 +47,9 @@ export default {
isDatabasesActive() {
return this.active === DATABASES_KEY;
},
isAimlActive() {
return this.active === AIML_KEY;
},
},
i18n,
};
@ -80,6 +90,17 @@ export default {
{{ $options.i18n.databases.title }}
</a>
</li>
<li role="presentation" class="nav-item">
<a
data-testid="aimlLink"
role="tab"
:href="aimlUrl"
class="nav-link gl-tab-nav-item hidden"
:class="{ 'gl-tab-nav-item-active': isAimlActive }"
>
{{ $options.i18n.aiml.title }}
</a>
</li>
</ul>
</div>
</template>

View File

@ -1,20 +1,7 @@
import Vue from 'vue';
import DiffStatsDropdown from '~/vue_shared/components/diff_stats_dropdown.vue';
import { stickyMonitor } from './lib/utils/sticky';
export const initDiffStatsDropdown = (stickyTop) => {
if (stickyTop) {
// We spend quite a bit of effort in our CSS to set the correct padding-top on the
// layout page, so we re-use the padding set there to determine at what height our
// element should be sticky
const pageLayout = document.querySelector('.layout-page');
const pageLayoutTopOffset = pageLayout
? parseFloat(window.getComputedStyle(pageLayout).getPropertyValue('padding-top') || 0)
: 0;
stickyMonitor(document.querySelector('.js-diff-files-changed'), pageLayoutTopOffset, false);
}
export const initDiffStatsDropdown = () => {
const el = document.querySelector('.js-diff-stats-dropdown');
if (!el) {

View File

@ -1,60 +0,0 @@
export const createPlaceholder = () => {
const placeholder = document.createElement('div');
placeholder.classList.add('sticky-placeholder');
return placeholder;
};
export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => {
const top = Math.floor(el.offsetTop - scrollY);
if (top <= stickyTop && !el.classList.contains('is-stuck')) {
const placeholder = insertPlaceholder ? createPlaceholder() : null;
const heightBefore = el.offsetHeight;
el.classList.add('is-stuck');
if (insertPlaceholder) {
el.parentNode.insertBefore(placeholder, el.nextElementSibling);
placeholder.style.height = `${heightBefore - el.offsetHeight}px`;
}
} else if (top > stickyTop && el.classList.contains('is-stuck')) {
el.classList.remove('is-stuck');
if (
insertPlaceholder &&
el.nextElementSibling &&
el.nextElementSibling.classList.contains('sticky-placeholder')
) {
el.nextElementSibling.remove();
}
}
};
/**
* Create a listener that will toggle a 'is-stuck' class, based on the current scroll position.
*
* - If the current environment does not support `position: sticky`, do nothing.
*
* @param {HTMLElement} el The `position: sticky` element.
* @param {Number} stickyTop Used to determine when an element is stuck.
* @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck?
*/
export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => {
if (!el) return;
if (
typeof CSS === 'undefined' ||
!CSS.supports('(position: -webkit-sticky) or (position: sticky)')
)
return;
document.addEventListener(
'scroll',
() => isSticky(el, window.scrollY, stickyTop, insertPlaceholder),
{
passive: true,
},
);
};

View File

@ -83,18 +83,6 @@ function scrollToContainer(container) {
}
}
function computeTopOffset(tabs) {
const navbar = document.querySelector('.navbar-gitlab');
const peek = document.getElementById('js-peek');
let stickyTop;
stickyTop = navbar ? navbar.offsetHeight : 0;
stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop;
stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop;
return stickyTop;
}
function mountPipelines() {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
const { mrWidgetData } = gl;
@ -145,11 +133,11 @@ function destroyPipelines(app) {
return null;
}
function loadDiffs({ url, sticky, tabs }) {
function loadDiffs({ url, tabs }) {
return axios.get(url).then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
initDiffStatsDropdown(sticky);
initDiffStatsDropdown();
localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
@ -537,7 +525,6 @@ export default class MergeRequestTabs {
loadDiffs({
url: diffUrl,
sticky: computeTopOffset(this.mergeRequestTabs),
tabs: this,
})
.then(() => {

View File

@ -18,7 +18,7 @@ import '~/sourcegraph/load';
import DiffStats from '~/diffs/components/diff_stats.vue';
import { initReportAbuse } from '~/projects/report_abuse';
initDiffStatsDropdown(true);
initDiffStatsDropdown();
new ZenMode();
new ShortcutsNavigation();

View File

@ -7,7 +7,7 @@ import syntaxHighlight from '~/syntax_highlight';
initCompareSelector();
new Diff(); // eslint-disable-line no-new
initDiffStatsDropdown(true);
initDiffStatsDropdown();
GpgBadges.fetch();
syntaxHighlight([document.querySelector('.files')]);

View File

@ -1,3 +1,7 @@
import syntaxHighlight from '~/syntax_highlight';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import Diff from '~/diff';
new Diff(); // eslint-disable-line no-new
initDiffStatsDropdown();
syntaxHighlight([document.querySelector('.files')]);

View File

@ -96,7 +96,7 @@ export default {
:is="component"
v-for="{ key, component } in $options.tabs"
:key="key"
class="container-fluid container-limited"
class="container-fluid container-limited gl-text-left"
:personal-projects="personalProjects"
:personal-projects-loading="personalProjectsLoading"
/>

View File

@ -147,13 +147,5 @@ export default {
</template>
</gl-sprintf>
</span>
<div
class="diff-stats-additions-deletions-collapsed gl-float-right gl-display-none"
data-testid="diff-stats-additions-deletions-collapsed"
>
<span class="gl-text-green-600 gl-font-weight-bold">+{{ added }}</span>
<span class="gl-text-red-500 gl-font-weight-bold">-{{ deleted }}</span>
</div>
</div>
</template>

View File

@ -21,7 +21,10 @@ export default {
* openIssuesCount: number;
* permissions: {
* projectAccess: { accessLevel: 50 };
* }[];
* };
* descriptionHtml: string;
* updatedAt: string;
* }[]
*/
projects: {
type: Array,

View File

@ -17,6 +17,8 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { __ } from '~/locale';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { truncate } from '~/lib/utils/text_utility';
import SafeHtml from '~/vue_shared/directives/safe_html';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
const MAX_TOPICS_TO_SHOW = 3;
const MAX_TOPIC_TITLE_LENGTH = 15;
@ -30,6 +32,10 @@ export default {
topics: __('Topics'),
topicsPopoverTargetText: __('+ %{count} more'),
moreTopics: __('More topics'),
updated: __('Updated'),
},
safeHtmlConfig: {
ADD_TAGS: ['gl-emoji'],
},
components: {
GlAvatarLabeled,
@ -39,9 +45,11 @@ export default {
GlBadge,
GlPopover,
GlSprintf,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
props: {
/**
@ -62,6 +70,9 @@ export default {
* permissions: {
* projectAccess: { accessLevel: 50 };
* };
* descriptionHtml: string;
* updatedAt: string;
* }
*/
project: {
type: Object,
@ -138,7 +149,7 @@ export default {
</script>
<template>
<li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
<li class="projects-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
<gl-avatar-labeled
class="gl-flex-grow-1"
:entity-id="project.id"
@ -158,6 +169,12 @@ export default {
accessLevelLabel
}}</user-access-role-badge>
</template>
<div
v-if="project.descriptionHtml"
v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
class="gl-font-sm gl-overflow-hidden gl-line-height-20 description"
data-testid="project-description"
></div>
<div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
<div
class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
@ -197,7 +214,7 @@ export default {
</div>
</gl-avatar-labeled>
<div
class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0"
class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-pl-10 gl-md-pl-0 gl-md-mt-0"
>
<div class="gl-display-flex gl-align-items-center gl-gap-x-3">
<gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
@ -231,6 +248,10 @@ export default {
<span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
</gl-link>
</div>
<div class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3">
<span>{{ $options.i18n.updated }}</span>
<time-ago-tooltip :time="project.updatedAt" />
</div>
</div>
</li>
</template>

View File

@ -51,12 +51,11 @@
pointer-events: none;
}
&.is-commit {
top: calc(#{$calc-application-header-height} + #{$commit-stat-summary-height});
}
&.is-compare {
top: calc(#{$calc-application-header-height} + #{$compare-branches-sticky-header-height});
&.is-commit,
&.is-compare,
&.is-wiki {
top: calc(#{$calc-application-header-height});
border-top: 0;
}
}
@ -682,40 +681,6 @@ table.code {
}
}
.diff-files-changed {
background-color: $body-bg;
.inline-parallel-buttons {
@include gl-relative;
z-index: 1;
}
@include media-breakpoint-up(sm) {
@include gl-sticky;
top: $calc-application-header-height;
z-index: 200;
&.is-stuck {
@include gl-py-0;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
.diff-stats-additions-deletions-expanded,
.inline-parallel-buttons {
@include gl-display-none;
}
}
}
@include media-breakpoint-up(lg) {
&.is-stuck {
.diff-stats-additions-deletions-collapsed {
@include gl-display-block;
}
}
}
}
.note-container {
background-color: $gray-light;
border-top: 1px solid $white-normal;

View File

@ -739,7 +739,6 @@ $calendar-activity-colors: (
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
$commit-stat-summary-height: 32px;
/*
* Files
@ -915,11 +914,6 @@ Merge requests
*/
$mr-tabs-height: 48px;
/*
Compare Branches
*/
$compare-branches-sticky-header-height: 32px;
/*
Board Swimlanes
*/

View File

@ -654,3 +654,17 @@
}
}
}
.projects-list-item {
.description {
max-height: $gl-spacing-scale-8;
p {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
}
}
}

View File

@ -1,9 +1,3 @@
// Disable sticky changes bar for tests
.diff-files-changed {
position: relative !important;
top: 0 !important;
}
// Un-hide inputs for @gitlab/ui custom checkboxes and radios so Capybara can target them
.custom-control-input {
z-index: 500;

View File

@ -109,7 +109,7 @@ class GraphqlController < ApplicationController
private
def permitted_params
def permitted_multiplex_params
params.permit(_json: [:query, :operationName, { variables: {} }])
end
@ -190,7 +190,7 @@ class GraphqlController < ApplicationController
end
def multiplex_param
permitted_params[:_json]
permitted_multiplex_params[:_json]
end
def multiplex_queries
@ -221,8 +221,10 @@ class GraphqlController < ApplicationController
Gitlab::Graphql::Variables.new(variable_info).to_h
end
# We support Apollo-style query batching where an array of queries will be in the `_json:` key.
# https://graphql-ruby.org/queries/multiplex.html#apollo-query-batching
def multiplex?
multiplex_param.present?
params[:_json].is_a?(Array)
end
def authorize_access_api!

View File

@ -146,8 +146,7 @@
.settings-content
= render 'users_api_limits'
- if Feature.enabled?(:rate_limit_for_unauthenticated_projects_api_access)
= render 'projects_api_limits'
= render 'projects_api_limits'
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header

View File

@ -8,16 +8,14 @@
- page = local_assigns.fetch(:page, nil)
- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, page: page, per: paginate_diffs_per_page)
.files-changed.diff-files-changed.js-diff-files-changed.gl-py-3
.js-diff-files-changed.gl-py-3
.files-changed-inner
.inline-parallel-buttons.gl-display-none.gl-md-display-flex
.inline-parallel-buttons.gl-display-none.gl-md-display-flex.gl-relative
- if !diffs_expanded? && diff_files.any?(&:collapsed?)
= link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'd-none d-sm-inline-block')
- elsif current_controller?('projects/merge_requests/diffs')
= diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'd-none d-sm-inline-block')
- elsif current_controller?(:compare)
= diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'd-none d-sm-inline-block')
- elsif current_controller?(:wikis)

View File

@ -28,5 +28,5 @@
%pre.commit-description<
= preserve(markdown_field(commit, :description))
= render 'projects/diffs/diffs', diffs: @diffs
= render 'projects/diffs/diffs', diffs: @diffs, diff_page_context: "is-wiki"
= render 'shared/wikis/sidebar'

View File

@ -1,8 +1,8 @@
---
name: rate_limit_for_unauthenticated_projects_api_access
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112283
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/391922
milestone: '15.10'
name: optimize_scope_projects_with_feature_available
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119950/
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/410693
milestone: '16.0'
type: development
group: group::tenant scale
default_enabled: true
default_enabled: false

View File

@ -0,0 +1,10 @@
- title: "Jira DVCS connector for Jira Cloud and Jira 8.13 and earlier"
announcement_milestone: "15.1"
removal_milestone: "16.0"
breaking_change: true
reporter: m_frankiewicz
stage: Manage
issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/362168
body: |
The [Jira DVCS connector](https://docs.gitlab.com/ee/integration/jira/dvcs/) for Jira Cloud was deprecated in GitLab 15.1 and has been removed in 16.0. Use the [GitLab for Jira Cloud app](https://docs.gitlab.com/ee/integration/jira/connect-app.html) instead. The Jira DVCS connector was also deprecated for Jira 8.13 and earlier. You can only use the Jira DVCS connector with Jira Data Center or Jira Server in Jira 8.14 and later. Upgrade your Jira instance to Jira 8.14 or later, and reconfigure the Jira integration in your GitLab instance.
If you cannot upgrade your Jira instance in time and are on GitLab self-managed version, we offer a workaround until GitLab 16.6. This breaking change is deployed in GitLab 16.0 behind a feature flag named `jira_dvcs_end_of_life_amnesty`. The flag is disabled by default, but you can ask an administrator to enable the flag at any time. For questions related to this announcement, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/408185).

View File

@ -18993,6 +18993,7 @@ Represents a product analytics dashboard.
| ---- | ---- | ----------- |
| <a id="productanalyticsdashboarddescription"></a>`description` | [`String`](#string) | Description of the dashboard. |
| <a id="productanalyticsdashboardpanels"></a>`panels` | [`ProductAnalyticsDashboardPanelConnection!`](#productanalyticsdashboardpanelconnection) | Panels shown on the dashboard. (see [Connections](#connections)) |
| <a id="productanalyticsdashboardslug"></a>`slug` | [`String!`](#string) | Slug of the dashboard. |
| <a id="productanalyticsdashboardtitle"></a>`title` | [`String!`](#string) | Title of the dashboard. |
### `ProductAnalyticsDashboardPanel`

View File

@ -147,7 +147,7 @@ Having addressed the details of the two aformentioned problem-domains, we can mo
The single, biggest challenge around introducing ClickHouse and related systems would be the ability to make it avaiable to our users running GitLab in self-managed environments. The intended goals of this proposal are intentionally kept within those constraints. It is also prudent to establish that what we're *proposing* here be applicable to applications consuming ClickHouse from inside self-managed environments.
There are ongoing efforts to streamline distribution and deployment of ClickHouse instances for managed environment within the larger scope of [ClickHouse Usage at GitLab](../../clickhouse_usage/index.md). A few other issues tackling parts of the aforementioned problem are:
There are ongoing efforts to streamline distribution and deployment of ClickHouse instances for managed environment within the larger scope of [ClickHouse Usage at GitLab](../clickhouse_usage/index.md). A few other issues tackling parts of the aforementioned problem are:
- [Research and understand component costs and maintenance requirements of running a ClickHouse instance with GitLab](https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/14384)
- [ClickHouse maintenance and cost research](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/116669)

View File

@ -10,6 +10,17 @@ participating-stages: []
# Consider an abstraction layer to interact with ClickHouse or alternatives
## Table of Contents
- [Summary](#summary)
- [Motivation](#motivation)
- [Goals](#goals)
- [Non-goals](#non-goals)
- [Possible solutions](#possible-solutions)
- [Recommended approach](#recommended-approach)
- [Overview of open source tools](#overview-of-open-source-tools)
- [Open Questions](#open-questions)
## Summary
Provide a solution standardizing read access to ClickHouse or its alternatives for GitLab installations that will not opt-in to install ClickHouse. After analyzing different [open-source tools](#overview-of-open-source-tools) and weighing them against an option to [build a solution internally](#recommended-approach). The current recommended approach proposes to use dedicated database-level drivers to connect to each data source. Additionally, it proposes the usage of [repository pattern](https://martinfowler.com/eaaCatalog/repository.html) to confine optionally database availability complexity to a single application layer.
@ -24,7 +35,7 @@ offering a unified interface for interactions with underlying data stores, to a
## Goals
- Limit the impact of optionally available data stores on the overall GitLab application codebase to [single abstraction layer](../../../../development/reusing_abstractions.md#abstractions)
- Limit the impact of optionally available data stores on the overall GitLab application codebase to [single abstraction layer](../../../development/reusing_abstractions.md#abstractions)
- Support all data store specific features
- Support communication for satellite services of the main GitLab application
@ -72,7 +83,7 @@ Following ClickHouse documentation there are the following drivers for Ruby and
To keep the codebase well organized and limit coupling to any specific database engine it is important to encapsulate
interactions, including querying data to a single application layer, that would present its interface to layers above in
similar vain to [ActiveRecord interface propagation through abstraction layers](../../../../development/reusing_abstractions.md)
similar vain to [ActiveRecord interface propagation through abstraction layers](../../../development/reusing_abstractions.md)
Keeping underlying database engines encapsulated makes the recommended solution a good two-way door decision that
keeps the opportunity to introduce other tools later on, while giving groups time to explore and understand their use cases.
@ -81,7 +92,7 @@ At the lowest abstraction layer, it can be expected that there will be a family
following MVC pattern implemented by Rails should be classified as _Models_.
Models-level abstraction builds well into existing patterns and guidelines but unfortunately does not solve the challenge of the optional availability of the ClickHouse database engine for self-managed instances. It is required to design a dedicated entity that will house responsibility of selecting best database to serve business logic request.
From the already mentioned existing abstraction [guidelines](../../../../development/reusing_abstractions.md) `Finders` seems to be the closest to the given requirements, due to the fact that `Finders` encapsulate database specific interaction behind their own public API, hiding database vendors detail from all layers above them.
From the already mentioned existing abstraction [guidelines](../../../development/reusing_abstractions.md) `Finders` seems to be the closest to the given requirements, due to the fact that `Finders` encapsulate database specific interaction behind their own public API, hiding database vendors detail from all layers above them.
However, they are closely coupled to `ActiveRecord` ORM framework, and are bound by existing GitLab convention to return `ActiveRecord::Relation` objects, that might be used to compose even more complex queries. That coupling makes `Finders` unfit to deal with the optional availability of ClickHouse because returned data might come from two different databases, and might not be compatible with each other.
@ -138,7 +149,7 @@ In this section authors provide an overview of existing 3rd party open-source so
1. It focuses on the fact whether the proposed abstraction layer can support both ClickHouse and PostgreSQL (must have)
1. Additional consideration might be if more than the two must-have storages are supported
1. The solution must support the [minimum required versions](../../../../install/requirements.md#postgresql-requirements) for PostgreSQL
1. The solution must support the [minimum required versions](../../../install/requirements.md#postgresql-requirements) for PostgreSQL
##### 3. Protocol compatibility

View File

@ -0,0 +1,63 @@
---
status: proposed
creation-date: "2023-03-15"
authors: [ "@furkanayhan" ]
coach: "@grzesiek"
approvers: [ "@jreporter", "@cheryl.li" ]
owning-stage: "~devops::verify"
participating-stages: [ "~devops::package", "~devops::deploy" ]
---
# GitLab CI Events
## Summary
In order to unlock innovation and build more value, GitLab is expected to be
the center of automation related to DevSecOps processes. We want to transform
GitLab into a programming environment, that will make it possible for engineers
to model various workflows on top of CI/CD pipelines. Today, users must create
custom automation around webhooks or scheduled pipelines to build required
workflows.
In order to make this automation easier for our users, we want to build a
powerful CI/CD eventing system, that will make it possible to run pipelines
whenever something happens inside or outside of GitLab.
A typical use-case is to run a CI/CD job whenever someone creates an issue,
posts a comment, changes a merge request status from "draft" to "ready for
review" or adds a new member to a group.
To build that new technology, we should:
1. Emit many hierarchical events from within GitLab in a more advanced way than we do it today.
1. Make it affordable to run this automation, that will react to GitLab events, at scale.
1. Provide a set of conventions and libraries to make writing the automation easier.
## Goals
While ["GitLab Events Platform"](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113700)
aims to build new abstractions around emitting events in GitLab, "GitLab CI
Events" blueprint is about making it possible to:
1. Define a way in which users will configure when an event emitted will result in a CI pipeline being run.
1. Describe technology required to match subscriptions with events at GitLab.com scale and beyond.
1. Describe technology we could use to reduce the cost of running automation jobs significantly.
## Proposals
For now, we have technical 4 proposals;
1. [Proposal 1: Using the `.gitlab-ci.yml` file](proposal-1-using-the-gitlab-ci-file.md)
Based on;
- [GitLab CI Workflows PoC](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91244)
- [PoC NPM CI events](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111693)
1. [Proposal 2: Using the `rules` keyword](proposal-2-using-the-rules-keyword.md)
Highly inefficient way.
1. [Proposal 3: Using the `.gitlab/ci/events` folder](proposal-3-using-the-gitlab-ci-events-folder.md)
Involves file reading for every event.
1. [Proposal 4: Creating events via CI files](proposal-4-creating-events-via-ci-files.md)
Combination of some proposals.
Each of them has its pros and cons. There could be many more proposals and we
would like to discuss them all. We can combine the best part of those proposals
and create a new one.

View File

@ -0,0 +1,60 @@
---
owning-stage: "~devops::verify"
description: 'GitLab CI Events Proposal 1: Using the .gitlab-ci.yml file'
---
# GitLab CI Events Proposal 1: Using the `.gitlab-ci.yml` file
Currently, we have two proof-of-concept (POC) implementations:
- [GitLab CI Workflows PoC](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91244)
- [PoC NPM CI events](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111693)
They both have similar ideas;
1. Find a new CI Config syntax to define the pipeline events.
Example 1:
```yaml
workflow:
events:
- events/package/published
# or
workflow:
on:
- events/package/published
```
Example 2:
```yaml
spec:
on:
- events/package/published
- events/package/removed
# on:
# package: [published, removed]
---
do_something:
script: echo "Hello World"
```
1. Upsert an event to the database when creating a pipeline.
1. Create [EventStore subscriptions](../../../development/event_store.md) to handle the events.
## Problems & Questions
1. The CI config of a project can be anything;
- `.gitlab-ci.yml` by default
- another file in the project
- another file in another project
- completely a remote/external file
How do we handle these cases?
1. Since we have these problems above, should we keep the events in its own file? (`.gitlab-ci-events.yml`)
1. Do we only accept the changes in the main branch?
1. We try to create event subscriptions every time a pipeline is created.
1. Can we move the existing workflows into the new CI events, for example, `merge_request_event`?

View File

@ -0,0 +1,38 @@
---
owning-stage: "~devops::verify"
description: 'GitLab CI Events Proposal 2: Using the rules keyword'
---
# GitLab CI Events Proposal 2: Using the `rules` keyword
Can we do it with our current [`rules`](../../../ci/yaml/index.md#rules) system?
```yaml
workflow:
rules:
- events: ["package/*"]
test_package_published:
script: echo testing published package
rules:
- events: ["package/published"]
test_package_removed:
script: echo testing removed package
rules:
- events: ["package/removed"]
```
1. We don't upsert anything to the database.
1. We'll have a single worker which subcribes to events
like `store.subscribe ::Ci::CreatePipelineFromEventWorker, to: ::Issues::CreatedEvent`.
1. The worker just runs `Ci::CreatePipelineService` with the correct parameters, the rest
will be handled by the `rules` system. Of course, we'll need modifications to the `rules` system to support `events`.
## Problems & Questions
1. For every defined event run, we need to enqueue a new `Ci::CreatePipelineFromEventWorker` job.
1. The worker will need to run `Ci::CreatePipelineService` for every event run.
This may be costly because we go through every cycle of `Ci::CreatePipelineService`.
1. This would be highly inefficient.
1. Can we move the existing workflows into the new CI events, for example, `merge_request_event`?

View File

@ -0,0 +1,64 @@
---
owning-stage: "~devops::verify"
description: 'GitLab CI Events Proposal 3: Using the .gitlab/ci/events folder'
---
# GitLab CI Events Proposal 3: Using the `.gitlab/ci/events` folder
We can also approach this problem by creating separate files for events.
Let's say we'll have the `.gitlab/ci/events` folder (or `.gitlab/workflows/ci`).
We can define events in the following format:
```yaml
# .gitlab/ci/events/package-published.yml
spec:
events:
- name: package/published
---
include:
- local: .gitlab-ci.yml
with:
event: $[[ gitlab.event.name ]]
```
And in the `.gitlab-ci.yml` file, we can use the input;
```yaml
# .gitlab-ci.yml
spec:
inputs:
event:
default: push
---
job1:
script: echo "Hello World"
job2:
script: echo "Hello World"
job-for-package-published:
script: echo "Hello World"
rules:
- if: $[[ inputs.event ]] == "package/published"
```
When an event happens;
1. We'll enqueue a new job for the event.
1. The job will search for the event file in the `.gitlab/ci/events` folder.
1. The job will run `Ci::CreatePipelineService` for the event file.
## Problems & Questions
1. For every defined event run, we need to enqueue a new job.
1. Every event-job will need to search for files.
1. This would be only for the project-scope events.
1. This can be inefficient because of searching for files for the project for every event.

View File

@ -0,0 +1,73 @@
---
owning-stage: "~devops::verify"
description: 'GitLab CI Events Proposal 4: Creating events via CI files'
---
# GitLab CI Events Proposal 4: Creating events via CI files
Each project can have its own event configuration file. Let's call it `.gitlab-ci-event.yml` for now.
In this file, we can define events in the following format:
```yaml
events:
- package/published
- issue/created
```
When this file is changed in the project repository, it is parsed and the events are created, updated, or deleted.
This is highly similar to [Proposal 1](proposal-1-using-the-gitlab-ci-file.md) except that we don't need to
track pipeline creations every time.
1. Upsert events to the database when `.gitlab-ci-event.yml` is updated.
1. Create [EventStore subscriptions](../../../development/event_store.md) to handle the events.
## Filtering jobs
We can filter jobs by using the `rules` keyword. For example:
```yaml
test_package_published:
script: echo testing published package
rules:
- events: ["package/published"]
test_package_removed:
script: echo testing removed package
rules:
- events: ["package/removed"]
```
Otherwise, we can make it work either a CI variable;
```yaml
test_package_published:
script: echo testing published package
rules:
- if: $CI_EVENT == "package/published"
test_package_removed:
script: echo testing removed package
rules:
- if: $CI_EVENT == "package/removed"
```
or an input like in the [Proposal 3](proposal-3-using-the-gitlab-ci-events-folder.md);
```yaml
spec:
inputs:
event:
default: push
---
test_package_published:
script: echo testing published package
rules:
- if: $[[ inputs.event ]] == "package/published"
test_package_removed:
script: echo testing removed package
rules:
- if: $[[ inputs.event ]] == "package/removed"
```

View File

@ -55,6 +55,8 @@ All AI features are experimental.
```ruby
Feature.enable(:ai_related_settings)
Feature.enable(:openai_experimentation)
Feature.enable(:tofa_experimentation_main_flag)
Feature.enable(:anthropic_experimentation)
```
1. Simulate the GDK to [simulate SaaS](ee_features.md#simulate-a-saas-instance) and ensure the group you want to test has an Ultimate license
@ -87,31 +89,49 @@ To populate the embedding database for GitLab chat:
1. Open a rails console
1. Run [this script](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/10588#note_1373586079) to populate the embedding database
### Internal-Only GCP account access
### Configure GCP Vertex access
In order to obtain a GCP service key for local development, please follow the steps below:
- Create a sandbox GCP environment by visiting [this page](https://about.gitlab.com/handbook/infrastructure-standards/#individual-environment) and following the instructions
- In the GCP console, go to `IAM & Admin` > `Service Accounts` and click on the "Create new service account" button
- Name the service account something specific to what you're using it for. Select Create and Continue. Under `Grant this service account access to project`, select the role `Vertex AI User`. Select `Continue` then `Done`
- Select your new service account and `Manage keys` > `Add Key` > `Create new key`. This will download the **private** JSON credentials for your service account.
- In the rails console, you will use this by `Gitlab::CurrentSettings.update(tofa_credentials: File.read('/YOUR_FILE.json'))`
- Select your new service account and `Manage keys` > `Add Key` > `Create new key`. This will download the **private** JSON credentials for your service account. Your full settings should then be:
```ruby
Gitlab::CurrentSettings.update(tofa_credentials: File.read('/YOUR_FILE.json'))
# Note: These credential examples will not work locally for all models
Gitlab::CurrentSettings.update(tofa_host: "<root-domain>") # Example: us-central1-aiplatform.googleapis.com
Gitlab::CurrentSettings.update(tofa_url: "<full-api-endpoint>") # Example: https://ROOT-DOMAIN/v1/projects/MY-COOL-PROJECT/locations/us-central1/publishers/google/models/MY-SPECIAL-MODEL:predict
```
Internal team members can [use this snippet](https://gitlab.com/gitlab-com/gl-infra/production/-/snippets/2541742) for help configuring these endpoints.
### Configure OpenAI access
```ruby
Gitlab::CurrentSettings.update(openai_api_key: "<open-ai-key>")
```
### Configure Anthropic access
```ruby
Feature.enable(:anthropic_experimentation)
Gitlab::CurrentSettings.update!(anthropic_api_key: <insert API key>)
```
## Experimental REST API
Use the [experimental REST API endpoints](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/api/ai/experimentation/open_ai.rb) to quickly experiment and prototype AI features.
Use the [experimental REST API endpoints](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/api/ai/experimentation) to quickly experiment and prototype AI features.
The endpoints are:
- `https://gitlab.example.com/api/v4/ai/experimentation/openai/completions`
- `https://gitlab.example.com/api/v4/ai/experimentation/openai/embeddings`
- `https://gitlab.example.com/api/v4/ai/experimentation/openai/chat/completions`
To use these endpoints locally, set the OpenAI API key in the application settings:
```ruby
Gitlab::CurrentSettings.update(openai_api_key: "<open-ai-key>")
```
- `https://gitlab.example.com/api/v4/ai/experimentation/anthropic/complete`
- `https://gitlab.example.com/api/v4/ai/experimentation/tofa/chat`
These endpoints are only for prototyping, not for rolling features out to customers.
The experimental endpoint is only available to GitLab team members on production. Use the

View File

@ -217,6 +217,15 @@ In GitLab 16.0 and later, the GraphQL query for runners will no longer return th
- `PAUSED` has been replaced with the field, `paused: true`.
- `ACTIVE` has been replaced with the field, `paused: false`.
### Jira DVCS connector for Jira Cloud and Jira 8.13 and earlier
WARNING:
This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/).
Review the details carefully before upgrading.
The [Jira DVCS connector](https://docs.gitlab.com/ee/integration/jira/dvcs/) for Jira Cloud was deprecated in GitLab 15.1 and has been removed in 16.0. Use the [GitLab for Jira Cloud app](https://docs.gitlab.com/ee/integration/jira/connect-app.html) instead. The Jira DVCS connector was also deprecated for Jira 8.13 and earlier. You can only use the Jira DVCS connector with Jira Data Center or Jira Server in Jira 8.14 and later. Upgrade your Jira instance to Jira 8.14 or later, and reconfigure the Jira integration in your GitLab instance.
If you cannot upgrade your Jira instance in time and are on GitLab self-managed version, we offer a workaround until GitLab 16.6. This breaking change is deployed in GitLab 16.0 behind a feature flag named `jira_dvcs_end_of_life_amnesty`. The flag is disabled by default, but you can ask an administrator to enable the flag at any time. For questions related to this announcement, see the [feedback issue](https://gitlab.com/gitlab-org/gitlab/-/issues/408185).
### License-Check and the Policies tab on the License Compliance page
WARNING:

View File

@ -10,6 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112283) in GitLab 15.10 with a [flag](../../../administration/feature_flags.md) named `rate_limit_for_unauthenticated_projects_api_access`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/391922) on May 08, 2023.
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119603) in GitLab 16.0 by default.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120445) in GitLab 16.0. Feature flag `rate_limit_for_unauthenticated_projects_api_access` removed.
You can configure the rate limit per IP address for unauthenticated requests to the [list all projects API](../../../api/projects.md#list-all-projects).

View File

@ -271,6 +271,7 @@ Payload example:
"human_time_estimate": null,
"human_time_change": null,
"weight": null,
"health_status": "at_risk",
"iid": 23,
"url": "http://example.com/diaspora/issues/23",
"state": "opened",

View File

@ -112,8 +112,6 @@ module API
end
def validate_projects_api_rate_limit_for_unauthenticated_users!
return unless Feature.enabled?(:rate_limit_for_unauthenticated_projects_api_access)
check_rate_limit!(:projects_api_rate_limit_unauthenticated, scope: [ip_address]) if current_user.blank?
end
end

View File

@ -18974,6 +18974,9 @@ msgstr ""
msgid "Forbidden"
msgstr ""
msgid "Forecast horizon must be %{max_horizon} days at the most."
msgstr ""
msgid "Forgot your password?"
msgstr ""
@ -24210,6 +24213,9 @@ msgstr ""
msgid "Invalid URL: %{url}"
msgstr ""
msgid "Invalid context. Project is expected."
msgstr ""
msgid "Invalid date"
msgstr ""
@ -47888,6 +47894,9 @@ msgstr ""
msgid "Unsubscribes from this %{quick_action_target}."
msgstr ""
msgid "Unsupported forecast type. Supported types: %{types}"
msgstr ""
msgid "Unsupported sort value."
msgstr ""

View File

@ -146,7 +146,7 @@ RSpec.describe GraphqlController, feature_category: :integrations do
])
end
it 'does not allow string as _json parameter' do
it 'does not allow string as _json parameter (a malformed multiplex query)' do
post :execute, params: { _json: 'bad' }
expect(response).to have_gitlab_http_status(:ok)

View File

@ -1,9 +1,9 @@
import { GlAlert, GlFormInput, GlForm, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { createStore } from '~/boards/stores';
@ -32,6 +32,7 @@ const TEST_ISSUE_B = {
describe('BoardSidebarTitle', () => {
let wrapper;
let store;
let storeDispatch;
let mockApollo;
const issueSetTitleMutationHandlerSuccess = jest.fn().mockResolvedValue(updateIssueTitleResponse);
@ -52,8 +53,9 @@ describe('BoardSidebarTitle', () => {
[issueSetTitleMutation, issueSetTitleMutationHandlerSuccess],
[updateEpicTitleMutation, updateEpicTitleMutationHandlerSuccess],
]);
storeDispatch = jest.spyOn(store, 'dispatch');
wrapper = shallowMount(BoardSidebarTitle, {
wrapper = shallowMountExtended(BoardSidebarTitle, {
store,
apolloProvider: mockApollo,
provide: {
@ -78,9 +80,9 @@ describe('BoardSidebarTitle', () => {
const findFormInput = () => wrapper.findComponent(GlFormInput);
const findGlLink = () => wrapper.findComponent(GlLink);
const findEditableItem = () => wrapper.findComponent(BoardEditableItem);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findTitle = () => wrapper.find('[data-testid="item-title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findTitle = () => wrapper.findByTestId('item-title');
const findCollapsed = () => wrapper.findByTestId('collapsed-content');
it('renders title and reference', () => {
createWrapper();
@ -105,9 +107,6 @@ describe('BoardSidebarTitle', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
@ -116,29 +115,34 @@ describe('BoardSidebarTitle', () => {
it('collapses sidebar and renders new title', async () => {
await waitForPromises();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_TITLE);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveItemTitle).toHaveBeenCalledWith({
title: TEST_TITLE,
expect(storeDispatch).toHaveBeenCalledWith('setActiveItemTitle', {
projectPath: 'h/b',
title: 'New item title',
});
});
it('renders correct title', async () => {
createWrapper({ item: { ...TEST_ISSUE_A, title: TEST_TITLE } });
await waitForPromises();
expect(findTitle().text()).toContain(TEST_TITLE);
});
});
describe('when submitting and invalid title', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {});
findFormInput().vm.$emit('input', '');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
expect(storeDispatch).not.toHaveBeenCalled();
});
});
@ -169,7 +173,7 @@ describe('BoardSidebarTitle', () => {
});
it('sets title, expands item and shows alert', () => {
expect(wrapper.vm.title).toBe(TEST_TITLE);
expect(findFormInput().attributes('value')).toBe(TEST_TITLE);
expect(findCollapsed().isVisible()).toBe(false);
expect(findAlert().exists()).toBe(true);
});
@ -179,16 +183,13 @@ describe('BoardSidebarTitle', () => {
beforeEach(async () => {
createWrapper({ item: TEST_ISSUE_B });
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findCancelButton().vm.$emit('click');
await nextTick();
});
it('collapses sidebar and render former title', () => {
expect(wrapper.vm.setActiveItemTitle).not.toHaveBeenCalled();
expect(storeDispatch).not.toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toBe(TEST_ISSUE_B.title);
});
@ -198,10 +199,6 @@ describe('BoardSidebarTitle', () => {
beforeEach(async () => {
createWrapper({ item: TEST_ISSUE_B });
jest.spyOn(wrapper.vm, 'setActiveItemTitle').mockImplementation(() => {
throw new Error(['failed mutation']);
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findFormInput().vm.$emit('input', 'Invalid title');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
@ -210,7 +207,10 @@ describe('BoardSidebarTitle', () => {
it('collapses sidebar and renders former item title', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_ISSUE_B.title);
expect(wrapper.vm.setError).toHaveBeenCalled();
expect(storeDispatch).toHaveBeenCalledWith(
'setError',
expect.objectContaining({ message: 'An error occurred when updating the title' }),
);
});
});

View File

@ -0,0 +1,43 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Panel from '~/google_cloud/aiml/panel.vue';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
import ServiceTable from '~/google_cloud/aiml/service_table.vue';
describe('google_cloud/databases/panel', () => {
let wrapper;
const props = {
configurationUrl: 'configuration-url',
deploymentsUrl: 'deployments-url',
databasesUrl: 'databases-url',
aimlUrl: 'aiml-url',
visionAiUrl: 'vision-ai-url',
translationAiUrl: 'translation-ai-url',
languageAiUrl: 'language-ai-url',
};
beforeEach(() => {
wrapper = shallowMountExtended(Panel, { propsData: props });
});
it('contains incubation banner', () => {
const target = wrapper.findComponent(IncubationBanner);
expect(target.exists()).toBe(true);
});
it('contains google cloud menu with `aiml` active', () => {
const target = wrapper.findComponent(GoogleCloudMenu);
expect(target.exists()).toBe(true);
expect(target.props('active')).toBe('aiml');
expect(target.props('configurationUrl')).toBe(props.configurationUrl);
expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
expect(target.props('databasesUrl')).toBe(props.databasesUrl);
expect(target.props('aimlUrl')).toBe(props.aimlUrl);
});
it('contains service table', () => {
const target = wrapper.findComponent(ServiceTable);
expect(target.exists()).toBe(true);
});
});

View File

@ -9,6 +9,7 @@ describe('google_cloud/components/google_cloud_menu', () => {
configurationUrl: 'configuration-url',
deploymentsUrl: 'deployments-url',
databasesUrl: 'databases-url',
aimlUrl: 'aiml-url',
};
beforeEach(() => {
@ -33,4 +34,10 @@ describe('google_cloud/components/google_cloud_menu', () => {
expect(link.text()).toBe(GoogleCloudMenu.i18n.databases.title);
expect(link.attributes('href')).toBe(props.databasesUrl);
});
it('contains ai/ml link', () => {
const link = wrapper.findByTestId('aimlLink');
expect(link.text()).toBe(GoogleCloudMenu.i18n.aiml.title);
expect(link.attributes('href')).toBe(props.aimlUrl);
});
});

View File

@ -1,77 +0,0 @@
import { setHTMLFixture } from 'helpers/fixtures';
import { isSticky } from '~/lib/utils/sticky';
const TEST_OFFSET_TOP = 500;
describe('sticky', () => {
let el;
let offsetTop;
beforeEach(() => {
setHTMLFixture(
`
<div class="parent">
<div id="js-sticky"></div>
</div>
`,
);
offsetTop = TEST_OFFSET_TOP;
el = document.getElementById('js-sticky');
Object.defineProperty(el, 'offsetTop', {
get() {
return offsetTop;
},
});
});
afterEach(() => {
el = null;
});
describe('when stuck', () => {
it('does not remove is-stuck class', () => {
isSticky(el, 0, el.offsetTop);
isSticky(el, 0, el.offsetTop);
expect(el.classList.contains('is-stuck')).toBe(true);
});
it('adds is-stuck class', () => {
isSticky(el, 0, el.offsetTop);
expect(el.classList.contains('is-stuck')).toBe(true);
});
it('inserts placeholder element', () => {
isSticky(el, 0, el.offsetTop, true);
expect(document.querySelector('.sticky-placeholder')).not.toBeNull();
});
});
describe('when not stuck', () => {
it('removes is-stuck class', () => {
jest.spyOn(el.classList, 'remove');
isSticky(el, 0, el.offsetTop);
isSticky(el, 0, 0);
expect(el.classList.remove).toHaveBeenCalledWith('is-stuck');
expect(el.classList.contains('is-stuck')).toBe(false);
});
it('does not add is-stuck class', () => {
isSticky(el, 0, 0);
expect(el.classList.contains('is-stuck')).toBe(false);
});
it('removes placeholder', () => {
isSticky(el, 0, el.offsetTop, true);
isSticky(el, 0, 0, true);
expect(document.querySelector('.sticky-placeholder')).toBeNull();
});
});
});

View File

@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { GlAvatar } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DiffsModule from '~/diffs/store/modules';
import NoteActions from '~/notes/components/note_actions.vue';
@ -37,7 +37,9 @@ describe('issue_note', () => {
const REPORT_ABUSE_PATH = '/abuse_reports/add_category';
const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]');
const findNoteBody = () => wrapper.findComponent(NoteBody);
const findMultilineComment = () => wrapper.findByTestId('multiline-comment');
const createWrapper = (props = {}, storeUpdater = (s) => s) => {
store = new Vuex.Store(
@ -52,7 +54,7 @@ describe('issue_note', () => {
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
wrapper = mount(issueNote, {
wrapper = mountExtended(issueNote, {
store,
propsData: {
note,
@ -250,21 +252,17 @@ describe('issue_note', () => {
});
it('should render issue body', () => {
const noteBody = wrapper.findComponent(NoteBody);
const noteBodyProps = noteBody.props();
expect(noteBodyProps.note).toBe(note);
expect(noteBodyProps.line).toBe(null);
expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
expect(noteBodyProps.isEditing).toBe(false);
expect(noteBodyProps.helpPagePath).toBe('');
expect(findNoteBody().props().note).toBe(note);
expect(findNoteBody().props().line).toBe(null);
expect(findNoteBody().props().canEdit).toBe(note.current_user.can_edit);
expect(findNoteBody().props().isEditing).toBe(false);
expect(findNoteBody().props().helpPagePath).toBe('');
});
it('prevents note preview xss', async () => {
const noteBody =
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />';
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const noteBodyComponent = wrapper.findComponent(NoteBody);
store.hotUpdate({
modules: {
@ -277,7 +275,7 @@ describe('issue_note', () => {
},
});
noteBodyComponent.vm.$emit('handleFormUpdate', {
findNoteBody().vm.$emit('handleFormUpdate', {
noteText: noteBody,
parentElement: null,
callback: () => {},
@ -285,7 +283,7 @@ describe('issue_note', () => {
await waitForPromises();
expect(alertSpy).not.toHaveBeenCalled();
expect(wrapper.vm.note.note_html).toBe(
expect(findNoteBody().props().note.note_html).toBe(
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">',
);
});
@ -321,26 +319,21 @@ describe('issue_note', () => {
},
},
});
const noteBody = wrapper.findComponent(NoteBody);
noteBody.vm.resetAutoSave = () => {};
noteBody.vm.$emit('handleFormUpdate', {
findNoteBody().vm.$emit('handleFormUpdate', {
noteText: updatedText,
parentElement: null,
callback: () => {},
});
await nextTick();
let noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(`<p dir="auto">${updatedText}</p>\n`);
expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${updatedText}</p>\n`);
noteBody.vm.$emit('cancelForm', {});
findNoteBody().vm.$emit('cancelForm', {});
await nextTick();
noteBodyProps = noteBody.props();
expect(noteBodyProps.note.note_html).toBe(note.note_html);
expect(findNoteBody().props().note.note_html).toBe(note.note_html);
});
});
@ -371,7 +364,7 @@ describe('issue_note', () => {
it('responds to handleFormUpdate', () => {
createWrapper();
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
findNoteBody().vm.$emit('handleFormUpdate', params);
expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1);
});
@ -380,16 +373,14 @@ describe('issue_note', () => {
createWrapper();
updateActions();
wrapper
.findComponent(NoteBody)
.vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage });
findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage });
expect(updateNote).not.toHaveBeenCalled();
});
it('does not stringify empty position', () => {
createWrapper();
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
findNoteBody().vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
});
@ -398,7 +389,7 @@ describe('issue_note', () => {
const expectation = JSON.stringify(position);
createWrapper({ note: { ...note, position } });
updateActions();
wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', params);
findNoteBody().vm.$emit('handleFormUpdate', params);
expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
});
@ -423,7 +414,7 @@ describe('issue_note', () => {
createWrapper({ note: noteDef, discussionFile: null }, storeUpdater);
expect(wrapper.vm.diffFile).toBe(null);
expect(findNoteBody().props().file).toBe(null);
},
);
@ -441,7 +432,7 @@ describe('issue_note', () => {
},
);
expect(wrapper.vm.diffFile.testId).toBe('diffFileTest');
expect(findNoteBody().props().file.testId).toBe('diffFileTest');
});
it('returns the provided diff file if the more robust getters fail', () => {
@ -457,7 +448,7 @@ describe('issue_note', () => {
},
);
expect(wrapper.vm.diffFile.testId).toBe('diffFileTest');
expect(findNoteBody().props().file.testId).toBe('diffFileTest');
});
});
});

View File

@ -58,7 +58,6 @@ describe('Diff Stats Dropdown', () => {
const findChangedFiles = () => findChanged().findAllComponents(GlDropdownItem);
const findNoFilesText = () => findChanged().findComponent(GlDropdownText);
const findCollapsed = () => wrapper.findByTestId('diff-stats-additions-deletions-expanded');
const findExpanded = () => wrapper.findByTestId('diff-stats-additions-deletions-collapsed');
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
describe('file item', () => {
@ -88,24 +87,17 @@ describe('Diff Stats Dropdown', () => {
});
describe.each`
changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedExpanded | expectedAddedDeletedCollapsed
${0} | ${0} | ${0} | ${'0 changed files'} | ${'+0 -0'} | ${'with 0 additions and 0 deletions'}
${2} | ${0} | ${2} | ${'2 changed files'} | ${'+0 -2'} | ${'with 0 additions and 2 deletions'}
${2} | ${2} | ${0} | ${'2 changed files'} | ${'+2 -0'} | ${'with 2 additions and 0 deletions'}
${2} | ${1} | ${1} | ${'2 changed files'} | ${'+1 -1'} | ${'with 1 addition and 1 deletion'}
${1} | ${0} | ${1} | ${'1 changed file'} | ${'+0 -1'} | ${'with 0 additions and 1 deletion'}
${1} | ${1} | ${0} | ${'1 changed file'} | ${'+1 -0'} | ${'with 1 addition and 0 deletions'}
${4} | ${2} | ${2} | ${'4 changed files'} | ${'+2 -2'} | ${'with 2 additions and 2 deletions'}
changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedCollapsed
${0} | ${0} | ${0} | ${'0 changed files'} | ${'with 0 additions and 0 deletions'}
${2} | ${0} | ${2} | ${'2 changed files'} | ${'with 0 additions and 2 deletions'}
${2} | ${2} | ${0} | ${'2 changed files'} | ${'with 2 additions and 0 deletions'}
${2} | ${1} | ${1} | ${'2 changed files'} | ${'with 1 addition and 1 deletion'}
${1} | ${0} | ${1} | ${'1 changed file'} | ${'with 0 additions and 1 deletion'}
${1} | ${1} | ${0} | ${'1 changed file'} | ${'with 1 addition and 0 deletions'}
${4} | ${2} | ${2} | ${'4 changed files'} | ${'with 2 additions and 2 deletions'}
`(
'when there are $changed changed file(s), $added added and $deleted deleted file(s)',
({
changed,
added,
deleted,
expectedDropdownHeader,
expectedAddedDeletedExpanded,
expectedAddedDeletedCollapsed,
}) => {
({ changed, added, deleted, expectedDropdownHeader, expectedAddedDeletedCollapsed }) => {
beforeEach(() => {
createComponent({ changed, added, deleted });
});
@ -114,10 +106,6 @@ describe('Diff Stats Dropdown', () => {
expect(findChanged().props('text')).toBe(expectedDropdownHeader);
});
it(`added and deleted count in expanded section should be '${expectedAddedDeletedExpanded}'`, () => {
expect(findExpanded().text()).toBe(expectedAddedDeletedExpanded);
});
it(`added and deleted count in collapsed section should be '${expectedAddedDeletedCollapsed}'`, () => {
expect(findCollapsed().text()).toBe(expectedAddedDeletedCollapsed);
});

View File

@ -12,6 +12,7 @@ import {
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
jest.mock('lodash/uniqueId', () => (prefix) => `${prefix}1`);
@ -36,6 +37,7 @@ describe('ProjectsListItem', () => {
const findForksLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.forks });
const findProjectTopics = () => wrapper.findByTestId('project-topics');
const findPopover = () => findProjectTopics().findComponent(GlPopover);
const findProjectDescription = () => wrapper.findByTestId('project-description');
it('renders project avatar', () => {
createComponent();
@ -105,6 +107,12 @@ describe('ProjectsListItem', () => {
expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o');
});
it('renders updated at', () => {
createComponent();
expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(project.updatedAt);
});
describe('when issues are enabled', () => {
it('renders issues count', () => {
createComponent();
@ -230,4 +238,29 @@ describe('ProjectsListItem', () => {
});
});
});
describe('when project has a description', () => {
it('renders description', () => {
const descriptionHtml = '<p>Foo bar</p>';
createComponent({
propsData: {
project: {
...project,
descriptionHtml,
},
},
});
expect(findProjectDescription().element.innerHTML).toBe(descriptionHtml);
});
});
describe('when project does not have a description', () => {
it('does not render description', () => {
createComponent();
expect(findProjectDescription().exists()).toBe(false);
});
});
});

View File

@ -1181,30 +1181,6 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :projects d
end
end
end
context 'when the feature flag `rate_limit_for_unauthenticated_projects_api_access` is disabled' do
before do
stub_feature_flags(rate_limit_for_unauthenticated_projects_api_access: false)
end
context 'when the user is not signed in' do
let_it_be(:current_user) { nil }
it_behaves_like 'does not log request and does not block the request' do
def request
get api(path, current_user)
end
end
end
context 'when the user is signed in' do
it_behaves_like 'does not log request and does not block the request' do
def request
get api(path, current_user)
end
end
end
end
end
end

View File

@ -38,7 +38,7 @@ JS_CONSOLE_FILTER = Regexp.union(
CAPYBARA_WINDOW_SIZE = [1366, 768].freeze
SCREENSHOT_FILENAME_LENGTH = ENV['CI'] || ENV['CI_SERVER'] ? 255 : 99
SCREENSHOT_FILENAME_LENGTH = ENV['CI'] || ENV['CI_SERVER'] ? 150 : 99
@blackhole_tcp_server = nil

View File

@ -7,14 +7,17 @@ module KeysetPaginationHelpers
link.split(',').filter_map do |link|
match = link.match(/<(?<url>.*)>; rel="(?<rel>\w+)"/)
break nil unless match
next unless match
{ url: match[:url], rel: match[:rel] }
end
end
def pagination_params_from_next_url(response)
next_url = pagination_links(response).find { |link| link[:rel] == 'next' }[:url]
next_link = pagination_links(response).find { |link| link[:rel] == 'next' }
next_url = next_link&.fetch(:url)
return unless next_url
Rack::Utils.parse_query(URI.parse(next_url).query)
end
end

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe KeysetPaginationHelpers, feature_category: :api do
include described_class
let(:headers) { { 'LINK' => %(<#{url}>; rel="#{rel}") } }
let(:response) { instance_double('HTTParty::Response', headers: headers) }
let(:rel) { 'next' }
let(:url) do
'http://127.0.0.1:3000/api/v4/projects/7/audit_eve' \
'nts?cursor=eyJpZCI6IjYyMjAiLCJfa2QiOiJuIn0%3D&id=7&o' \
'rder_by=id&page=1&pagination=keyset&per_page=2'
end
describe '#pagination_links' do
subject { pagination_links(response) }
let(:expected_result) { [{ url: url, rel: rel }] }
it { is_expected.to eq expected_result }
context 'with a partially malformed LINK header' do
# malformed as the regxe is expecting the url to be surrounded by `<>`
let(:headers) do
{ 'LINK' => %(<#{url}>; rel="next", GARBAGE, #{url}; rel="prev") }
end
it { is_expected.to eq expected_result }
end
context 'with a malformed LINK header' do
# malformed as the regxe is expecting the url to be surrounded by `<>`
let(:headers) { { 'LINK' => %(rel="next", GARBAGE, #{url}; rel="prev") } }
let(:expected_result) { [] }
it { is_expected.to eq expected_result }
end
end
describe '#pagination_params_from_next_url' do
subject { pagination_params_from_next_url(response) }
let(:expected_result) do
{
'cursor' => 'eyJpZCI6IjYyMjAiLCJfa2QiOiJuIn0=',
'id' => '7',
'order_by' => 'id',
'page' => '1',
'pagination' => 'keyset',
'per_page' => '2'
}
end
it { is_expected.to eq expected_result }
context 'with both prev and next rel links' do
let(:prev_url) do
'http://127.0.0.1:3000/api/v4/projects/7/audit_eve' \
'nts?cursor=foocursor&id=8&o' \
'rder_by=id&page=0&pagination=keyset&per_page=2'
end
let(:headers) do
{ 'LINK' => %(<#{url}>; rel="next", <#{prev_url}>; rel="prev") }
end
it { is_expected.to eq expected_result }
end
context 'with a partially malformed LINK header' do
# malformed as the regxe is expecting the url to be surrounded by `<>`
let(:headers) do
{ 'LINK' => %(<#{url}>; rel="next", GARBAGE, #{url}; rel="prev") }
end
it { is_expected.to eq expected_result }
end
context 'with a malformed LINK header' do
# malformed as the regxe is expecting the url to be surrounded by `<>`
let(:headers) { { 'LINK' => %(rel="next", GARBAGE, #{url}; rel="prev") } }
it { is_expected.to be nil }
end
end
end

View File

@ -17,17 +17,5 @@ RSpec.describe 'admin/application_settings/network.html.haml', feature_category:
expect(rendered).to have_field('application_setting_projects_api_rate_limit_unauthenticated')
end
context 'when the feature flag `rate_limit_for_unauthenticated_projects_api_access` is turned off' do
before do
stub_feature_flags(rate_limit_for_unauthenticated_projects_api_access: false)
end
it 'does not render the `projects_api_rate_limit_unauthenticated` field' do
render
expect(rendered).not_to have_field('application_setting_projects_api_rate_limit_unauthenticated')
end
end
end
end