diff options
43 files changed, 1634 insertions, 37 deletions
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 0f78756aac8..4a1e6c5d68c 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize } }, }, }); + +/** + * Takes a dataset and returns an array containing the y-values of it's first and last entry. + * (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3']) + * + * @param {Array} data + * @returns {[*, *]} + */ +export const firstAndLastY = data => { + const [firstEntry] = data; + const [lastEntry] = data.slice(-1); + + const firstY = firstEntry[1]; + const lastY = lastEntry[1]; + + return [firstY, lastY]; +}; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0f2cc57b1f9..bc87232f40b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -117,3 +117,36 @@ export const median = arr => { const sorted = arr.sort((a, b) => a - b); return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; + +/** + * Computes the change from one value to the other as a percentage. + * @param {Number} firstY + * @param {Number} lastY + * @returns {Number} + */ +export const changeInPercent = (firstY, lastY) => { + if (firstY === lastY) { + return 0; + } + + return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100); +}; + +/** + * Computes and formats the change from one value to the other as a percentage. + * Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and + * returns a given string if the result is not finite (for example, if the first value is "0"). + * @param firstY + * @param lastY + * @param nonFiniteResult + * @returns {String} + */ +export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => { + const change = changeInPercent(firstY, lastY); + + if (!Number.isFinite(change)) { + return nonFiniteResult; + } + + return `${change >= 0 ? '+' : ''}${change}%`; +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 5275de3bc8b..afb8439511f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -265,7 +265,11 @@ export default { <div class="table-section section-10 commit-link"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> <div class="table-mobile-content"> - <ci-badge :status="pipelineStatus" :show-text="!isChildView" /> + <ci-badge + :status="pipelineStatus" + :show-text="!isChildView" + data-qa-selector="pipeline_commit_status" + /> </div> </div> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index c2f6aa47c47..f4e7e4e456b 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -562,6 +562,8 @@ img.emoji { } .gl-font-size-small { font-size: $gl-font-size-small; } +.gl-font-size-large { font-size: $gl-font-size-large; } + .gl-line-height-24 { line-height: $gl-line-height-24; } .gl-font-size-12 { font-size: $gl-font-size-12; } diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index df06e68c941..42a15234e57 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -110,7 +110,10 @@ class ProjectsFinder < UnionFinder # rubocop: disable CodeReuse/ActiveRecord def by_ids(items) - project_ids_relation ? items.where(id: project_ids_relation) : items + items = items.where(id: project_ids_relation) if project_ids_relation + items = items.where('id > ?', params[:id_after]) if params[:id_after] + items = items.where('id < ?', params[:id_before]) if params[:id_before] + items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb new file mode 100644 index 00000000000..71f7a353bc9 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_labels.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLabels < Base + graphql_name 'MergeRequestSetLabels' + + argument :label_ids, + [GraphQL::ID_TYPE], + required: true, + description: <<~DESC + The Label IDs to set. Replaces existing labels by default. + DESC + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: <<~DESC + Changes the operation mode. Defaults to REPLACE. + DESC + + def resolve(project_path:, iid:, label_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + label_ids = label_ids + .select(&method(:label_descendant?)) + .map { |gid| GlobalID.parse(gid).model_id } # MergeRequests::UpdateService expects integers + + attribute_name = case operation_mode + when Types::MutationOperationModeEnum.enum[:append] + :add_label_ids + when Types::MutationOperationModeEnum.enum[:remove] + :remove_label_ids + else + :label_ids + end + + ::MergeRequests::UpdateService.new(project, current_user, attribute_name => label_ids) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + + def label_descendant?(gid) + GlobalID.parse(gid)&.model_class&.ancestors&.include?(Label) + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_locked.rb b/app/graphql/mutations/merge_requests/set_locked.rb new file mode 100644 index 00000000000..09aaa0b39aa --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_locked.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLocked < Base + graphql_name 'MergeRequestSetLocked' + + argument :locked, + GraphQL::BOOLEAN_TYPE, + required: true, + description: <<~DESC + Whether or not to lock the merge request. + DESC + + def resolve(project_path:, iid:, locked:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project, current_user, discussion_locked: locked) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb new file mode 100644 index 00000000000..86750152775 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_subscription.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetSubscription < Base + graphql_name 'MergeRequestSetSubscription' + + argument :subscribed_state, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'The desired state of the subscription' + + def resolve(project_path:, iid:, subscribed_state:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + merge_request.set_subscription(current_user, subscribed_state, project) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index b21503540f8..d0bcf2068b7 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -6,6 +6,8 @@ module Types authorize :read_label + field :id, GraphQL::ID_TYPE, null: false, + description: 'Label ID' field :description, GraphQL::STRING_TYPE, null: true, description: 'Description of the label (markdown rendered as HTML for caching)' markdown_field :description_html, null: true diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 56e724292d5..b3c7c162bb3 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -9,7 +9,10 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::MergeRequests::SetLabels + mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone + mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index a2b1f0d9298..b5f5025b581 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -3,7 +3,7 @@ .container.section-body .row .blank-state-welcome.w-100 - %h2.blank-state-welcome-title + %h2.blank-state-welcome-title{ data: { qa_selector: 'welcome_title_content' } } = _('Welcome to GitLab') %p.blank-state-text = _('Faster releases. Better code. Less pain.') diff --git a/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml b/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml new file mode 100644 index 00000000000..15839300343 --- /dev/null +++ b/changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml @@ -0,0 +1,5 @@ +--- +title: Vulnerabilities history chart - use sparklines +merge_request: 19745 +author: +type: changed diff --git a/changelogs/unreleased/31919-graphql-MR-label-mutation.yml b/changelogs/unreleased/31919-graphql-MR-label-mutation.yml new file mode 100644 index 00000000000..41a1a91713d --- /dev/null +++ b/changelogs/unreleased/31919-graphql-MR-label-mutation.yml @@ -0,0 +1,5 @@ +--- +title: 'GraphQL: Create MR mutations needed for the sidebar' +merge_request: 19913 +author: +type: added diff --git a/changelogs/unreleased/ab-projects-id-filter.yml b/changelogs/unreleased/ab-projects-id-filter.yml new file mode 100644 index 00000000000..6bc21ac4452 --- /dev/null +++ b/changelogs/unreleased/ab-projects-id-filter.yml @@ -0,0 +1,5 @@ +--- +title: Add id_before, id_after filter param to projects API +merge_request: 19949 +author: +type: added diff --git a/changelogs/unreleased/change-default-factor-on-merge-train.yml b/changelogs/unreleased/change-default-factor-on-merge-train.yml new file mode 100644 index 00000000000..7228366e44c --- /dev/null +++ b/changelogs/unreleased/change-default-factor-on-merge-train.yml @@ -0,0 +1,5 @@ +--- +title: Change the default concurrency factor of merge train to 20 +merge_request: 20201 +author: +type: changed diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 2023f135550..1eb010b1ad6 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -2815,6 +2815,11 @@ type Label { descriptionHtml: String """ + Label ID + """ + id: ID! + + """ Text color of the label """ textColor: String! @@ -3408,6 +3413,101 @@ type MergeRequestSetAssigneesPayload { } """ +Autogenerated input type of MergeRequestSetLabels +""" +input MergeRequestSetLabelsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The Label IDs to set. Replaces existing labels by default. + """ + labelIds: [ID!]! + + """ + Changes the operation mode. Defaults to REPLACE. + """ + operationMode: MutationOperationMode + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetLabels +""" +type MergeRequestSetLabelsPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" +Autogenerated input type of MergeRequestSetLocked +""" +input MergeRequestSetLockedInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + Whether or not to lock the merge request. + """ + locked: Boolean! + + """ + The project the merge request to mutate is in + """ + projectPath: ID! +} + +""" +Autogenerated return type of MergeRequestSetLocked +""" +type MergeRequestSetLockedPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" Autogenerated input type of MergeRequestSetMilestone """ input MergeRequestSetMilestoneInput { @@ -3453,6 +3553,51 @@ type MergeRequestSetMilestonePayload { } """ +Autogenerated input type of MergeRequestSetSubscription +""" +input MergeRequestSetSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The iid of the merge request to mutate + """ + iid: String! + + """ + The project the merge request to mutate is in + """ + projectPath: ID! + + """ + The desired state of the subscription + """ + subscribedState: Boolean! +} + +""" +Autogenerated return type of MergeRequestSetSubscription +""" +type MergeRequestSetSubscriptionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The merge request after mutation + """ + mergeRequest: MergeRequest +} + +""" Autogenerated input type of MergeRequestSetWip """ input MergeRequestSetWipInput { @@ -3588,7 +3733,10 @@ type Mutation { epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload mergeRequestSetAssignees(input: MergeRequestSetAssigneesInput!): MergeRequestSetAssigneesPayload + mergeRequestSetLabels(input: MergeRequestSetLabelsInput!): MergeRequestSetLabelsPayload + mergeRequestSetLocked(input: MergeRequestSetLockedInput!): MergeRequestSetLockedPayload mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload + mergeRequestSetSubscription(input: MergeRequestSetSubscriptionInput!): MergeRequestSetSubscriptionPayload mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 420b20919a7..5e6a9dba4ed 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -6480,6 +6480,24 @@ "deprecationReason": null }, { + "name": "id", + "description": "Label ID", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "textColor", "description": "Text color of the label", "args": [ @@ -14764,6 +14782,60 @@ "deprecationReason": null }, { + "name": "mergeRequestSetLabels", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLabelsInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetLabelsPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequestSetLocked", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLockedInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetLockedPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "mergeRequestSetMilestone", "description": null, "args": [ @@ -14791,6 +14863,33 @@ "deprecationReason": null }, { + "name": "mergeRequestSetSubscription", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequestSetSubscriptionPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "mergeRequestSetWip", "description": null, "args": [ @@ -15449,6 +15548,313 @@ }, { "kind": "OBJECT", + "name": "MergeRequestSetLabelsPayload", + "description": "Autogenerated return type of MergeRequestSetLabels", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLabelsInput", + "description": "Autogenerated input type of MergeRequestSetLabels", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "labelIds", + "description": "The Label IDs to set. Replaces existing labels by default.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + }, + "defaultValue": null + }, + { + "name": "operationMode", + "description": "Changes the operation mode. Defaults to REPLACE.\n", + "type": { + "kind": "ENUM", + "name": "MutationOperationMode", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MutationOperationMode", + "description": "Different toggles for changing mutator behavior.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "REPLACE", + "description": "Performs a replace operation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "APPEND", + "description": "Performs an append operation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "REMOVE", + "description": "Performs a removal operation", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MergeRequestSetLockedPayload", + "description": "Autogenerated return type of MergeRequestSetLocked", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetLockedInput", + "description": "Autogenerated input type of MergeRequestSetLocked", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "locked", + "description": "Whether or not to lock the merge request.\n", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "MergeRequestSetMilestonePayload", "description": "Autogenerated return type of MergeRequestSetMilestone", "fields": [ @@ -15575,6 +15981,136 @@ }, { "kind": "OBJECT", + "name": "MergeRequestSetSubscriptionPayload", + "description": "Autogenerated return type of MergeRequestSetSubscription", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergeRequest", + "description": "The merge request after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "MergeRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MergeRequestSetSubscriptionInput", + "description": "Autogenerated input type of MergeRequestSetSubscription", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the merge request to mutate is in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The iid of the merge request to mutate", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "subscribedState", + "description": "The desired state of the subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "MergeRequestSetWipPayload", "description": "Autogenerated return type of MergeRequestSetWip", "fields": [ @@ -15852,35 +16388,6 @@ "possibleTypes": null }, { - "kind": "ENUM", - "name": "MutationOperationMode", - "description": "Different toggles for changing mutator behavior.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "REPLACE", - "description": "Performs a replace operation", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "APPEND", - "description": "Performs an append operation", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REMOVE", - "description": "Performs a removal operation", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { "kind": "OBJECT", "name": "CreateNotePayload", "description": "Autogenerated return type of CreateNote", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 957ea489dac..56fdde34666 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -410,6 +410,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | Name | Type | Description | | --- | ---- | ---------- | +| `id` | ID! | Label ID | | `description` | String | Description of the label (markdown rendered as HTML for caching) | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `title` | String! | Content of the label | @@ -491,6 +492,22 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | +### MergeRequestSetLabelsPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + +### MergeRequestSetLockedPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + ### MergeRequestSetMilestonePayload | Name | Type | Description | @@ -499,6 +516,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | +### MergeRequestSetSubscriptionPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `mergeRequest` | MergeRequest | The merge request after mutation | + ### MergeRequestSetWipPayload | Name | Type | Description | diff --git a/doc/api/projects.md b/doc/api/projects.md index 4497b3e68d3..2ec412d0f56 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -58,6 +58,8 @@ GET /projects | `wiki_checksum_failed` | boolean | no | **(PREMIUM)** Limit projects where the wiki checksum calculation has failed ([Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6137) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.2) | | `repository_checksum_failed` | boolean | no | **(PREMIUM)** Limit projects where the repository checksum calculation has failed ([Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6137) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.2) | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | +| `id_after` | integer | no | Limit results to projects with IDs greater than the specified ID | +| `id_before` | integer | no | Limit results to projects with IDs less than the specified ID | When `simple=true` or the user is unauthenticated this returns something like: @@ -304,6 +306,8 @@ GET /users/:user_id/projects | `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | | `with_programming_language` | string | no | Limit by projects which use the given programming language | | `min_access_level` | integer | no | Limit by current user minimal [access level](members.md) | +| `id_after` | integer | no | Limit results to projects with IDs greater than the specified ID | +| `id_before` | integer | no | Limit results to projects with IDs less than the specified ID | ```json [ diff --git a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md index f2a7902c9ca..b8976ffae7f 100644 --- a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md +++ b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md @@ -32,8 +32,8 @@ Merge trains have the following requirements and limitations: - This feature requires that [pipelines for merged results](../index.md#pipelines-for-merged-results-premium) are **configured properly**. -- Each merge train can run a maximum of **four** pipelines in parallel. - If more than four merge requests are added to the merge train, the merge requests +- Each merge train can run a maximum of **twenty** pipelines in parallel. + If more than twenty merge requests are added to the merge train, the merge requests will be queued until a slot in the merge train is free. There is no limit to the number of merge requests that can be queued. - This feature does not support [squash and merge](../../../../user/project/merge_requests/squash_and_merge.md). diff --git a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png Binary files differdeleted file mode 100644 index 1fe76a9e08f..00000000000 --- a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png +++ /dev/null diff --git a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png Binary files differnew file mode 100644 index 00000000000..682dcbec63f --- /dev/null +++ b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md index 17f63577f0c..688d231d568 100644 --- a/doc/user/application_security/security_dashboard/index.md +++ b/doc/user/application_security/security_dashboard/index.md @@ -76,7 +76,7 @@ To the right of the filters, you should see a **Hide dismissed** toggle button ( NOTE: **Note:** The dashboard only shows projects with [security reports](#supported-reports) enabled in a group. -![dashboard with action buttons and metrics](img/group_security_dashboard_v12_3.png) +![dashboard with action buttons and metrics](img/group_security_dashboard_v12_4.png) Selecting one or more filters will filter the results in this page. Disabling the **Hide dismissed** toggle button will let you also see vulnerabilities that have been dismissed. diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index ca49687081d..49b86489a8b 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -479,6 +479,8 @@ module API finder_params[:user] = params.delete(:user) if params[:user] finder_params[:custom_attributes] = params[:custom_attributes] if params[:custom_attributes] finder_params[:min_access_level] = params[:min_access_level] if params[:min_access_level] + finder_params[:id_after] = params[:id_after] if params[:id_after] + finder_params[:id_before] = params[:id_before] if params[:id_before] finder_params end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index d2dacafe7f9..3d10f41d2e0 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -61,6 +61,8 @@ module API optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' optional :with_programming_language, type: String, desc: 'Limit to repositories which use the given programming language' optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user' + optional :id_after, type: Integer, desc: 'Limit results to projects with IDs greater than the specified ID' + optional :id_before, type: Integer, desc: 'Limit results to projects with IDs less than the specified ID' use :optional_filter_params_ee end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ccb4e7266b6..1972ba4aa7e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4994,6 +4994,9 @@ msgstr "" msgid "Current password" msgstr "" +msgid "Current vulnerabilities count" +msgstr "" + msgid "CurrentUser|Profile" msgstr "" @@ -5791,6 +5794,9 @@ msgstr "" msgid "Diff limits" msgstr "" +msgid "Difference between start date and now" +msgstr "" + msgid "DiffsCompareBaseBranch|(base)" msgstr "" @@ -19180,6 +19186,9 @@ msgstr "" msgid "VulnerabilityChart|%{formattedStartDate} to today" msgstr "" +msgid "VulnerabilityChart|Severity" +msgstr "" + msgid "Vulnerability|Class" msgstr "" @@ -165,6 +165,7 @@ module QA module Dashboard autoload :Projects, 'qa/page/dashboard/projects' autoload :Groups, 'qa/page/dashboard/groups' + autoload :Welcome, 'qa/page/dashboard/welcome' module Snippet autoload :New, 'qa/page/dashboard/snippet/new' diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index 71df90f2f42..c256a895718 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -135,6 +135,40 @@ module QA has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time) end + def has_loaded_all_images? + # I don't know of a foolproof way to wait for all images to load + # This loop gives time for the img tags to be rendered and for + # images to start loading. + previous_total_images = 0 + wait(interval: 1) do + current_total_images = all("img").size + result = previous_total_images == current_total_images + previous_total_images = current_total_images + result + end + + # Retry until all images found can be fetched via HTTP, and + # check that the image has a non-zero natural width (a broken + # img tag could have a width, but wouldn't have a natural width) + + # Unfortunately, this doesn't account for SVGs. They're rendered + # as HTML, so there doesn't seem to be a way to check that they + # display properly via Selenium. However, if the SVG couldn't be + # rendered (e.g., because the file doesn't exist), the whole page + # won't display properly, so we should catch that with the test + # this method is called from. + + # The user's avatar is an img, which could be a gravatar image, + # so we skip that by only checking for images hosted internally + retry_until(sleep_interval: 1) do + all("img").all? do |image| + next true unless URI(image['src']).host == URI(page.current_url).host + + asset_exists?(image['src']) && image['naturalWidth'].to_i > 0 + end + end + end + def wait_for_animated_element(name) # It would be ideal if we could detect when the animation is complete # but in some cases there's nothing we can easily access via capybara diff --git a/qa/qa/page/dashboard/welcome.rb b/qa/qa/page/dashboard/welcome.rb new file mode 100644 index 00000000000..b54205780d9 --- /dev/null +++ b/qa/qa/page/dashboard/welcome.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module QA + module Page + module Dashboard + class Welcome < Page::Base + view 'app/views/dashboard/projects/_zero_authorized_projects.html.haml' do + element :welcome_title_content + end + + def has_welcome_title?(text) + has_element?(:welcome_title_content, text: text) + end + end + end + end +end diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb index fae7818f871..b52f3e99a36 100644 --- a/qa/qa/page/project/pipeline/index.rb +++ b/qa/qa/page/project/pipeline/index.rb @@ -7,6 +7,10 @@ module QA::Page element :pipeline_link, 'class="js-pipeline-url-link' # rubocop:disable QA/ElementWithPattern end + view 'app/assets/javascripts/pipelines/components/pipelines_table_row.vue' do + element :pipeline_commit_status + end + def click_on_latest_pipeline css = '.js-pipeline-url-link' @@ -16,6 +20,14 @@ module QA::Page link.click end + + def wait_for_latest_pipeline_success + wait(reload: false, max: 300) do + within_element_by_index(:pipeline_commit_status, 0) do + has_text?('passed') + end + end + end end end end diff --git a/qa/qa/resource/user.rb b/qa/qa/resource/user.rb index 57663afeef5..bdbe5f3ef51 100644 --- a/qa/qa/resource/user.rb +++ b/qa/qa/resource/user.rb @@ -7,13 +7,14 @@ module QA class User < Base attr_reader :unique_id attr_writer :username, :password - attr_accessor :provider, :extern_uid + attr_accessor :admin, :provider, :extern_uid attribute :id attribute :name attribute :email def initialize + @admin = false @unique_id = SecureRandom.hex(8) end @@ -75,6 +76,16 @@ module QA super end + def api_delete + super + + QA::Runtime::Logger.debug("Deleted user '#{username}'") if Runtime::Env.debug? + end + + def api_delete_path + "/users/#{id}" + end + def api_get_path "/users/#{fetch_id(username)}" end @@ -85,6 +96,7 @@ module QA def api_post_body { + admin: admin, email: email, password: password, username: username, diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb new file mode 100644 index 00000000000..6a5bc6173e0 --- /dev/null +++ b/qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'nokogiri' + +module QA + context 'Manage' do + describe 'Check for broken images', :requires_admin do + before(:context) do + admin = QA::Resource::User.new.tap do |user| + user.username = QA::Runtime::User.admin_username + user.password = QA::Runtime::User.admin_password + end + @api_client = Runtime::API::Client.new(:gitlab, user: admin) + @new_user = Resource::User.fabricate_via_api! do |user| + user.api_client = @api_client + end + @new_admin = Resource::User.fabricate_via_api! do |user| + user.admin = true + user.api_client = @api_client + end + + Page::Main::Menu.perform(&:sign_out_if_signed_in) + end + + after(:context) do + @new_user.remove_via_api! + @new_admin.remove_via_api! + end + + shared_examples 'loads all images' do + it 'loads all images' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform { |login| login.sign_in_using_credentials(user: new_user) } + + Page::Dashboard::Welcome.perform do |welcome| + expect(welcome).to have_welcome_title("Welcome to GitLab") + + # This would be better if it were a visual validation test + expect(welcome).to have_loaded_all_images + end + end + end + + context 'when logged in as a new user' do + it_behaves_like 'loads all images' do + let(:new_user) { @new_user } + end + end + + context 'when logged in as a new admin' do + it_behaves_like 'loads all images' do + let(:new_user) { @new_admin } + end + end + end + end +end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index e97a94f6fa5..a9344cd593a 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -58,6 +58,31 @@ describe ProjectsFinder, :do_not_mock_admin_mode do it { is_expected.to eq([internal_project]) } end + describe 'with id_after' do + context 'only returns projects with a project id greater than given' do + let(:params) { { id_after: internal_project.id }} + + it { is_expected.to eq([public_project]) } + end + end + + describe 'with id_before' do + context 'only returns projects with a project id less than given' do + let(:params) { { id_before: public_project.id }} + + it { is_expected.to eq([internal_project]) } + end + end + + describe 'with both id_before and id_after' do + context 'only returns projects with a project id less than given' do + let!(:projects) { create_list(:project, 5, :public) } + let(:params) { { id_after: projects.first.id, id_before: projects.last.id }} + + it { is_expected.to contain_exactly(*projects[1..-2]) } + end + end + describe 'filter by visibility_level' do before do private_project.add_maintainer(user) diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js new file mode 100644 index 00000000000..e811b8405fb --- /dev/null +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -0,0 +1,11 @@ +import { firstAndLastY } from '~/lib/utils/chart_utils'; + +describe('Chart utils', () => { + describe('firstAndLastY', () => { + it('returns the first and last y-values of a given data set as an array', () => { + const data = [['', 1], ['', 2], ['', 3]]; + + expect(firstAndLastY(data)).toEqual([1, 3]); + }); + }); +}); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 381d7c6f8d9..2f8f1092612 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -7,6 +7,8 @@ import { sum, isOdd, median, + changeInPercent, + formattedChangeInPercent, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -122,4 +124,42 @@ describe('Number Utils', () => { expect(median(items)).toBe(14.5); }); }); + + describe('changeInPercent', () => { + it.each` + firstValue | secondValue | expectedOutput + ${99} | ${100} | ${1} + ${100} | ${99} | ${-1} + ${0} | ${99} | ${Infinity} + ${2} | ${2} | ${0} + ${-100} | ${-99} | ${1} + `( + 'computes the change between $firstValue and $secondValue in percent', + ({ firstValue, secondValue, expectedOutput }) => { + expect(changeInPercent(firstValue, secondValue)).toBe(expectedOutput); + }, + ); + }); + + describe('formattedChangeInPercent', () => { + it('prepends "%" to the output', () => { + expect(formattedChangeInPercent(1, 2)).toMatch(/%$/); + }); + + it('indicates if the change was a decrease', () => { + expect(formattedChangeInPercent(100, 99)).toContain('-1'); + }); + + it('indicates if the change was an increase', () => { + expect(formattedChangeInPercent(99, 100)).toContain('+1'); + }); + + it('shows "-" per default if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1)).toBe('-'); + }); + + it('shows the given fallback if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*'); + }); + }); }); diff --git a/spec/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/graphql/mutations/merge_requests/set_labels_spec.rb new file mode 100644 index 00000000000..3729251bab7 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_labels_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetLabels do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:label) { create(:label, project: merge_request.project) } + let(:label2) { create(:label, project: merge_request.project) } + let(:label_ids) { [label.to_global_id] } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'sets the labels, removing all others' do + merge_request.update!(labels: [label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label) + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing an empty array' do + let(:label_ids) { [] } + + it 'removes all labels' do + merge_request.update!(labels: [label]) + + expect(mutated_merge_request.labels).to be_empty + end + end + + context 'when passing operation_mode as APPEND' do + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids, operation_mode: Types::MutationOperationModeEnum.enum[:append]) } + + it 'sets the labels, without removing others' do + merge_request.update!(labels: [label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label, label2) + expect(subject[:errors]).to be_empty + end + end + + context 'when passing operation_mode as REMOVE' do + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids, operation_mode: Types::MutationOperationModeEnum.enum[:remove])} + + it 'removes the labels, without removing others' do + merge_request.update!(labels: [label, label2]) + + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.labels).to contain_exactly(label2) + expect(subject[:errors]).to be_empty + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/graphql/mutations/merge_requests/set_locked_spec.rb new file mode 100644 index 00000000000..51249854378 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_locked_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetLocked do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:locked) { true } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, locked: locked) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'returns the merge request as discussion locked' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request).to be_discussion_locked + expect(subject[:errors]).to be_empty + end + + it 'returns errors merge request could not be updated' do + # Make the merge request invalid + merge_request.allow_broken = true + merge_request.update!(source_project: nil) + + expect(subject[:errors]).not_to be_empty + end + + context 'when passing locked as false' do + let(:locked) { false } + + it 'unlocks the discussion' do + merge_request.update(discussion_locked: true) + + expect(mutated_merge_request).not_to be_discussion_locked + end + end + end + end +end diff --git a/spec/graphql/mutations/merge_requests/set_subscription_spec.rb b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb new file mode 100644 index 00000000000..116a77abcc0 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/set_subscription_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::MergeRequests::SetSubscription do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:user) { create(:user) } + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) } + + describe '#resolve' do + let(:subscribe) { true } + let(:mutated_merge_request) { subject[:merge_request] } + subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, subscribed_state: subscribe) } + + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when the user can update the merge request' do + before do + merge_request.project.add_developer(user) + end + + it 'returns the merge request as discussion locked' do + expect(mutated_merge_request).to eq(merge_request) + expect(mutated_merge_request.subscribed?(user, project)).to eq(true) + expect(subject[:errors]).to be_empty + end + + context 'when passing subscribe as false' do + let(:subscribe) { false } + + it 'unsubscribes from the discussion' do + merge_request.subscribe(user, project) + + expect(mutated_merge_request.subscribed?(user, project)).to eq(false) + end + end + end + end +end diff --git a/spec/graphql/types/label_type_spec.rb b/spec/graphql/types/label_type_spec.rb index 8e7b2c69eff..a023a75eeff 100644 --- a/spec/graphql/types/label_type_spec.rb +++ b/spec/graphql/types/label_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe GitlabSchema.types['Label'] do it 'has the correct fields' do - expected_fields = [:description, :description_html, :title, :color, :text_color] + expected_fields = [:id, :description, :description_html, :title, :color, :text_color] is_expected.to have_graphql_fields(*expected_fields) end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb new file mode 100644 index 00000000000..2112ff0dc74 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting labels of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:label) { create(:label, project: project) } + let(:label2) { create(:label, project: project) } + let(:input) { { label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_labels, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + labels { + nodes { + id + } + } + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_labels) + end + + def mutation_label_nodes + mutation_response['mergeRequest']['labels']['nodes'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'sets the merge request labels, removing existing ones' do + merge_request.update(labels: [label2]) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(1) + expect(mutation_label_nodes[0]['id']).to eq(label.to_global_id.to_s) + end + + context 'when passing label_ids empty array as input' do + let(:input) { { label_ids: [] } } + + it 'removes the merge request labels' do + merge_request.update!(labels: [label]) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(0) + end + end + + context 'when passing operation_mode as APPEND' do + let(:input) { { operation_mode: Types::MutationOperationModeEnum.enum[:append], label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + before do + merge_request.update!(labels: [label2]) + end + + it 'sets the labels, without removing others' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(2) + expect(mutation_label_nodes).to contain_exactly({ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s }) + end + end + + context 'when passing operation_mode as REMOVE' do + let(:input) { { operation_mode: Types::MutationOperationModeEnum.enum[:remove], label_ids: [GitlabSchema.id_from_object(label).to_s] } } + + before do + merge_request.update!(labels: [label, label2]) + end + + it 'removes the labels, without removing others' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_label_nodes.count).to eq(1) + expect(mutation_label_nodes[0]['id']).to eq(label2.to_global_id.to_s) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb new file mode 100644 index 00000000000..c45da613591 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting locked status of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:input) { { locked: true } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_locked, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + discussionLocked + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_locked)['mergeRequest']['discussionLocked'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'marks the merge request as WIP' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + it 'does not do anything if the merge request was already locked' do + merge_request.update!(discussion_locked: true) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + context 'when passing locked false as input' do + let(:input) { { locked: false } } + + it 'does not do anything if the merge request was not marked locked' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + + it 'unmarks the merge request as locked' do + merge_request.update!(discussion_locked: true) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb new file mode 100644 index 00000000000..975735bf246 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Setting subscribed status of a merge request' do + include GraphqlHelpers + + let(:current_user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + let(:input) { { subscribed_state: true } } + + let(:mutation) do + variables = { + project_path: project.full_path, + iid: merge_request.iid.to_s + } + graphql_mutation(:merge_request_set_subscription, variables.merge(input), + <<-QL.strip_heredoc + clientMutationId + errors + mergeRequest { + id + subscribed + } + QL + ) + end + + def mutation_response + graphql_mutation_response(:merge_request_set_subscription)['mergeRequest']['subscribed'] + end + + before do + project.add_developer(current_user) + end + + it 'returns an error if the user is not allowed to update the merge request' do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + + it 'marks the merge request as WIP' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(true) + end + + context 'when passing subscribe false as input' do + let(:input) { { subscribed_state: false } } + + it 'unmarks the merge request as subscribed' do + merge_request.subscribe(current_user, project) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to eq(false) + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index ffac7872464..f1447536e0f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -362,6 +362,30 @@ describe API::Projects do end end + context 'and using id_after' do + it_behaves_like 'projects response' do + let(:filter) { { id_after: project2.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id > project2.id } } + end + end + + context 'and using id_before' do + it_behaves_like 'projects response' do + let(:filter) { { id_before: project2.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id } } + end + end + + context 'and using both id_after and id_before' do + it_behaves_like 'projects response' do + let(:filter) { { id_before: project2.id, id_after: public_project.id } } + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3].select { |p| p.id < project2.id && p.id > public_project.id } } + end + end + context 'and membership=true' do it_behaves_like 'projects response' do let(:filter) { { membership: true } } @@ -848,6 +872,63 @@ describe API::Projects do expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) end + context 'and using id_after' do + let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id_after filter given' do + get api("/users/#{user4.id}/projects?id_after=#{public_project.id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(another_public_project.id) + end + + it 'returns both projects without a id_after filter' do + get api("/users/#{user4.id}/projects", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id, another_public_project.id) + end + end + + context 'and using id_before' do + let!(:another_public_project) { create(:project, :public, name: 'another_public_project', creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id_before filter given' do + get api("/users/#{user4.id}/projects?id_before=#{another_public_project.id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) + end + + it 'returns both projects without a id_before filter' do + get api("/users/#{user4.id}/projects", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id, another_public_project.id) + end + end + + context 'and using both id_before and id_after' do + let!(:more_projects) { create_list(:project, 5, :public, creator_id: user4.id, namespace: user4.namespace) } + + it 'only returns projects with id matching the range' do + get api("/users/#{user4.id}/projects?id_after=#{more_projects.first.id}&id_before=#{more_projects.last.id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(*more_projects[1..-2].map(&:id)) + end + end + it 'returns projects filtered by username' do get api("/users/#{user4.username}/projects/", user) |