diff options
111 files changed, 3313 insertions, 397 deletions
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index 275a66d5956..e08918a6720 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,9 +1,13 @@ <script> import { mapState } from 'vuex'; +import { GlAlert } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'JiraConnectApp', + components: { + GlAlert, + }, mixins: [glFeatureFlagsMixin()], computed: { ...mapState(['errorMessage']), @@ -16,6 +20,12 @@ export default { <template> <div> + <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + + <h1>GitLab for Jira Configuration</h1> + <div v-if="showNewUi"> <h3 data-testid="new-jira-connect-ui-heading">{{ s__('Integrations|Linked namespaces') }}</h3> </div> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 05f13de6f53..0dbcb778a6c 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -23,11 +23,9 @@ const initJiraFormHandlers = () => { }; const reqFailed = (res, fallbackErrorMessage) => { - const { responseJSON: { error = fallbackErrorMessage } = {} } = res || {}; + const { error = fallbackErrorMessage } = res || {}; store.commit(SET_ERROR_MESSAGE, error); - // eslint-disable-next-line no-alert - alert(error); }; if (typeof AP.getLocation === 'function') { diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js index f68767b9acd..6d6361d19b6 100644 --- a/app/assets/javascripts/lib/utils/grammar.js +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -16,12 +16,12 @@ import { sprintf, s__ } from '~/locale'; * * @param {String[]} items */ -export const toNounSeriesText = (items) => { +export const toNounSeriesText = (items, { onlyCommas = false } = {}) => { if (items.length === 0) { return ''; } else if (items.length === 1) { return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false); - } else if (items.length === 2) { + } else if (items.length === 2 && !onlyCommas) { return sprintf( s__('nounSeries|%{firstItem} and %{lastItem}'), { @@ -33,7 +33,7 @@ export const toNounSeriesText = (items) => { } return items.reduce((item, nextItem, idx) => - idx === items.length - 1 + idx === items.length - 1 && !onlyCommas ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false) : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false), ); diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue new file mode 100644 index 00000000000..aaf64702ffd --- /dev/null +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -0,0 +1,69 @@ +<script> +import EmailParticipantsWarning from './email_participants_warning.vue'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; + +const DEFAULT_NOTEABLE_TYPE = 'Issue'; + +export default { + components: { + EmailParticipantsWarning, + NoteableWarning, + }, + props: { + noteableData: { + type: Object, + required: true, + }, + noteableType: { + type: String, + required: false, + default: DEFAULT_NOTEABLE_TYPE, + }, + withAlertContainer: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + isLocked() { + return Boolean(this.noteableData.discussion_locked); + }, + isConfidential() { + return Boolean(this.noteableData.confidential); + }, + hasWarning() { + return this.isConfidential || this.isLocked; + }, + emailParticipants() { + return this.noteableData.issue_email_participants?.map(({ email }) => email) || []; + }, + }, +}; +</script> +<template> + <div + class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100" + > + <div + v-if="withAlertContainer" + class="error-alert" + data-testid="comment-field-alert-container" + ></div> + <noteable-warning + v-if="hasWarning" + class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none" + :is-locked="isLocked" + :is-confidential="isConfidential" + :noteable-type="noteableType" + :locked-noteable-docs-path="noteableData.locked_discussion_docs_path" + :confidential-noteable-docs-path="noteableData.confidential_issues_docs_path" + /> + <slot></slot> + <email-participants-warning + v-if="emailParticipants.length" + class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!" + :emails="emailParticipants" + /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 67e9b8b2c19..111af977ec5 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -17,18 +17,17 @@ import { import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import * as constants from '../constants'; import eventHub from '../event_hub'; -import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue'; import issuableStateMixin from '../mixins/issuable_state'; +import CommentFieldLayout from './comment_field_layout.vue'; export default { name: 'CommentForm', components: { - NoteableWarning, noteSignedOutWidget, discussionLockedWidget, markdownField, @@ -36,6 +35,7 @@ export default { GlButton, TimelineEntryItem, GlIcon, + CommentFieldLayout, }, mixins: [glFeatureFlagsMixin(), issuableStateMixin], props: { @@ -287,6 +287,9 @@ export default { Autosize.update(this.$refs.textarea); }); }, + hasEmailParticipants() { + return this.getNoteableData.issue_email_participants?.length; + }, }, }; </script> @@ -309,46 +312,41 @@ export default { </div> <div class="timeline-content timeline-content-form"> <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> - <div class="error-alert"></div> - - <noteable-warning - v-if="hasWarning(getNoteableData)" - :is-locked="isLocked(getNoteableData)" - :is-confidential="isConfidential(getNoteableData)" + <comment-field-layout + :with-alert-container="true" + :noteable-data="getNoteableData" :noteable-type="noteableType" - :locked-noteable-docs-path="lockedIssueDocsPath" - :confidential-noteable-docs-path="confidentialIssueDocsPath" - /> - - <markdown-field - ref="markdownField" - :is-submitting="isSubmitting" - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :add-spacing-classes="false" - :textarea-value="note" > - <textarea - id="note-body" - ref="textarea" - slot="textarea" - v-model="note" - dir="auto" - :disabled="isSubmitting" - name="note[note]" - class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" - data-qa-selector="comment_field" - data-testid="comment-field" - :data-supports-quick-actions="!glFeatures.tributeAutocomplete" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - @keydown.up="editCurrentUserLastNote()" - @keydown.meta.enter="handleSave()" - @keydown.ctrl.enter="handleSave()" - ></textarea> - </markdown-field> - + <markdown-field + ref="markdownField" + :is-submitting="isSubmitting" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false" + :textarea-value="note" + > + <template #textarea> + <textarea + id="note-body" + ref="textarea" + v-model="note" + dir="auto" + :disabled="isSubmitting" + name="note[note]" + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" + data-qa-selector="comment_field" + data-testid="comment-field" + :data-supports-quick-actions="!glFeatures.tributeAutocomplete" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + @keydown.up="editCurrentUserLastNote()" + @keydown.meta.enter="handleSave()" + @keydown.ctrl.enter="handleSave()" + ></textarea> + </template> + </markdown-field> + </comment-field-layout> <div class="note-form-actions"> <div class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" diff --git a/app/assets/javascripts/notes/components/email_participants_warning.vue b/app/assets/javascripts/notes/components/email_participants_warning.vue new file mode 100644 index 00000000000..bb1ff58120a --- /dev/null +++ b/app/assets/javascripts/notes/components/email_participants_warning.vue @@ -0,0 +1,70 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { toNounSeriesText } from '~/lib/utils/grammar'; + +export default { + components: { + GlSprintf, + }, + props: { + emails: { + type: Array, + required: true, + }, + numberOfLessParticipants: { + type: Number, + required: false, + default: 3, + }, + }, + data() { + return { + isShowingMoreParticipants: false, + }; + }, + computed: { + title() { + return this.moreParticipantsAvailable + ? toNounSeriesText(this.lessParticipants, { onlyCommas: true }) + : toNounSeriesText(this.emails); + }, + lessParticipants() { + return this.emails.slice(0, this.numberOfLessParticipants); + }, + moreLabel() { + return sprintf(s__('EmailParticipantsWarning|and %{moreCount} more'), { + moreCount: this.emails.length - this.numberOfLessParticipants, + }); + }, + moreParticipantsAvailable() { + return !this.isShowingMoreParticipants && this.emails.length > this.numberOfLessParticipants; + }, + message() { + return this.moreParticipantsAvailable + ? s__('EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment.') + : s__('EmailParticipantsWarning|%{emails} will be notified of your comment.'); + }, + }, + methods: { + showMoreParticipants() { + this.isShowingMoreParticipants = true; + }, + }, +}; +</script> + +<template> + <div class="issuable-note-warning" data-testid="email-participants-warning"> + <gl-sprintf :message="message"> + <template #andMore> + <button type="button" class="btn-transparent btn-link" @click="showMoreParticipants"> + {{ moreLabel }} + </button> + </template> + <template #emails> + <span>{{ title }}</span> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 47202d0e241..9acb837c27f 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -3,19 +3,19 @@ import { mapGetters, mapActions, mapState } from 'vuex'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; -import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; import { __, sprintf } from '~/locale'; import { getDraft, updateDraft } from '~/lib/utils/autosave'; +import CommentFieldLayout from './comment_field_layout.vue'; export default { name: 'NoteForm', components: { - NoteableWarning, markdownField, + CommentFieldLayout, }, mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], props: { @@ -303,6 +303,9 @@ export default { this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve); }, + hasEmailParticipants() { + return this.getNoteableData.issue_email_participants?.length; + }, }, }; </script> @@ -316,46 +319,41 @@ export default { ></div> <div class="flash-container timeline-content"></div> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> - <noteable-warning - v-if="hasWarning(getNoteableData)" - :is-locked="isLocked(getNoteableData)" - :is-confidential="isConfidential(getNoteableData)" - :locked-noteable-docs-path="lockedIssueDocsPath" - :confidential-noteable-docs-path="confidentialIssueDocsPath" - /> - - <markdown-field - :markdown-preview-path="markdownPreviewPath" - :markdown-docs-path="markdownDocsPath" - :quick-actions-docs-path="quickActionsDocsPath" - :line="line" - :note="discussionNote" - :can-suggest="canSuggest" - :add-spacing-classes="false" - :help-page-path="helpPagePath" - :show-suggest-popover="showSuggestPopover" - :textarea-value="updatedNoteBody" - @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" - > - <textarea - id="note_note" - ref="textarea" - slot="textarea" - v-model="updatedNoteBody" - :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" - name="note[note]" - class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" - data-qa-selector="reply_field" - dir="auto" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" - @keydown.meta.enter="handleKeySubmit()" - @keydown.ctrl.enter="handleKeySubmit()" - @keydown.exact.up="editMyLastNote()" - @keydown.exact.esc="cancelHandler(true)" - @input="onInput" - ></textarea> - </markdown-field> + <comment-field-layout :noteable-data="getNoteableData"> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :line="line" + :note="discussionNote" + :can-suggest="canSuggest" + :add-spacing-classes="false" + :help-page-path="helpPagePath" + :show-suggest-popover="showSuggestPopover" + :textarea-value="updatedNoteBody" + @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" + > + <template #textarea> + <textarea + id="note_note" + ref="textarea" + v-model="updatedNoteBody" + :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" + name="note[note]" + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" + data-qa-selector="reply_field" + dir="auto" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + @keydown.meta.enter="handleKeySubmit()" + @keydown.ctrl.enter="handleKeySubmit()" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" + @input="onInput" + ></textarea> + </template> + </markdown-field> + </comment-field-layout> <div class="note-form-actions clearfix"> <template v-if="showBatchCommentsActions"> <p v-if="showResolveDiscussionToggle"> diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js index 0ca8c8c98a3..52b67764b70 100644 --- a/app/assets/javascripts/notes/mixins/issuable_state.js +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -12,21 +12,10 @@ export default { lockedIssueDocsPath() { return this.getNoteableDataByProp('locked_discussion_docs_path'); }, - confidentialIssueDocsPath() { - return this.getNoteableDataByProp('confidential_issues_docs_path'); - }, }, methods: { - isConfidential(issue) { - return Boolean(issue.confidential); - }, - isLocked(issue) { return Boolean(issue.discussion_locked); }, - - hasWarning(issue) { - return this.isConfidential(issue) || this.isLocked(issue); - }, }, }; diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue index e2529613844..ef2be2a5fba 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue @@ -44,13 +44,21 @@ export default { <template> <div data-testid="ci-lint-value"> - <pre v-if="scripts.beforeScript.show" data-testid="ci-lint-before-script">{{ - scripts.beforeScript.content - }}</pre> - <pre v-if="scripts.script.show" data-testid="ci-lint-script">{{ scripts.script.content }}</pre> - <pre v-if="scripts.afterScript.show" data-testid="ci-lint-after-script">{{ - scripts.afterScript.content + <pre + v-if="scripts.beforeScript.show" + class="gl-white-space-pre-wrap" + data-testid="ci-lint-before-script" + >{{ scripts.beforeScript.content }}</pre + > + <pre v-if="scripts.script.show" class="gl-white-space-pre-wrap" data-testid="ci-lint-script">{{ + scripts.script.content }}</pre> + <pre + v-if="scripts.afterScript.show" + class="gl-white-space-pre-wrap" + data-testid="ci-lint-after-script" + >{{ scripts.afterScript.content }}</pre + > <ul class="gl-list-style-none gl-pl-0 gl-mb-0"> <li v-if="tagList"> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 5958d198be2..96a674e342f 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,5 +1,6 @@ <script> import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; +import LinksLayer from '../graph_shared/links_layer.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; @@ -8,6 +9,7 @@ import { reportToSentry } from './utils'; export default { name: 'PipelineGraph', components: { + LinksLayer, LinkedGraphWrapper, LinkedPipelinesColumn, StageColumnComponent, @@ -32,9 +34,15 @@ export default { DOWNSTREAM, UPSTREAM, }, + CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF', + BASE_CONTAINER_ID: 'pipeline-links-container', data() { return { hoveredJobName: '', + measurements: { + width: 0, + height: 0, + }, pipelineExpanded: { jobName: '', expanded: false, @@ -42,6 +50,9 @@ export default { }; }, computed: { + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`; + }, downstreamPipelines() { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, @@ -54,12 +65,13 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, - // The two show checks prevent upstream / downstream from showing redundant linked columns + // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM ); }, + // The show upstream check prevents showing redundant linked columns showUpstreamPipelines() { return ( this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM @@ -72,7 +84,19 @@ export default { errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, + mounted() { + this.measurements = this.getMeasurements(); + }, methods: { + getMeasurements() { + return { + width: this.$refs[this.containerId].scrollWidth, + height: this.$refs[this.containerId].scrollHeight, + }; + }, + onError(errorType) { + this.$emit('error', errorType); + }, setJob(jobName) { this.hoveredJobName = jobName; }, @@ -88,43 +112,57 @@ export default { <template> <div class="js-pipeline-graph"> <div + :id="containerId" + :ref="containerId" class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" :class="{ 'gl-py-5': !isLinkedPipeline }" > - <linked-graph-wrapper> - <template #upstream> - <linked-pipelines-column - v-if="showUpstreamPipelines" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :type="$options.pipelineTypeConstants.UPSTREAM" - @error="emit('error', errorType)" - /> - </template> - <template #main> - <stage-column-component - v-for="stage in graph" - :key="stage.name" - :title="stage.name" - :groups="stage.groups" - :action="stage.status.action" - :job-hovered="hoveredJobName" - :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="$emit('refreshPipelineGraph')" - /> - </template> - <template #downstream> - <linked-pipelines-column - v-if="showDownstreamPipelines" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :type="$options.pipelineTypeConstants.DOWNSTREAM" - @downstreamHovered="setJob" - @pipelineExpandToggle="togglePipelineExpanded" - @error="emit('error', errorType)" - /> - </template> - </linked-graph-wrapper> + <links-layer + :pipeline-data="graph" + :pipeline-id="pipeline.id" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="hoveredJobName" + default-link-color="gl-stroke-transparent" + @error="onError" + > + <linked-graph-wrapper> + <template #upstream> + <linked-pipelines-column + v-if="showUpstreamPipelines" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :type="$options.pipelineTypeConstants.UPSTREAM" + @error="onError" + /> + </template> + <template #main> + <stage-column-component + v-for="stage in graph" + :key="stage.name" + :title="stage.name" + :groups="stage.groups" + :action="stage.status.action" + :job-hovered="hoveredJobName" + :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipeline.id" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @jobHover="setJob" + /> + </template> + <template #downstream> + <linked-pipelines-column + v-if="showDownstreamPipelines" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :type="$options.pipelineTypeConstants.DOWNSTREAM" + @downstreamHovered="setJob" + @pipelineExpandToggle="togglePipelineExpanded" + @error="onError" + /> + </template> + </linked-graph-wrapper> + </links-layer> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index b6e756ceaba..08d6162aeb8 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -23,8 +23,16 @@ export default { type: Object, required: true, }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, }, computed: { + computedJobId() { + return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : ''; + }, tooltipText() { const { name, status } = this.group; return `${name} - ${status.label}`; @@ -41,7 +49,7 @@ export default { }; </script> <template> - <div class="ci-job-dropdown-container dropdown dropright"> + <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button v-gl-tooltip.hover="{ boundary: 'viewport' }" :title="tooltipText" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 4f414cbb31f..8262d728a24 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -74,6 +74,11 @@ export default { required: false, default: () => ({}), }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, }, computed: { boundary() { @@ -85,6 +90,9 @@ export default { hasDetails() { return accessValue(this.dataMethod, 'hasDetails', this.status); }, + computedJobId() { + return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + }, status() { return this.job && this.job.status ? this.job.status : {}; }, @@ -146,6 +154,7 @@ export default { </script> <template> <div + :id="computedJobId" class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" data-qa-selector="job_item_container" > diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 45d183cce97..e65ae318952 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -24,6 +24,10 @@ export default { type: Array, required: true, }, + pipelineId: { + type: Number, + required: true, + }, action: { type: Object, required: false, @@ -94,16 +98,19 @@ export default { :key="getGroupId(group)" data-testid="stage-column-group" class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" + @mouseenter="$emit('jobHover', group.name)" + @mouseleave="$emit('jobHover', '')" > <job-item v-if="group.size === 1" :job="group.jobs[0]" :job-hovered="jobHovered" :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipelineId" css-class-job-name="gl-build-content" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> - <job-group-dropdown v-else :group="group" /> + <job-group-dropdown v-else :group="group" :pipeline-id="pipelineId" /> </div> </template> </main-graph-wrapper> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index b550c599fda..fe59af12011 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -1,5 +1,7 @@ import * as d3 from 'd3'; -import { createUniqueLinkId } from '../../utils'; + +export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; + /** * This function expects its first argument data structure * to be the same shaped as the one generated by `parseData`, @@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils'; * we find the nodes in the graph, calculate their coordinates and * trace the lines that represent the needs of each job. * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links - * @param {Object} jobs - An object where each key is the job name that contains the job data - * @param {ref} svg - Reference to the svg we draw in + * @param {String} containerID - Id for the svg the links will be draw in * @returns {Array} Links that contain all the information about them */ -export const generateLinksData = ({ links }, containerID) => { +export const generateLinksData = ({ links }, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); return links.map((link) => { const path = d3.path(); @@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => { const sourceId = link.source; const targetId = link.target; - const sourceNodeEl = document.getElementById(sourceId); - const targetNodeEl = document.getElementById(targetId); + const modifiedSourceId = `${sourceId}${modifier}`; + const modifiedTargetId = `${targetId}${modifier}`; + + const sourceNodeEl = document.getElementById(modifiedSourceId); + const targetNodeEl = document.getElementById(modifiedTargetId); const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); @@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => { // from the total to make sure it's aligned properly. We then make the line // positioned in the center of the job node by adding half the height // of the job pill. - const paddingLeft = Number( - window.getComputedStyle(containerEl, null).getPropertyValue('padding-left').replace('px', ''), + const paddingLeft = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'), ); - const paddingTop = Number( - window.getComputedStyle(containerEl, null).getPropertyValue('padding-top').replace('px', ''), + const paddingTop = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'), ); const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue new file mode 100644 index 00000000000..480cb032e11 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -0,0 +1,137 @@ +<script> +import { isEmpty } from 'lodash'; +import { DRAW_FAILURE } from '../../constants'; +import { createJobsHash, generateJobNeedsDict } from '../../utils'; +import { parseData } from '../parsing_utils'; +import { generateLinksData } from './drawing_utils'; + +export default { + name: 'LinksInner', + STROKE_WIDTH: 2, + props: { + containerId: { + type: String, + required: true, + }, + containerMeasurements: { + type: Object, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + defaultLinkColor: { + type: String, + required: false, + default: 'gl-stroke-gray-200', + }, + highlightedJob: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + links: [], + needsObject: null, + }; + }, + computed: { + hasHighlightedJob() { + return Boolean(this.highlightedJob); + }, + isPipelineDataEmpty() { + return isEmpty(this.pipelineData); + }, + highlightedJobs() { + // If you are hovering on a job, then the jobs we want to highlight are: + // The job you are currently hovering + all of its needs. + return this.hasHighlightedJob + ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]] + : []; + }, + highlightedLinks() { + // If you are hovering on a job, then the links we want to highlight are: + // All the links whose `source` and `target` are highlighted jobs. + if (this.hasHighlightedJob) { + const filteredLinks = this.links.filter((link) => { + return ( + this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) + ); + }); + + return filteredLinks.map((link) => link.ref); + } + + return []; + }, + viewBox() { + return [0, 0, this.containerMeasurements.width, this.containerMeasurements.height]; + }, + }, + watch: { + highlightedJob() { + // On first hover, generate the needs reference + if (!this.needsObject) { + const jobs = createJobsHash(this.pipelineData); + this.needsObject = generateJobNeedsDict(jobs) ?? {}; + } + }, + }, + mounted() { + if (!isEmpty(this.pipelineData)) { + this.prepareLinkData(); + } + }, + methods: { + isLinkHighlighted(linkRef) { + return this.highlightedLinks.includes(linkRef); + }, + prepareLinkData() { + try { + const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); + } catch { + this.$emit('error', DRAW_FAILURE); + } + }, + getLinkClasses(link) { + return [ + this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor, + { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) }, + ]; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-relative"> + <svg + id="link-svg" + class="gl-absolute" + :viewBox="viewBox" + :width="`${containerMeasurements.width}px`" + :height="`${containerMeasurements.height}px`" + > + <template> + <path + v-for="link in links" + :key="link.path" + :ref="link.ref" + :d="link.path" + class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" + :class="getLinkClasses(link)" + :stroke-width="$options.STROKE_WIDTH" + /> + </template> + </svg> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue new file mode 100644 index 00000000000..0993892a574 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -0,0 +1,86 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LinksInner from './links_inner.vue'; + +export default { + name: 'LinksLayer', + components: { + GlAlert, + LinksInner, + }, + MAX_GROUPS: 200, + props: { + containerMeasurements: { + type: Object, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + }, + data() { + return { + alertDismissed: false, + showLinksOverride: false, + }; + }, + i18n: { + showLinksAnyways: __('Show links anyways'), + tooManyJobs: __( + 'This graph has a large number of jobs and showing the links between them may have performance implications.', + ), + }, + computed: { + containerZero() { + return !this.containerMeasurements.width || !this.containerMeasurements.height; + }, + numGroups() { + return this.pipelineData.reduce((acc, { groups }) => { + return acc + Number(groups.length); + }, 0); + }, + showAlert() { + return !this.showLinkedLayers && !this.alertDismissed; + }, + showLinkedLayers() { + return ( + !this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS) + ); + }, + }, + methods: { + dismissAlert() { + this.alertDismissed = true; + }, + overrideShowLinks() { + this.dismissAlert(); + this.showLinksOverride = true; + }, + }, +}; +</script> +<template> + <links-inner + v-if="showLinkedLayers" + :container-measurements="containerMeasurements" + :pipeline-data="pipelineData" + v-bind="$attrs" + v-on="$listeners" + > + <slot></slot> + </links-inner> + <div v-else> + <gl-alert + v-if="showAlert" + class="gl-w-max-content gl-ml-4" + :primary-button-text="$options.i18n.showLinksAnyways" + @primaryAction="overrideShowLinks" + @dismiss="dismissAlert" + > + {{ $options.i18n.tooManyJobs }} + </gl-alert> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 6c957d09e46..8636808b69e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,9 +1,9 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; +import { generateLinksData } from '../graph_shared/drawing_utils'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; -import { generateLinksData } from './drawing_utils'; import { parseData } from '../parsing_utils'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; import { createJobsHash, generateJobNeedsDict } from '../../utils'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 8c9040dce04..133608b9801 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -158,7 +158,7 @@ export default async function () { ); const { pipelineProjectPath, pipelineIid } = dataset; - createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 54fcd5502de..50bb23b7e63 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -6,8 +6,6 @@ export const validateParams = (params) => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; -export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; - /** * This function takes the stages array and transform it * into a hash where each key is a job name and the job data diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 67be76604a3..24386c90954 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -111,6 +111,6 @@ export default { <template> <div ref="markdownPreview" class="md-previewer" data-testid="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> - <div v-else class="md" v-html="previewContent"></div> + <div v-else class="md gl-ml-auto gl-mr-auto" v-html="previewContent"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 4dac72df2b5..a4c5ca28494 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -256,7 +256,7 @@ export default { return { ...filter, value: { - data: stripQuotes(valueString), + data: typeof valueString === 'string' ? stripQuotes(valueString) : valueString, operator: filter.value.operator, }, }; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 7ba9236b833..e3d02d01496 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -99,6 +99,10 @@ line-height: $list-text-height; color: $gl-text-color-secondary; + @include media-breakpoint-down(xs) { + padding-top: $gl-padding-6; + } + span { margin-right: 15px; } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 15cc10d1532..f6b9473d235 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -179,6 +179,10 @@ $ide-commit-header-height: 48px; overflow: auto; padding: $gl-padding; background-color: var(--ide-empty-state-background, transparent); + + .md { + max-width: $limited-layout-width; + } } .file-container { diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index ff989b474ad..81e9b04b18e 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -1,11 +1,14 @@ @import 'mixins_and_variables_and_functions'; -// We should only import styles that we actually use. -// @import '@gitlab/ui/src/scss/gitlab_ui'; - +/** +NOTE: We should only import styles that we actually use. +Ex: + @import '@gitlab/ui/src/scss/gitlab_ui'; +*/ @import '@gitlab/ui/src/scss/bootstrap'; @import 'bootstrap-vue/src/index'; @import '@gitlab/ui/src/scss/utilities'; +@import '@gitlab/ui/src/components/base/alert/alert'; $atlaskit-border-color: #dfe1e6; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 0c24ea9ccc6..254ad96bb57 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -100,8 +100,6 @@ color: $orange-600; background-color: $orange-50; border-radius: $border-radius-default $border-radius-default 0 0; - border: 1px solid $border-gray-normal; - border-bottom: 0; padding: 3px 12px; margin: auto; align-items: center; @@ -454,3 +452,9 @@ table { .markdown-selector { color: $blue-600; } + +.comment-warning-wrapper { + .md-area { + border: 0; + } +} diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb new file mode 100644 index 00000000000..dcf4430e55f --- /dev/null +++ b/app/graphql/resolvers/package_details_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + # No return types defined because they can be different. + # rubocop: disable Graphql/ResolverType + class PackageDetailsResolver < BaseResolver + argument :id, ::Types::GlobalIDType[::Packages::Package], + required: true, + description: 'The global ID of the package.' + + def resolve(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Packages::Package].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end +end diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/packages_resolver.rb index 519fb87183e..d19099e94d4 100644 --- a/app/graphql/resolvers/packages_resolver.rb +++ b/app/graphql/resolvers/packages_resolver.rb @@ -2,7 +2,7 @@ module Resolvers class PackagesResolver < BaseResolver - type Types::PackageType, null: true + type Types::Packages::PackageType, null: true def resolve(**args) return unless packages_available? diff --git a/app/graphql/types/package_type.rb b/app/graphql/types/package_type.rb deleted file mode 100644 index 0604bf827a5..00000000000 --- a/app/graphql/types/package_type.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Types - class PackageType < BaseObject - graphql_name 'Package' - description 'Represents a package' - authorize :read_package - - field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the package' - field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the package' - field :created_at, Types::TimeType, null: false, description: 'The created date' - field :updated_at, Types::TimeType, null: false, description: 'The update date' - field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package' - field :package_type, Types::PackageTypeEnum, null: false, description: 'The type of the package' - end -end diff --git a/app/graphql/types/package_type_enum.rb b/app/graphql/types/package_type_enum.rb deleted file mode 100644 index 6f50c166da3..00000000000 --- a/app/graphql/types/package_type_enum.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Types - class PackageTypeEnum < BaseEnum - PACKAGE_TYPE_NAMES = { - pypi: 'PyPI', - npm: 'NPM' - }.freeze - - ::Packages::Package.package_types.keys.each do |package_type| - type_name = PACKAGE_TYPE_NAMES.fetch(package_type.to_sym, package_type.capitalize) - value package_type.to_s.upcase, "Packages from the #{type_name} package manager", value: package_type.to_s - end - end -end diff --git a/app/graphql/types/packages/composer/details_type.rb b/app/graphql/types/packages/composer/details_type.rb new file mode 100644 index 00000000000..8c6845a6fb3 --- /dev/null +++ b/app/graphql/types/packages/composer/details_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Packages + module Composer + class DetailsType < Types::Packages::PackageType + graphql_name 'PackageComposerDetails' + description 'Details of a Composer package' + + authorize :read_package + + field :composer_metadatum, Types::Packages::Composer::MetadatumType, null: false, description: 'The Composer metadatum.' + end + end + end +end diff --git a/app/graphql/types/packages/composer/json_type.rb b/app/graphql/types/packages/composer/json_type.rb new file mode 100644 index 00000000000..b7aa32f0170 --- /dev/null +++ b/app/graphql/types/packages/composer/json_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Packages + module Composer + # rubocop: disable Graphql/AuthorizeTypes + class JsonType < BaseObject + graphql_name 'PackageComposerJsonType' + description 'Represents a composer JSON file' + + field :name, GraphQL::STRING_TYPE, null: true, description: 'The name set in the Composer JSON file.' + field :type, GraphQL::STRING_TYPE, null: true, description: 'The type set in the Composer JSON file.' + field :license, GraphQL::STRING_TYPE, null: true, description: 'The license set in the Composer JSON file.' + field :version, GraphQL::STRING_TYPE, null: true, description: 'The version set in the Composer JSON file.' + end + end + end +end diff --git a/app/graphql/types/packages/composer/metadatum_type.rb b/app/graphql/types/packages/composer/metadatum_type.rb new file mode 100644 index 00000000000..a97818b1fb8 --- /dev/null +++ b/app/graphql/types/packages/composer/metadatum_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + module Composer + class MetadatumType < BaseObject + graphql_name 'PackageComposerMetadatumType' + description 'Composer metadatum' + + authorize :read_package + + field :target_sha, GraphQL::STRING_TYPE, null: false, description: 'Target SHA of the package.' + field :composer_json, Types::Packages::Composer::JsonType, null: false, description: 'Data of the Composer JSON file.' + end + end + end +end diff --git a/app/graphql/types/packages/package_tag_type.rb b/app/graphql/types/packages/package_tag_type.rb new file mode 100644 index 00000000000..a05ce03da67 --- /dev/null +++ b/app/graphql/types/packages/package_tag_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageTagType < BaseObject + graphql_name 'PackageTag' + description 'Represents a package tag' + authorize :read_package + + field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the tag.' + field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the tag.' + field :created_at, Types::TimeType, null: false, description: 'The created date.' + field :updated_at, Types::TimeType, null: false, description: 'The updated date.' + end + end +end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb new file mode 100644 index 00000000000..b13d16e91c6 --- /dev/null +++ b/app/graphql/types/packages/package_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageType < BaseObject + graphql_name 'Package' + description 'Represents a package in the Package Registry' + + authorize :read_package + + field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the package.' + field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the package.' + field :created_at, Types::TimeType, null: false, description: 'The created date.' + field :updated_at, Types::TimeType, null: false, description: 'The updated date.' + field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package.' + field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'The type of the package.' + field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'The package tags.' + field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.' + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, description: 'Pipelines that built the package.' + field :versions, Types::Packages::PackageType.connection_type, null: true, description: 'The other versions of the package.' + + def project + Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find + end + end + end +end diff --git a/app/graphql/types/packages/package_type_enum.rb b/app/graphql/types/packages/package_type_enum.rb new file mode 100644 index 00000000000..9713c9d49b1 --- /dev/null +++ b/app/graphql/types/packages/package_type_enum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageTypeEnum < BaseEnum + PACKAGE_TYPE_NAMES = { + pypi: 'PyPI', + npm: 'NPM' + }.freeze + + ::Packages::Package.package_types.keys.each do |package_type| + type_name = PACKAGE_TYPE_NAMES.fetch(package_type.to_sym, package_type.capitalize) + value package_type.to_s.upcase, "Packages from the #{type_name} package manager", value: package_type.to_s + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 195da01f41f..f66d8926a9f 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -175,7 +175,7 @@ module Types description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single - field :packages, Types::PackageType.connection_type, null: true, + field :packages, Types::Packages::PackageType.connection_type, null: true, description: 'Packages of the project', resolver: Resolvers::PackagesResolver diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 05bb371088c..0e0c060f374 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -58,6 +58,11 @@ module Types argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository' end + field :package_composer_details, Types::Packages::Composer::DetailsType, + null: true, + description: 'Find a composer package', + resolver: Resolvers::PackageDetailsResolver + field :user, Types::UserType, null: true, description: 'Find a user', diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index f1f99771b40..ef3891908f7 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -210,7 +210,8 @@ module Ci project: downstream_project, source: :pipeline, target_revision: { - ref: target_ref || downstream_project.default_branch + ref: target_ref || downstream_project.default_branch, + variables_attributes: downstream_variables }, execute_params: { ignore_skip_ci: true, @@ -230,7 +231,8 @@ module Ci checkout_sha: parent_pipeline.sha, before: parent_pipeline.before_sha, source_sha: parent_pipeline.source_sha, - target_sha: parent_pipeline.target_sha + target_sha: parent_pipeline.target_sha, + variables_attributes: downstream_variables }, execute_params: { ignore_skip_ci: true, diff --git a/app/models/project.rb b/app/models/project.rb index b9911fd308b..b03dcde9f3f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -412,6 +412,8 @@ class Project < ApplicationRecord delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci delegate :keep_latest_artifact, :keep_latest_artifact=, :keep_latest_artifact?, to: :ci_cd_settings, prefix: :ci + delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, :restrict_user_defined_variables?, + to: :ci_cd_settings delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?, diff --git a/app/policies/packages/composer/metadatum_policy.rb b/app/policies/packages/composer/metadatum_policy.rb new file mode 100644 index 00000000000..66bac31f48f --- /dev/null +++ b/app/policies/packages/composer/metadatum_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Composer + class MetadatumPolicy < BasePolicy + delegate { @subject.package } + end + end +end diff --git a/app/policies/packages/tag_policy.rb b/app/policies/packages/tag_policy.rb new file mode 100644 index 00000000000..84bad30470a --- /dev/null +++ b/app/policies/packages/tag_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Packages + class TagPolicy < BasePolicy + delegate { @subject.package } + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e9bdf88fa5e..03cb53f55be 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -135,6 +135,10 @@ class ProjectPolicy < BasePolicy ::Feature.enabled?(:build_service_proxy, @subject) end + condition(:user_defined_variables_allowed) do + !@subject.restrict_user_defined_variables? + end + with_scope :subject condition(:packages_disabled) { !@subject.packages_enabled } @@ -616,6 +620,10 @@ class ProjectPolicy < BasePolicy enable :admin_resource_access_tokens end + rule { user_defined_variables_allowed | can?(:maintainer_access) }.policy do + enable :set_pipeline_variables + end + private def user_is_user? diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 7c12e0956f3..647a73495f8 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -71,6 +71,10 @@ class IssueEntity < IssuableEntity expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue| help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project') end + + expose :issue_email_participants do |issue| + issue.issue_email_participants.map { |x| { email: x.email } } + end end IssueEntity.prepend_if_ee('::EE::IssueEntity') diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 86d0cf079fc..629d85b041f 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -33,9 +33,7 @@ module Ci pipeline_params.fetch(:target_revision)) downstream_pipeline = service.execute( - pipeline_params.fetch(:source), **pipeline_params[:execute_params]) do |pipeline| - pipeline.variables.build(@bridge.downstream_variables) - end + pipeline_params.fetch(:source), **pipeline_params[:execute_params]) downstream_pipeline.tap do |pipeline| update_bridge_status!(@bridge, pipeline) diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index d9f41b7040e..a31f5e9056e 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -21,10 +21,10 @@ module Ci # this check is to not leak the presence of the project if user cannot read it return unless trigger.project == project - pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: params[:ref]) + pipeline = Ci::CreatePipelineService + .new(project, trigger.owner, ref: params[:ref], variables_attributes: variables) .execute(:trigger, ignore_skip_ci: true) do |pipeline| pipeline.trigger_requests.build(trigger: trigger) - pipeline.variables.build(variables) end if pipeline.persisted? @@ -44,7 +44,8 @@ module Ci # this check is to not leak the presence of the project if user cannot read it return unless can?(job.user, :read_project, project) - pipeline = Ci::CreatePipelineService.new(project, job.user, ref: params[:ref]) + pipeline = Ci::CreatePipelineService + .new(project, job.user, ref: params[:ref], variables_attributes: variables) .execute(:pipeline, ignore_skip_ci: true) do |pipeline| source = job.sourced_pipelines.build( source_pipeline: job.pipeline, @@ -53,7 +54,6 @@ module Ci project: project) pipeline.source_pipeline = source - pipeline.variables.build(variables) end if pipeline.persisted? diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index 6adeca624a8..ebc980a9053 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -5,6 +5,10 @@ module Ci def execute(build, job_variables_attributes = nil) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build) + if job_variables_attributes.present? && !can?(current_user, :set_pipeline_variables, project) + raise Gitlab::Access::AccessDeniedError + end + # Try to enqueue the build, otherwise create a duplicate. # if build.enqueue diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index 90a92f5c6d5..30dad19be37 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -9,10 +9,9 @@ = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' .jira-connect-app - %h1 - GitLab for Jira Configuration - - if current_user.blank? && @subscriptions.empty? + %h1 + GitLab for Jira Configuration %h2.heading-with-border Sign in to GitLab.com to get started. .gl-mt-5 diff --git a/changelogs/unreleased/13019-improve-error-messages-when-adding-namespaces-in-jira-connect-app.yml b/changelogs/unreleased/13019-improve-error-messages-when-adding-namespaces-in-jira-connect-app.yml new file mode 100644 index 00000000000..a6724919107 --- /dev/null +++ b/changelogs/unreleased/13019-improve-error-messages-when-adding-namespaces-in-jira-connect-app.yml @@ -0,0 +1,5 @@ +--- +title: Improve error messages when adding namespaces in Jira Connect App +merge_request: 48651 +author: +type: changed diff --git a/changelogs/unreleased/285467-package-registry-graphql-api.yml b/changelogs/unreleased/285467-package-registry-graphql-api.yml new file mode 100644 index 00000000000..7bb0398e25f --- /dev/null +++ b/changelogs/unreleased/285467-package-registry-graphql-api.yml @@ -0,0 +1,5 @@ +--- +title: Add composer details GraphQL type and query +merge_request: 51059 +author: +type: added diff --git a/changelogs/unreleased/297011-add-additional-aliases-for-reviewer-quick-commands.yml b/changelogs/unreleased/297011-add-additional-aliases-for-reviewer-quick-commands.yml new file mode 100644 index 00000000000..6234ffe87ec --- /dev/null +++ b/changelogs/unreleased/297011-add-additional-aliases-for-reviewer-quick-commands.yml @@ -0,0 +1,5 @@ +--- +title: Adding /reviewer and /remove_reviewer aliases and specs +merge_request: 51384 +author: +type: added diff --git a/changelogs/unreleased/ajk-297358-fix-preloaded-pagination.yml b/changelogs/unreleased/ajk-297358-fix-preloaded-pagination.yml new file mode 100644 index 00000000000..a882c0ffd1e --- /dev/null +++ b/changelogs/unreleased/ajk-297358-fix-preloaded-pagination.yml @@ -0,0 +1,5 @@ +--- +title: Generate page-info for connections of preloaded associations +merge_request: 51642 +author: +type: fixed diff --git a/changelogs/unreleased/ci-setting-prevent-user-defined-ci-variables.yml b/changelogs/unreleased/ci-setting-prevent-user-defined-ci-variables.yml new file mode 100644 index 00000000000..3ae1c06e04a --- /dev/null +++ b/changelogs/unreleased/ci-setting-prevent-user-defined-ci-variables.yml @@ -0,0 +1,5 @@ +--- +title: Prevent user-defined variables from being used by non-maintainers +merge_request: 51682 +author: +type: security diff --git a/changelogs/unreleased/fix-admin-project-overview-badge-alignment.yml b/changelogs/unreleased/fix-admin-project-overview-badge-alignment.yml new file mode 100644 index 00000000000..f88f11bda54 --- /dev/null +++ b/changelogs/unreleased/fix-admin-project-overview-badge-alignment.yml @@ -0,0 +1,5 @@ +--- +title: Fix admin project overview badge alignment +merge_request: 51066 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/webide-markdown-center.yml b/changelogs/unreleased/webide-markdown-center.yml new file mode 100644 index 00000000000..095f6a81251 --- /dev/null +++ b/changelogs/unreleased/webide-markdown-center.yml @@ -0,0 +1,5 @@ +--- +title: Centered Markdown Preview in Web IDE with a set max width to limit the container size +merge_request: 50291 +author: Mehul Sharma +type: other diff --git a/db/migrate/20201223114050_add_restrict_user_defined_variables_to_project_settings.rb b/db/migrate/20201223114050_add_restrict_user_defined_variables_to_project_settings.rb new file mode 100644 index 00000000000..d04c6981bf9 --- /dev/null +++ b/db/migrate/20201223114050_add_restrict_user_defined_variables_to_project_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddRestrictUserDefinedVariablesToProjectSettings < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + add_column :project_ci_cd_settings, :restrict_user_defined_variables, :boolean, default: false, null: false + end + end + + def down + with_lock_retries do + remove_column :project_ci_cd_settings, :restrict_user_defined_variables + end + end +end diff --git a/db/schema_migrations/20201223114050 b/db/schema_migrations/20201223114050 new file mode 100644 index 00000000000..25ac0eac211 --- /dev/null +++ b/db/schema_migrations/20201223114050 @@ -0,0 +1 @@ +35acb5bbabfd12f97c988776aafa6ff380e2cbe2267e856b8f439f7102a6fbf2
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3e1723463d0..de4218ed405 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15572,7 +15572,8 @@ CREATE TABLE project_ci_cd_settings ( forward_deployment_enabled boolean, merge_trains_enabled boolean DEFAULT false, auto_rollback_enabled boolean DEFAULT false NOT NULL, - keep_latest_artifact boolean DEFAULT true NOT NULL + keep_latest_artifact boolean DEFAULT true NOT NULL, + restrict_user_defined_variables boolean DEFAULT false NOT NULL ); CREATE SEQUENCE project_ci_cd_settings_id_seq diff --git a/doc/administration/postgresql/replication_and_failover.md b/doc/administration/postgresql/replication_and_failover.md index efc5b5ada81..75d0c558962 100644 --- a/doc/administration/postgresql/replication_and_failover.md +++ b/doc/administration/postgresql/replication_and_failover.md @@ -714,28 +714,6 @@ consul['configuration'] = { The manual steps for this configuration are the same as for the [example recommended setup](#example-recommended-setup-manual-steps). -### Manual failover procedure for Patroni - -While Patroni supports automatic failover, you also have the ability to perform -a manual one, where you have two slightly different options: - -- **Failover**: allows you to perform a manual failover when there are no healthy nodes. - You can perform this action in any PostgreSQL node: - - ```shell - sudo gitlab-ctl patroni failover - ``` - -- **Switchover**: only works when the cluster is healthy and allows you to schedule a switchover (it can happen immediately). - You can perform this action in any PostgreSQL node: - - ```shell - sudo gitlab-ctl patroni switchover - ``` - -For further details on this subject, see the -[Patroni documentation](https://patroni.readthedocs.io/en/latest/rest_api.html#switchover-and-failover-endpoints). - ## Patroni NOTE: @@ -828,6 +806,38 @@ want to signal Patroni to reload its configuration or restart PostgreSQL process must use the `reload` or `restart` sub-commands of `gitlab-ctl patroni` instead. These two sub-commands are wrappers of the same `patronictl` commands. +### Manual failover procedure for Patroni + +While Patroni supports automatic failover, you also have the ability to perform +a manual one, where you have two slightly different options: + +- **Failover**: allows you to perform a manual failover when there are no healthy nodes. + You can perform this action in any PostgreSQL node: + + ```shell + sudo gitlab-ctl patroni failover + ``` + +- **Switchover**: only works when the cluster is healthy and allows you to schedule a switchover (it can happen immediately). + You can perform this action in any PostgreSQL node: + + ```shell + sudo gitlab-ctl patroni switchover + ``` + +For further details on this subject, see the +[Patroni documentation](https://patroni.readthedocs.io/en/latest/rest_api.html#switchover-and-failover-endpoints). + +#### Geo secondary site considerations + +Similar to `repmgr`, when a Geo secondary site is replicating from a primary site that uses `Patroni` and `PgBouncer`, [replicating through PgBouncer is not supported](https://github.com/pgbouncer/pgbouncer/issues/382#issuecomment-517911529) and the secondary must replicate directly from the leader node in the `Patroni` cluster. Therefore, when there is an automatic or manual failover in the `Patroni` cluster, you will need to manually re-point your secondary site to replicate from the new leader with: + +```shell +sudo gitlab-ctl replicate-geo-database --host=<new_leader_ip> --replication-slot=<slot_name> +``` + +Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. You may also need to run `gitlab-ctl reconfigure` if replication is still not working after re-syncing. + ### Recovering the Patroni cluster To recover the old primary and rejoin it to the cluster as a replica, you can simply start Patroni with: @@ -1222,7 +1232,7 @@ When a Geo secondary site is replicating from a primary site that uses `repmgr` sudo gitlab-ctl replicate-geo-database --host=<new_leader_ip> --replication-slot=<slot_name> ``` -Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. +Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. You may also need to run `gitlab-ctl reconfigure` if replication is still not working after re-syncing. ### Repmgr Restore procedure diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index d71fabdd6d3..8c27a3b72d9 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -16521,38 +16521,278 @@ input OncallUserInputType { } """ -Represents a package +Represents a package in the Package Registry """ type Package { """ - The created date + The created date. """ createdAt: Time! """ - The ID of the package + The ID of the package. """ id: ID! """ - The name of the package + The name of the package. """ name: String! """ - The type of the package + The type of the package. """ packageType: PackageTypeEnum! """ - The update date + Pipelines that built the package. + """ + pipelines( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PipelineConnection + + """ + Project where the package is stored. + """ + project: Project! + + """ + The package tags. + """ + tags( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PackageTagConnection + + """ + The updated date. """ updatedAt: Time! """ - The version of the package + The version of the package. """ version: String + + """ + The other versions of the package. + """ + versions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PackageConnection +} + +""" +Details of a Composer package +""" +type PackageComposerDetails { + """ + The Composer metadatum. + """ + composerMetadatum: PackageComposerMetadatumType! + + """ + The created date. + """ + createdAt: Time! + + """ + The ID of the package. + """ + id: ID! + + """ + The name of the package. + """ + name: String! + + """ + The type of the package. + """ + packageType: PackageTypeEnum! + + """ + Pipelines that built the package. + """ + pipelines( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PipelineConnection + + """ + Project where the package is stored. + """ + project: Project! + + """ + The package tags. + """ + tags( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PackageTagConnection + + """ + The updated date. + """ + updatedAt: Time! + + """ + The version of the package. + """ + version: String + + """ + The other versions of the package. + """ + versions( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PackageConnection +} + +""" +Represents a composer JSON file +""" +type PackageComposerJsonType { + """ + The license set in the Composer JSON file. + """ + license: String + + """ + The name set in the Composer JSON file. + """ + name: String + + """ + The type set in the Composer JSON file. + """ + type: String + + """ + The version set in the Composer JSON file. + """ + version: String +} + +""" +Composer metadatum +""" +type PackageComposerMetadatumType { + """ + Data of the Composer JSON file. + """ + composerJson: PackageComposerJsonType! + + """ + Target SHA of the package. + """ + targetSha: String! } """ @@ -16686,6 +16926,66 @@ type PackageSettings { mavenDuplicatesAllowed: Boolean! } +""" +Represents a package tag +""" +type PackageTag { + """ + The created date. + """ + createdAt: Time! + + """ + The ID of the tag. + """ + id: ID! + + """ + The name of the tag. + """ + name: String! + + """ + The updated date. + """ + updatedAt: Time! +} + +""" +The connection type for PackageTag. +""" +type PackageTagConnection { + """ + A list of edges. + """ + edges: [PackageTagEdge] + + """ + A list of nodes. + """ + nodes: [PackageTag] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type PackageTagEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: PackageTag +} + enum PackageTypeEnum { """ Packages from the Composer package manager @@ -16734,6 +17034,11 @@ enum PackageTypeEnum { } """ +Identifier of Packages::Package +""" +scalar PackagesPackageID + +""" Information about pagination in a connection. """ type PageInfo { @@ -19868,6 +20173,16 @@ type Query { ): Namespace """ + Find a composer package + """ + packageComposerDetails( + """ + The global ID of the package. + """ + id: PackagesPackageID! + ): PackageComposerDetails + + """ Find a project """ project( diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 6792f6cd07e..6ae3f9afda7 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -48921,11 +48921,11 @@ { "kind": "OBJECT", "name": "Package", - "description": "Represents a package", + "description": "Represents a package in the Package Registry", "fields": [ { "name": "createdAt", - "description": "The created date", + "description": "The created date.", "args": [ ], @@ -48943,7 +48943,7 @@ }, { "name": "id", - "description": "The ID of the package", + "description": "The ID of the package.", "args": [ ], @@ -48961,7 +48961,7 @@ }, { "name": "name", - "description": "The name of the package", + "description": "The name of the package.", "args": [ ], @@ -48979,7 +48979,7 @@ }, { "name": "packageType", - "description": "The type of the package", + "description": "The type of the package.", "args": [ ], @@ -48996,8 +48996,132 @@ "deprecationReason": null }, { + "name": "pipelines", + "description": "Pipelines that built the package.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PipelineConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "Project where the package is stored.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tags", + "description": "The package tags.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageTagConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "updatedAt", - "description": "The update date", + "description": "The updated date.", "args": [ ], @@ -49015,7 +49139,7 @@ }, { "name": "version", - "description": "The version of the package", + "description": "The version of the package.", "args": [ ], @@ -49026,6 +49150,489 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "versions", + "description": "The other versions of the package.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageComposerDetails", + "description": "Details of a Composer package", + "fields": [ + { + "name": "composerMetadatum", + "description": "The Composer metadatum.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageComposerMetadatumType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "The created date.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The ID of the package.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the package.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageType", + "description": "The type of the package.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PackageTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pipelines", + "description": "Pipelines that built the package.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PipelineConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "Project where the package is stored.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tags", + "description": "The package tags.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageTagConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "The updated date.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": "The version of the package.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "versions", + "description": "The other versions of the package.", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageComposerJsonType", + "description": "Represents a composer JSON file", + "fields": [ + { + "name": "license", + "description": "The license set in the Composer JSON file.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name set in the Composer JSON file.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "The type set in the Composer JSON file.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": "The version set in the Composer JSON file.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageComposerMetadatumType", + "description": "Composer metadatum", + "fields": [ + { + "name": "composerJson", + "description": "Data of the Composer JSON file.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageComposerJsonType", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetSha", + "description": "Target SHA of the package.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -49438,6 +50045,203 @@ "possibleTypes": null }, { + "kind": "OBJECT", + "name": "PackageTag", + "description": "Represents a package tag", + "fields": [ + { + "name": "createdAt", + "description": "The created date.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The ID of the tag.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the tag.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "The updated date.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageTagConnection", + "description": "The connection type for PackageTag.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageTagEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageTag", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageTagEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "PackageTag", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "ENUM", "name": "PackageTypeEnum", "description": null, @@ -49503,6 +50307,16 @@ "possibleTypes": null }, { + "kind": "SCALAR", + "name": "PackagesPackageID", + "description": "Identifier of Packages::Package", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { "kind": "OBJECT", "name": "PageInfo", "description": "Information about pagination in a connection.", @@ -57938,6 +58752,33 @@ "deprecationReason": null }, { + "name": "packageComposerDetails", + "description": "Find a composer package", + "args": [ + { + "name": "id", + "description": "The global ID of the package.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "PackagesPackageID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageComposerDetails", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "project", "description": "Find a project", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 4930b136894..0a732c59832 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2496,16 +2496,58 @@ Autogenerated return type of OncallScheduleUpdate. ### Package -Represents a package. +Represents a package in the Package Registry. | Field | Type | Description | | ----- | ---- | ----------- | -| `createdAt` | Time! | The created date | -| `id` | ID! | The ID of the package | -| `name` | String! | The name of the package | -| `packageType` | PackageTypeEnum! | The type of the package | -| `updatedAt` | Time! | The update date | -| `version` | String | The version of the package | +| `createdAt` | Time! | The created date. | +| `id` | ID! | The ID of the package. | +| `name` | String! | The name of the package. | +| `packageType` | PackageTypeEnum! | The type of the package. | +| `pipelines` | PipelineConnection | Pipelines that built the package. | +| `project` | Project! | Project where the package is stored. | +| `tags` | PackageTagConnection | The package tags. | +| `updatedAt` | Time! | The updated date. | +| `version` | String | The version of the package. | +| `versions` | PackageConnection | The other versions of the package. | + +### PackageComposerDetails + +Details of a Composer package. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `composerMetadatum` | PackageComposerMetadatumType! | The Composer metadatum. | +| `createdAt` | Time! | The created date. | +| `id` | ID! | The ID of the package. | +| `name` | String! | The name of the package. | +| `packageType` | PackageTypeEnum! | The type of the package. | +| `pipelines` | PipelineConnection | Pipelines that built the package. | +| `project` | Project! | Project where the package is stored. | +| `tags` | PackageTagConnection | The package tags. | +| `updatedAt` | Time! | The updated date. | +| `version` | String | The version of the package. | +| `versions` | PackageConnection | The other versions of the package. | + +### PackageComposerJsonType + +Represents a composer JSON file. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `license` | String | The license set in the Composer JSON file. | +| `name` | String | The name set in the Composer JSON file. | +| `type` | String | The type set in the Composer JSON file. | +| `version` | String | The version set in the Composer JSON file. | + +### PackageComposerMetadatumType + +Composer metadatum. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `composerJson` | PackageComposerJsonType! | Data of the Composer JSON file. | +| `targetSha` | String! | Target SHA of the package. | ### PackageFileRegistry @@ -2531,6 +2573,17 @@ Namespace-level Package Registry settings. | `mavenDuplicateExceptionRegex` | UntrustedRegexp | When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. | | `mavenDuplicatesAllowed` | Boolean! | Indicates whether duplicate Maven packages are allowed for this namespace. | +### PackageTag + +Represents a package tag. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `createdAt` | Time! | The created date. | +| `id` | ID! | The ID of the tag. | +| `name` | String! | The name of the tag. | +| `updatedAt` | Time! | The updated date. | + ### PageInfo Information about pagination in a connection.. diff --git a/doc/api/projects.md b/doc/api/projects.md index a344f53a2b1..f9a4b3ba55e 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -160,6 +160,7 @@ When the user is authenticated and `simple` is not set this returns something li "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -253,6 +254,7 @@ When the user is authenticated and `simple` is not set this returns something li "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -418,6 +420,7 @@ GET /users/:user_id/projects "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -511,6 +514,7 @@ GET /users/:user_id/projects "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -640,6 +644,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -726,6 +731,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -881,6 +887,7 @@ GET /projects/:id "repository_storage": "default", "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "printing_merge_requests_link_enabled": true, @@ -1234,6 +1241,7 @@ PUT /projects/:id | `packages_enabled` | boolean | **{dotted-circle}** No | Enable or disable packages repository feature. | | `pages_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, `enabled`, or `public`. | | `requirements_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, `enabled` or `public` | +| `restrict_user_defined_variables` | boolean | **{dotted-circle}** No | Allow only maintainers to pass user-defined variables when triggering a pipeline. For example when the pipeline is triggered in the UI, with the API, or by a trigger token. | | `path` | string | **{dotted-circle}** No | Custom repository name for the project. By default generated based on name. | | `public_builds` | boolean | **{dotted-circle}** No | If `true`, jobs can be viewed by non-project members. | | `remove_source_branch_after_merge` | boolean | **{dotted-circle}** No | Enable `Delete source branch` option by default for all new merge requests. | @@ -1356,6 +1364,7 @@ Example responses: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -1449,6 +1458,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -1540,6 +1550,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -1725,6 +1736,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -1837,6 +1849,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": false, + "restrict_user_defined_variables": false, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": false, "request_access_enabled": false, @@ -2397,6 +2410,7 @@ Example response: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "allow_merge_on_skipped_pipeline": null, + "restrict_user_defined_variables": false, "request_access_enabled": true, "only_allow_merge_if_all_discussions_are_resolved": false, "remove_source_branch_after_merge": true, diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 4d1b21cfe46..3fd388c899d 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -594,7 +594,35 @@ WARNING: Variables with multi-line values are not supported due to limitations with the Auto DevOps scripting environment. -### Override a variable by manually running a pipeline +### When you can override variables + +You can override the value of a variable when: + +1. [Manually running](#override-a-variable-by-manually-running-a-pipeline) pipelines in the UI. +1. Manually creating pipelines [via API](../../api/pipelines.md#create-a-new-pipeline). +1. Manually playing a job via the UI. +1. Using [push options](../../user/project/push_options.md#push-options-for-gitlab-cicd). +1. Manually triggering pipelines with [the API](../triggers/README.md#making-use-of-trigger-variables). +1. Passing variables to a [downstream pipeline](../multi_project_pipelines.md#passing-variables-to-a-downstream-pipeline). + +These pipeline variables declared in these events take [priority over other variables](#priority-of-environment-variables). + +#### Restrict who can override variables + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/295234) in GitLab 13.8. + +To allow only users with Maintainer role to set these variables, you can use +[the API](../../api/projects.md#edit-project) to enable the project setting `restrict_user_defined_variables`. +When a user without Maintainer role tries to run a pipeline with overridden +variables, an `Insufficient permissions to set pipeline variables` error occurs. + +The setting is `disabled` by default. + +If you [store your CI configurations in a different repository](../../ci/pipelines/settings.md#custom-ci-configuration-path), +use this setting for strict control over all aspects of the environment +the pipeline runs in. + +#### Override a variable by manually running a pipeline > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/44059) in GitLab 10.8. diff --git a/doc/install/README.md b/doc/install/README.md index 7b037da4c4a..7ed478439e0 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -17,7 +17,7 @@ troubleshooting), and the cost of hosting. Depending on your platform, select from the following available methods to install GitLab: -- [_Omnibus GitLab_](#installing-gitlab-using-the-omnibus-gitlab-package-recommended): +- [_Omnibus GitLab_](#installing-gitlab-on-linux-using-the-omnibus-gitlab-package-recommended): The official deb/rpm packages that contain a bundle of GitLab and the components it depends on, including PostgreSQL, Redis, and Sidekiq. - [_GitLab Helm chart_](#installing-gitlab-on-kubernetes-via-the-gitlab-helm-charts): @@ -42,16 +42,24 @@ Before you install GitLab, be sure to review the [system requirements](requireme The system requirements include details about the minimum hardware, software, database, and additional requirements to support GitLab. -## Installing GitLab using the Omnibus GitLab package (recommended) +## Installing GitLab on Linux using the Omnibus GitLab package (recommended) The Omnibus GitLab package uses our official deb/rpm repositories, and is recommended for most users. -If you need additional flexibility and resilience, we recommend deploying +If you need additional scale or resilience, we recommend deploying GitLab as described in our [reference architecture documentation](../administration/reference_architectures/index.md). [**> Install GitLab using the Omnibus GitLab package.**](https://about.gitlab.com/install/) +### GitLab Environment Toolkit (alpha) + +The [GitLab Environment Toolkit](https://gitlab.com/gitlab-org/quality/gitlab-environment-toolkit) provides a set of automation tools to easily deploy a [reference architecture](../administration/reference_architectures/index.md) on most major cloud providers. + +It is currently in alpha, and is not recommended for production use. + +[**> Install a GitLab reference architecture using the GitLab Environment Toolkit.**](https://gitlab.com/gitlab-org/quality/gitlab-environment-toolkit#documentation) + ## Installing GitLab on Kubernetes via the GitLab Helm charts When installing GitLab on Kubernetes, there are some trade-offs that you @@ -95,8 +103,7 @@ the above methods, provided the cloud provider supports it. - [Install GitLab on Google Cloud Platform](google_cloud_platform/index.md): Install Omnibus GitLab on a VM in GCP. - [Install GitLab on Azure](azure/index.md): Install Omnibus GitLab from Azure Marketplace. - [Install GitLab on OpenShift](https://docs.gitlab.com/charts/installation/cloud/openshift.html): Install GitLab on OpenShift by using the GitLab Helm charts. -- [Install GitLab on DC/OS](https://d2iq.com/blog/gitlab-dcos): Install GitLab on Mesosphere DC/OS via the [GitLab-Mesosphere integration](https://about.gitlab.com/blog/2016/09/16/announcing-gitlab-and-mesosphere/). -- [Install GitLab on DigitalOcean](https://about.gitlab.com/blog/2016/04/27/getting-started-with-gitlab-and-digitalocean/): Install Omnibus GitLab on DigitalOcean. +- [Install GitLab on DigitalOcean](https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-gitlab-on-ubuntu-18-04): Install Omnibus GitLab on DigitalOcean. - _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md): Quickly test any version of GitLab on DigitalOcean using Docker Machine. diff --git a/doc/user/group/roadmap/img/roadmap_filters_v13_7.png b/doc/user/group/roadmap/img/roadmap_filters_v13_7.png Binary files differdeleted file mode 100644 index 00505a7f34f..00000000000 --- a/doc/user/group/roadmap/img/roadmap_filters_v13_7.png +++ /dev/null diff --git a/doc/user/group/roadmap/img/roadmap_filters_v13_8.png b/doc/user/group/roadmap/img/roadmap_filters_v13_8.png Binary files differnew file mode 100644 index 00000000000..d826909b022 --- /dev/null +++ b/doc/user/group/roadmap/img/roadmap_filters_v13_8.png diff --git a/doc/user/group/roadmap/index.md b/doc/user/group/roadmap/index.md index 6dfa7641dbb..f3b7be536ae 100644 --- a/doc/user/group/roadmap/index.md +++ b/doc/user/group/roadmap/index.md @@ -67,8 +67,9 @@ You can also filter epics in the Roadmap view by: - Author - Label - Milestone +- Confidentiality of epics -![roadmap date range in weeks](img/roadmap_filters_v13_7.png) +![roadmap date range in weeks](img/roadmap_filters_v13_8.png) Roadmaps can also be [visualized inside an epic](../epics/index.md#roadmap-in-epics). diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index 317caefe0a1..6ad6123a20e 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -100,6 +100,7 @@ module API end expose :only_allow_merge_if_pipeline_succeeds expose :allow_merge_on_skipped_pipeline + expose :restrict_user_defined_variables expose :request_access_enabled expose :only_allow_merge_if_all_discussions_are_resolved expose :remove_source_branch_after_merge diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index 3115e968e84..cf2bcace33b 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -87,6 +87,7 @@ module API params :optional_update_params_ce do optional :ci_forward_deployment_enabled, type: Boolean, desc: 'Skip older deployment jobs that are still pending' + optional :restrict_user_defined_variables, type: Boolean, desc: 'Restrict use of user-defined variables when triggering a pipeline' end params :optional_update_params_ee do @@ -141,6 +142,7 @@ module API :repository_access_level, :request_access_enabled, :resolve_outdated_diff_discussions, + :restrict_user_defined_variables, :shared_runners_enabled, :snippets_access_level, :tag_list, diff --git a/lib/gitlab/ci/pipeline/chain/build.rb b/lib/gitlab/ci/pipeline/chain/build.rb index c68419c8aa4..f0548284001 100644 --- a/lib/gitlab/ci/pipeline/chain/build.rb +++ b/lib/gitlab/ci/pipeline/chain/build.rb @@ -5,6 +5,9 @@ module Gitlab module Pipeline module Chain class Build < Chain::Base + include Gitlab::Allowable + include Chain::Helpers + def perform! @pipeline.assign_attributes( source: @command.source, @@ -20,13 +23,34 @@ module Gitlab pipeline_schedule: @command.schedule, merge_request: @command.merge_request, external_pull_request: @command.external_pull_request, - variables_attributes: Array(@command.variables_attributes), - locked: @command.project.latest_pipeline_locked + locked: @command.project.latest_pipeline_locked, + variables_attributes: variables_attributes ) end def break? - false + @pipeline.errors.any? + end + + private + + def variables_attributes + variables = Array(@command.variables_attributes) + + # We allow parent pipelines to pass variables to child pipelines since + # these variables are coming from internal configurations. We will check + # permissions to :set_pipeline_variables when those are injected upstream, + # to the parent pipeline. + # In other scenarios (e.g. multi-project pipelines or run pipeline via UI) + # the variables are provided from the outside and those should be guarded. + return variables if @command.creates_child_pipeline? + + if variables.present? && !can?(@command.current_user, :set_pipeline_variables, @command.project) + error("Insufficient permissions to set pipeline variables") + variables = [] + end + + variables end end end diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index d05be54267c..815fe6bac6d 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -79,6 +79,10 @@ module Gitlab bridge&.parent_pipeline end + def creates_child_pipeline? + bridge&.triggers_child_pipeline? + end + def metrics @metrics ||= ::Gitlab::Ci::Pipeline::Metrics.new end diff --git a/lib/gitlab/graphql/pagination/keyset/connection.rb b/lib/gitlab/graphql/pagination/keyset/connection.rb index 2ad8d2f7ab7..f95c91c5706 100644 --- a/lib/gitlab/graphql/pagination/keyset/connection.rb +++ b/lib/gitlab/graphql/pagination/keyset/connection.rb @@ -67,9 +67,14 @@ module Gitlab # next page true elsif first - # If we count the number of requested items plus one (`limit_value + 1`), - # then if we get `limit_value + 1` then we know there is a next page - relation_count(set_limit(sliced_nodes, limit_value + 1)) == limit_value + 1 + case sliced_nodes + when Array + sliced_nodes.size > limit_value + else + # If we count the number of requested items plus one (`limit_value + 1`), + # then if we get `limit_value + 1` then we know there is a next page + relation_count(set_limit(sliced_nodes, limit_value + 1)) == limit_value + 1 + end else false end @@ -157,8 +162,8 @@ module Gitlab list = OrderInfo.build_order_list(items) - if loaded?(items) - @order_list = list.presence || [items.primary_key] + if loaded?(items) && !before.present? && !after.present? + @order_list = list.presence || [OrderInfo.new(items.primary_key)] # already sorted, or trivially sorted next items if list.present? || items.size <= 1 @@ -194,7 +199,7 @@ module Gitlab ordering = { 'id' => node[:id].to_s } order_list.each do |field| - field_name = field.attribute_name + field_name = field.try(:attribute_name) || field field_value = node[field_name] ordering[field_name] = if field_value.is_a?(Time) field_value.strftime('%Y-%m-%d %H:%M:%S.%N %Z') diff --git a/lib/gitlab/graphql/pagination/keyset/query_builder.rb b/lib/gitlab/graphql/pagination/keyset/query_builder.rb index 331981ce723..29169449843 100644 --- a/lib/gitlab/graphql/pagination/keyset/query_builder.rb +++ b/lib/gitlab/graphql/pagination/keyset/query_builder.rb @@ -40,7 +40,10 @@ module Gitlab # "issues"."id" > 500 # def conditions - attr_values = order_list.map { |field| decoded_cursor[field.attribute_name] } + attr_values = order_list.map do |field| + name = field.try(:attribute_name) || field + decoded_cursor[name] + end if order_list.count == 1 && attr_values.first.nil? raise Gitlab::Graphql::Errors::ArgumentError.new('Before/after cursor invalid: `nil` was provided as only sortable value') diff --git a/lib/gitlab/quick_actions/merge_request_actions.rb b/lib/gitlab/quick_actions/merge_request_actions.rb index 31cc3f930c0..b56fd8278a1 100644 --- a/lib/gitlab/quick_actions/merge_request_actions.rb +++ b/lib/gitlab/quick_actions/merge_request_actions.rb @@ -163,16 +163,17 @@ module Gitlab end end explanation do |users| + reviewers = reviewers_to_add(users) _('Assigns %{reviewer_users_sentence} as %{reviewer_text}.') % { reviewer_users_sentence: reviewer_users_sentence(users), - reviewer_text: 'reviewer'.pluralize(users.size) } + reviewer_text: 'reviewer'.pluralize(reviewers.size) } end execution_message do |users = nil| - if users.blank? + reviewers = reviewers_to_add(users) + if reviewers.blank? _("Failed to assign a reviewer because no user was found.") else - users = [users.first] unless quick_action_target.allows_multiple_reviewers? _('Assigned %{reviewer_users_sentence} as %{reviewer_text}.') % { reviewer_users_sentence: reviewer_users_sentence(users), - reviewer_text: 'reviewer'.pluralize(users.size) } + reviewer_text: 'reviewer'.pluralize(reviewers.size) } end end params do @@ -186,7 +187,7 @@ module Gitlab parse_params do |reviewer_param| extract_users(reviewer_param) end - command :assign_reviewer do |users| + command :assign_reviewer, :reviewer do |users| next if users.empty? if quick_action_target.allows_multiple_reviewers? @@ -228,7 +229,7 @@ module Gitlab # When multiple users are assigned, all will be unassigned if multiple reviewers are no longer allowed extract_users(unassign_reviewer_param) if quick_action_target.allows_multiple_reviewers? end - command :unassign_reviewer do |users = nil| + command :unassign_reviewer, :remove_reviewer do |users = nil| if quick_action_target.allows_multiple_reviewers? && users&.any? @updates[:reviewer_ids] ||= quick_action_target.reviewers.map(&:id) @updates[:reviewer_ids] -= users.map(&:id) @@ -239,11 +240,7 @@ module Gitlab end def reviewer_users_sentence(users) - if quick_action_target.allows_multiple_reviewers? - users - else - [users.first] - end.map(&:to_reference).to_sentence + reviewers_to_add(users).map(&:to_reference).to_sentence end def reviewers_for_removal(users) @@ -255,6 +252,16 @@ module Gitlab end end + def reviewers_to_add(users) + return if users.blank? + + if quick_action_target.allows_multiple_reviewers? + users + else + [users.first] + end + end + def merge_orchestration_service @merge_orchestration_service ||= MergeRequests::MergeOrchestrationService.new(project, current_user) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f218030fcf4..c5ed8add78a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10617,6 +10617,15 @@ msgstr "" msgid "EmailError|Your account has been blocked. If you believe this is in error, contact a staff member." msgstr "" +msgid "EmailParticipantsWarning|%{emails} will be notified of your comment." +msgstr "" + +msgid "EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment." +msgstr "" + +msgid "EmailParticipantsWarning|and %{moreCount} more" +msgstr "" + msgid "EmailToken|reset it" msgstr "" @@ -25947,6 +25956,9 @@ msgstr "" msgid "Show latest version" msgstr "" +msgid "Show links anyways" +msgstr "" + msgid "Show list" msgstr "" @@ -28799,6 +28811,9 @@ msgstr "" msgid "This field is required." msgstr "" +msgid "This graph has a large number of jobs and showing the links between them may have performance implications." +msgstr "" + msgid "This group" msgstr "" diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 12c8c84dd77..65d8babc837 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -227,6 +227,22 @@ RSpec.describe Projects::IssuesController do end end + describe "GET #show" do + before do + sign_in(user) + project.add_developer(user) + end + + it "returns issue_email_participants" do + participants = create_list(:issue_email_participant, 2, issue: issue) + + get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }, format: :json + + 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 + end + describe 'GET #new' do it 'redirects to signin if not logged in' do get :new, params: { namespace_id: project.namespace, project_id: project } diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index def60c9cb69..f5e496080c4 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -45,6 +45,7 @@ FactoryBot.define do import_correlation_id { nil } import_last_error { nil } forward_deployment_enabled { nil } + restrict_user_defined_variables { nil } end before(:create) do |project, evaluator| @@ -84,6 +85,7 @@ FactoryBot.define do project.merge_pipelines_enabled = evaluator.merge_pipelines_enabled unless evaluator.merge_pipelines_enabled.nil? project.merge_trains_enabled = evaluator.merge_trains_enabled unless evaluator.merge_trains_enabled.nil? project.ci_keep_latest_artifact = evaluator.ci_keep_latest_artifact unless evaluator.ci_keep_latest_artifact.nil? + project.restrict_user_defined_variables = evaluator.restrict_user_defined_variables unless evaluator.restrict_user_defined_variables.nil? if evaluator.import_status import_state = project.import_state || project.build_import_state diff --git a/spec/features/projects/issues/email_participants_spec.rb b/spec/features/projects/issues/email_participants_spec.rb new file mode 100644 index 00000000000..3ffe0a5ced8 --- /dev/null +++ b/spec/features/projects/issues/email_participants_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'viewing an issue', :js do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:note) { create(:note_on_issue, project: project, noteable: issue) } + let_it_be(:participants) { create_list(:issue_email_participant, 4, issue: issue) } + + before do + sign_in(user) + visit project_issue_path(project, issue) + end + + shared_examples 'email participants warning' do |selector| + it 'shows the correct message' do + expect(find(selector)).to have_content(", and 1 more will be notified of your comment") + end + end + + context 'for a new note' do + it_behaves_like 'email participants warning', '.new-note' + end + + context 'for a reply form' do + before do + find('.js-reply-button').click + end + + it_behaves_like 'email participants warning', '.note-edit-form' + end +end diff --git a/spec/fixtures/api/schemas/graphql/packages/package_composer_details.json b/spec/fixtures/api/schemas/graphql/packages/package_composer_details.json new file mode 100644 index 00000000000..bcf64a6e567 --- /dev/null +++ b/spec/fixtures/api/schemas/graphql/packages/package_composer_details.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "allOf": [{ "$ref": "./package_details.json" }], + "properties": { + "target_sha": { + "type": "string" + }, + "composer_json": { + "type": "object" + } + } +} diff --git a/spec/fixtures/api/schemas/graphql/packages/package_details.json b/spec/fixtures/api/schemas/graphql/packages/package_details.json new file mode 100644 index 00000000000..4f90285183c --- /dev/null +++ b/spec/fixtures/api/schemas/graphql/packages/package_details.json @@ -0,0 +1,36 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": ["string", "null"] + }, + "package_type": { + "type": ["string"], + "enum": ["MAVEN", "NPM", "CONAN", "NUGET", "PYPI", "COMPOSER", "GENERIC", "GOLANG", "DEBIAN"] + }, + "tags": { + "type": "object" + }, + "project": { + "type": "object" + }, + "pipelines": { + "type": "object" + }, + "versions": { + "type": "object" + } + } +} diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/components/app_spec.js index 42ce14e397a..be990d5061c 100644 --- a/spec/frontend/jira_connect/components/app_spec.js +++ b/spec/frontend/jira_connect/components/app_spec.js @@ -1,16 +1,28 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { GlAlert } from '@gitlab/ui'; import JiraConnectApp from '~/jira_connect/components/app.vue'; +import createStore from '~/jira_connect/store'; +import { SET_ERROR_MESSAGE } from '~/jira_connect/store/mutation_types'; + +Vue.use(Vuex); describe('JiraConnectApp', () => { let wrapper; + let store; + const findAlert = () => wrapper.findComponent(GlAlert); const findHeader = () => wrapper.findByTestId('new-jira-connect-ui-heading'); const findHeaderText = () => findHeader().text(); const createComponent = (options = {}) => { + store = createStore(); + wrapper = extendedWrapper( shallowMount(JiraConnectApp, { + store, provide: { glFeatures: { newJiraConnectUi: true }, }, @@ -43,5 +55,26 @@ describe('JiraConnectApp', () => { expect(findHeader().exists()).toBe(false); }); }); + + it.each` + errorMessage | errorShouldRender + ${'Test error'} | ${true} + ${''} | ${false} + ${undefined} | ${false} + `( + 'renders correct alert when errorMessage is `$errorMessage`', + async ({ errorMessage, errorShouldRender }) => { + createComponent(); + + store.commit(SET_ERROR_MESSAGE, errorMessage); + await wrapper.vm.$nextTick(); + + expect(findAlert().exists()).toBe(errorShouldRender); + if (errorShouldRender) { + expect(findAlert().isVisible()).toBe(errorShouldRender); + expect(findAlert().html()).toContain(errorMessage); + } + }, + ); }); }); diff --git a/spec/frontend/notes/components/comment_field_layout_spec.js b/spec/frontend/notes/components/comment_field_layout_spec.js new file mode 100644 index 00000000000..4d9b4ea8c6f --- /dev/null +++ b/spec/frontend/notes/components/comment_field_layout_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommentFieldLayout from '~/notes/components/comment_field_layout.vue'; +import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue'; +import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; + +describe('Comment Field Layout Component', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const LOCKED_DISCUSSION_DOCS_PATH = 'docs/locked/path'; + const CONFIDENTIAL_ISSUES_DOCS_PATH = 'docs/confidential/path'; + + const noteableDataMock = { + confidential: false, + discussion_locked: false, + locked_discussion_docs_path: LOCKED_DISCUSSION_DOCS_PATH, + confidential_issues_docs_path: CONFIDENTIAL_ISSUES_DOCS_PATH, + }; + + const findIssuableNoteWarning = () => wrapper.find(NoteableWarning); + const findEmailParticipantsWarning = () => wrapper.find(EmailParticipantsWarning); + const findErrorAlert = () => wrapper.findByTestId('comment-field-alert-container'); + + const createWrapper = (props = {}, slots = {}) => { + wrapper = extendedWrapper( + shallowMount(CommentFieldLayout, { + propsData: { + noteableData: noteableDataMock, + ...props, + }, + slots, + }), + ); + }; + + describe('.error-alert', () => { + it('does not exist by default', () => { + createWrapper(); + + expect(findErrorAlert().exists()).toBe(false); + }); + + it('exists when withAlertContainer is true', () => { + createWrapper({ withAlertContainer: true }); + + expect(findErrorAlert().isVisible()).toBe(true); + }); + }); + + describe('issue is not confidential and not locked', () => { + it('does not show IssuableNoteWarning', () => { + createWrapper(); + + expect(findIssuableNoteWarning().exists()).toBe(false); + }); + }); + + describe('issue is confidential', () => { + beforeEach(() => { + createWrapper({ + noteableData: { ...noteableDataMock, confidential: true }, + }); + }); + + it('shows IssuableNoteWarning', () => { + expect(findIssuableNoteWarning().isVisible()).toBe(true); + }); + + it('sets IssuableNoteWarning props', () => { + expect(findIssuableNoteWarning().props()).toMatchObject({ + isLocked: false, + isConfidential: true, + lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH, + confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH, + }); + }); + }); + + describe('issue is locked', () => { + beforeEach(() => { + createWrapper({ + noteableData: { ...noteableDataMock, discussion_locked: true }, + }); + }); + + it('shows IssuableNoteWarning', () => { + expect(findIssuableNoteWarning().isVisible()).toBe(true); + }); + + it('sets IssuableNoteWarning props', () => { + expect(findIssuableNoteWarning().props()).toMatchObject({ + isConfidential: false, + isLocked: true, + lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH, + confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH, + }); + }); + }); + + describe('issue has no email participants', () => { + it('does not show EmailParticipantsWarning', () => { + createWrapper(); + + expect(findEmailParticipantsWarning().exists()).toBe(false); + }); + }); + + describe('issue has email participants', () => { + beforeEach(() => { + createWrapper({ + noteableData: { + ...noteableDataMock, + issue_email_participants: [ + { email: 'someone@gitlab.com' }, + { email: 'another@gitlab.com' }, + ], + }, + }); + }); + + it('shows EmailParticipantsWarning', () => { + expect(findEmailParticipantsWarning().isVisible()).toBe(true); + }); + + it('sets EmailParticipantsWarning props', () => { + expect(findEmailParticipantsWarning().props('emails')).toEqual([ + 'someone@gitlab.com', + 'another@gitlab.com', + ]); + }); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index fca1beca999..002c4f206cb 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -181,7 +181,7 @@ describe('issue_comment_form component', () => { describe('edit mode', () => { beforeEach(() => { - mountComponent(); + mountComponent({ mountFunction: mount }); }); it('should enter edit mode when arrow up is pressed', () => { @@ -200,7 +200,7 @@ describe('issue_comment_form component', () => { describe('event enter', () => { beforeEach(() => { - mountComponent(); + mountComponent({ mountFunction: mount }); }); it('should save note when cmd+enter is pressed', () => { @@ -368,17 +368,6 @@ describe('issue_comment_form component', () => { }); }); }); - - describe('issue is confidential', () => { - it('shows information warning', () => { - mountComponent({ - noteableData: { ...noteableDataMock, confidential: true }, - mountFunction: mount, - }); - - expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true); - }); - }); }); describe('user is not logged in', () => { diff --git a/spec/frontend/notes/components/email_participants_warning_spec.js b/spec/frontend/notes/components/email_participants_warning_spec.js new file mode 100644 index 00000000000..ab1a6b152a4 --- /dev/null +++ b/spec/frontend/notes/components/email_participants_warning_spec.js @@ -0,0 +1,70 @@ +import { mount } from '@vue/test-utils'; +import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue'; + +describe('Email Participants Warning Component', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findMoreButton = () => wrapper.find('button'); + + const createWrapper = (emails) => { + wrapper = mount(EmailParticipantsWarning, { + propsData: { emails }, + }); + }; + + describe('with 3 or less emails', () => { + beforeEach(() => { + createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com']); + }); + + it('more button does not exist', () => { + expect(findMoreButton().exists()).toBe(false); + }); + + it('all emails are displayed', () => { + expect(wrapper.text()).toBe( + 'a@gitlab.com, b@gitlab.com, and c@gitlab.com will be notified of your comment.', + ); + }); + }); + + describe('with more than 3 emails', () => { + beforeEach(() => { + createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com', 'd@gitlab.com']); + }); + + it('only displays first 3 emails', () => { + expect(wrapper.text()).toContain('a@gitlab.com, b@gitlab.com, c@gitlab.com'); + expect(wrapper.text()).not.toContain('d@gitlab.com'); + }); + + it('more button does exist', () => { + expect(findMoreButton().exists()).toBe(true); + }); + + it('more button displays the correct wordage', () => { + expect(findMoreButton().text()).toBe('and 1 more'); + }); + + describe('when more button clicked', () => { + beforeEach(() => { + findMoreButton().trigger('click'); + }); + + it('more button no longer exists', () => { + expect(findMoreButton().exists()).toBe(false); + }); + + it('all emails are displayed', () => { + expect(wrapper.text()).toBe( + 'a@gitlab.com, b@gitlab.com, c@gitlab.com, and d@gitlab.com will be notified of your comment.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 95f35af8ebf..e64a75bede9 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -1,5 +1,5 @@ +import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import { shallowMount } from '@vue/test-utils'; import createStore from '~/notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; @@ -19,7 +19,7 @@ describe('issue_note_form component', () => { let props; const createComponentWrapper = () => { - return shallowMount(NoteForm, { + return mount(NoteForm, { store, propsData: props, }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 3692e51cf0b..3eacc467c51 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -30,7 +30,7 @@ job_test_2: job_build: stage: build - script: + script: - echo "build" needs: ["job_test_2"] `; diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 7b12070e08f..dbaa0066ff8 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import { GRAPHQL } from '~/pipelines/components/graph/constants'; import { generateResponse, @@ -13,6 +14,7 @@ describe('graph component', () => { let wrapper; const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); + const findLinksLayer = () => wrapper.find(LinksLayer); const findStageColumns = () => wrapper.findAll(StageColumnComponent); const defaultProps = { @@ -28,6 +30,9 @@ describe('graph component', () => { provide: { dataMethod: GRAPHQL, }, + stubs: { + 'links-inner': true, + }, }); }; @@ -45,6 +50,10 @@ describe('graph component', () => { expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); }); + it('renders the links layer', () => { + expect(findLinksLayer().exists()).toBe(true); + }); + describe('when column requests a refresh', () => { beforeEach(() => { findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index 44803929f6d..202e25ccda3 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -30,6 +30,7 @@ const mockGroups = Array(4) const defaultProps = { title: 'Fish', groups: mockGroups, + pipelineId: 159, }; describe('stage column component', () => { @@ -92,36 +93,51 @@ describe('stage column component', () => { }); describe('job', () => { - beforeEach(() => { - createComponent({ - method: mount, - props: { - groups: [ - { - id: 4259, - name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', + describe('text handling', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, }, - }, - ], - title: 'test <img src=x onerror=alert(document.domain)>', - }, + ], + title: 'test <img src=x onerror=alert(document.domain)>', + }, + }); }); - }); - it('capitalizes and escapes name', () => { - expect(findStageColumnTitle().text()).toBe( - 'Test <img src=x onerror=alert(document.domain)>', - ); + it('capitalizes and escapes name', () => { + expect(findStageColumnTitle().text()).toBe( + 'Test <img src=x onerror=alert(document.domain)>', + ); + }); + + it('escapes id', () => { + expect(findStageColumnGroup().attributes('id')).toBe( + 'ci-badge-<img src=x onerror=alert(document.domain)>', + ); + }); }); - it('escapes id', () => { - expect(findStageColumnGroup().attributes('id')).toBe( - 'ci-badge-<img src=x onerror=alert(document.domain)>', - ); + describe('interactions', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('emits jobHovered event on mouseenter and mouseleave', async () => { + await findStageColumnGroup().trigger('mouseenter'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]); + await findStageColumnGroup().trigger('mouseleave'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 1f0dc97f2f3..7d1a7a79c7f 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -1,4 +1,4 @@ -import { createUniqueLinkId } from '~/pipelines/utils'; +import { createUniqueLinkId } from '~/pipelines/components/graph_shared/drawing_utils'; export const yamlString = `stages: - empty diff --git a/spec/frontend/pipelines/shared/links_layer_spec.js b/spec/frontend/pipelines/shared/links_layer_spec.js new file mode 100644 index 00000000000..9ef5233dbce --- /dev/null +++ b/spec/frontend/pipelines/shared/links_layer_spec.js @@ -0,0 +1,99 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlAlert, GlButton } from '@gitlab/ui'; +import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; +import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; +import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; + +describe('links layer component', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findShowAnyways = () => findAlert().find(GlButton); + const findLinksInner = () => wrapper.find(LinksInner); + + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const containerId = `pipeline-links-container-${pipeline.id}`; + const slotContent = "<div>Ceci n'est pas un graphique</div>"; + + const tooManyStages = Array(101) + .fill(0) + .flatMap(() => pipeline.stages); + + const defaultProps = { + containerId, + containerMeasurements: { width: 400, height: 400 }, + pipelineId: pipeline.id, + pipelineData: pipeline.stages, + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinksLayer, { + propsData: { + ...defaultProps, + ...props, + }, + slots: { + default: slotContent, + }, + stubs: { + 'links-inner': true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with data under max stages', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('renders the inner links component', () => { + expect(findLinksInner().exists()).toBe(true); + }); + }); + + describe('with more than the max number of stages', () => { + describe('rendering', () => { + beforeEach(() => { + createComponent({ props: { pipelineData: tooManyStages } }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('renders the alert component', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } }); + }); + + it('renders the disable button', () => { + expect(findShowAnyways().exists()).toBe(true); + expect(findShowAnyways().text()).toBe(wrapper.vm.$options.i18n.showLinksAnyways); + }); + + it('shows links when override is clicked', async () => { + expect(findLinksInner().exists()).toBe(false); + await findShowAnyways().trigger('click'); + expect(findLinksInner().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 7c964be7825..b58ce0083c0 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -25,6 +25,7 @@ import { tokenValueLabel, tokenValueMilestone, tokenValueMembership, + tokenValueConfidential, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -227,12 +228,13 @@ describe('FilteredSearchBarRoot', () => { }); describe('removeQuotesEnclosure', () => { - const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo']; + const mockFilters = [tokenValueAuthor, tokenValueLabel, tokenValueConfidential, 'foo']; it('returns filter array with unescaped strings for values which have spaces', () => { expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ tokenValueAuthor, tokenValueLabel, + tokenValueConfidential, 'foo', ]); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 2c7b6de9ce3..7606b3bd91c 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -155,6 +155,14 @@ export const tokenValueMembership = { }, }; +export const tokenValueConfidential = { + type: 'confidential', + value: { + operator: '=', + data: true, + }, +}; + export const tokenValuePlain = { type: 'filtered-search-term', value: { data: 'foo' }, diff --git a/spec/graphql/resolvers/package_details_resolver_spec.rb b/spec/graphql/resolvers/package_details_resolver_spec.rb new file mode 100644 index 00000000000..825b2aed40a --- /dev/null +++ b/spec/graphql/resolvers/package_details_resolver_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::PackageDetailsResolver do + include GraphqlHelpers + + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:user) { project.owner } + let_it_be(:package) { create(:composer_package, project: project) } + + describe '#resolve' do + let(:args) do + { id: package.to_global_id.to_s } + end + + subject { resolve(described_class, ctx: { current_user: user }, args: args).sync } + + it { is_expected.to eq(package) } + end +end diff --git a/spec/graphql/types/packages/composer/details_type_spec.rb b/spec/graphql/types/packages/composer/details_type_spec.rb new file mode 100644 index 00000000000..2e4cb965ded --- /dev/null +++ b/spec/graphql/types/packages/composer/details_type_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageComposerDetails'] do + it { expect(described_class.graphql_name).to eq('PackageComposerDetails') } + + it 'includes all the package fields' do + expected_fields = %w[ + id name version created_at updated_at package_type tags project pipelines versions + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end + + it 'includes composer specific files' do + expected_fields = %w[ + composer_metadatum + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/packages/composer/json_type_spec.rb b/spec/graphql/types/packages/composer/json_type_spec.rb new file mode 100644 index 00000000000..af5194ffb49 --- /dev/null +++ b/spec/graphql/types/packages/composer/json_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageComposerJsonType'] do + it { expect(described_class.graphql_name).to eq('PackageComposerJsonType') } + + it 'includes composer json files' do + expected_fields = %w[ + name type license version + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/packages/composer/metadatum_type_spec.rb b/spec/graphql/types/packages/composer/metadatum_type_spec.rb new file mode 100644 index 00000000000..0f47d8f1812 --- /dev/null +++ b/spec/graphql/types/packages/composer/metadatum_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageComposerMetadatumType'] do + it { expect(described_class.graphql_name).to eq('PackageComposerMetadatumType') } + + it 'includes composer metadatum fields' do + expected_fields = %w[ + target_sha composer_json + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/package_type_enum_spec.rb b/spec/graphql/types/packages/package_type_enum_spec.rb index 407d5786f65..407d5786f65 100644 --- a/spec/graphql/types/package_type_enum_spec.rb +++ b/spec/graphql/types/packages/package_type_enum_spec.rb diff --git a/spec/graphql/types/package_type_spec.rb b/spec/graphql/types/packages/package_type_spec.rb index 22048e7a693..7003a4d4d07 100644 --- a/spec/graphql/types/package_type_spec.rb +++ b/spec/graphql/types/packages/package_type_spec.rb @@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['Package'] do it 'includes all the package fields' do expected_fields = %w[ - id name version created_at updated_at package_type + id name version created_at updated_at package_type tags project pipelines versions ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/packages/tag_type_spec.rb b/spec/graphql/types/packages/tag_type_spec.rb new file mode 100644 index 00000000000..83b705157d8 --- /dev/null +++ b/spec/graphql/types/packages/tag_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageTag'] do + it { expect(described_class.graphql_name).to eq('PackageTag') } + + it 'includes all the package tag fields' do + expected_fields = %w[ + id name created_at updated_at + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 7a0b3035607..3e716865e56 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -94,4 +94,10 @@ RSpec.describe GitlabSchema.types['Query'] do it { is_expected.to have_graphql_type(Types::ContainerRepositoryDetailsType) } end + + describe 'package_composer_details field' do + subject { described_class.fields['packageComposerDetails'] } + + it { is_expected.to have_graphql_type(Types::Packages::Composer::DetailsType) } + end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb index 40eb8638a83..20406acb658 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do - let_it_be(:project) { create(:project, :repository) } + let_it_be(:project, reload: true) { create(:project, :repository) } let_it_be(:user) { create(:user, developer_projects: [project]) } let(:pipeline) { Ci::Pipeline.new } @@ -29,29 +29,96 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do let(:step) { described_class.new(pipeline, command) } - before do - stub_ci_pipeline_yaml_file(gitlab_ci_yaml) + shared_examples 'builds pipeline' do + it 'builds a pipeline with the expected attributes' do + step.perform! + + expect(pipeline.sha).not_to be_empty + expect(pipeline.sha).to eq project.commit.id + expect(pipeline.ref).to eq 'master' + expect(pipeline.tag).to be false + expect(pipeline.user).to eq user + expect(pipeline.project).to eq project + end end - it 'never breaks the chain' do - step.perform! + shared_examples 'breaks the chain' do + it 'returns true' do + step.perform! - expect(step.break?).to be false + expect(step.break?).to be true + end end - it 'fills pipeline object with data' do + shared_examples 'does not break the chain' do + it 'returns false' do + step.perform! + + expect(step.break?).to be false + end + end + + before do + stub_ci_pipeline_yaml_file(gitlab_ci_yaml) + end + + it_behaves_like 'does not break the chain' + it_behaves_like 'builds pipeline' + + it 'sets pipeline variables' do step.perform! - expect(pipeline.sha).not_to be_empty - expect(pipeline.sha).to eq project.commit.id - expect(pipeline.ref).to eq 'master' - expect(pipeline.tag).to be false - expect(pipeline.user).to eq user - expect(pipeline.project).to eq project expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) .to eq variables_attributes.map(&:with_indifferent_access) end + context 'when project setting restrict_user_defined_variables is enabled' do + before do + project.update!(restrict_user_defined_variables: true) + end + + context 'when user is developer' do + it_behaves_like 'breaks the chain' + it_behaves_like 'builds pipeline' + + it 'returns an error on variables_attributes', :aggregate_failures do + step.perform! + + expect(pipeline.errors.full_messages).to eq(['Insufficient permissions to set pipeline variables']) + expect(pipeline.variables).to be_empty + end + + context 'when variables_attributes is not specified' do + let(:variables_attributes) { nil } + + it_behaves_like 'does not break the chain' + it_behaves_like 'builds pipeline' + + it 'assigns empty variables' do + step.perform! + + expect(pipeline.variables).to be_empty + end + end + end + + context 'when user is maintainer' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'does not break the chain' + it_behaves_like 'builds pipeline' + + it 'assigns variables_attributes' do + step.perform! + + expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) }) + .to eq variables_attributes.map(&:with_indifferent_access) + end + end + end + it 'returns a valid pipeline' do step.perform! diff --git a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb index bc2012e83bd..9ca5aeeea58 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb @@ -295,4 +295,30 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Command do it { is_expected.to eq(false) } end end + + describe '#creates_child_pipeline?' do + let(:command) { described_class.new(bridge: bridge) } + + subject { command.creates_child_pipeline? } + + context 'when bridge is present' do + context 'when bridge triggers a child pipeline' do + let(:bridge) { double(:bridge, triggers_child_pipeline?: true) } + + it { is_expected.to be_truthy } + end + + context 'when bridge triggers a multi-project pipeline' do + let(:bridge) { double(:bridge, triggers_child_pipeline?: false) } + + it { is_expected.to be_falsey } + end + end + + context 'when bridge is not present' do + let(:bridge) { nil } + + it { is_expected.to be_falsey } + end + end end diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb index 0ac54a20fcc..02e67488d3f 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb @@ -21,6 +21,47 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor)) end + # see: https://gitlab.com/gitlab-org/gitlab/-/issues/297358 + context 'the relation has been preloaded' do + let(:projects) { Project.all.preload(:issues) } + let(:nodes) { projects.first.issues } + + before do + project = create(:project) + create_list(:issue, 3, project: project) + end + + it 'is loaded' do + expect(nodes).to be_loaded + end + + it 'does not error when accessing pagination information' do + connection.first = 2 + + expect(connection).to have_attributes( + has_previous_page: false, + has_next_page: true + ) + end + + it 'can generate cursors' do + connection.send(:ordered_items) # necessary to generate the order-list + + expect(connection.cursor_for(nodes.first)).to be_a(String) + end + + it 'can read the next page' do + connection.send(:ordered_items) # necessary to generate the order-list + ordered = nodes.reorder(id: :desc) + next_page = described_class.new(nodes, + context: context, + max_page_size: 3, + after: connection.cursor_for(ordered.second)) + + expect(next_page.sliced_nodes).to contain_exactly(ordered.third) + end + end + it_behaves_like 'a connection with collection methods' it_behaves_like 'a redactable connection' do diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index c21d3b0939f..e6650549f7f 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -401,6 +401,48 @@ RSpec.describe ProjectPolicy do end end + describe 'set_pipeline_variables' do + context 'when user is developer' do + let(:current_user) { developer } + + context 'when project allows user defined variables' do + before do + project.update!(restrict_user_defined_variables: false) + end + + it { is_expected.to be_allowed(:set_pipeline_variables) } + end + + context 'when project restricts use of user defined variables' do + before do + project.update!(restrict_user_defined_variables: true) + end + + it { is_expected.not_to be_allowed(:set_pipeline_variables) } + end + end + + context 'when user is maintainer' do + let(:current_user) { maintainer } + + context 'when project allows user defined variables' do + before do + project.update!(restrict_user_defined_variables: false) + end + + it { is_expected.to be_allowed(:set_pipeline_variables) } + end + + context 'when project restricts use of user defined variables' do + before do + project.update!(restrict_user_defined_variables: true) + end + + it { is_expected.to be_allowed(:set_pipeline_variables) } + end + end + end + context 'support bot' do let(:current_user) { User.support_bot } diff --git a/spec/requests/api/graphql/packages/package_composer_details_spec.rb b/spec/requests/api/graphql/packages/package_composer_details_spec.rb new file mode 100644 index 00000000000..1a2cf4a972a --- /dev/null +++ b/spec/requests/api/graphql/packages/package_composer_details_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'package composer details' do + using RSpec::Parameterized::TableSyntax + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:package) { create(:composer_package, project: project) } + let_it_be(:composer_metadatum) do + # we are forced to manually create the metadatum, without using the factory to force the sha to be a string + # and avoid an error where gitaly can't find the repository + create(:composer_metadatum, package: package, target_sha: 'foo_sha', composer_json: { name: 'name', type: 'type', license: 'license', version: 1 }) + end + + let(:query) do + graphql_query_for( + 'packageComposerDetails', + { id: package_global_id }, + all_graphql_fields_for('PackageComposerDetails', max_depth: 2) + ) + end + + let(:user) { project.owner } + let(:package_global_id) { package.to_global_id.to_s } + let(:package_composer_details_response) { graphql_data.dig('packageComposerDetails') } + + subject { post_graphql(query, current_user: user) } + + it_behaves_like 'a working graphql query' do + before do + subject + end + + it 'matches the JSON schema' do + expect(package_composer_details_response).to match_schema('graphql/packages/package_composer_details') + end + end +end diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index d711376024a..e1b867ad097 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -252,4 +252,41 @@ RSpec.describe 'getting merge request information nested in a project' do expect(merge_request_graphql_data['mergeStatus']).to eq('checking') end end + + # see: https://gitlab.com/gitlab-org/gitlab/-/issues/297358 + context 'when the notes have been preloaded (by participants)' do + let(:query) do + <<~GQL + query($path: ID!) { + project(fullPath: $path) { + mrs: mergeRequests(first: 1) { + nodes { + participants { nodes { id } } + notes(first: 1) { + pageInfo { endCursor hasPreviousPage hasNextPage } + nodes { id } + } + } + } + } + } + GQL + end + + before do + create_list(:note_on_merge_request, 3, project: project, noteable: merge_request) + end + + it 'does not error' do + post_graphql(query, + current_user: current_user, + variables: { path: project.full_path }) + + expect(graphql_data_at(:project, :mrs, :nodes, :notes, :pageInfo)).to contain_exactly a_hash_including( + 'endCursor' => String, + 'hasNextPage' => true, + 'hasPreviousPage' => false + ) + end + end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 53c0e95c777..72f2dc79633 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1583,6 +1583,7 @@ RSpec.describe API::Projects do expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access) expect(json_response['only_allow_merge_if_pipeline_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds) expect(json_response['allow_merge_on_skipped_pipeline']).to eq(project.allow_merge_on_skipped_pipeline) + expect(json_response['restrict_user_defined_variables']).to eq(project.restrict_user_defined_variables?) expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) expect(json_response['operations_access_level']).to be_present end @@ -1654,6 +1655,7 @@ RSpec.describe API::Projects do expect(json_response['shared_with_groups'][0]).to have_key('expires_at') expect(json_response['only_allow_merge_if_pipeline_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds) expect(json_response['allow_merge_on_skipped_pipeline']).to eq(project.allow_merge_on_skipped_pipeline) + expect(json_response['restrict_user_defined_variables']).to eq(project.restrict_user_defined_variables?) expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) expect(json_response['ci_default_git_depth']).to eq(project.ci_default_git_depth) expect(json_response['ci_forward_deployment_enabled']).to eq(project.ci_forward_deployment_enabled) @@ -2597,6 +2599,18 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:bad_request) end + it 'updates restrict_user_defined_variables', :aggregate_failures do + project_param = { restrict_user_defined_variables: true } + + put api("/projects/#{project3.id}", user), params: project_param + + expect(response).to have_gitlab_http_status(:ok) + + project_param.each_pair do |k, v| + expect(json_response[k.to_s]).to eq(v) + end + end + it 'updates avatar' do project_param = { avatar: fixture_file_upload('spec/fixtures/banana_sample.gif', diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb index 03cea4074bf..860932d4fde 100644 --- a/spec/services/ci/create_downstream_pipeline_service_spec.rb +++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb @@ -371,6 +371,26 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do expect { service.execute(bridge) }.to change { Ci::Pipeline.count }.by(1) end end + + context 'when downstream project does not allow user-defined variables for child pipelines' do + before do + bridge.yaml_variables = [{ key: 'BRIDGE', value: '$PIPELINE_VARIABLE-var', public: true }] + + upstream_pipeline.project.update!(restrict_user_defined_variables: true) + end + + it 'creates a new pipeline allowing variables to be passed downstream' do + expect { service.execute(bridge) }.to change { Ci::Pipeline.count }.by(1) + end + + it 'passes variables downstream from the bridge' do + pipeline = service.execute(bridge) + + pipeline.variables.map(&:key).tap do |variables| + expect(variables).to include 'BRIDGE' + end + end + end end end @@ -460,6 +480,33 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do expect(variable.value).to eq 'my-value-var' end end + + context 'when downstream project does not allow user-defined variables for multi-project pipelines' do + before do + downstream_project.update!(restrict_user_defined_variables: true) + end + + it 'does not create a new pipeline' do + expect { service.execute(bridge) } + .not_to change { Ci::Pipeline.count } + end + + it 'ignores variables passed downstream from the bridge' do + pipeline = service.execute(bridge) + + pipeline.variables.map(&:key).tap do |variables| + expect(variables).not_to include 'BRIDGE' + end + end + + it 'sets errors', :aggregate_failures do + service.execute(bridge) + + expect(bridge.reload).to be_failed + expect(bridge.failure_reason).to eq('downstream_pipeline_creation_failed') + expect(bridge.options[:downstream_errors]).to eq(['Insufficient permissions to set pipeline variables']) + end + end end end diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb index ac077e3c30e..0cc66e67b91 100644 --- a/spec/services/ci/pipeline_trigger_service_spec.rb +++ b/spec/services/ci/pipeline_trigger_service_spec.rb @@ -3,14 +3,16 @@ require 'spec_helper' RSpec.describe Ci::PipelineTriggerService do - let(:project) { create(:project, :repository) } + include AfterNextHelpers + + let_it_be(:project) { create(:project, :repository) } before do stub_ci_pipeline_to_return_yaml_file end describe '#execute' do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } let(:result) { described_class.new(project, user, params).execute } before do @@ -29,8 +31,8 @@ RSpec.describe Ci::PipelineTriggerService do end end - context 'when params have an existsed trigger token' do - context 'when params have an existsed ref' do + context 'when params have an existing trigger token' do + context 'when params have an existing ref' do let(:params) { { token: trigger.token, ref: 'master', variables: nil } } it 'triggers a pipeline' do @@ -45,9 +47,7 @@ RSpec.describe Ci::PipelineTriggerService do context 'when commit message has [ci skip]' do before do - allow_next_instance_of(Ci::Pipeline) do |instance| - allow(instance).to receive(:git_commit_message) { '[ci skip]' } - end + allow_next(Ci::Pipeline).to receive(:git_commit_message) { '[ci skip]' } end it 'ignores [ci skip] and create as general' do diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb index c9ecbad3167..00c6de7681d 100644 --- a/spec/services/ci/play_build_service_spec.rb +++ b/spec/services/ci/play_build_service_spec.rb @@ -72,6 +72,31 @@ RSpec.describe Ci::PlayBuildService, '#execute' do expect(build.reload.job_variables.map(&:key)).to contain_exactly('first', 'second') end + + context 'when user defined variables are restricted' do + before do + project.update!(restrict_user_defined_variables: true) + end + + context 'when user is maintainer' do + before do + project.add_maintainer(user) + end + + it 'assigns the variables to the build' do + service.execute(build, job_variables) + + expect(build.reload.job_variables.map(&:key)).to contain_exactly('first', 'second') + end + end + + context 'when user is developer' do + it 'raises an error' do + expect { service.execute(build, job_variables) } + .to raise_error Gitlab::Access::AccessDeniedError + end + end + end end end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index e95f24c4200..21e294418a1 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -669,15 +669,19 @@ RSpec.describe QuickActions::InterpretService do shared_examples 'assign_reviewer command' do it 'assigns a reviewer to a single user' do - _, updates, _ = service.execute(content, issuable) + _, updates, message = service.execute(content, issuable) expect(updates).to eq(reviewer_ids: [developer.id]) + expect(message).to eq("Assigned #{developer.to_reference} as reviewer.") end + end - it 'returns the assign reviewer message' do - _, _, message = service.execute(content, issuable) + shared_examples 'unassign_reviewer command' do + it 'removes a single reviewer' do + _, updates, message = service.execute(content, issuable) - expect(message).to eq("Assigned #{developer.to_reference} as reviewer.") + expect(updates).to eq(reviewer_ids: []) + expect(message).to eq("Removed reviewer #{developer.to_reference}.") end end @@ -876,85 +880,117 @@ RSpec.describe QuickActions::InterpretService do end context 'when the merge_request_reviewers flag is enabled' do - context 'assign_reviewer command with one user' do - it_behaves_like 'assign_reviewer command' do - let(:content) { "/assign_reviewer @#{developer.username}" } - let(:issuable) { merge_request } + describe 'assign_reviewer command' do + let(:content) { "/assign_reviewer @#{developer.username}" } + let(:issuable) { merge_request } + + context 'with one user' do + it_behaves_like 'assign_reviewer command' end - it_behaves_like 'empty command' do - let(:content) { "/assign_reviewer @#{developer.username}" } + context 'with an issue instead of a merge request' do let(:issuable) { issue } + + it_behaves_like 'empty command' end - end - # CE does not have multiple reviewers - context 'assign_reviewer command with multiple assignees' do - let(:issuable) { merge_request } + # CE does not have multiple reviewers + context 'assign command with multiple assignees' do + before do + project.add_developer(developer2) + end + + # There's no guarantee that the reference extractor will preserve + # the order of the mentioned users since this is dependent on the + # order in which rows are returned. We just ensure that at least + # one of the mentioned users is assigned. + context 'assigns to one of the two users' do + let(:content) { "/assign_reviewer @#{developer.username} @#{developer2.username}" } - it 'assigns only the first reviewer to the merge request' do - content = "/assign_reviewer @#{developer.username} @#{developer2.username}" - _, updates, _ = service.execute(content, issuable) + it 'assigns to a single reviewer' do + _, updates, message = service.execute(content, issuable) - expect(updates).to eq(reviewer_ids: [developer.id]) + expect(updates[:reviewer_ids].count).to eq(1) + reviewer = updates[:reviewer_ids].first + expect([developer.id, developer2.id]).to include(reviewer) + + user = reviewer == developer.id ? developer : developer2 + + expect(message).to match("Assigned #{user.to_reference} as reviewer.") + end + end end - end - context 'assign_reviewer command with me alias' do - it_behaves_like 'assign_reviewer command' do + context 'with "me" alias' do let(:content) { '/assign_reviewer me' } - let(:issuable) { merge_request } + + it_behaves_like 'assign_reviewer command' end - end - context 'assign_reviewer command with me alias and whitespace' do - it_behaves_like 'assign_reviewer command' do + context 'with an alias and whitespace' do let(:content) { '/assign_reviewer me ' } - let(:issuable) { merge_request } + + it_behaves_like 'assign_reviewer command' end - end - it_behaves_like 'empty command', "Failed to assign a reviewer because no user was found." do - let(:content) { '/assign_reviewer @abcd1234' } - let(:issuable) { merge_request } - end + context 'with an incorrect user' do + let(:content) { '/assign_reviewer @abcd1234' } - it_behaves_like 'empty command', "Failed to assign a reviewer because no user was found." do - let(:content) { '/assign_reviewer' } - let(:issuable) { merge_request } - end + it_behaves_like 'empty command', "Failed to assign a reviewer because no user was found." + end - describe 'assign_reviewer command' do - let(:content) { "/assign_reviewer @#{developer.username} do it!" } + context 'with the "reviewer" alias' do + let(:content) { "/reviewer @#{developer.username}" } + + it_behaves_like 'assign_reviewer command' + end + + context 'with no user' do + let(:content) { '/assign_reviewer' } + + it_behaves_like 'empty command', "Failed to assign a reviewer because no user was found." + end - it 'includes only the user reference' do - _, explanations = service.explain(content, merge_request) + context 'includes only the user reference with extra text' do + let(:content) { "/assign_reviewer @#{developer.username} do it!" } - expect(explanations).to eq(["Assigns @#{developer.username} as reviewer."]) + it_behaves_like 'assign_reviewer command' end end describe 'unassign_reviewer command' do - let(:content) { '/unassign_reviewer' } - let(:merge_request) { create(:merge_request, reviewers: [developer]) } + # CE does not have multiple reviewers, so basically anything + # after /unassign_reviewer (including whitespace) will remove + # all the current reviewers. + let(:issuable) { create(:merge_request, reviewers: [developer]) } + let(:content) { "/unassign_reviewer @#{developer.username}" } + + context 'with one user' do + it_behaves_like 'unassign_reviewer command' + end - it 'includes current reviewer reference' do - _, explanations = service.explain(content, merge_request) + context 'with an issue instead of a merge request' do + let(:issuable) { issue } - expect(explanations).to eq(["Removes reviewer @#{developer.username}."]) + it_behaves_like 'empty command' end - it 'populates reviewer_ids: [] if content contains /unassign_reviewer' do - _, updates, _ = service.execute(content, merge_request) + context 'with anything after the command' do + let(:content) { '/unassign_reviewer supercalifragilisticexpialidocious' } - expect(updates).to eq(reviewer_ids: []) + it_behaves_like 'unassign_reviewer command' end - it 'returns the unassign reviewer message for all the reviewers if content contains /unassign_reviewer' do - merge_request.update!(reviewer_ids: [developer.id, developer2.id]) - _, _, message = service.execute(content, merge_request) + context 'with the "remove_reviewer" alias' do + let(:content) { "/remove_reviewer @#{developer.username}" } - expect(message).to eq("Removed reviewers #{developer.to_reference} and #{developer2.to_reference}.") + it_behaves_like 'unassign_reviewer command' + end + + context 'with no user' do + let(:content) { '/unassign_reviewer' } + + it_behaves_like 'unassign_reviewer command' end end end @@ -1969,6 +2005,28 @@ RSpec.describe QuickActions::InterpretService do end end + describe 'unassign_reviewer command' do + let(:content) { '/unassign_reviewer' } + let(:merge_request) { create(:merge_request, source_project: project, reviewers: [developer]) } + + it 'includes current assignee reference' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(["Removes reviewer @#{developer.username}."]) + end + end + + describe 'assign_reviewer command' do + let(:content) { "/assign_reviewer #{developer.to_reference}" } + let(:merge_request) { create(:merge_request, source_project: project, assignees: [developer]) } + + it 'includes only the user reference' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(["Assigns #{developer.to_reference} as reviewer."]) + end + end + describe 'milestone command' do let(:content) { '/milestone %wrong-milestone' } let!(:milestone) { create(:milestone, project: project, title: '9.10') } |