Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-16 15:11:17 +00:00
parent e2999d09ec
commit dad48b4af2
82 changed files with 1548 additions and 395 deletions

View File

@ -41,16 +41,6 @@ Graphql/Descriptions:
- 'ee/app/graphql/types/vulnerability_severity_enum.rb'
- 'ee/app/graphql/types/vulnerability_state_enum.rb'
# WIP See https://gitlab.com/gitlab-org/gitlab/-/issues/267606
FactoryBot/InlineAssociation:
Exclude:
- 'spec/factories/atlassian_identities.rb'
- 'spec/factories/events.rb'
- 'spec/factories/git_wiki_commit_details.rb'
- 'spec/factories/gitaly/commit.rb'
- 'spec/factories/group_group_links.rb'
- 'spec/factories/import_export_uploads.rb'
# WIP: See https://gitlab.com/gitlab-org/gitlab/-/issues/220040
Rails/SaveBang:
Exclude:

View File

@ -1,9 +1,6 @@
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardSidebarEpicSelect from 'ee_component/boards/components/sidebar/board_sidebar_epic_select.vue';
import BoardSidebarWeightInput from 'ee_component/boards/components/sidebar/board_sidebar_weight_input.vue';
import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
@ -26,9 +23,12 @@ export default {
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
BoardSidebarEpicSelect,
SidebarIterationWidget,
BoardSidebarWeightInput,
BoardSidebarEpicSelect: () =>
import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
BoardSidebarWeightInput: () =>
import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
SidebarIterationWidget: () =>
import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
},
mixins: [glFeatureFlagsMixin()],
computed: {

View File

@ -61,55 +61,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*
* Flash banner supports different types of Flash configurations
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
* @param {String} message Flash message text
* @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
* @param {Object} parent Reference to parent element under which Flash needs to appear
* @param {Object} actionConfig Map of config to show action on banner
* @param {String} href URL to which action config should point to (default: '#')
* @param {String} title Title of action
* @param {Function} clickHandler Method to call when action is clicked on
* @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
*/
const deprecatedCreateFlash = function deprecatedCreateFlash(
message,
type = FLASH_TYPES.ALERT,
parent = document,
actionConfig = null,
fadeTransition = true,
addBodyClass = false,
) {
const flashContainer = parent.querySelector('.flash-container');
if (!flashContainer) return null;
flashContainer.innerHTML = createFlashEl(message, type);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
if (actionConfig) {
flashEl.innerHTML += createAction(actionConfig);
if (actionConfig.clickHandler) {
flashEl
.querySelector('.flash-action')
.addEventListener('click', (e) => actionConfig.clickHandler(e));
}
}
removeFlashClickListener(flashEl, fadeTransition);
flashContainer.style.display = 'block';
if (addBodyClass) document.body.classList.add('flash-shown');
return flashContainer;
};
/*
* Flash banner supports different types of Flash configurations
* along with ability to provide actionConfig which can be used to show
@ -166,6 +117,31 @@ const createFlash = function createFlash({
return flashContainer;
};
/*
* Flash banner supports different types of Flash configurations
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
* @param {String} message Flash message text
* @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
* @param {Object} parent Reference to parent element under which Flash needs to appear
* @param {Object} actionConfig Map of config to show action on banner
* @param {String} href URL to which action config should point to (default: '#')
* @param {String} title Title of action
* @param {Function} clickHandler Method to call when action is clicked on
* @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
*/
const deprecatedCreateFlash = function deprecatedCreateFlash(
message,
type,
parent,
actionConfig,
fadeTransition,
addBodyClass,
) {
return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass });
};
export {
createFlash as default,
deprecatedCreateFlash,

View File

@ -11,10 +11,12 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import { INVITE_MEMBERS_IN_COMMENT } from '../constants';
import eventHub from '../event_hub';
export default {
@ -122,8 +124,9 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
openModal({ inviteeType }) {
openModal({ inviteeType, source }) {
this.inviteeType = inviteeType;
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@ -138,6 +141,12 @@ export default {
}
this.closeModal();
},
trackInvite() {
if (this.source === INVITE_MEMBERS_IN_COMMENT) {
const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT);
tracking.event('comment_invite_success');
}
},
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
@ -177,6 +186,8 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
this.trackInvite();
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {

View File

@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@ -26,10 +27,29 @@ export default {
required: false,
default: undefined,
},
triggerSource: {
type: String,
required: false,
default: 'unknown',
},
trackExperiment: {
type: String,
required: false,
default: undefined,
},
},
mounted() {
this.trackExperimentOnShow();
},
methods: {
openModal() {
eventHub.$emit('openModal', { inviteeType: 'members' });
eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
},
trackExperimentOnShow() {
if (this.trackExperiment) {
const tracking = new ExperimentTracking(this.trackExperiment);
tracking.event('comment_invite_shown');
}
},
},
};

View File

@ -1 +1,3 @@
export const SEARCH_DELAY = 200;
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';

View File

@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => {
}
}
return !state.isSubmitting && hasCommitMessage && !unresolved;
return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved);
};
export const getCommitButtonText = (state) => {

View File

@ -3,6 +3,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
import '~/notes/index';
@ -34,6 +35,7 @@ export default function initShowIssue() {
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
initInviteMembersModal();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())

View File

@ -5,6 +5,7 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
@ -20,6 +21,7 @@ export default function initMergeRequestShow() {
loadAwardsHandler();
initInviteMemberModal();
initInviteMemberTrigger();
initInviteMembersModal();
const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new

View File

@ -1,13 +1,18 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
import { isExperimentVariant } from '~/experimentation/utils';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@ -29,6 +34,9 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
inviteCommentEnabled() {
return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
},
},
};
</script>
@ -37,9 +45,9 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<gl-link :href="markdownDocsPath" target="_blank">{{
__('Markdown is supported')
}}</gl-link>
<gl-link :href="markdownDocsPath" target="_blank">
{{ __('Markdown is supported') }}
</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
@ -59,6 +67,16 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
<invite-members-trigger
v-if="inviteCommentEnabled"
classes="gl-mr-3 gl-vertical-align-text-bottom"
:display-text="s__('InviteMember|Invite Member')"
icon="assignee"
variant="link"
:track-experiment="$options.inviteMembersInComment"
:trigger-source="$options.inviteMembersInComment"
data-track-event="comment_invite_click"
/>
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>

View File

@ -55,6 +55,15 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
experiment_instance.exclude! unless helpers.can_import_members?
experiment_instance.use {}
experiment_instance.try(:invite_member_link) {}
experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
end
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]

View File

@ -45,6 +45,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
experiment_instance.exclude! unless helpers.can_import_members?
experiment_instance.use {}
experiment_instance.try(:invite_member_link) {}
experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
end
end
before_action do

View File

@ -4,6 +4,8 @@
module GitlabRoutingHelper
extend ActiveSupport::Concern
include ::ProjectsHelper
include ::ApplicationSettingsHelper
include API::Helpers::RelatedResourcesHelpers
included do
Gitlab::Routing.includes_helpers(self)

View File

@ -36,6 +36,8 @@ class Project < ApplicationRecord
include Integration
include Repositories::CanHousekeepRepository
include EachBatch
include GitlabRoutingHelper
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@ -1848,7 +1850,7 @@ class Project < ApplicationRecord
# where().update_all to perform update in the single transaction with check for null
ProjectPagesMetadatum
.where(project_id: id, pages_deployment_id: nil)
.update_all(pages_deployment_id: deployment.id)
.update_all(deployed: deployment.present?, pages_deployment_id: deployment&.id)
end
def write_repository_config(gl_full_path: full_path)

View File

@ -33,7 +33,11 @@ module Ci
end
def runner_variables
variables.to_runner_variables
if Feature.enabled?(:variable_inside_variable, project)
variables.sort_and_expand_all(project, keep_undefined: true).to_runner_variables
else
variables.to_runner_variables
end
end
def refspecs

View File

@ -10,7 +10,11 @@ module Ci
Result = Struct.new(:build, :build_json, :valid?)
MAX_QUEUE_DEPTH = 50
##
# The queue depth limit number has been determined by observing 95
# percentile of effective queue depth on gitlab.com. This is only likely to
# affect 5% of the worst case scenarios.
MAX_QUEUE_DEPTH = 45
def initialize(runner)
@runner = runner
@ -105,7 +109,7 @@ module Ci
builds = builds.queued_before(params[:job_age].seconds.ago)
end
if Feature.enabled?(:ci_register_job_service_one_by_one, runner)
if Feature.enabled?(:ci_register_job_service_one_by_one, runner, default_enabled: true)
build_ids = builds.pluck(:id)
@metrics.observe_queue_size(-> { build_ids.size })
@ -171,7 +175,7 @@ module Ci
def max_queue_depth
@max_queue_depth ||= begin
if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: false)
if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: true)
MAX_QUEUE_DEPTH
else
::Gitlab::Database::MAX_INT_VALUE

View File

@ -64,7 +64,7 @@ module Pages
end
if result[:status] == :success
@logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds")
@logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds: #{result[:message]}")
@counters_lock.synchronize { @migrated += 1 }
else
@logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time.round(2)} seconds: #{result[:message]}")

View File

@ -30,16 +30,18 @@ module Pages
zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute
if zip_result[:status] == :error
if !project.pages_metadatum&.reload&.pages_deployment &&
Feature.enabled?(:pages_migration_mark_as_not_deployed, project)
project.mark_pages_as_not_deployed
end
return error("Can't create zip archive: #{zip_result[:message]}")
end
archive_path = zip_result[:archive_path]
unless archive_path
project.set_first_pages_deployment!(nil)
return success(
message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
end
deployment = nil
File.open(archive_path) do |file|
deployment = project.pages_deployments.create!(

View File

@ -19,6 +19,10 @@ module Pages
def execute
unless resolve_public_dir
if Feature.enabled?(:pages_migration_mark_as_not_deployed)
return success
end
return error("Can not find valid public dir in #{@input_dir}")
end

View File

@ -4,3 +4,4 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
= render 'projects/issuable/show', issuable: @issue
= render 'shared/issuable/invite_members_trigger', project: @project

View File

@ -108,3 +108,6 @@
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
#js-review-bar
= render 'shared/issuable/invite_members_trigger', project: @project

View File

@ -0,0 +1,8 @@
- return unless can_import_members?
.js-invite-members-modal{ data: { id: project.id,
name: project.name,
is_project: 'true',
access_levels: ProjectMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }

View File

@ -0,0 +1,5 @@
---
title: Support daily DORA metrics API
merge_request: 56080
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Document how to use custom omniauth button icon
merge_request: 55388
author: Diego Louzán
type: other

View File

@ -0,0 +1,5 @@
---
title: Fix bug in Gollum Tags filter
merge_request: 56638
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add JavaScript, TypeScript, and React support to the semgrep analyzer.
merge_request: 55257
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Resolve nested variable values sent to the runner
merge_request: 48627
author:
type: added

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323177
milestone: '13.10'
type: development
group: group::memory
default_enabled: false
default_enabled: true

View File

@ -1,8 +0,0 @@
---
name: dora_daily_metrics
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55473
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/291746
milestone: '13.10'
type: development
group: group::release
default_enabled: false

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323201
milestone: '13.10'
type: development
group: group::continuous integration
default_enabled: false
default_enabled: true

View File

@ -1,8 +1,8 @@
---
name: variable_inside_variable
introduced_by_url:
rollout_issue_url:
milestone: '13.7'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50156
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/297382
milestone: '13.11'
type: development
group: group::runner
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: invite_members_in_comment
introduced_by_url: 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51400'
rollout_issue_url: 'https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/300'
milestone: '13.10'
type: experiment
group: group::expansion
default_enabled: false

View File

@ -860,8 +860,8 @@ Parameters for all comments:
| `position[line_range]` | hash | no | Line range for a multi-line diff note |
| `position[width]` | integer | no | Width of the image (for `image` diff notes) |
| `position[height]` | integer | no | Height of the image (for `image` diff notes) |
| `position[x]` | integer | no | X coordinate (for `image` diff notes) |
| `position[y]` | integer | no | Y coordinate (for `image` diff notes) |
| `position[x]` | float | no | X coordinate (for `image` diff notes) |
| `position[y]` | float | no | Y coordinate (for `image` diff notes) |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions?body=comment"

90
doc/api/dora/metrics.md Normal file
View File

@ -0,0 +1,90 @@
---
stage: Release
group: Release
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
type: reference, api
---
# DevOps Research and Assessment (DORA) key metrics API **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.10.
All methods require [reporter permissions and above](../../user/permissions.md).
## Get project-level DORA metrics
Get project-level DORA metrics.
```plaintext
GET /projects/:id/dora/metrics
```
| Attribute | Type | Required | Description |
|-------------- |-------- |----------|----------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../README.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | The [metric name](../../user/analytics/ci_cd_analytics.md#supported-metrics-in-gitlab). One of `deployment_frequency` or `lead_time_for_changes`. |
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/dora/metrics?metric=deployment_frequency"
```
Example response:
```json
[
{ "2021-03-01": 3 },
{ "2021-03-02": 6 },
{ "2021-03-03": 0 },
{ "2021-03-04": 0 },
{ "2021-03-05": 0 },
{ "2021-03-06": 0 },
{ "2021-03-07": 0 },
{ "2021-03-08": 4 }
]
```
## Get group-level DORA metrics
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.10.
Get group-level DORA metrics.
```plaintext
GET /groups/:id/dora/metrics
```
| Attribute | Type | Required | Description |
|-------------- |-------- |----------|----------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../README.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | The [metric name](../../user/analytics/ci_cd_analytics.md#supported-metrics-in-gitlab). One of `deployment_frequency` or `lead_time_for_changes`. |
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/dora/metrics?metric=deployment_frequency"
```
Example response:
```json
[
{ "2021-03-01": 3 },
{ "2021-03-02": 6 },
{ "2021-03-03": 0 },
{ "2021-03-04": 0 },
{ "2021-03-05": 0 },
{ "2021-03-06": 0 },
{ "2021-03-07": 0 },
{ "2021-03-08": 4 }
]
```

View File

@ -28,7 +28,7 @@ There are two places defined variables can be used. On the:
| `environment:name` | yes | GitLab | Similar to `environment:url`, but the variables expansion doesn't support the following:<br/><br/>- Variables that are based on the environment's name (`CI_ENVIRONMENT_NAME`, `CI_ENVIRONMENT_SLUG`).<br/>- Any other variables related to environment (currently only `CI_ENVIRONMENT_URL`).<br/>- [Persisted variables](#persisted-variables). |
| `resource_group` | yes | GitLab | Similar to `environment:url`, but the variables expansion doesn't support the following:<br/><br/>- Variables that are based on the environment's name (`CI_ENVIRONMENT_NAME`, `CI_ENVIRONMENT_SLUG`).<br/>- Any other variables related to environment (currently only `CI_ENVIRONMENT_URL`).<br/>- [Persisted variables](#persisted-variables). |
| `include` | yes | GitLab | The variable expansion is made by the [internal variable expansion mechanism](#gitlab-internal-variable-expansion-mechanism) in GitLab. <br/><br/>Predefined project variables are supported: `GITLAB_FEATURES`, `CI_DEFAULT_BRANCH`, and all variables that start with `CI_PROJECT_` (for example `CI_PROJECT_NAME`). |
| `variables` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
| `variables` | yes | GitLab/Runner | The variable expansion is first made by the [internal variable expansion mechanism](#gitlab-internal-variable-expansion-mechanism) in GitLab, and then any unrecognized or unavailable variables are expanded by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism). |
| `image` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
| `services:[]` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
| `services:[]:name` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
@ -61,6 +61,54 @@ The expanded part needs to be in a form of `$variable`, or `${variable}` or `%va
Each form is handled in the same way, no matter which OS/shell handles the job,
because the expansion is done in GitLab before any runner gets the job.
#### Nested variable expansion
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48627) in GitLab 13.10.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It can be enabled or disabled for a single project.
> - It's disabled on GitLab.com.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enabling-the-nested-variable-expansion-feature). **(FREE SELF)**
GitLab expands job variable values recursively before sending them to the runner. For example:
```yaml
- BUILD_ROOT_DIR: '${CI_BUILDS_DIR}'
- OUT_PATH: '${BUILD_ROOT_DIR}/out'
- PACKAGE_PATH: '${OUT_PATH}/pkg'
```
If nested variable expansion is:
- **Disabled**: the runner receives `${BUILD_ROOT_DIR}/out/pkg`. This is not a valid path.
- **Enabled**: the runner receives a valid, fully-formed path. For example, if `${CI_BUILDS_DIR}` is `/output`, then `PACKAGE_PATH` would be `/output/out/pkg`.
References to unavailable variables are left intact. In this case, the runner
[attempts to expand the variable value](#gitlab-runner-internal-variable-expansion-mechanism) at runtime.
For example, a variable like `CI_BUILDS_DIR` is known by the runner only at runtime.
##### Enabling the nested variable expansion feature **(FREE SELF)**
This feature comes with the `:variable_inside_variable` feature flag disabled by default.
To enable this feature, ask a GitLab administrator with [Rails console access](../../administration/feature_flags.md#how-to-enable-and-disable-features-behind-flags) to run the
following command:
```ruby
# For the instance
Feature.enable(:variable_inside_variable)
# For a single project
Feature.enable(:variable_inside_variable, Project.find(<project id>))
```
To disable it:
```ruby
# For the instance
Feature.disable(:variable_inside_variable)
# For a single project
Feature.disable(:variable_inside_variable, Project.find(<project id>))
```
### GitLab Runner internal variable expansion mechanism
- Supported: project/group variables, `.gitlab-ci.yml` variables, `config.toml` variables, and
@ -70,16 +118,17 @@ because the expansion is done in GitLab before any runner gets the job.
The runner uses Go's `os.Expand()` method for variable expansion. It means that it handles
only variables defined as `$variable` and `${variable}`. What's also important, is that
the expansion is done only once, so nested variables may or may not work, depending on the
ordering of variables definitions.
ordering of variables definitions, and whether [nested variable expansion](#nested-variable-expansion)
is enabled in GitLab.
### Execution shell environment
This is an expansion that takes place during the `script` execution.
How it works depends on the used shell (`bash`, `sh`, `cmd`, PowerShell). For example, if the job's
This is an expansion phase that takes place during the `script` execution.
Its behavior depends on the shell used (`bash`, `sh`, `cmd`, PowerShell). For example, if the job's
`script` contains a line `echo $MY_VARIABLE-${MY_VARIABLE_2}`, it should be properly handled
by bash/sh (leaving empty strings or some values depending whether the variables were
defined or not), but don't work with Windows' `cmd` or PowerShell, since these shells
are using a different variables syntax.
use a different variables syntax.
Supported:
@ -88,10 +137,10 @@ Supported:
`.gitlab-ci.yml` variables, `config.toml` variables, and variables from triggers and pipeline schedules).
- The `script` may also use all variables defined in the lines before. So, for example, if you define
a variable `export MY_VARIABLE="test"`:
- In `before_script`, it works in the following lines of `before_script` and
- In `before_script`, it works in the subsequent lines of `before_script` and
all lines of the related `script`.
- In `script`, it works in the following lines of `script`.
- In `after_script`, it works in following lines of `after_script`.
- In `script`, it works in the subsequent lines of `script`.
- In `after_script`, it works in subsequent lines of `after_script`.
In the case of `after_script` scripts, they can:
@ -99,7 +148,7 @@ In the case of `after_script` scripts, they can:
section.
- Not use variables defined in `before_script` and `script`.
These restrictions are because `after_script` scripts are executed in a
These restrictions exist because `after_script` scripts are executed in a
[separated shell context](../yaml/README.md#after_script).
## Persisted variables

View File

@ -54,9 +54,12 @@ See the [Rails guides](https://guides.rubyonrails.org/action_mailer_basics.html#
incoming_email:
enabled: true
# The email address including the %{key} placeholder that will be replaced to reference the item being replied to. This %{key} should be included in its entirety within the email address and not replaced by another value.
# For example: emailadress+%key@gmail.com.
# The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
# The email address including the %{key} placeholder that will be replaced to reference the
# item being replied to. This %{key} should be included in its entirety within the email
# address and not replaced by another value.
# For example: emailadress+%{key}@gmail.com.
# The placeholder must appear in the "user" part of the address (before the `@`). It can be omitted but some features,
# including Service Desk, may not work properly.
address: "gitlab-incoming+%{key}@gmail.com"
# Email account username

View File

@ -66,3 +66,29 @@ TypeError: $ is not a function
```
**Remedy - Try moving the script into a separate repository and point to it to files in the GitLab repository**
## Using Vue component issues
### When rendering a component that uses GlFilteredSearch and the component or its parent uses Vue Apollo
When trying to render our component GlFilteredSearch, you might get an error in the component's `provide` function:
`cannot read suggestionsListClass of undefined`
Currently, `vue-apollo` tries to [manually call a component's `provide()` in the `beforeCreate` part](https://github.com/vuejs/vue-apollo/blob/35e27ec398d844869e1bbbde73c6068b8aabe78a/packages/vue-apollo/src/mixin.js#L149) of the component lifecycle. This means that when a `provide()` references props, which aren't actually setup until after `created`, it will blow up.
See this [closed MR](https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2019#note_514671251) for more context.
**Remedy - try providing `apolloProvider` to the top-level Vue instance options**
VueApollo will skip manually running `provide()` if it sees that an `apolloProvider` is provided in the `$options`.
```patch
new Vue(
el,
+ apolloProvider: {},
render(h) {
return h(App);
},
);
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -257,9 +257,9 @@ To enable/disable an OmniAuth provider:
1. In the top navigation bar, go to **Admin Area**.
1. In the left sidebar, go to **Settings**.
1. Scroll to the **Sign-in Restrictions** section, and click **Expand**.
1. Next to **Enabled OAuth Sign-In sources**, select the check box for each provider you want to enable or disable.
1. Below **Enabled OAuth Sign-In sources**, select the check box for each provider you want to enable or disable.
![Enabled OAuth Sign-In sources](img/enabled-oauth-sign-in-sources.png)
![Enabled OAuth Sign-In sources](img/enabled-oauth-sign-in-sources_v13_10.png)
## Disabling OmniAuth
@ -356,3 +356,32 @@ You may also bypass the auto sign in feature by browsing to
The [Generated passwords for users created through integrated authentication](../security/passwords_for_integrated_authentication_methods.md)
guide provides an overview about how GitLab generates and sets passwords for
users created with OmniAuth.
## Custom OmniAuth provider icon
Most supported providers include a built-in icon for the rendered sign-in button.
After you ensure your image is optimized for rendering at 64 x 64 pixels,
you can override this icon in one of two ways:
- **Provide a custom image path**:
1. *If you are hosting the image outside of your GitLab server domain,* ensure
your [content security policies](https://docs.gitlab.com/omnibus/settings/configuration.html#content-security-policy)
are configured to allow access to the image file.
1. Depending on your method of installing GitLab, add a custom `icon` parameter
to your GitLab configuration file. Read [OpenID Connect OmniAuth provider](../administration/auth/oidc.md)
for an example for the OpenID Connect provider.
- **Directly embed an image in a configuration file**: This example creates a Base64-encoded
version of your image you can serve through a
[Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs):
1. Encode your image file with GNU `base64` command (such as `base64 -w 0 <logo.png>`)
which returns a single-line `<base64-data>` string.
1. Add the Base64-encoded data to a custom `icon` parameter in your GitLab configuration file:
```yaml
omniauth:
providers:
- { name: '...'
icon: 'data:image/png;base64,<base64-data>'
...
}
```

View File

@ -51,7 +51,7 @@ and the work required to fix them - all without leaving GitLab.
- Discover and view errors generated by your applications with
[Error Tracking](error_tracking.md).
## Trace application health and performance **(ULTIMATE)**
## Trace application health and performance
Application tracing in GitLab is a way to measure an application's performance and
health while it's running. After configuring your application to enable tracing, you
@ -63,7 +63,7 @@ GitLab integrates with [Jaeger](https://www.jaegertracing.io/) - an open-source,
end-to-end distributed tracing system tool used for monitoring and troubleshooting
microservices-based distributed systems - and displays results within GitLab.
- [Trace the performance and health](tracing.md) of a deployed application. **(ULTIMATE)**
- [Trace the performance and health](tracing.md) of a deployed application.
## Aggregate and store logs

View File

@ -22,7 +22,10 @@ View pipeline duration history:
![Pipeline duration](img/pipelines_duration_chart.png)
## DORA4 Metrics
## DevOps Research and Assessment (DORA) key metrics **(ULTIMATE)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/275991) in GitLab 13.7.
> - Added support for [lead time for changes](https://gitlab.com/gitlab-org/gitlab/-/issues/291746) in GitLab 13.10.
Customer experience is a key metric. Users want to measure platform stability and other
post-deployment performance KPIs, and set targets for customer behavior, experience, and financial
@ -41,9 +44,18 @@ performance indicators for software development teams:
- Time to restore service: How long it takes an organization to recover from a failure in
production.
GitLab plans to add support for all the DORA4 metrics at the project and group levels. GitLab added
the first metric, deployment frequency, at the project and group scopes for [CI/CD charts](ci_cd_analytics.md#deployment-frequency-charts),
the [Project API]( ../../api/dora4_project_analytics.md), and the [Group API]( ../../api/dora4_group_analytics.md).
### Supported metrics in GitLab
The following table shows the supported metrics, at which level they are supported, and which GitLab version (API and UI) they were introduced:
| Metric | Level | API version | Chart (UI) version | Comments |
| --------------- | ----------- | --------------- | ---------- | ------- |
| `deployment_frequency` | Project-level | [13.7+](../../api/dora/metrics.md) | [13.8+](#deployment-frequency-charts) | The [old API endopint](../../api/dora4_project_analytics.md) was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/323713) in 13.10. |
| `deployment_frequency` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | |
| `lead_time_for_changes` | Project-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
| `lead_time_for_changes` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
| `change_failure_rate` | Project/Group-level | To be supported | To be supported | |
| `time_to_restore_service` | Project/Group-level | To be supported | To be supported | |
## Deployment frequency charts **(ULTIMATE)**

View File

@ -64,32 +64,35 @@ GitLab SAST supports a variety of languages, package managers, and frameworks. O
You can also [view our language roadmap](https://about.gitlab.com/direction/secure/static-analysis/sast/#language-support) and [request other language support by opening an issue](https://gitlab.com/groups/gitlab-org/-/epics/297).
| Language (package managers) / framework | Scan tool | Introduced in GitLab Version |
|--------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0 |
| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0 |
| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1 |
| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7 |
| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.1 |
| Go | [Gosec](https://github.com/securego/gosec) | 10.7 |
| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT) |
| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1 |
| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT) |
| Java (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8 |
| Kotlin (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6 |
| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1 |
| Objective-C (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8 |
| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 |
| Python | [Semgrep](https://semgrep.dev) | 13.9 |
| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5 |
| Ruby | [brakeman](https://brakemanscanner.org) | 13.9 |
| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 |
| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) |
| Swift (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| TypeScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.9, [merged](https://gitlab.com/gitlab-org/gitlab/-/issues/36059) with ESLint in 13.2 |
| Language (package managers) / framework | Scan tool | Introduced in GitLab Version |
|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0 |
| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0 |
| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1 |
| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7 |
| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.1 |
| Go | [Gosec](https://github.com/securego/gosec) | 10.7 |
| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT) |
| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1 |
| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT) |
| Java (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8 |
| JavaScript | [Semgrep](https://semgrep.dev) | 13.10 |
| Kotlin (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6 |
| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1 |
| Objective-C (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8 |
| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 |
| Python | [Semgrep](https://semgrep.dev) | 13.9 |
| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5 |
| React | [Semgrep](https://semgrep.dev) | 13.10 |
| Ruby | [brakeman](https://brakemanscanner.org) | 13.9 |
| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 |
| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) |
| Swift (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
| TypeScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.9, [merged](https://gitlab.com/gitlab-org/gitlab/-/issues/36059) with ESLint in 13.2 |
| TypeScript | [Semgrep](https://semgrep.dev) | 13.10 |
Note that the Java analyzers can also be used for variants like the
[Gradle wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html),

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -193,17 +193,21 @@ GitLab allows users to create multiple value streams, hide default stages and cr
### Stage path
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210315) in GitLab 13.0.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210315) in GitLab 13.0.
> - It's [deployed behind a feature flag](../../feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](../../../administration/feature_flags.md). **(FREE SELF)**
Stages are visually depicted as a horizontal process flow. Selecting a stage will update the
the content below the value stream.
![Value stream path navigation](img/vsa_path_nav_v13_10.png "Value stream path navigation")
This is disabled by default. If you have a self-managed instance, an
Stages are visually depicted as a horizontal process flow. Selecting a stage updates the content below the value stream.
This is enabled by default. If you have a self-managed instance, an
administrator can [open a Rails console](../../../administration/troubleshooting/navigating_gitlab_via_rails_console.md)
and enable it with the following command:
and disable it with the following command:
```ruby
Feature.enable(:value_stream_analytics_path_navigation)
Feature.disable(:value_stream_analytics_path_navigation)
```
### Adding a stage

View File

@ -87,6 +87,7 @@ The following table depicts the various user permission levels in a project.
| See a commit status | | ✓ | ✓ | ✓ | ✓ |
| See a container registry | | ✓ | ✓ | ✓ | ✓ |
| See environments | | ✓ | ✓ | ✓ | ✓ |
| See [DORA metrics](analytics/ci_cd_analytics.md) | | ✓ | ✓ | ✓ | ✓ |
| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ |
| View CI/CD analytics | | ✓ | ✓ | ✓ | ✓ |
| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ |

View File

@ -6,35 +6,35 @@ pre-push:
eslint:
tags: frontend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "*.{js,vue}"
glob: '*.{js,vue}'
run: yarn run lint:eslint {files}
haml-lint:
tags: view haml style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "*.html.haml"
glob: '*.html.haml'
run: bundle exec haml-lint --config .haml-lint.yml {files}
markdownlint:
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "doc/*.md"
glob: 'doc/*.md'
run: yarn markdownlint {files}
stylelint:
tags: stylesheet css style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "*.scss{,.css}"
run: yarn stylelint -q {files}
glob: '*.scss{,.css}'
run: yarn stylelint {files}
prettier:
tags: frontend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "*.{js,vue,graphql}"
glob: '*.{js,vue,graphql}'
run: yarn run prettier --check {files}
rubocop:
tags: backend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "*.rb"
glob: '*.rb'
run: bundle exec rubocop --parallel --force-exclusion {files}
vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/#install-linters
vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/#install-linters
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
glob: "doc/*.md"
glob: 'doc/*.md'
run: if command -v vale 2> /dev/null; then vale --config .vale.ini --minAlertLevel error {files}; else echo "Vale not found. Install Vale"; fi

View File

@ -98,14 +98,15 @@ module Banzai
return unless image?(content)
if url?(content)
path = content
elsif file = wiki.find_file(content, load_content: false)
path = ::File.join(wiki_base_path, file.path)
end
path =
if url?(content)
content
elsif file = wiki.find_file(content, load_content: false)
file.path
end
if path
content_tag(:img, nil, data: { src: path }, class: 'gfm')
content_tag(:img, nil, src: path, class: 'gfm')
end
end

View File

@ -5,7 +5,7 @@ module Banzai
class WikiPipeline < FullPipeline
def self.filters
@filters ||= begin
super.insert_after(Filter::TableOfContentsFilter, Filter::GollumTagsFilter)
super.insert_before(Filter::ImageLazyLoadFilter, Filter::GollumTagsFilter)
.insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter)
end
end

View File

@ -303,6 +303,10 @@ semgrep-sast:
$SAST_EXPERIMENTAL_FEATURES == 'true'
exists:
- '**/*.py'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
sobelow-sast:
extends: .sast-analyzer

View File

@ -16875,6 +16875,9 @@ msgstr ""
msgid "InviteMember|Don't worry, you can always invite teammates later"
msgstr ""
msgid "InviteMember|Invite Member"
msgstr ""
msgid "InviteMember|Invite Members (optional)"
msgstr ""

View File

@ -12,6 +12,7 @@
"file-coverage": "scripts/frontend/file_test_coverage.js",
"lint-docs": "scripts/lint-doc.sh",
"internal:eslint": "eslint --cache --max-warnings 0 --report-unused-disable-directives --ext .js,.vue",
"internal:stylelint": "stylelint -q '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
"prejest": "yarn check-dependencies",
"jest": "jest --config jest.config.js",
"jest-debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
@ -32,7 +33,7 @@
"lint:prettier:fix": "yarn run prettier --write '**/*.{graphql,js,vue}'",
"lint:prettier:staged": "scripts/frontend/execute-on-staged-files.sh prettier '(graphql|js|vue)' --check",
"lint:prettier:staged:fix": "scripts/frontend/execute-on-staged-files.sh prettier '(graphql|js|vue)' --write",
"lint:stylelint": "stylelint -q '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
"lint:stylelint": "stylelint '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
"lint:stylelint:fix": "yarn run lint:stylelint --fix",
"lint:stylelint:staged": "scripts/frontend/execute-on-staged-files.sh stylelint '(css|scss)' -q",
"lint:stylelint:staged:fix": "yarn run lint:stylelint:staged --fix",

View File

@ -33,7 +33,7 @@ class StaticAnalysis
%w[bin/rake gitlab:sidekiq:all_queues_yml:check] => 13,
(Gitlab.ee? ? %w[bin/rake gitlab:sidekiq:sidekiq_queues_yml:check] : nil) => 13,
%w[bin/rake config_lint] => 11,
%w[yarn run lint:stylelint] => 9,
%w[yarn run internal:stylelint] => 9,
%w[scripts/lint-conflicts.sh] => 0.59,
%w[yarn run block-dependencies] => 0.35,
%w[scripts/lint-rugged] => 0.23,

View File

@ -209,6 +209,32 @@ RSpec.describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issue_email_participants']).to contain_exactly({ "email" => participants[0].email }, { "email" => participants[1].email })
end
context 'with the invite_members_in_comment experiment', :experiment do
context 'when user can invite' do
before do
stub_experiments(invite_members_in_comment: :invite_member_link)
project.add_maintainer(user)
end
it 'assigns the candidate experience and tracks the event' do
expect(experiment(:invite_member_link)).to track(:view, property: project.root_ancestor.id.to_s)
.on_any_instance
.for(:invite_member_link)
.with_context(namespace: project.root_ancestor)
get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
end
end
context 'when user can not invite' do
it 'does not track the event' do
expect(experiment(:invite_member_link)).not_to track(:view)
get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
end
end
end
end
describe 'GET #new' do

View File

@ -40,6 +40,32 @@ RSpec.describe Projects::MergeRequestsController do
get :show, params: params.merge(extra_params)
end
context 'with the invite_members_in_comment experiment', :experiment do
context 'when user can invite' do
before do
stub_experiments(invite_members_in_comment: :invite_member_link)
project.add_maintainer(user)
end
it 'assigns the candidate experience and tracks the event' do
expect(experiment(:invite_member_link)).to track(:view, property: project.root_ancestor.id.to_s)
.on_any_instance
.for(:invite_member_link)
.with_context(namespace: project.root_ancestor)
go
end
end
context 'when user can not invite' do
it 'does not track the event' do
expect(experiment(:invite_member_link)).not_to track(:view)
go
end
end
end
context 'with view param' do
before do
go(view: 'parallel')

View File

@ -3,7 +3,7 @@
FactoryBot.define do
factory :atlassian_identity, class: 'Atlassian::Identity' do
extern_uid { generate(:username) }
user { create(:user) }
user { association(:user) }
expires_at { 2.weeks.from_now }
token { SecureRandom.alphanumeric(1254) }
refresh_token { SecureRandom.alphanumeric(45) }

View File

@ -27,17 +27,20 @@ FactoryBot.define do
factory :wiki_page_event do
action { :created }
# rubocop: disable FactoryBot/InlineAssociation
# A persistent project is needed to have a wiki page being created properly.
project { @overrides[:wiki_page]&.container || create(:project, :wiki_repo) }
target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
# rubocop: enable FactoryBot/InlineAssociation
target { association(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do
wiki_page { create(:wiki_page, container: project) }
wiki_page { association(:wiki_page, container: project) }
end
end
trait :has_design do
transient do
design { create(:design, issue: create(:issue, project: project)) }
design { association(:design, issue: association(:issue, project: project)) }
end
end
@ -45,7 +48,7 @@ FactoryBot.define do
has_design
transient do
note { create(:note, author: author, project: project, noteable: design) }
note { association(:note, author: author, project: project, noteable: design) }
end
action { :commented }

View File

@ -5,7 +5,7 @@ FactoryBot.define do
skip_create
transient do
author { create(:user) }
author { association(:user) }
end
sequence(:message) { |n| "Commit message #{n}" }

View File

@ -14,7 +14,7 @@ FactoryBot.define do
subject { "My commit" }
body { subject + "\nMy body" }
author { build(:gitaly_commit_author) }
committer { build(:gitaly_commit_author) }
author { association(:gitaly_commit_author) }
committer { association(:gitaly_commit_author) }
end
end

View File

@ -2,8 +2,8 @@
FactoryBot.define do
factory :group_group_link do
shared_group { create(:group) }
shared_with_group { create(:group) }
shared_group { association(:group) }
shared_with_group { association(:group) }
group_access { Gitlab::Access::DEVELOPER }
trait(:guest) { group_access { Gitlab::Access::GUEST } }

View File

@ -2,6 +2,6 @@
FactoryBot.define do
factory :import_export_upload do
project { create(:project) }
project { association(:project) }
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe "User invites from a comment", :js do
let_it_be(:project) { create(:project_empty_repo, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { project.owner }
before do
sign_in(user)
end
it "launches the invite modal from invite link on a comment" do
stub_experiments(invite_members_in_comment: :invite_member_link)
visit project_issue_path(project, issue)
page.within(".new-note") do
click_button 'Invite Member'
end
expect(page).to have_content("You're inviting members to the")
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require "spec_helper"
RSpec.describe "User invites from a comment", :js do
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:user) { project.owner }
before do
sign_in(user)
end
it "launches the invite modal from invite link on a comment" do
stub_experiments(invite_members_in_comment: :invite_member_link)
visit project_merge_request_path(project, merge_request)
page.within(".new-note") do
click_button 'Invite Member'
end
expect(page).to have_content("You're inviting members to the")
end
end

View File

@ -19,12 +19,14 @@ describe('BoardContentSidebar', () => {
store = new Vuex.Store({
state: {
sidebarType: ISSUABLE,
issues: { [mockIssue.id]: mockIssue },
issues: { [mockIssue.id]: { ...mockIssue, epic: null } },
activeId: mockIssue.id,
issuableType: 'issue',
},
getters: {
activeIssue: () => mockIssue,
activeIssue: () => {
return { ...mockIssue, epic: null };
},
groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath,
isSidebarOpen: () => true,
@ -35,11 +37,18 @@ describe('BoardContentSidebar', () => {
};
const createComponent = () => {
/*
Dynamically imported components (in our case ee imports)
aren't stubbed automatically in VTU v1:
https://github.com/vuejs/vue-test-utils/issues/1279.
This requires us to additionally mock apollo or vuex stores.
*/
wrapper = shallowMount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '/',
groupId: '#',
groupId: 1,
},
store,
stubs: {
@ -53,6 +62,12 @@ describe('BoardContentSidebar', () => {
participants: {
loading: false,
},
currentIteration: {
loading: false,
},
iterations: {
loading: false,
},
},
},
},
@ -117,7 +132,7 @@ describe('BoardContentSidebar', () => {
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: mockIssue,
boardItem: { ...mockIssue, epic: null },
sidebarType: ISSUABLE,
});
});

View File

@ -126,9 +126,17 @@ describe('Flash', () => {
});
describe('deprecatedCreateFlash', () => {
const message = 'test';
const type = 'alert';
const parent = document;
const actionConfig = null;
const fadeTransition = false;
const addBodyClass = true;
const defaultParams = [message, type, parent, actionConfig, fadeTransition, addBodyClass];
describe('no flash-container', () => {
it('does not add to the DOM', () => {
const flashEl = deprecatedCreateFlash('testing');
const flashEl = deprecatedCreateFlash(message);
expect(flashEl).toBeNull();
@ -138,11 +146,9 @@ describe('Flash', () => {
describe('with flash-container', () => {
beforeEach(() => {
document.body.innerHTML += `
<div class="content-wrapper js-content-wrapper">
<div class="flash-container"></div>
</div>
`;
setFixtures(
'<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>',
);
});
afterEach(() => {
@ -150,7 +156,7 @@ describe('Flash', () => {
});
it('adds flash element into container', () => {
deprecatedCreateFlash('test', 'alert', document, null, false, true);
deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-alert')).not.toBeNull();
@ -158,26 +164,35 @@ describe('Flash', () => {
});
it('adds flash into specified parent', () => {
deprecatedCreateFlash('test', 'alert', document.querySelector('.content-wrapper'));
deprecatedCreateFlash(
message,
type,
document.querySelector('.content-wrapper'),
actionConfig,
fadeTransition,
addBodyClass,
);
expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull();
expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message);
});
it('adds container classes when inside content-wrapper', () => {
deprecatedCreateFlash('test');
deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-text').className).toBe('flash-text');
expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message);
});
it('does not add container when outside of content-wrapper', () => {
document.querySelector('.content-wrapper').className = 'js-content-wrapper';
deprecatedCreateFlash('test');
deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text');
});
it('removes element after clicking', () => {
deprecatedCreateFlash('test', 'alert', document, null, false, true);
deprecatedCreateFlash(...defaultParams);
document.querySelector('.flash-alert .js-close-icon').click();
@ -188,24 +203,37 @@ describe('Flash', () => {
describe('with actionConfig', () => {
it('adds action link', () => {
deprecatedCreateFlash('test', 'alert', document, {
title: 'test',
});
const newActionConfig = { title: 'test' };
deprecatedCreateFlash(
message,
type,
parent,
newActionConfig,
fadeTransition,
addBodyClass,
);
expect(document.querySelector('.flash-action')).not.toBeNull();
});
it('calls actionConfig clickHandler on click', () => {
const actionConfig = {
const newActionConfig = {
title: 'test',
clickHandler: jest.fn(),
};
deprecatedCreateFlash('test', 'alert', document, actionConfig);
deprecatedCreateFlash(
message,
type,
parent,
newActionConfig,
fadeTransition,
addBodyClass,
);
document.querySelector('.flash-action').click();
expect(actionConfig.clickHandler).toHaveBeenCalled();
expect(newActionConfig.clickHandler).toHaveBeenCalled();
});
});
});

View File

@ -3,7 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
jest.mock('~/experimentation/experiment_tracking');
const id = '1';
const name = 'test name';
@ -303,6 +307,7 @@ describe('InviteMembersModal', () => {
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
jest.spyOn(wrapper.vm, 'trackInvite');
clickInviteButton();
});
@ -396,5 +401,46 @@ describe('InviteMembersModal', () => {
});
});
});
describe('tracking', () => {
const postData = {
user_id: '1',
access_level: defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user3] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
});
it('tracks the invite', () => {
wrapper.vm.openModal({ inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT });
clickInviteButton();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_success');
});
it('does not track invite for unknown source', () => {
wrapper.vm.openModal({ inviteeType: 'members', source: 'unknown' });
clickInviteButton();
expect(ExperimentTracking).not.toHaveBeenCalled();
});
it('does not track invite undefined source', () => {
wrapper.vm.openModal({ inviteeType: 'members' });
clickInviteButton();
expect(ExperimentTracking).not.toHaveBeenCalled();
});
});
});
});

View File

@ -1,11 +1,16 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import eventHub from '~/invite_members/event_hub';
jest.mock('~/experimentation/experiment_tracking');
const displayText = 'Invite team members';
let wrapper;
const createComponent = (props = {}) => {
return shallowMount(InviteMembersTrigger, {
wrapper = shallowMount(InviteMembersTrigger, {
propsData: {
displayText,
...props,
@ -14,7 +19,7 @@ const createComponent = (props = {}) => {
};
describe('InviteMembersTrigger', () => {
let wrapper;
const findButton = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
@ -22,14 +27,52 @@ describe('InviteMembersTrigger', () => {
});
describe('displayText', () => {
const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
wrapper = createComponent();
});
it('includes the correct displayText for the button', () => {
createComponent();
expect(findButton().text()).toBe(displayText);
});
});
describe('clicking the link', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(eventHub, '$emit');
});
it('emits openModal from an unknown source', () => {
createComponent();
findButton().vm.$emit('click');
expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', source: 'unknown' });
});
it('emits openModal from a named source', () => {
createComponent({ triggerSource: '_trigger_source_' });
findButton().vm.$emit('click');
expect(spy).toHaveBeenCalledWith('openModal', {
inviteeType: 'members',
source: '_trigger_source_',
});
});
});
describe('tracking', () => {
it('tracks on mounting', () => {
createComponent({ trackExperiment: '_track_experiment_' });
expect(ExperimentTracking).toHaveBeenCalledWith('_track_experiment_');
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_shown');
});
it('does not track on mounting', () => {
createComponent();
expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
});
});
});

View File

@ -1,5 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
@ -10,6 +11,7 @@ import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflict
jest.mock('~/flash.js');
jest.mock('~/merge_conflicts/utils');
jest.mock('js-cookie');
describe('merge conflicts actions', () => {
let mock;
@ -80,6 +82,25 @@ describe('merge conflicts actions', () => {
});
});
describe('setConflictsData', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
decorateFiles.mockReturnValue([{ bar: 'baz' }]);
testAction(
actions.setConflictsData,
{ files, foo: 'bar' },
{},
[
{
type: types.SET_CONFLICTS_DATA,
payload: { foo: 'bar', files: [{ bar: 'baz' }] },
},
],
[],
done,
);
});
});
describe('submitResolvedConflicts', () => {
useMockLocationHelper();
const resolveConflictsPath = 'resolve/conflicts/path/mock';
@ -120,21 +141,109 @@ describe('merge conflicts actions', () => {
});
});
describe('setConflictsData', () => {
it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
decorateFiles.mockReturnValue([{ bar: 'baz' }]);
describe('setLoadingState', () => {
it('commits the right mutation', () => {
testAction(
actions.setConflictsData,
{ files, foo: 'bar' },
actions.setLoadingState,
true,
{},
[
{
type: types.SET_CONFLICTS_DATA,
payload: { foo: 'bar', files: [{ bar: 'baz' }] },
type: types.SET_LOADING_STATE,
payload: true,
},
],
[],
);
});
});
describe('setErrorState', () => {
it('commits the right mutation', () => {
testAction(
actions.setErrorState,
true,
{},
[
{
type: types.SET_ERROR_STATE,
payload: true,
},
],
[],
);
});
});
describe('setFailedRequest', () => {
it('commits the right mutation', () => {
testAction(
actions.setFailedRequest,
'errors in the request',
{},
[
{
type: types.SET_FAILED_REQUEST,
payload: 'errors in the request',
},
],
[],
);
});
});
describe('setViewType', () => {
it('commits the right mutation', (done) => {
const payload = 'viewType';
testAction(
actions.setViewType,
payload,
{},
[
{
type: types.SET_VIEW_TYPE,
payload,
},
],
[],
() => {
expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload);
done();
},
);
});
});
describe('setSubmitState', () => {
it('commits the right mutation', () => {
testAction(
actions.setSubmitState,
true,
{},
[
{
type: types.SET_SUBMIT_STATE,
payload: true,
},
],
[],
);
});
});
describe('updateCommitMessage', () => {
it('commits the right mutation', () => {
testAction(
actions.updateCommitMessage,
'some message',
{},
[
{
type: types.UPDATE_CONFLICTS_DATA,
payload: { commitMessage: 'some message' },
},
],
[],
done,
);
});
});

View File

@ -0,0 +1,187 @@
import {
CONFLICT_TYPES,
EDIT_RESOLVE_MODE,
INTERACTIVE_RESOLVE_MODE,
} from '~/merge_conflicts/constants';
import * as getters from '~/merge_conflicts/store/getters';
import realState from '~/merge_conflicts/store/state';
describe('Merge Conflicts getters', () => {
let state;
beforeEach(() => {
state = realState();
});
describe('getConflictsCount', () => {
it('returns zero when there are no files', () => {
state.conflictsData.files = [];
expect(getters.getConflictsCount(state)).toBe(0);
});
it(`counts the number of sections in files of type ${CONFLICT_TYPES.TEXT}`, () => {
state.conflictsData.files = [
{ sections: [{ conflict: true }], type: CONFLICT_TYPES.TEXT },
{ sections: [{ conflict: true }, { conflict: true }], type: CONFLICT_TYPES.TEXT },
];
expect(getters.getConflictsCount(state)).toBe(3);
});
it(`counts the number of file in files not of type ${CONFLICT_TYPES.TEXT}`, () => {
state.conflictsData.files = [
{ sections: [{ conflict: true }], type: '' },
{ sections: [{ conflict: true }, { conflict: true }], type: '' },
];
expect(getters.getConflictsCount(state)).toBe(2);
});
});
describe('getConflictsCountText', () => {
it('with one conflicts', () => {
const getConflictsCount = 1;
expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('1 conflict');
});
it('with more than one conflicts', () => {
const getConflictsCount = 3;
expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('3 conflicts');
});
});
describe('isReadyToCommit', () => {
it('return false when isSubmitting is true', () => {
state.conflictsData.files = [];
state.isSubmitting = true;
state.conflictsData.commitMessage = 'foo';
expect(getters.isReadyToCommit(state)).toBe(false);
});
it('returns false when has no commit message', () => {
state.conflictsData.files = [];
state.isSubmitting = false;
state.conflictsData.commitMessage = '';
expect(getters.isReadyToCommit(state)).toBe(false);
});
it('returns true when all conflicts are resolved and is not submitting and we have a commitMessage', () => {
state.conflictsData.files = [
{
resolveMode: INTERACTIVE_RESOLVE_MODE,
type: CONFLICT_TYPES.TEXT,
sections: [{ conflict: true }],
resolutionData: { foo: 'bar' },
},
];
state.isSubmitting = false;
state.conflictsData.commitMessage = 'foo';
expect(getters.isReadyToCommit(state)).toBe(true);
});
describe('unresolved', () => {
it(`files with resolvedMode set to ${EDIT_RESOLVE_MODE} and empty count as unresolved`, () => {
state.conflictsData.files = [
{ content: '', resolveMode: EDIT_RESOLVE_MODE },
{ content: 'foo' },
];
state.isSubmitting = false;
state.conflictsData.commitMessage = 'foo';
expect(getters.isReadyToCommit(state)).toBe(false);
});
it(`in files with resolvedMode = ${INTERACTIVE_RESOLVE_MODE} we count resolvedConflicts vs unresolved ones`, () => {
state.conflictsData.files = [
{
resolveMode: INTERACTIVE_RESOLVE_MODE,
type: CONFLICT_TYPES.TEXT,
sections: [{ conflict: true }],
resolutionData: {},
},
];
state.isSubmitting = false;
state.conflictsData.commitMessage = 'foo';
expect(getters.isReadyToCommit(state)).toBe(false);
});
});
});
describe('getCommitButtonText', () => {
it('when is submitting', () => {
state.isSubmitting = true;
expect(getters.getCommitButtonText(state)).toBe('Committing...');
});
it('when is not submitting', () => {
expect(getters.getCommitButtonText(state)).toBe('Commit to source branch');
});
});
describe('getCommitData', () => {
it('returns commit data', () => {
const baseFile = {
new_path: 'new_path',
old_path: 'new_path',
};
state.conflictsData.commitMessage = 'foo';
state.conflictsData.files = [
{
...baseFile,
resolveMode: INTERACTIVE_RESOLVE_MODE,
type: CONFLICT_TYPES.TEXT,
sections: [{ conflict: true }],
resolutionData: { bar: 'baz' },
},
{
...baseFile,
resolveMode: EDIT_RESOLVE_MODE,
type: CONFLICT_TYPES.TEXT,
content: 'resolve_mode_content',
},
{
...baseFile,
type: CONFLICT_TYPES.TEXT_EDITOR,
content: 'text_editor_content',
},
];
expect(getters.getCommitData(state)).toStrictEqual({
commit_message: 'foo',
files: [
{ ...baseFile, sections: { bar: 'baz' } },
{ ...baseFile, content: 'resolve_mode_content' },
{ ...baseFile, content: 'text_editor_content' },
],
});
});
});
describe('fileTextTypePresent', () => {
it(`returns true if there is a file with type ${CONFLICT_TYPES.TEXT}`, () => {
state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT }];
expect(getters.fileTextTypePresent(state)).toBe(true);
});
it(`returns false if there is no file with type ${CONFLICT_TYPES.TEXT}`, () => {
state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT_EDITOR }];
expect(getters.fileTextTypePresent(state)).toBe(false);
});
});
describe('getFileIndex', () => {
it(`returns the index of a file from it's blob path`, () => {
const blobPath = 'blobPath/foo';
state.conflictsData.files = [{ foo: 'bar' }, { baz: 'foo', blobPath }];
expect(getters.getFileIndex(state)({ blobPath })).toBe(1);
});
});
});

View File

@ -0,0 +1,99 @@
import { VIEW_TYPES } from '~/merge_conflicts/constants';
import * as types from '~/merge_conflicts/store/mutation_types';
import mutations from '~/merge_conflicts/store/mutations';
import realState from '~/merge_conflicts/store/state';
describe('Mutations merge conflicts store', () => {
let mockState;
beforeEach(() => {
mockState = realState();
});
describe('SET_LOADING_STATE', () => {
it('should set loading', () => {
mutations[types.SET_LOADING_STATE](mockState, true);
expect(mockState.isLoading).toBe(true);
});
});
describe('SET_ERROR_STATE', () => {
it('should set hasError', () => {
mutations[types.SET_ERROR_STATE](mockState, true);
expect(mockState.hasError).toBe(true);
});
});
describe('SET_FAILED_REQUEST', () => {
it('should set hasError and errorMessage', () => {
const payload = 'message';
mutations[types.SET_FAILED_REQUEST](mockState, payload);
expect(mockState.hasError).toBe(true);
expect(mockState.conflictsData.errorMessage).toBe(payload);
});
});
describe('SET_VIEW_TYPE', () => {
it('should set diffView', () => {
mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.INLINE);
expect(mockState.diffView).toBe(VIEW_TYPES.INLINE);
});
it(`if payload is ${VIEW_TYPES.PARALLEL} sets isParallel`, () => {
mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.PARALLEL);
expect(mockState.isParallel).toBe(true);
});
});
describe('SET_SUBMIT_STATE', () => {
it('should set isSubmitting', () => {
mutations[types.SET_SUBMIT_STATE](mockState, true);
expect(mockState.isSubmitting).toBe(true);
});
});
describe('SET_CONFLICTS_DATA', () => {
it('should set conflictsData', () => {
mutations[types.SET_CONFLICTS_DATA](mockState, {
files: [],
commit_message: 'foo',
source_branch: 'bar',
target_branch: 'baz',
commit_sha: '123456789',
});
expect(mockState.conflictsData).toStrictEqual({
files: [],
commitMessage: 'foo',
sourceBranch: 'bar',
targetBranch: 'baz',
shortCommitSha: '1234567',
});
});
});
describe('UPDATE_CONFLICTS_DATA', () => {
it('should update existing conflicts data', () => {
const payload = { foo: 'bar' };
mutations[types.UPDATE_CONFLICTS_DATA](mockState, payload);
expect(mockState.conflictsData).toStrictEqual(payload);
});
});
describe('UPDATE_FILE', () => {
it('should update a file based on its index', () => {
mockState.conflictsData.files = [{ foo: 'bar' }, { baz: 'bar' }];
mutations[types.UPDATE_FILE](mockState, { file: { new: 'one' }, index: 1 });
expect(mockState.conflictsData.files).toStrictEqual([{ foo: 'bar' }, { new: 'one' }]);
});
});
});

View File

@ -0,0 +1,106 @@
import * as utils from '~/merge_conflicts/utils';
describe('merge conflicts utils', () => {
describe('getFilePath', () => {
it('returns new path if they are the same', () => {
expect(utils.getFilePath({ new_path: 'a', old_path: 'a' })).toBe('a');
});
it('returns concatenated paths if they are different', () => {
expect(utils.getFilePath({ new_path: 'b', old_path: 'a' })).toBe('a → b');
});
});
describe('checkLineLengths', () => {
it('add empty lines to the left when right has more lines', () => {
const result = utils.checkLineLengths({ left: [1], right: [1, 2] });
expect(result.left).toHaveLength(result.right.length);
expect(result.left).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]);
});
it('add empty lines to the right when left has more lines', () => {
const result = utils.checkLineLengths({ left: [1, 2], right: [1] });
expect(result.right).toHaveLength(result.left.length);
expect(result.right).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]);
});
});
describe('getHeadHeaderLine', () => {
it('decorates the id', () => {
expect(utils.getHeadHeaderLine(1)).toStrictEqual({
buttonTitle: 'Use ours',
id: 1,
isHead: true,
isHeader: true,
isSelected: false,
isUnselected: false,
richText: 'HEAD//our changes',
section: 'head',
type: 'new',
});
});
});
describe('decorateLineForInlineView', () => {
it.each`
type | truthyProp
${'new'} | ${'isHead'}
${'old'} | ${'isOrigin'}
${'match'} | ${'hasMatch'}
`(
'when the type is $type decorates the line with $truthyProp set as true',
({ type, truthyProp }) => {
expect(utils.decorateLineForInlineView({ type, rich_text: 'rich' }, 1, true)).toStrictEqual(
{
id: 1,
hasConflict: true,
isHead: false,
isOrigin: false,
hasMatch: false,
richText: 'rich',
isSelected: false,
isUnselected: false,
[truthyProp]: true,
},
);
},
);
});
describe('getLineForParallelView', () => {
it.todo('should return a proper value');
});
describe('getOriginHeaderLine', () => {
it('decorates the id', () => {
expect(utils.getOriginHeaderLine(1)).toStrictEqual({
buttonTitle: 'Use theirs',
id: 1,
isHeader: true,
isOrigin: true,
isSelected: false,
isUnselected: false,
richText: 'origin//their changes',
section: 'origin',
type: 'old',
});
});
});
describe('setInlineLine', () => {
it.todo('should return a proper value');
});
describe('setParallelLine', () => {
it.todo('should return a proper value');
});
describe('decorateFiles', () => {
it.todo('should return a proper value');
});
describe('restoreFileLinesState', () => {
it.todo('should return a proper value');
});
describe('markLine', () => {
it.todo('should return a proper value');
});
});

View File

@ -1,38 +1,35 @@
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue';
describe('MRWidgetHeader', () => {
let vm;
let Component;
let wrapper;
beforeEach(() => {
Component = Vue.extend(headerComponent);
});
const createComponent = (propsData = {}) => {
wrapper = shallowMount(Header, {
propsData,
});
};
afterEach(() => {
vm.$destroy();
wrapper.destroy();
gon.relative_url_root = '';
});
const expectDownloadDropdownItems = () => {
const downloadEmailPatchesEl = vm.$el.querySelector('.js-download-email-patches');
const downloadPlainDiffEl = vm.$el.querySelector('.js-download-plain-diff');
const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches');
const downloadPlainDiffEl = wrapper.find('.js-download-plain-diff');
expect(downloadEmailPatchesEl.innerText.trim()).toEqual('Email patches');
expect(downloadEmailPatchesEl.querySelector('a').getAttribute('href')).toEqual(
'/mr/email-patches',
);
expect(downloadPlainDiffEl.innerText.trim()).toEqual('Plain diff');
expect(downloadPlainDiffEl.querySelector('a').getAttribute('href')).toEqual(
'/mr/plainDiffPath',
);
expect(downloadEmailPatchesEl.text().trim()).toBe('Email patches');
expect(downloadEmailPatchesEl.attributes('href')).toBe('/mr/email-patches');
expect(downloadPlainDiffEl.text().trim()).toBe('Plain diff');
expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath');
};
describe('computed', () => {
describe('shouldShowCommitsBehindText', () => {
it('return true when there are divergedCommitsCount', () => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@ -42,11 +39,11 @@ describe('MRWidgetHeader', () => {
},
});
expect(vm.shouldShowCommitsBehindText).toEqual(true);
expect(wrapper.vm.shouldShowCommitsBehindText).toBe(true);
});
it('returns false where there are no divergedComits count', () => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 0,
sourceBranch: 'mr-widget-refactor',
@ -56,13 +53,13 @@ describe('MRWidgetHeader', () => {
},
});
expect(vm.shouldShowCommitsBehindText).toEqual(false);
expect(wrapper.vm.shouldShowCommitsBehindText).toBe(false);
});
});
describe('commitsBehindText', () => {
it('returns singular when there is one commit', () => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 1,
sourceBranch: 'mr-widget-refactor',
@ -73,13 +70,13 @@ describe('MRWidgetHeader', () => {
},
});
expect(vm.commitsBehindText).toEqual(
expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">1 commit behind</a> the target branch',
);
});
it('returns plural when there is more than one commit', () => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 2,
sourceBranch: 'mr-widget-refactor',
@ -90,7 +87,7 @@ describe('MRWidgetHeader', () => {
},
});
expect(vm.commitsBehindText).toEqual(
expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">2 commits behind</a> the target branch',
);
});
@ -100,7 +97,7 @@ describe('MRWidgetHeader', () => {
describe('template', () => {
describe('common elements', () => {
beforeEach(() => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@ -118,17 +115,17 @@ describe('MRWidgetHeader', () => {
});
it('renders source branch link', () => {
expect(vm.$el.querySelector('.js-source-branch').innerHTML).toEqual(
expect(wrapper.find('.js-source-branch').html()).toContain(
'<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
);
});
it('renders clipboard button', () => {
expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null);
expect(wrapper.find('[data-testid="mr-widget-copy-clipboard"]')).not.toBe(null);
});
it('renders target branch', () => {
expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
expect(wrapper.find('.js-target-branch').text().trim()).toBe('master');
});
});
@ -151,71 +148,68 @@ describe('MRWidgetHeader', () => {
targetProjectFullPath: 'gitlab-org/gitlab-ce',
};
afterEach(() => {
vm.$destroy();
});
beforeEach(() => {
vm = mountComponent(Component, {
createComponent({
mr: { ...mrDefaultOptions },
});
});
it('renders checkout branch button with modal trigger', () => {
const button = vm.$el.querySelector('.js-check-out-branch');
const button = wrapper.find('.js-check-out-branch');
expect(button.textContent.trim()).toBe('Check out branch');
expect(button.text().trim()).toBe('Check out branch');
});
it('renders web ide button', () => {
const button = vm.$el.querySelector('.js-web-ide');
it('renders web ide button', async () => {
const button = wrapper.find('.js-web-ide');
expect(button.textContent.trim()).toEqual('Open in Web IDE');
expect(button.classList.contains('disabled')).toBe(false);
expect(button.getAttribute('href')).toEqual(
await nextTick();
expect(button.text().trim()).toBe('Open in Web IDE');
expect(button.classes('disabled')).toBe(false);
expect(button.attributes('href')).toBe(
'/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce',
);
});
it('renders web ide button in disabled state with no href', () => {
it('renders web ide button in disabled state with no href', async () => {
const mr = { ...mrDefaultOptions, canPushToSourceBranch: false };
vm = mountComponent(Component, { mr });
createComponent({ mr });
const link = vm.$el.querySelector('.js-web-ide');
await nextTick();
expect(link.classList.contains('disabled')).toBe(true);
expect(link.getAttribute('href')).toBeNull();
const link = wrapper.find('.js-web-ide');
expect(link.attributes('disabled')).toBe('true');
expect(link.attributes('href')).toBeUndefined();
});
it('renders web ide button with blank query string if target & source project branch', (done) => {
vm.mr.targetProjectFullPath = 'root/gitlab-ce';
it('renders web ide button with blank query string if target & source project branch', async () => {
createComponent({ mr: { ...mrDefaultOptions, targetProjectFullPath: 'root/gitlab-ce' } });
vm.$nextTick(() => {
const button = vm.$el.querySelector('.js-web-ide');
await nextTick();
expect(button.textContent.trim()).toEqual('Open in Web IDE');
expect(button.getAttribute('href')).toEqual(
'/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
);
const button = wrapper.find('.js-web-ide');
done();
});
expect(button.text().trim()).toBe('Open in Web IDE');
expect(button.attributes('href')).toBe(
'/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
);
});
it('renders web ide button with relative URL', (done) => {
it('renders web ide button with relative URL', async () => {
gon.relative_url_root = '/gitlab';
vm.mr.iid = 2;
vm.$nextTick(() => {
const button = vm.$el.querySelector('.js-web-ide');
createComponent({ mr: { ...mrDefaultOptions, iid: 2 } });
expect(button.textContent.trim()).toEqual('Open in Web IDE');
expect(button.getAttribute('href')).toEqual(
'/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
);
await nextTick();
done();
});
const button = wrapper.find('.js-web-ide');
expect(button.text().trim()).toBe('Open in Web IDE');
expect(button.attributes('href')).toBe(
'/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
);
});
it('renders download dropdown with links', () => {
@ -225,7 +219,7 @@ describe('MRWidgetHeader', () => {
describe('with a closed merge request', () => {
beforeEach(() => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@ -243,9 +237,9 @@ describe('MRWidgetHeader', () => {
});
it('does not render checkout branch button with modal trigger', () => {
const button = vm.$el.querySelector('.js-check-out-branch');
const button = wrapper.find('.js-check-out-branch');
expect(button).toEqual(null);
expect(button.exists()).toBe(false);
});
it('renders download dropdown with links', () => {
@ -255,7 +249,7 @@ describe('MRWidgetHeader', () => {
describe('without diverged commits', () => {
beforeEach(() => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 0,
sourceBranch: 'mr-widget-refactor',
@ -273,13 +267,13 @@ describe('MRWidgetHeader', () => {
});
it('does not render diverged commits info', () => {
expect(vm.$el.querySelector('.diverged-commits-count')).toEqual(null);
expect(wrapper.find('.diverged-commits-count').exists()).toBe(false);
});
});
describe('with diverged commits', () => {
beforeEach(() => {
vm = mountComponent(Component, {
createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@ -297,17 +291,13 @@ describe('MRWidgetHeader', () => {
});
it('renders diverged commits info', () => {
expect(vm.$el.querySelector('.diverged-commits-count').textContent).toEqual(
expect(wrapper.find('.diverged-commits-count').text().trim()).toBe(
'The source branch is 12 commits behind the target branch',
);
expect(vm.$el.querySelector('.diverged-commits-count a').textContent).toEqual(
'12 commits behind',
);
expect(vm.$el.querySelector('.diverged-commits-count a')).toHaveAttr(
'href',
vm.mr.targetBranchPath,
expect(wrapper.find('.diverged-commits-count a').text().trim()).toBe('12 commits behind');
expect(wrapper.find('.diverged-commits-count a').attributes('href')).toBe(
wrapper.vm.mr.targetBranchPath,
);
});
});

View File

@ -1,35 +1,75 @@
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
import { mount } from '@vue/test-utils';
import { isExperimentVariant } from '~/experimentation/utils';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
import Toolbar from '~/vue_shared/components/markdown/toolbar.vue';
jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() }));
describe('toolbar', () => {
let vm;
const Toolbar = Vue.extend(toolbar);
const props = {
markdownDocsPath: '',
let wrapper;
const createMountedWrapper = (props = {}) => {
wrapper = mount(Toolbar, {
propsData: { markdownDocsPath: '', ...props },
stubs: { 'invite-members-trigger': true },
});
};
afterEach(() => {
vm.$destroy();
wrapper.destroy();
isExperimentVariant.mockReset();
});
describe('user can attach file', () => {
beforeEach(() => {
vm = mountComponent(Toolbar, props);
createMountedWrapper();
});
it('should render uploading-container', () => {
expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
expect(wrapper.vm.$el.querySelector('.uploading-container')).not.toBeNull();
});
});
describe('user cannot attach file', () => {
beforeEach(() => {
vm = mountComponent(Toolbar, { ...props, canAttachFile: false });
createMountedWrapper({ canAttachFile: false });
});
it('should not render uploading-container', () => {
expect(vm.$el.querySelector('.uploading-container')).toBeNull();
expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull();
});
});
describe('user can invite member', () => {
const findInviteLink = () => wrapper.find(InviteMembersTrigger);
beforeEach(() => {
isExperimentVariant.mockReturnValue(true);
createMountedWrapper();
});
it('should render the invite members trigger', () => {
expect(findInviteLink().exists()).toBe(true);
});
it('should have correct props', () => {
expect(findInviteLink().props().displayText).toBe('Invite Member');
expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT);
expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT);
});
});
describe('user can not invite member', () => {
const findInviteLink = () => wrapper.find(InviteMembersTrigger);
beforeEach(() => {
isExperimentVariant.mockReturnValue(false);
createMountedWrapper();
});
it('should render the invite members trigger', () => {
expect(findInviteLink().exists()).toBe(false);
});
});
});

View File

@ -166,7 +166,7 @@ RSpec.describe Mutations::ReleaseAssetLinks::Update do
end
context "when the link doesn't exist" do
let(:mutation_arguments) { super().merge(id: 'gid://gitlab/Releases::Link/999999') }
let(:mutation_arguments) { super().merge(id: "gid://gitlab/Releases::Link/#{non_existing_record_id}") }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)

View File

@ -27,7 +27,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('img')['data-src']).to eq "#{wiki.wiki_base_path}/images/image.jpg"
expect(doc.at_css('img')['src']).to eq 'images/image.jpg'
end
it 'does not creates img tag if image does not exist' do
@ -45,7 +45,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
tag = '[[http://example.com/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
expect(doc.at_css('img')['data-src']).to eq "http://example.com/image.jpg"
expect(doc.at_css('img')['src']).to eq "http://example.com/image.jpg"
end
it 'does not creates img tag for invalid URL' do

View File

@ -289,4 +289,29 @@ RSpec.describe Banzai::Pipeline::WikiPipeline do
expect(output).to include('<audio src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/audio%20file%20name.wav"')
end
end
describe 'gollum tag filters' do
context 'when local image file exists' do
it 'sets the proper attributes for the image' do
gollum_file_double = double('Gollum::File',
mime_type: 'image/jpeg',
name: 'images/image.jpg',
path: 'images/image.jpg',
raw_data: '')
wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double)
markdown = "[[#{wiki_file.path}]]"
expect(wiki).to receive(:find_file).with(wiki_file.path, load_content: false).and_return(wiki_file)
output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
doc = Nokogiri::HTML::DocumentFragment.parse(output)
full_path = "/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/#{wiki_file.path}"
expect(doc.css('a')[0].attr('href')).to eq(full_path)
expect(doc.css('img')[0].attr('class')).to eq('gfm lazy')
expect(doc.css('img')[0].attr('data-src')).to eq(full_path)
end
end
end
end

View File

@ -821,45 +821,6 @@ RSpec.describe Ci::Build do
{ cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] }
end
context 'with multiple_cache_per_job FF disabled' do
before do
stub_feature_flags(multiple_cache_per_job: false)
end
let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } }
subject { build.cache }
context 'when build has cache' do
before do
allow(build).to receive(:options).and_return(options)
end
context 'when project has jobs_cache_index' do
before do
allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
end
it { is_expected.to be_an(Array).and all(include(key: "key-1")) }
end
context 'when project does not have jobs_cache_index' do
before do
allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil)
end
it { is_expected.to eq([options[:cache]]) }
end
end
context 'when build does not have cache' do
before do
allow(build).to receive(:options).and_return({})
end
it { is_expected.to eq([]) }
end
end
subject { build.cache }
context 'when build has cache' do

View File

@ -6016,12 +6016,15 @@ RSpec.describe Project, factory_default: :keep do
project.set_first_pages_deployment!(deployment)
expect(project.pages_metadatum.reload.pages_deployment).to eq(deployment)
expect(project.pages_metadatum.reload.deployed).to eq(true)
end
it "updates the existing metadara record with deployment" do
expect do
project.set_first_pages_deployment!(deployment)
end.to change { project.pages_metadatum.reload.pages_deployment }.from(nil).to(deployment)
expect(project.pages_metadatum.reload.deployed).to eq(true)
end
it 'only updates metadata for this project' do
@ -6030,6 +6033,8 @@ RSpec.describe Project, factory_default: :keep do
expect do
project.set_first_pages_deployment!(deployment)
end.not_to change { other_project.pages_metadatum.reload.pages_deployment }.from(nil)
expect(other_project.pages_metadatum.reload.deployed).to eq(false)
end
it 'does nothing if metadata already references some deployment' do
@ -6040,6 +6045,14 @@ RSpec.describe Project, factory_default: :keep do
project.set_first_pages_deployment!(deployment)
end.not_to change { project.pages_metadatum.reload.pages_deployment }.from(existing_deployment)
end
it 'marks project as not deployed if deployment is nil' do
project.mark_pages_as_deployed
expect do
project.set_first_pages_deployment!(nil)
end.to change { project.pages_metadatum.reload.deployed }.from(true).to(false)
end
end
describe '#has_pool_repsitory?' do

View File

@ -85,7 +85,7 @@ RSpec.describe Ci::BuildRunnerPresenter do
Ci::JobArtifact::DEFAULT_FILE_NAMES.each do |file_type, filename|
context file_type.to_s do
let(:report) { { "#{file_type}": [filename] } }
let(:build) { create(:ci_build, options: { artifacts: { reports: report } } ) }
let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) }
let(:report_expectation) do
{
@ -106,7 +106,7 @@ RSpec.describe Ci::BuildRunnerPresenter do
context "when option has both archive and reports specification" do
let(:report) { { junit: ['junit.xml'] } }
let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } } ) }
let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } }) }
let(:report_expectation) do
{
@ -272,27 +272,82 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
end
describe '#variables' do
subject { presenter.variables }
let(:build) { create(:ci_build) }
it 'returns a Collection' do
is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
end
end
describe '#runner_variables' do
subject { presenter.runner_variables }
let(:build) { create(:ci_build) }
let_it_be(:project_with_flag_disabled) { create(:project, :repository) }
let_it_be(:project_with_flag_enabled) { create(:project, :repository) }
it 'returns an array' do
is_expected.to be_an_instance_of(Array)
before do
stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
end
it 'returns the expected variables' do
is_expected.to eq(presenter.variables.to_runner_variables)
shared_examples 'returns an array with the expected variables' do
it 'returns an array' do
is_expected.to be_an_instance_of(Array)
end
it 'returns the expected variables' do
is_expected.to eq(presenter.variables.to_runner_variables)
end
end
context 'when FF :variable_inside_variable is disabled' do
let(:sha) { project_with_flag_disabled.repository.commit.sha }
let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_disabled) }
let(:build) { create(:ci_build, pipeline: pipeline) }
it_behaves_like 'returns an array with the expected variables'
end
context 'when FF :variable_inside_variable is enabled' do
let(:sha) { project_with_flag_enabled.repository.commit.sha }
let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_enabled) }
let(:build) { create(:ci_build, pipeline: pipeline) }
it_behaves_like 'returns an array with the expected variables'
end
end
describe '#runner_variables subset' do
subject { presenter.runner_variables.select { |v| %w[A B C].include?(v.fetch(:key)) } }
let(:build) { create(:ci_build) }
context 'with references in pipeline variables' do
before do
create(:ci_pipeline_variable, key: 'A', value: 'refA-$B', pipeline: build.pipeline)
create(:ci_pipeline_variable, key: 'B', value: 'refB-$C-$D', pipeline: build.pipeline)
create(:ci_pipeline_variable, key: 'C', value: 'value', pipeline: build.pipeline)
end
context 'when FF :variable_inside_variable is disabled' do
before do
stub_feature_flags(variable_inside_variable: false)
end
it 'returns non-expanded variables' do
is_expected.to eq [
{ key: 'A', value: 'refA-$B', public: false, masked: false },
{ key: 'B', value: 'refB-$C-$D', public: false, masked: false },
{ key: 'C', value: 'value', public: false, masked: false }
]
end
end
context 'when FF :variable_inside_variable is enabled' do
before do
stub_feature_flags(variable_inside_variable: [build.project])
end
it 'returns expanded and sorted variables' do
is_expected.to eq [
{ key: 'C', value: 'value', public: false, masked: false },
{ key: 'B', value: 'refB-value-$D', public: false, masked: false },
{ key: 'A', value: 'refA-refB-value-$D', public: false, masked: false }
]
end
end
end
end
end

View File

@ -48,12 +48,12 @@ RSpec.describe Pages::MigrateFromLegacyStorageService do
end
context 'when pages directory does not exist' do
it 'tries to migrate the project, but does not crash' do
it 'counts project as migrated' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project, ignore_invalid_entries: false) do |service|
expect(service).to receive(:execute).and_call_original
end
expect(service.execute).to eq(migrated: 0, errored: 1)
expect(service.execute).to eq(migrated: 1, errored: 0)
end
end

View File

@ -11,7 +11,7 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(zip_service).to receive(:execute).and_call_original
end
expect(described_class.new(project, ignore_invalid_entries: true).execute[:status]).to eq(:error)
expect(described_class.new(project, ignore_invalid_entries: true).execute[:status]).to eq(:success)
end
it 'marks pages as not deployed if public directory is absent' do
@ -20,8 +20,8 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(project.pages_metadatum.reload.deployed).to eq(true)
expect(service.execute).to(
eq(status: :error,
message: "Can't create zip archive: Can not find valid public dir in #{project.pages_path}")
eq(status: :success,
message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
)
expect(project.pages_metadatum.reload.deployed).to eq(false)
@ -35,8 +35,8 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(project.pages_metadatum.reload.deployed).to eq(true)
expect(service.execute).to(
eq(status: :error,
message: "Can't create zip archive: Can not find valid public dir in #{project.pages_path}")
eq(status: :success,
message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
)
expect(project.pages_metadatum.reload.deployed).to eq(true)

View File

@ -12,8 +12,10 @@ RSpec.describe Pages::ZipDirectoryService do
let(:ignore_invalid_entries) { false }
let(:service_directory) { @work_dir }
let(:service) do
described_class.new(@work_dir, ignore_invalid_entries: ignore_invalid_entries)
described_class.new(service_directory, ignore_invalid_entries: ignore_invalid_entries)
end
let(:result) do
@ -25,32 +27,41 @@ RSpec.describe Pages::ZipDirectoryService do
let(:archive) { result[:archive_path] }
let(:entries_count) { result[:entries_count] }
it 'returns error if project pages dir does not exist' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
shared_examples 'handles invalid public directory' do
it 'returns success' do
expect(status).to eq(:success)
expect(archive).to be_nil
expect(entries_count).to be_nil
end
expect(
described_class.new("/tmp/not/existing/dir").execute
).to eq(status: :error, message: "Can not find valid public dir in /tmp/not/existing/dir")
it 'returns error if pages_migration_mark_as_not_deployed is disabled' do
stub_feature_flags(pages_migration_mark_as_not_deployed: false)
expect(status).to eq(:error)
expect(message).to eq("Can not find valid public dir in #{service_directory}")
expect(archive).to be_nil
expect(entries_count).to be_nil
end
end
it 'returns nils if there is no public directory and does not leave archive' do
expect(status).to eq(:error)
expect(message).to eq("Can not find valid public dir in #{@work_dir}")
expect(archive).to eq(nil)
expect(entries_count).to eq(nil)
context "when work direcotry doesn't exist" do
let(:service_directory) { "/tmp/not/existing/dir" }
expect(File.exist?(File.join(@work_dir, '@migrated.zip'))).to eq(false)
include_examples 'handles invalid public directory'
end
it 'returns nils if public directory is a symlink' do
create_dir('target')
create_file('./target/index.html', 'hello')
create_link("public", "./target")
context 'when public directory is absent' do
include_examples 'handles invalid public directory'
end
expect(status).to eq(:error)
expect(message).to eq("Can not find valid public dir in #{@work_dir}")
expect(archive).to eq(nil)
expect(entries_count).to eq(nil)
context 'when public directory is a symlink' do
before do
create_dir('target')
create_file('./target/index.html', 'hello')
create_link("public", "./target")
end
include_examples 'handles invalid public directory'
end
context 'when there is a public directory' do