summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js17
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js33
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue6
-rw-r--r--app/assets/stylesheets/framework/common.scss2
-rw-r--r--app/finders/projects_finder.rb5
-rw-r--r--app/graphql/mutations/merge_requests/set_labels.rb53
-rw-r--r--app/graphql/mutations/merge_requests/set_locked.rb29
-rw-r--r--app/graphql/mutations/merge_requests/set_subscription.rb26
-rw-r--r--app/graphql/types/label_type.rb2
-rw-r--r--app/graphql/types/mutation_type.rb3
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml2
-rw-r--r--changelogs/unreleased/12881-security-dashboard-leveraging-sparklines-to-show-vulnerability-tren.yml5
-rw-r--r--changelogs/unreleased/31919-graphql-MR-label-mutation.yml5
-rw-r--r--changelogs/unreleased/ab-projects-id-filter.yml5
-rw-r--r--changelogs/unreleased/change-default-factor-on-merge-train.yml5
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql148
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json565
-rw-r--r--doc/api/graphql/reference/index.md25
-rw-r--r--doc/api/projects.md4
-rw-r--r--doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md4
-rw-r--r--doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.pngbin61667 -> 0 bytes
-rw-r--r--doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.pngbin0 -> 62965 bytes
-rw-r--r--doc/user/application_security/security_dashboard/index.md2
-rw-r--r--lib/api/helpers.rb2
-rw-r--r--lib/api/projects.rb2
-rw-r--r--locale/gitlab.pot9
-rw-r--r--qa/qa.rb1
-rw-r--r--qa/qa/page/base.rb34
-rw-r--r--qa/qa/page/dashboard/welcome.rb17
-rw-r--r--qa/qa/page/project/pipeline/index.rb12
-rw-r--r--qa/qa/resource/user.rb14
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/dashboard_images_spec.rb57
-rw-r--r--spec/finders/projects_finder_spec.rb25
-rw-r--r--spec/frontend/lib/utils/chart_utils_spec.js11
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js40
-rw-r--r--spec/graphql/mutations/merge_requests/set_labels_spec.rb77
-rw-r--r--spec/graphql/mutations/merge_requests/set_locked_spec.rb49
-rw-r--r--spec/graphql/mutations/merge_requests/set_subscription_spec.rb42
-rw-r--r--spec/graphql/types/label_type_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb108
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_locked_spec.rb79
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_subscription_spec.rb63
-rw-r--r--spec/requests/api/projects_spec.rb81
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
deleted file mode 100644
index 1fe76a9e08f..00000000000
--- a/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_3.png
+++ /dev/null
Binary files differ
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
new file mode 100644
index 00000000000..682dcbec63f
--- /dev/null
+++ b/doc/user/application_security/security_dashboard/img/group_security_dashboard_v12_4.png
Binary files differ
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 ""
diff --git a/qa/qa.rb b/qa/qa.rb
index 88dd51604be..6397e4216d9 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -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)