diff options
34 files changed, 668 insertions, 214 deletions
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 1c56327c03c..2d222903c0c 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -187,6 +187,21 @@ } ] }, + "coverage_report": { + "type": "object", + "description": "Used to collect coverage reports from the job.", + "properties": { + "coverage_format": { + "description": "Code coverage format used by the test framework.", + "enum": ["cobertura"] + }, + "path": { + "description": "Path to the coverage report file that should be parsed.", + "type": "string", + "minLength": 1 + } + } + }, "codequality": { "$ref": "#/definitions/string_file_list", "description": "Path to file or list of files with code quality report(s) (such as Code Climate)." diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 4ebb49b4756..3726743c032 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -19,3 +19,4 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; export const TYPE_VULNERABILITY = 'Vulnerability'; +export const TYPE_WORK_ITEM = 'WorkItem'; diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 0490728c6bc..78ef909c458 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -185,6 +185,11 @@ export default { required: false, default: false, }, + issueId: { + type: Number, + required: false, + default: null, + }, }, data() { const store = new Store({ @@ -534,6 +539,7 @@ export default { <component :is="descriptionComponent" + :issue-id="issueId" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" @@ -545,6 +551,7 @@ export default { @taskListUpdateStarted="taskListUpdateStarted" @taskListUpdateSucceeded="taskListUpdateSucceeded" @taskListUpdateFailed="taskListUpdateFailed" + @updateDescription="state.descriptionHtml = $event" /> <edited-component diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 68ed7bb4062..9f2d48ad82b 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -7,6 +7,8 @@ import { GlButton, } from '@gitlab/ui'; import $ from 'jquery'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; import TaskList from '~/task_list'; @@ -63,6 +65,11 @@ export default { required: false, default: 0, }, + issueId: { + type: Number, + required: false, + default: null, + }, }, data() { return { @@ -81,6 +88,9 @@ export default { workItemsEnabled() { return this.glFeatures.workItems; }, + issueGid() { + return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null; + }, }, watch: { descriptionHtml(newDescription, oldDescription) { @@ -92,6 +102,9 @@ export default { this.$nextTick(() => { this.renderGFM(); + if (this.workItemsEnabled) { + this.renderTaskActions(); + } }); }, taskStatus() { @@ -168,9 +181,24 @@ export default { return; } + this.taskButtons = []; const taskListFields = this.$el.querySelectorAll('.task-list-item'); taskListFields.forEach((item, index) => { + const taskLink = item.querySelector('.gfm-issue'); + if (taskLink) { + const { issue, referenceType } = taskLink.dataset; + taskLink.addEventListener('click', (e) => { + e.preventDefault(); + this.workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue); + this.track('viewed_work_item_from_modal', { + category: 'workItems:show', + label: 'work_item_view', + property: `type_${referenceType}`, + }); + }); + return; + } const button = document.createElement('button'); button.classList.add( 'btn', @@ -195,7 +223,14 @@ export default { }); }, openCreateTaskModal(id) { - this.activeTask = { id, title: this.$el.querySelector(`#${id}`).parentElement.innerText }; + const { parentElement } = this.$el.querySelector(`#${id}`); + const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g); + this.activeTask = { + id, + title: parentElement.innerText, + lineNumberStart: lineNumbers[0], + lineNumberEnd: lineNumbers[1], + }; this.$refs.modal.show(); }, closeCreateTaskModal() { @@ -207,38 +242,10 @@ export default { handleWorkItemDetailModalError(message) { createFlash({ message }); }, - handleCreateTask({ id, title, type }) { - const listItem = this.$el.querySelector(`#${this.activeTask.id}`).parentElement; - const taskBadge = document.createElement('span'); - taskBadge.innerHTML = ` - <svg data-testid="issue-open-m-icon" role="img" aria-hidden="true" class="gl-icon gl-fill-green-500 s12"> - <use href="${gon.sprite_icons}#issue-open-m"></use> - </svg> - <span class="badge badge-info badge-pill gl-badge sm gl-mr-1"> - ${__('Task')} - </span> - `; - const button = this.createWorkItemDetailButton(id, title, type); - taskBadge.append(button); - - listItem.insertBefore(taskBadge, listItem.lastChild); - listItem.removeChild(listItem.lastChild); + handleCreateTask(description) { + this.$emit('updateDescription', description); this.closeCreateTaskModal(); }, - createWorkItemDetailButton(id, title, type) { - const button = document.createElement('button'); - button.addEventListener('click', () => { - this.workItemId = id; - this.track('viewed_work_item_from_modal', { - category: 'workItems:show', - label: 'work_item_view', - property: `type_${type}`, - }); - }); - button.classList.add('btn-link'); - button.innerText = title; - return button; - }, focusButton() { this.$refs.convertButton[0].$el.focus(); }, @@ -287,6 +294,10 @@ export default { <create-work-item :is-modal="true" :initial-title="activeTask.title" + :issue-gid="issueGid" + :lock-version="lockVersion" + :line-number-start="activeTask.lineNumberStart" + :line-number-end="activeTask.lineNumberEnd" @closeModal="closeCreateTaskModal" @onCreate="handleCreateTask" /> diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index c9af5d9b4a7..4a5ebf9615b 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -102,7 +102,7 @@ export function initIssueApp(issueData, store) { isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, - id: this.getNoteableData?.id, + issueId: this.getNoteableData?.id, }, }); }, diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 942677bb937..d5687d26499 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -1,5 +1,5 @@ <script> -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import workItemQuery from '../graphql/work_item.query.graphql'; import ItemTitle from './item_title.vue'; @@ -7,6 +7,7 @@ import ItemTitle from './item_title.vue'; export default { components: { GlModal, + GlLoadingIcon, ItemTitle, }, props: { @@ -57,6 +58,7 @@ export default { <template> <gl-modal hide-footer modal-id="work-item-detail-modal" :visible="visible" @hide="$emit('close')"> - <item-title class="gl-m-0!" :initial-title="workItemTitle" /> + <gl-loading-icon v-if="$apollo.queries.workItem.loading" size="md" /> + <item-title v-else class="gl-m-0!" :initial-title="workItemTitle" /> </gl-modal> </template> diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql new file mode 100644 index 00000000000..b25210f5c74 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -0,0 +1,9 @@ +mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { + workItemCreateFromTask(input: $input) { + workItem { + id + descriptionHtml + } + errors + } +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index cc90cedb110..bbbeecbeaeb 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,21 +1,25 @@ <script> -import { GlButton, GlAlert, GlLoadingIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; +import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import ItemTitle from '../components/item_title.vue'; export default { + createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + fetchTypesErrorText: s__( + 'WorkItem|Something went wrong when fetching work item types. Please try again', + ), components: { GlButton, GlAlert, GlLoadingIcon, - GlDropdown, - GlDropdownItem, ItemTitle, + GlFormSelect, }, inject: ['fullPath'], props: { @@ -29,6 +33,26 @@ export default { required: false, default: '', }, + issueGid: { + type: String, + required: false, + default: '', + }, + lockVersion: { + type: Number, + required: false, + default: null, + }, + lineNumberStart: { + type: String, + required: false, + default: null, + }, + lineNumberEnd: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -36,6 +60,7 @@ export default { error: null, workItemTypes: [], selectedWorkItemType: null, + loading: false, }; }, apollo: { @@ -47,12 +72,13 @@ export default { }; }, update(data) { - return data.workspace?.workItemTypes?.nodes; + return data.workspace?.workItemTypes?.nodes.map((node) => ({ + value: node.id, + text: node.name, + })); }, error() { - this.error = s__( - 'WorkItem|Something went wrong when fetching work item types. Please try again', - ); + this.error = this.$options.fetchTypesErrorText; }, }, }, @@ -60,9 +86,27 @@ export default { dropdownButtonText() { return this.selectedWorkItemType?.name || s__('WorkItem|Type'); }, + formOptions() { + return [ + { value: null, text: s__('WorkItem|Please select work item type') }, + ...this.workItemTypes, + ]; + }, + isButtonDisabled() { + return this.title.trim().length === 0 || !this.selectedWorkItemType; + }, }, methods: { async createWorkItem() { + this.loading = true; + if (this.isModal) { + await this.createWorkItemFromTask(); + } else { + await this.createStandaloneWorkItem(); + } + this.loading = false; + }, + async createStandaloneWorkItem() { try { const response = await this.$apollo.mutate({ mutation: createWorkItemMutation, @@ -70,7 +114,7 @@ export default { input: { title: this.title, projectPath: this.fullPath, - workItemTypeId: this.selectedWorkItemType?.id, + workItemTypeId: this.selectedWorkItemType, }, }, update(store, { data: { workItemCreate } }) { @@ -96,23 +140,38 @@ export default { }); }, }); - const { data: { workItemCreate: { - workItem: { id, type }, + workItem: { id }, }, }, } = response; - if (!this.isModal) { - this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); - } else { - this.$emit('onCreate', { id, title: this.title, type }); - } + this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); + } catch { + this.error = this.$options.createErrorText; + } + }, + async createWorkItemFromTask() { + try { + const { data } = await this.$apollo.mutate({ + mutation: createWorkItemFromTaskMutation, + variables: { + input: { + id: this.issueGid, + workItemData: { + lockVersion: this.lockVersion, + title: this.title, + lineNumberStart: Number(this.lineNumberStart), + lineNumberEnd: Number(this.lineNumberEnd), + workItemTypeId: this.selectedWorkItemType, + }, + }, + }, + }); + this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); } catch { - this.error = s__( - 'WorkItem|Something went wrong when creating a work item. Please try again', - ); + this.error = this.$options.createErrorText; } }, handleTitleInput(title) { @@ -125,9 +184,6 @@ export default { } this.$emit('closeModal'); }, - selectWorkItemType(type) { - this.selectedWorkItemType = type; - }, }, }; </script> @@ -142,22 +198,17 @@ export default { @title-input="handleTitleInput" /> <div> - <gl-dropdown :text="dropdownButtonText"> - <gl-loading-icon - v-if="$apollo.queries.workItemTypes.loading" - size="md" - data-testid="loading-types" - /> - <template v-else> - <gl-dropdown-item - v-for="type in workItemTypes" - :key="type.id" - @click="selectWorkItemType(type)" - > - {{ type.name }} - </gl-dropdown-item> - </template> - </gl-dropdown> + <gl-loading-icon + v-if="$apollo.queries.workItemTypes.loading" + size="md" + data-testid="loading-types" + /> + <gl-form-select + v-else + v-model="selectedWorkItemType" + :options="formOptions" + class="gl-max-w-26" + /> </div> </div> <div @@ -166,8 +217,9 @@ export default { > <gl-button variant="confirm" - :disabled="title.length === 0" + :disabled="isButtonDisabled" :class="{ 'gl-mr-3': !isModal }" + :loading="loading" data-testid="create-button" type="submit" > diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 082993130a1..015dfc16df0 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -64,35 +64,50 @@ module Ci def create_archive(artifacts) return unless artifacts[:untracked] || artifacts[:paths] - archive = { - artifact_type: :archive, - artifact_format: :zip, - name: artifacts[:name], - untracked: artifacts[:untracked], - paths: artifacts[:paths], - when: artifacts[:when], - expire_in: artifacts[:expire_in] - } - - if artifacts.dig(:exclude).present? - archive.merge(exclude: artifacts[:exclude]) - else - archive + BuildArtifact.for_archive(artifacts).to_h.tap do |artifact| + artifact.delete(:exclude) unless artifact[:exclude].present? end end def create_reports(reports, expire_in:) return unless reports&.any? - reports.map do |report_type, report_paths| - { - artifact_type: report_type.to_sym, - artifact_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(report_type.to_sym), - name: ::Ci::JobArtifact::DEFAULT_FILE_NAMES.fetch(report_type.to_sym), - paths: report_paths, + reports.map { |report| BuildArtifact.for_report(report, expire_in).to_h.compact } + end + + BuildArtifact = Struct.new(:name, :untracked, :paths, :exclude, :when, :expire_in, :artifact_type, :artifact_format, keyword_init: true) do + def self.for_archive(artifacts) + self.new( + artifact_type: :archive, + artifact_format: :zip, + name: artifacts[:name], + untracked: artifacts[:untracked], + paths: artifacts[:paths], + when: artifacts[:when], + expire_in: artifacts[:expire_in], + exclude: artifacts[:exclude] + ) + end + + def self.for_report(report, expire_in) + type, params = report + + if type == :coverage_report + artifact_type = params[:coverage_format].to_sym + paths = [params[:path]] + else + artifact_type = type + paths = params + end + + self.new( + artifact_type: artifact_type, + artifact_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(artifact_type), + name: ::Ci::JobArtifact::DEFAULT_FILE_NAMES.fetch(artifact_type), + paths: paths, when: 'always', expire_in: expire_in - } + ) end end diff --git a/config/feature_flags/development/sbom_survey.yml b/config/feature_flags/development/container_security_policy_selection.yml index aac523ee846..8e05e3a271a 100644 --- a/config/feature_flags/development/sbom_survey.yml +++ b/config/feature_flags/development/container_security_policy_selection.yml @@ -1,8 +1,8 @@ --- -name: sbom_survey -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76446 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/348181 -milestone: '14.6' +name: container_security_policy_selection +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80272 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353071 +milestone: '14.10' type: development -group: group::secure +group: group::container security default_enabled: false diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml index 7f9539c3604..2c591c78585 100644 --- a/config/gitlab_loose_foreign_keys.yml +++ b/config/gitlab_loose_foreign_keys.yml @@ -11,6 +11,9 @@ ci_builds: - table: projects column: project_id on_delete: async_delete + - table: ci_runners + column: runner_id + on_delete: async_nullify ci_builds_metadata: - table: projects column: project_id diff --git a/data/deprecations/14-9-deprecate-debian-9.yml b/data/deprecations/14-9-deprecate-debian-9.yml new file mode 100644 index 00000000000..8f512393aa4 --- /dev/null +++ b/data/deprecations/14-9-deprecate-debian-9.yml @@ -0,0 +1,7 @@ +- name: "Deprecate support for Debian 9" + announcement_milestone: "14.9" + announcement_date: "2022-03-22" + removal_milestone: "15.1" + removal_date: "2022-06-22" + body: | + Long term service and support (LTSS) for [Debian 9 Stretch ends in July 2022](https://wiki.debian.org/LTS). Therefore, we will longer support the Debian 9 distribution for the GitLab package. Users can upgrade to Debian 10 or Debian 11. diff --git a/db/post_migrate/20220317161914_remove_ci_runners_ci_builds_runner_id_fk.rb b/db/post_migrate/20220317161914_remove_ci_runners_ci_builds_runner_id_fk.rb new file mode 100644 index 00000000000..3c7c4e73199 --- /dev/null +++ b/db/post_migrate/20220317161914_remove_ci_runners_ci_builds_runner_id_fk.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveCiRunnersCiBuildsRunnerIdFk < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + return unless foreign_key_exists?(:ci_builds, :ci_runners, name: "fk_e4ef9c2f27") + + with_lock_retries do + execute('LOCK ci_runners, ci_builds IN ACCESS EXCLUSIVE MODE') if transaction_open? + + remove_foreign_key_if_exists(:ci_builds, :ci_runners, name: "fk_e4ef9c2f27") + end + end + + def down + add_concurrent_foreign_key :ci_builds, :ci_runners, name: "fk_e4ef9c2f27", column: :runner_id, target_column: :id, on_delete: :nullify, validate: false + end +end diff --git a/db/schema_migrations/20220317161914 b/db/schema_migrations/20220317161914 new file mode 100644 index 00000000000..7cefac33187 --- /dev/null +++ b/db/schema_migrations/20220317161914 @@ -0,0 +1 @@ +94a8bc74fc935ba863d22b59b0ac6808bf5a9714c3759ca75a6dbee50c3c647d
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8d1679b3832..46398ed3f80 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -31696,9 +31696,6 @@ ALTER TABLE ONLY ci_builds_metadata ALTER TABLE ONLY gitlab_subscriptions ADD CONSTRAINT fk_e2595d00a1 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; -ALTER TABLE ONLY ci_builds - ADD CONSTRAINT fk_e4ef9c2f27 FOREIGN KEY (runner_id) REFERENCES ci_runners(id) ON DELETE SET NULL NOT VALID; - ALTER TABLE ONLY merge_requests ADD CONSTRAINT fk_e719a85f8a FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/doc/administration/operations/puma.md b/doc/administration/operations/puma.md index 9e828b39f46..c12f75989c3 100644 --- a/doc/administration/operations/puma.md +++ b/doc/administration/operations/puma.md @@ -108,6 +108,12 @@ To change the worker timeout to 600 seconds: ## Disable Puma clustered mode in memory-constrained environments +WARNING: +This is an experimental [Alpha feature](../../policy/alpha-beta-support.md#alpha-features) and subject to change without notice. The feature +is not ready for production use. If you want to use this feature, we recommend testing +with non-production data first. See the [known issues](#puma-single-mode-known-issues) +for additional details. + In a memory-constrained environment with less than 4GB of RAM available, consider disabling Puma [clustered mode](https://github.com/puma/puma#clustered-mode). @@ -131,6 +137,8 @@ For details on Puma worker and thread settings, see the [Puma requirements](../. The downside of running Puma in this configuration is the reduced throughput, which can be considered a fair tradeoff in a memory-constrained environment. +### Puma single mode known issues + When running Puma in single mode, some features are not supported: - [Phased restart](https://gitlab.com/gitlab-org/gitlab/-/issues/300665) diff --git a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md index 5fca3513ff7..389429f3f0f 100644 --- a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md +++ b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md @@ -277,3 +277,19 @@ read_secrets: ```  + +### Limit token access to Vault secrets + +You can control `CI_JOB_JWT` access to Vault secrets by using Vault protections +and GitLab features. For example, restrict the token by: + +- Using Vault [bound_claims](https://www.vaultproject.io/docs/auth/jwt#bound-claims) + for specific groups using `group_claim`. +- Hard coding values for Vault bound claims based on the `user_login` and `user_email` + of specific users. +- Setting Vault time limits for TTL of the token as specified in [`token_explicit_max_ttl`](https://www.vaultproject.io/api/auth/jwt#token_explicit_max_ttl), + where the token expires after authentication. +- Scoping the JWT to [GitLab projected branches](../../../user/project/protected_branches.md) + that are restricted to a subset of project users. +- Scoping the JWT to [GitLab projected tags](../../../user/project/protected_tags.md), + that are restricted to a subset of project users. diff --git a/doc/ci/yaml/artifacts_reports.md b/doc/ci/yaml/artifacts_reports.md index e010dd21b9e..bd28d917cd7 100644 --- a/doc/ci/yaml/artifacts_reports.md +++ b/doc/ci/yaml/artifacts_reports.md @@ -80,9 +80,14 @@ GitLab can display the results of one or more reports in: - The [security dashboard](../../user/application_security/security_dashboard/index.md). - The [Project Vulnerability report](../../user/application_security/vulnerability_report/index.md). -## `artifacts:reports:cobertura` +## `artifacts:reports:cobertura` (DEPRECATED) -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3708) in GitLab 12.9. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/3708) in GitLab 12.9. +> - [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78132) in GitLab 14.9. + +WARNING: +This feature is in its end-of-life process. It is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78132) for use in GitLab +14.8 and replaced with `artifacts:reports:coverage_report`. The `cobertura` report collects [Cobertura coverage XML files](../../user/project/merge_requests/test_coverage_visualization.md). The collected Cobertura coverage reports upload to GitLab as an artifact. @@ -93,6 +98,28 @@ GitLab can display the results of one or more reports in the merge request Cobertura was originally developed for Java, but there are many third-party ports for other languages such as JavaScript, Python, and Ruby. +## `artifacts:reports:coverage_report` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/344533) in GitLab 14.9. + +Use `coverage_report` to collect coverage report in Cobertura format, similar to `artifacts:reports:cobertura`. + +NOTE: +`artifacts:reports:coverage_report` cannot be used at the same time with `artifacts:reports:cobertura`. + +```yaml +artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage/cobertura-coverage.xml +``` + +The collected coverage report is uploaded to GitLab as an artifact. + +GitLab can display the results of coverage report in the merge request +[diff annotations](../../user/project/merge_requests/test_coverage_visualization.md). + ## `artifacts:reports:codequality` > [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212499) to GitLab Free in 13.2. diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index e89c45c522c..a13da207dde 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -38,6 +38,12 @@ For deprecation reviewers (Technical Writers only): ## 14.9 +### Deprecate support for Debian 9 + +Long term service and support (LTSS) for [Debian 9 Stretch ends in July 2022](https://wiki.debian.org/LTS). Therefore, we will longer support the Debian 9 distribution for the GitLab package. Users can upgrade to Debian 10 or Debian 11. + +**Planned removal milestone: 15.1 (2022-06-22)** + ### GitLab Pages running as daemon In 15.0, support for daemon mode for GitLab Pages will be removed. diff --git a/doc/user/project/merge_requests/test_coverage_visualization.md b/doc/user/project/merge_requests/test_coverage_visualization.md index d7177208a6e..16c5dbe9199 100644 --- a/doc/user/project/merge_requests/test_coverage_visualization.md +++ b/doc/user/project/merge_requests/test_coverage_visualization.md @@ -28,7 +28,7 @@ between pipeline completion and the visualization loading on the page. For the coverage analysis to work, you have to provide a properly formatted [Cobertura XML](https://cobertura.github.io/cobertura/) report to -[`artifacts:reports:cobertura`](../../../ci/yaml/artifacts_reports.md#artifactsreportscobertura). +[`artifacts:reports:cobertura`](../../../ci/yaml/artifacts_reports.md#artifactsreportscobertura-deprecated). This format was originally developed for Java, but most coverage analysis frameworks for other languages have plugins to add support for it, like: diff --git a/lib/api/entities/ci/job_request/artifacts.rb b/lib/api/entities/ci/job_request/artifacts.rb index 4b09db40504..d1fb7d330b9 100644 --- a/lib/api/entities/ci/job_request/artifacts.rb +++ b/lib/api/entities/ci/job_request/artifacts.rb @@ -6,7 +6,7 @@ module API module JobRequest class Artifacts < Grape::Entity expose :name - expose :untracked + expose :untracked, expose_nil: false expose :paths expose :exclude, expose_nil: false expose :when diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e8075deb16c..69c1792e0fc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14891,6 +14891,9 @@ msgstr "" msgid "Example: @sub\\.company\\.com$" msgstr "" +msgid "Examples" +msgstr "" + msgid "Except policy:" msgstr "" @@ -32931,6 +32934,9 @@ msgstr "" msgid "SecurityOrchestration|All policies" msgstr "" +msgid "SecurityOrchestration|Allow all inbound traffic to all pods from all pods on ports 443/TCP." +msgstr "" + msgid "SecurityOrchestration|An error occurred assigning your security policy project" msgstr "" @@ -32946,12 +32952,21 @@ msgstr "" msgid "SecurityOrchestration|Don't show the alert anymore" msgstr "" +msgid "SecurityOrchestration|Edit network policy" +msgstr "" + msgid "SecurityOrchestration|Edit policy" msgstr "" msgid "SecurityOrchestration|Edit policy project" msgstr "" +msgid "SecurityOrchestration|Edit scan exection policy" +msgstr "" + +msgid "SecurityOrchestration|Edit scan result policy" +msgstr "" + msgid "SecurityOrchestration|Empty policy name" msgstr "" @@ -32961,6 +32976,9 @@ msgstr "" msgid "SecurityOrchestration|Enforce security for this project. %{linkStart}More information.%{linkEnd}" msgstr "" +msgid "SecurityOrchestration|If any scanner finds a newly detected critical vulnerability in an open merge request targeting the master branch, then require two approvals from any member of App security." +msgstr "" + msgid "SecurityOrchestration|If you are using Auto DevOps, your %{monospacedStart}auto-deploy-values.yaml%{monospacedEnd} file will not be updated if you change a policy in this section. Auto DevOps users should make changes by following the %{linkStart}Container Network Policy documentation%{linkEnd}." msgstr "" @@ -32973,9 +32991,21 @@ msgstr "" msgid "SecurityOrchestration|Network" msgstr "" +msgid "SecurityOrchestration|Network policy" +msgstr "" + +msgid "SecurityOrchestration|New network policy" +msgstr "" + msgid "SecurityOrchestration|New policy" msgstr "" +msgid "SecurityOrchestration|New scan exection policy" +msgstr "" + +msgid "SecurityOrchestration|New scan result policy" +msgstr "" + msgid "SecurityOrchestration|No actions defined - policy will not run." msgstr "" @@ -33024,6 +33054,9 @@ msgstr "" msgid "SecurityOrchestration|Rules" msgstr "" +msgid "SecurityOrchestration|Run a DAST scan with Scan Profile A and Site Profile A when a pipeline run against the main branch." +msgstr "" + msgid "SecurityOrchestration|Runs %{actions} and %{lastAction} scans" msgstr "" @@ -33042,12 +33075,21 @@ msgstr "" msgid "SecurityOrchestration|Scan execution policies can only be created by project owners." msgstr "" +msgid "SecurityOrchestration|Scan execution policy" +msgstr "" + +msgid "SecurityOrchestration|Scan execution policy allow to create rules which forces security scans for particular branches at certain time. Supported types are SAST, DAST, Secret detection, Container scan, License scan, API fuzzing, coverage-guided fuzzing." +msgstr "" + msgid "SecurityOrchestration|Scan result" msgstr "" msgid "SecurityOrchestration|Scan result policies can only be created by project owners." msgstr "" +msgid "SecurityOrchestration|Scan result policy" +msgstr "" + msgid "SecurityOrchestration|Scan to be performed %{cadence}" msgstr "" @@ -33066,6 +33108,9 @@ msgstr "" msgid "SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}" msgstr "" +msgid "SecurityOrchestration|Select policy" +msgstr "" + msgid "SecurityOrchestration|Select security project" msgstr "" @@ -33075,6 +33120,12 @@ msgstr "" msgid "SecurityOrchestration|Status" msgstr "" +msgid "SecurityOrchestration|Step 1: Choose a policy type" +msgstr "" + +msgid "SecurityOrchestration|Step 2: Policy details" +msgstr "" + msgid "SecurityOrchestration|Summary" msgstr "" @@ -33102,6 +33153,12 @@ msgstr "" msgid "SecurityOrchestration|Update scan policies" msgstr "" +msgid "SecurityOrchestration|Use a scan result policy to create rules that ensure security issues are checked before merging a merge request." +msgstr "" + +msgid "SecurityOrchestration|Use network policies to create firewall rules for network connections in your Kubernetes cluster." +msgstr "" + msgid "SecurityOrchestration|View policy project" msgstr "" @@ -33357,9 +33414,6 @@ msgstr "" msgid "SecurityReports|Severity" msgstr "" -msgid "SecurityReports|Software and container dependency survey" -msgstr "" - msgid "SecurityReports|Sometimes a scanner can't determine a finding's severity. Those findings may still be a potential source of risk though. Please review these manually." msgstr "" @@ -33375,9 +33429,6 @@ msgstr "" msgid "SecurityReports|Take survey" msgstr "" -msgid "SecurityReports|The Composition Analysis group is planning significant updates to how we make available the list of software and container dependency information in your projects. Therefore, we ask that you assist us by taking a short -no longer than 5 minute- survey to help align our direction with your needs." -msgstr "" - msgid "SecurityReports|The Vulnerability Report shows the results of the latest successful pipeline on your project's default branch, as well as vulnerabilities from your latest container scan. %{linkStart}Learn more.%{linkEnd}" msgstr "" @@ -33456,9 +33507,6 @@ msgstr "" msgid "SecurityReports|You must sign in as an authorized user to see this report" msgstr "" -msgid "SecurityReports|Your feedback is important to us! We will ask again in 7 days." -msgstr "" - msgid "SecurityReports|Your feedback is important to us! We will ask again in a week." msgstr "" @@ -42170,6 +42218,9 @@ msgstr "" msgid "WorkItem|New Task" msgstr "" +msgid "WorkItem|Please select work item type" +msgstr "" + msgid "WorkItem|Something went wrong when creating a work item. Please try again" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb index bd3135bafdc..b1f77382f6a 100644 --- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb @@ -58,6 +58,16 @@ module QA artifacts: paths: - my-artifacts/ + + test-coverage-report: + tags: + - #{executor} + script: mkdir coverage; echo "CONTENTS" > coverage/cobertura.xml + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage/cobertura.xml YAML } ] @@ -71,7 +81,8 @@ module QA 'test-success': 'passed', 'test-failure': 'failed', 'test-tags-mismatch': 'pending', - 'test-artifacts': 'passed' + 'test-artifacts': 'passed', + 'test-coverage-report': 'passed' }.each do |job, status| Page::Project::Pipeline::Show.perform do |pipeline| pipeline.click_job(job) diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 011021f6320..9545378780a 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -497,6 +497,22 @@ FactoryBot.define do options { {} } end + trait :coverage_report_cobertura do + options do + { + artifacts: { + expire_in: '7d', + reports: { + coverage_report: { + coverage_format: 'cobertura', + path: 'cobertura.xml' + } + } + } + } + end + end + # TODO: move Security traits to ee_ci_build # https://gitlab.com/gitlab-org/gitlab/-/issues/210486 trait :dast do diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index ac2717a5028..5ab64d8e9ca 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import '~/behaviors/markdown/render_gfm'; import { IssuableStatus, IssuableStatusText } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; +import EditedComponent from '~/issues/show/components/edited.vue'; +import FormComponent from '~/issues/show/components/form.vue'; +import TitleComponent from '~/issues/show/components/title.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import PinnedLinks from '~/issues/show/components/pinned_links.vue'; import { POLLING_DELAY } from '~/issues/show/constants'; @@ -21,10 +24,6 @@ import { zoomMeetingUrl, } from '../mock_data/mock_data'; -function formatText(text) { - return text.trim().replace(/\s\s+/g, ' '); -} - jest.mock('~/lib/utils/url_utility'); jest.mock('~/issues/show/event_hub'); @@ -39,10 +38,15 @@ describe('Issuable output', () => { const findLockedBadge = () => wrapper.findByTestId('locked'); const findConfidentialBadge = () => wrapper.findByTestId('confidential'); const findHiddenBadge = () => wrapper.findByTestId('hidden'); - const findAlert = () => wrapper.find('.alert'); + + const findTitle = () => wrapper.findComponent(TitleComponent); + const findDescription = () => wrapper.findComponent(DescriptionComponent); + const findEdited = () => wrapper.findComponent(EditedComponent); + const findForm = () => wrapper.findComponent(FormComponent); + const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const mountComponent = (props = {}, options = {}, data = {}) => { - wrapper = mountExtended(IssuableApp, { + wrapper = shallowMountExtended(IssuableApp, { directives: { GlTooltip: createMockDirective(), }, @@ -104,23 +108,15 @@ describe('Issuable output', () => { }); it('should render a title/description/edited and update title/description/edited on update', () => { - let editedText; return axios .waitForAll() .then(() => { - editedText = wrapper.find('.edited-text'); - }) - .then(() => { - expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); - expect(wrapper.find('.title').text()).toContain('this is a title'); - expect(wrapper.find('.md').text()).toContain('this is a description!'); - expect(wrapper.find('.js-task-list-field').element.value).toContain( - 'this is a description', - ); + expect(findTitle().props('titleText')).toContain('this is a title'); + expect(findDescription().props('descriptionText')).toContain('this is a description'); - expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/); - expect(editedText.find('time').text()).toBeTruthy(); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version); }) .then(() => { @@ -128,20 +124,13 @@ describe('Issuable output', () => { return axios.waitForAll(); }) .then(() => { - expect(document.querySelector('title').innerText).toContain('2 (#1)'); - expect(wrapper.find('.title').text()).toContain('2'); - expect(wrapper.find('.md').text()).toContain('42'); - expect(wrapper.find('.js-task-list-field').element.value).toContain('42'); - expect(wrapper.find('.edited-text').text()).toBeTruthy(); - expect(formatText(wrapper.find('.edited-text').text())).toMatch( - /Edited[\s\S]+?by Other User/, - ); + expect(findTitle().props('titleText')).toContain('2'); + expect(findDescription().props('descriptionText')).toContain('42'); - expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/); - expect(editedText.find('time').text()).toBeTruthy(); - // As the lock_version value does not differ from the server, - // we should not see an alert - expect(findAlert().exists()).toBe(false); + expect(findEdited().exists()).toBe(true); + expect(findEdited().props('updatedByName')).toBe('Other User'); + expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/); + expect(findEdited().props('updatedAt')).toBeTruthy(); }); }); @@ -149,7 +138,7 @@ describe('Issuable output', () => { wrapper.vm.showForm = true; await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(true); + expect(findForm().exists()).toBe(true); }); it('does not show actions if permissions are incorrect', async () => { @@ -157,7 +146,7 @@ describe('Issuable output', () => { wrapper.setProps({ canUpdate: false }); await nextTick(); - expect(wrapper.find('.markdown-selector').exists()).toBe(false); + expect(findForm().exists()).toBe(false); }); it('does not update formState if form is already open', async () => { @@ -177,8 +166,7 @@ describe('Issuable output', () => { ${'zoomMeetingUrl'} | ${zoomMeetingUrl} ${'publishedIncidentUrl'} | ${publishedIncidentUrl} `('sets the $prop correctly on underlying pinned links', ({ prop, value }) => { - expect(wrapper.vm[prop]).toBe(value); - expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value); + expect(findPinnedLinks().props(prop)).toBe(value); }); }); @@ -327,7 +315,6 @@ describe('Issuable output', () => { expect(wrapper.vm.formState.lockedWarningVisible).toBe(true); expect(wrapper.vm.formState.lock_version).toBe(1); - expect(findAlert().exists()).toBe(true); }); }); @@ -374,15 +361,22 @@ describe('Issuable output', () => { }); describe('show inline edit button', () => { - it('should not render by default', () => { - expect(wrapper.find('.btn-edit').exists()).toBe(true); + it('should render by default', () => { + expect(findTitle().props('showInlineEditButton')).toBe(true); }); it('should render if showInlineEditButton', async () => { wrapper.setProps({ showInlineEditButton: true }); await nextTick(); - expect(wrapper.find('.btn-edit').exists()).toBe(true); + expect(findTitle().props('showInlineEditButton')).toBe(true); + }); + + it('should not render if showInlineEditButton is false', async () => { + wrapper.setProps({ showInlineEditButton: false }); + + await nextTick(); + expect(findTitle().props('showInlineEditButton')).toBe(false); }); }); @@ -533,13 +527,11 @@ describe('Issuable output', () => { describe('Composable description component', () => { const findIncidentTabs = () => wrapper.findComponent(IncidentTabs); - const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); - const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; describe('when using description component', () => { it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + expect(findDescription().exists()).toBe(true); }); it('does not render incident tabs', () => { @@ -572,8 +564,8 @@ describe('Issuable output', () => { ); }); - it('renders the description component', () => { - expect(findDescriptionComponent().exists()).toBe(true); + it('does not the description component', () => { + expect(findDescription().exists()).toBe(false); }); it('renders incident tabs', () => { diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 08f8996de6f..88d97a017d4 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -14,6 +14,7 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import { descriptionProps as initialProps, descriptionHtmlWithCheckboxes, + descriptionHtmlWithTask, } from '../mock_data/mock_data'; jest.mock('~/flash'); @@ -29,7 +30,6 @@ describe('Description component', () => { const findTextarea = () => wrapper.find('[data-testid="textarea"]'); const findTaskActionButtons = () => wrapper.findAll('.js-add-task'); const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]'); - const findTaskSvg = () => wrapper.find('[data-testid="issue-open-m-icon"]'); const findPopovers = () => wrapper.findAllComponents(GlPopover); const findModal = () => wrapper.findComponent(GlModal); @@ -39,6 +39,7 @@ describe('Description component', () => { function createComponent({ props = {}, provide = {} } = {}) { wrapper = shallowMountExtended(Description, { propsData: { + issueId: 1, ...initialProps, ...props, }, @@ -277,33 +278,21 @@ describe('Description component', () => { expect(hideModal).toHaveBeenCalled(); }); - it('updates description HTML on `onCreate` event', async () => { - const newTitle = 'New title'; - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { title: newTitle }); + it('emits `updateDescription` on `onCreate` event', async () => { + const newDescription = `<p>New description</p>`; + findCreateWorkItem().vm.$emit('onCreate', newDescription); expect(hideModal).toHaveBeenCalled(); - await nextTick(); - - expect(findTaskSvg().exists()).toBe(true); - expect(wrapper.text()).toContain(newTitle); + expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]); }); }); describe('work items detail', () => { - const id = '1'; - const title = 'my first task'; - const type = 'task'; - - const createThenClickOnTask = () => { - findConvertToTaskButton().vm.$emit('click'); - findCreateWorkItem().vm.$emit('onCreate', { id, title, type }); - return wrapper.findByRole('button', { name: title }).trigger('click'); - }; + const findTaskLink = () => wrapper.find('a.gfm-issue'); beforeEach(() => { createComponent({ props: { - descriptionHtml: descriptionHtmlWithCheckboxes, + descriptionHtml: descriptionHtmlWithTask, }, provide: { glFeatures: { workItems: true }, @@ -315,13 +304,13 @@ describe('Description component', () => { it('opens when task button is clicked', async () => { expect(findWorkItemDetailModal().props('visible')).toBe(false); - await createThenClickOnTask(); + await findTaskLink().trigger('click'); expect(findWorkItemDetailModal().props('visible')).toBe(true); }); it('closes from an open state', async () => { - await createThenClickOnTask(); + await findTaskLink().trigger('click'); expect(findWorkItemDetailModal().props('visible')).toBe(true); @@ -334,7 +323,7 @@ describe('Description component', () => { it('shows error on error', async () => { const message = 'I am error'; - await createThenClickOnTask(); + await findTaskLink().trigger('click'); findWorkItemDetailModal().vm.$emit('error', message); expect(createFlash).toHaveBeenCalledWith({ message }); @@ -343,7 +332,7 @@ describe('Description component', () => { it('tracks when opened', async () => { const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await createThenClickOnTask(); + await findTaskLink().trigger('click'); expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', { category: 'workItems:show', diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index 89653ff82b2..7b0b8ca686a 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = ` </li> </ul> `; + +export const descriptionHtmlWithTask = ` + <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto"> + <li data-sourcepos="1:1-1:10" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> + <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a> + </li> + <li data-sourcepos="2:1-2:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 2 + </li> + <li data-sourcepos="3:1-3:7" class="task-list-item"> + <input type="checkbox" class="task-list-item-checkbox" disabled> 3 + </li> + </ul> +`; diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 305f43ad8ba..c403680ba23 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1,24 +1,28 @@ -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import WorkItemTitle from '~/work_items/components/item_title.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import { workItemQueryResponse } from '../mock_data'; describe('WorkItemDetailModal component', () => { let wrapper; Vue.use(VueApollo); + const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const findModal = () => wrapper.findComponent(GlModal); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); - const createComponent = () => { + const createComponent = ({ workItemId = '1', handler = successHandler } = {}) => { wrapper = shallowMount(WorkItemDetailModal, { - apolloProvider: createMockApollo([], resolvers), - propsData: { visible: true }, + apolloProvider: createMockApollo([[workItemQuery, handler]]), + propsData: { visible: true, workItemId }, }); }; @@ -32,9 +36,57 @@ describe('WorkItemDetailModal component', () => { expect(findModal().props()).toMatchObject({ visible: true }); }); - it('renders work item title', () => { - createComponent(); + describe('when there is no `workItemId` prop', () => { + beforeEach(() => { + createComponent({ workItemId: null }); + }); + + it('renders empty title when there is no `workItemId` prop', () => { + expect(findWorkItemTitle().exists()).toBe(true); + }); + + it('skips the work item query', () => { + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render title', () => { + expect(findWorkItemTitle().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders title', () => { + expect(findWorkItemTitle().exists()).toBe(true); + }); + }); + + it('emits an error if query has errored', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); - expect(findWorkItemTitle().exists()).toBe(true); + expect(errorHandler).toHaveBeenCalled(); + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong when fetching the work item. Please try again.'], + ]); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 832795fc4ac..fc732a6c06f 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -78,3 +78,17 @@ export const createWorkItemMutationResponse = { }, }, }; + +export const createWorkItemFromTaskMutationResponse = { + data: { + workItemCreateFromTask: { + __typename: 'WorkItemCreateFromTaskPayload', + errors: [], + workItem: { + descriptionHtml: '<p>New description</p>', + id: 'gid://gitlab/WorkItem/13', + __typename: 'WorkItem', + }, + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 185b05c5191..ab4ca2acae3 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -1,6 +1,6 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlFormSelect } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -9,7 +9,12 @@ import ItemTitle from '~/work_items/components/item_title.vue'; import { resolvers } from '~/work_items/graphql/resolvers'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; -import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; +import { + projectWorkItemTypesQueryResponse, + createWorkItemMutationResponse, + createWorkItemFromTaskMutationResponse, +} from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -20,12 +25,15 @@ describe('Create work item component', () => { let fakeApollo; const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); - const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemFromTaskSuccessHandler = jest + .fn() + .mockResolvedValue(createWorkItemFromTaskMutationResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const findAlert = () => wrapper.findComponent(GlAlert); const findTitleInput = () => wrapper.findComponent(ItemTitle); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSelect = () => wrapper.findComponent(GlFormSelect); const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); @@ -36,12 +44,13 @@ describe('Create work item component', () => { data = {}, props = {}, queryHandler = querySuccessHandler, - mutationHandler = mutationSuccessHandler, + mutationHandler = createWorkItemSuccessHandler, } = {}) => { fakeApollo = createMockApollo( [ [projectWorkItemTypesQuery, queryHandler], [createWorkItemMutation, mutationHandler], + [createWorkItemFromTaskMutation, mutationHandler], ], resolvers, ); @@ -123,6 +132,7 @@ describe('Create work item component', () => { props: { isModal: true, }, + mutationHandler: createWorkItemFromTaskSuccessHandler, }); }); @@ -133,14 +143,12 @@ describe('Create work item component', () => { }); it('emits `onCreate` on successful mutation', async () => { - const mockTitle = 'Test title'; findTitleInput().vm.$emit('title-input', 'Test title'); wrapper.find('form').trigger('submit'); await waitForPromises(); - const expected = { id: '1', title: mockTitle }; - expect(wrapper.emitted('onCreate')).toEqual([[expected]]); + expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]); }); it('does not right margin for create button', () => { @@ -177,16 +185,14 @@ describe('Create work item component', () => { }); it('displays a list of work item types', () => { - expect(findDropdownItems()).toHaveLength(2); - expect(findDropdownItems().at(0).text()).toContain('Issue'); + expect(findSelect().attributes('options').split(',')).toHaveLength(3); }); it('selects a work item type on click', async () => { - expect(findDropdown().props('text')).toBe('Type'); - findDropdownItems().at(0).vm.$emit('click'); + const mockId = 'work-item-1'; + findSelect().vm.$emit('input', mockId); await nextTick(); - - expect(findDropdown().props('text')).toBe('Issue'); + expect(findSelect().attributes('value')).toBe(mockId); }); }); @@ -210,17 +216,32 @@ describe('Create work item component', () => { }); describe('when title input field has a text', () => { - beforeEach(() => { + beforeEach(async () => { const mockTitle = 'Test title'; createComponent(); + await waitForPromises(); findTitleInput().vm.$emit('title-input', mockTitle); }); - it('renders a non-disabled Create button', () => { + it('renders a disabled Create button', () => { + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('renders a non-disabled Create button when work item type is selected', async () => { + findSelect().vm.$emit('input', 'work-item-1'); + await nextTick(); expect(findCreateButton().props('disabled')).toBe(false); }); + }); + + it('shows an alert on mutation error', async () => { + createComponent({ mutationHandler: errorHandler }); + await waitForPromises(); + findTitleInput().vm.$emit('title-input', 'some title'); + findSelect().vm.$emit('input', 'work-item-1'); + wrapper.find('form').trigger('submit'); + await waitForPromises(); - // TODO: write a proper test here when we have a backend implementation - it.todo('shows an alert on mutation error'); + expect(findAlert().text()).toBe(CreateWorkItem.createErrorText); }); }); diff --git a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb index 588f53150ff..0fd9a83a4fa 100644 --- a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport do let(:entry) { described_class.new(config) } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 86ee159b97e..155e0fbb0e9 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -994,4 +994,11 @@ RSpec.describe CommitStatus do let!(:model) { create(:ci_build, project: parent) } end end + + context 'loose foreign key on ci_builds.runner_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:ci_runner) } + let!(:model) { create(:ci_build, runner: parent) } + end + end end diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index d25102532a7..ace65307321 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -78,16 +78,72 @@ RSpec.describe Ci::BuildRunnerPresenter do artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(file_type), paths: [filename], when: 'always' - } + }.compact end it 'presents correct hash' do - expect(presenter.artifacts.first).to include(report_expectation) + expect(presenter.artifacts).to contain_exactly(report_expectation) end end end end + context 'when a specific coverage_report type is given' do + let(:coverage_format) { :cobertura } + let(:filename) { 'cobertura-coverage.xml' } + let(:coverage_report) { { path: filename, coverage_format: coverage_format } } + let(:report) { { coverage_report: coverage_report } } + let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) } + + let(:expected_coverage_report) do + { + name: filename, + artifact_type: coverage_format, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format), + paths: [filename], + when: 'always' + } + end + + it 'presents the coverage report hash with the coverage format' do + expect(presenter.artifacts).to contain_exactly(expected_coverage_report) + end + end + + context 'when a specific coverage_report type is given with another report type' do + let(:coverage_format) { :cobertura } + let(:coverage_filename) { 'cobertura-coverage.xml' } + let(:coverage_report) { { path: coverage_filename, coverage_format: coverage_format } } + let(:ds_filename) { 'gl-dependency-scanning-report.json' } + + let(:report) { { coverage_report: coverage_report, dependency_scanning: [ds_filename] } } + let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) } + + let(:expected_coverage_report) do + { + name: coverage_filename, + artifact_type: coverage_format, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format), + paths: [coverage_filename], + when: 'always' + } + end + + let(:expected_ds_report) do + { + name: ds_filename, + artifact_type: :dependency_scanning, + artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(:dependency_scanning), + paths: [ds_filename], + when: 'always' + } + end + + it 'presents both reports' do + expect(presenter.artifacts).to contain_exactly(expected_coverage_report, expected_ds_report) + end + end + 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 } }) } diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb index d317386dc73..17c7b54e988 100644 --- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb +++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb @@ -611,6 +611,40 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end end + context 'when job has code coverage report' do + let(:job) do + create(:ci_build, :pending, :queued, :coverage_report_cobertura, + pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) + end + + let(:expected_artifacts) do + [ + { + 'name' => 'cobertura-coverage.xml', + 'paths' => ['cobertura.xml'], + 'when' => 'always', + 'expire_in' => '7d', + "artifact_type" => "cobertura", + "artifact_format" => "gzip" + } + ] + end + + it 'returns job with the correct artifact specification', :aggregate_failures do + request_job info: { platform: :darwin, features: { upload_multiple_artifacts: true } } + + expect(response).to have_gitlab_http_status(:created) + expect(response.headers['Content-Type']).to eq('application/json') + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + expect(runner.reload.platform).to eq('darwin') + expect(json_response['id']).to eq(job.id) + expect(json_response['token']).to eq(job.token) + expect(json_response['job_info']).to eq(expected_job_info) + expect(json_response['git_info']).to eq(expected_git_info) + expect(json_response['artifacts']).to eq(expected_artifacts) + end + end + context 'when triggered job is available' do let(:expected_variables) do [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false }, |