diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
index 346a8836b8a..97e857df29d 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -158,9 +158,7 @@ export default {
-
+
findHierarchyWidgets(child.widgets) || {})
.some((hierarchy) => hierarchy.hasChildren);
},
+ disableList() {
+ return this.disableContent || this.updateInProgress;
+ },
},
methods: {
async removeChild(child) {
@@ -121,6 +130,7 @@ export default {
},
async undoChildRemoval(child) {
try {
+ this.updateInProgress = true;
const { data } = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } },
@@ -140,6 +150,8 @@ export default {
} catch (error) {
this.$emit('error', s__('WorkItem|Something went wrong while undoing child removal.'));
Sentry.captureException(error);
+ } finally {
+ this.updateInProgress = false;
}
},
addWorkItemQuery({ iid }) {
@@ -210,6 +222,7 @@ export default {
const updatedChildren = this.children.slice();
updatedChildren.splice(oldIndex, 1);
updatedChildren.splice(newIndex, 0, targetItem);
+ this.updateInProgress = true;
this.$apollo
.mutate({
@@ -255,6 +268,9 @@ export default {
.catch((error) => {
this.$emit('error', error.message);
Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.updateInProgress = false;
});
},
},
@@ -266,7 +282,8 @@ export default {
:is="treeRootWrapper"
v-bind="treeRootOptions"
class="content-list"
- :class="{ 'gl-cursor-grab sortable-container': canReorder }"
+ data-testid="child-items-container"
+ :class="{ 'gl-cursor-grab sortable-container': canReorder, 'disabled-content': disableList }"
@end="handleDragOnEnd"
>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 95a6522115c..003201e4b95 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -278,8 +278,12 @@ export default {
this.error = null;
this.isInputValid = true;
},
+ markFormSubmitInProgress(value) {
+ this.submitInProgress = value;
+ this.$emit('update-in-progress', this.submitInProgress);
+ },
addChild() {
- this.submitInProgress = true;
+ this.markFormSubmitInProgress(true);
this.$apollo
.mutate({
mutation: updateWorkItemHierarchyMutation,
@@ -293,6 +297,9 @@ export default {
},
})
.then(({ data }) => {
+ // Marking submitInProgress cannot be in finally block
+ // as the form may get close before the event is emitted
+ this.markFormSubmitInProgress(false);
if (data.workItemUpdate?.errors?.length) {
[this.error] = data.workItemUpdate.errors;
} else {
@@ -304,17 +311,17 @@ export default {
.catch(() => {
this.error = this.$options.i18n.addChildErrorMessage;
this.isInputValid = false;
+ this.markFormSubmitInProgress(false);
})
.finally(() => {
this.search = '';
- this.submitInProgress = false;
});
},
createChild() {
if (!this.canSubmitForm) {
return;
}
- this.submitInProgress = true;
+ this.markFormSubmitInProgress(true);
this.$apollo
.mutate({
mutation: createWorkItemMutation,
@@ -329,6 +336,9 @@ export default {
}),
})
.then(({ data }) => {
+ // Marking submitInProgress cannot be in finally block
+ // as the form may get close before the event is emitted
+ this.markFormSubmitInProgress(false);
if (data.workItemCreate?.errors?.length) {
[this.error] = data.workItemCreate.errors;
} else {
@@ -340,11 +350,11 @@ export default {
.catch(() => {
this.error = this.$options.i18n.createChildErrorMessage;
this.isInputValid = false;
+ this.markFormSubmitInProgress(false);
})
.finally(() => {
this.search = '';
this.childToCreateTitle = null;
- this.submitInProgress = false;
});
},
closeForm() {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index a7e11b1b13a..f83be7f4415 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -106,6 +106,7 @@ export default {
showLabels: true,
fetchNextPageInProgress: false,
workItem: null,
+ disableContent: false,
};
},
apollo: {
@@ -324,6 +325,7 @@ export default {
:parent-confidential="confidential"
@cancel="hideAddForm"
@addChild="$emit('addChild')"
+ @update-in-progress="disableContent = $event"
/>
diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb
index 7dce1bbbf16..8bfeb45058b 100644
--- a/app/validators/json_schema_validator.rb
+++ b/app/validators/json_schema_validator.rb
@@ -30,10 +30,7 @@ class JsonSchemaValidator < ActiveModel::EachValidator
if options[:detail_errors]
validator.validate(value).each do |error|
- message = format(
- _("'%{data_pointer}' must be a valid '%{type}'"),
- data_pointer: error['data_pointer'], type: error['type']
- )
+ message = format_error_message(error)
record.errors.add(attribute, message)
end
else
@@ -45,6 +42,32 @@ class JsonSchemaValidator < ActiveModel::EachValidator
attr_reader :base_directory
+ def format_error_message(error)
+ case error['type']
+ when 'oneOf'
+ format_one_of_error(error)
+ else
+ error['error']
+ end
+ end
+
+ def format_one_of_error(error)
+ schema_options = error['schema']['oneOf']
+ required_props = schema_options.flat_map { |option| option['required'] }.uniq
+
+ message = if error['root_schema']['type'] == 'array'
+ _("value at %{data_pointer} should use only one of: %{requirements}")
+ else
+ _("should use only one of: %{requirements}")
+ end
+
+ format(
+ message,
+ requirements: required_props.join(', '),
+ data_pointer: error['data_pointer']
+ )
+ end
+
def valid_schema?(value)
validator.valid?(value)
end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 7a197ec26e4..42cd9877e57 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -19021,6 +19021,7 @@ GitLab CI/CD configuration template.
| `id` | [`ID!`](#id) | ID of the cluster agent. |
| `name` | [`String`](#string) | Name of the cluster agent. |
| `project` | [`Project`](#project) | Project this cluster agent is associated with. |
+| `remoteDevelopmentAgentConfig` | [`RemoteDevelopmentAgentConfig`](#remotedevelopmentagentconfig) | Remote development agent config for the cluster agent. |
| `tokens` | [`ClusterAgentTokenConnection`](#clusteragenttokenconnection) | Tokens associated with the cluster agent. (see [Connections](#connections)) |
| `updatedAt` | [`Time`](#time) | Timestamp the cluster agent was updated. |
| `userAccessAuthorizations` | [`ClusterAgentAuthorizationUserAccess`](#clusteragentauthorizationuseraccess) | User access config for the cluster agent. |
@@ -31135,6 +31136,28 @@ Represents the source code attached to a release in a particular format.
| `format` | [`String`](#string) | Format of the source. |
| `url` | [`String`](#string) | Download URL of the source. |
+### `RemoteDevelopmentAgentConfig`
+
+Represents a remote development agent configuration.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clusterAgent` | [`ClusterAgent!`](#clusteragent) | Cluster agent that the remote development agent config belongs to. |
+| `createdAt` | [`Time!`](#time) | Timestamp of when the remote development agent config was created. |
+| `defaultMaxHoursBeforeTermination` | [`Int!`](#int) | Default max hours before worksapce termination of the remote development agent config. |
+| `dnsZone` | [`String!`](#string) | DNS zone where workspaces are available. |
+| `enabled` | [`Boolean!`](#boolean) | Indicates whether remote development is enabled for the GitLab agent. |
+| `gitlabWorkspacesProxyNamespace` | [`String!`](#string) | Namespace where gitlab-workspaces-proxy is installed. |
+| `id` | [`RemoteDevelopmentRemoteDevelopmentAgentConfigID!`](#remotedevelopmentremotedevelopmentagentconfigid) | Global ID of the remote development agent config. |
+| `maxHoursBeforeTerminationLimit` | [`Int!`](#int) | Max hours before worksapce termination limit of the remote development agent config. |
+| `networkPolicyEnabled` | [`Boolean!`](#boolean) | Whether the network policy of the remote development agent config is enabled. |
+| `projectId` | [`ID`](#id) | ID of the project that the remote development agent config belongs to. |
+| `updatedAt` | [`Time!`](#time) | Timestamp of the last update to any mutable remote development agent config property. |
+| `workspacesPerUserQuota` | [`Int!`](#int) | Maximum number of workspaces per user. |
+| `workspacesQuota` | [`Int!`](#int) | Maximum number of workspaces for the GitLab agent. |
+
### `Repository`
#### Fields
@@ -38683,6 +38706,12 @@ A `ReleasesLinkID` is a global ID. It is encoded as a string.
An example `ReleasesLinkID` is: `"gid://gitlab/Releases::Link/1"`.
+### `RemoteDevelopmentRemoteDevelopmentAgentConfigID`
+
+A `RemoteDevelopmentRemoteDevelopmentAgentConfigID` is a global ID. It is encoded as a string.
+
+An example `RemoteDevelopmentRemoteDevelopmentAgentConfigID` is: `"gid://gitlab/RemoteDevelopment::RemoteDevelopmentAgentConfig/1"`.
+
### `RemoteDevelopmentWorkspaceID`
A `RemoteDevelopmentWorkspaceID` is a global ID. It is encoded as a string.
diff --git a/doc/api/groups.md b/doc/api/groups.md
index 5fa9f1e601f..911f1e6b178 100644
--- a/doc/api/groups.md
+++ b/doc/api/groups.md
@@ -35,7 +35,7 @@ Parameters:
| `search` | string | no | Return the list of authorized groups matching the search criteria |
| `order_by` | string | no | Order groups by `name`, `path`, `id`, or `similarity`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
-| `statistics` | boolean | no | Include group statistics (administrators only).
*Note:* The REST API response does not provide the full `RootStorageStatistics` data that is shown in the UI. To match the data in the UI, use GraphQL instead of REST. For more information, see the [Group GraphQL API resources](../api/graphql/reference/index.md#group).|
+| `statistics` | boolean | no | Include group statistics (administrators only).
*Note:* For top-level groups, the response returns the full `root_storage_statistics` data displayed in the UI. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/469254) in GitLab 17.3. |
| `visibility` | string | no | Limit to groups with `public`, `internal`, or `private` visibility. |
| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (administrators only) |
| `owned` | boolean | no | Limit to groups explicitly owned by the current user |
@@ -95,7 +95,7 @@ GET /groups
]
```
-When adding the parameter `statistics=true` and the authenticated user is an administrator, additional group statistics are returned.
+When adding the parameter `statistics=true` and the authenticated user is an administrator, additional group statistics are returned. For top-level groups, `root_storage_statistics` are added as well.
```plaintext
GET /groups?statistics=true
@@ -154,6 +154,20 @@ GET /groups?statistics=true
"snippets_size": 50,
"uploads_size": 0
},
+ "root_storage_statistics": {
+ "build_artifacts_size": 0,
+ "container_registry_size": 0,
+ "container_registry_size_is_estimated": false,
+ "dependency_proxy_size": 0,
+ "lfs_objects_size": 0,
+ "packages_size": 0,
+ "pipeline_artifacts_size": 0,
+ "repository_size": 0,
+ "snippets_size": 0,
+ "storage_size": 0,
+ "uploads_size": 0,
+ "wiki_size": 0
+ },
"wiki_access_level": "private",
"duo_features_enabled": true,
"lock_duo_features_enabled": false,
diff --git a/doc/update/versions/gitlab_16_changes.md b/doc/update/versions/gitlab_16_changes.md
index 8872e79e6ac..17cd68f34c5 100644
--- a/doc/update/versions/gitlab_16_changes.md
+++ b/doc/update/versions/gitlab_16_changes.md
@@ -86,9 +86,24 @@ For more information about upgrading GitLab Helm Chart, see [the release notes f
## 16.11.0
+- A [`groups_direct` field was added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146881)
+ to the [JSON web token (ID token)](../../ci/secrets/id_token_authentication.md).
+ - If you use GitLab CI/CD ID tokens to authenticate with third party services,
+ this change can cause the HTTP header size to increase. Proxy servers might reject
+ the request if the headers get too big.
+ - If possible, increase the header limit on the receiving system.
+ - See [issue 467253](https://gitlab.com/gitlab-org/gitlab/-/issues/467253) for more details.
+- After upgrading to GitLab 16.11 some users with large environments and databases experience
+ timeouts loading source code pages in the web UI.
+ - These timeouts are caused by slow PostgreSQL queries for pipeline data, which then
+ exceed the internal 60 second timeout.
+ - You can still clone Git repositories, and other requests for repository data works.
+ - See [issue 472420](https://gitlab.com/gitlab-org/gitlab/-/issues/472420) for more details,
+ including steps to confirm you're affected and housekeeping to run in PostgreSQL to correct it.
+
### Linux package installations
-In GitLab 16.11, PostgreSQL will automatically be upgraded to 14.x except for the following cases:
+In GitLab 16.11, PostgreSQL is automatically upgraded to 14.x except for the following cases:
- You are running the database in high availability using Patroni.
- Your database nodes are part of a GitLab Geo configuration.
diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md
index c2f93cfa817..8803a73345c 100644
--- a/doc/user/application_security/container_scanning/index.md
+++ b/doc/user/application_security/container_scanning/index.md
@@ -716,6 +716,7 @@ Container Scanning for Registry populates the Vulnerability Report only when a n
- You must have at least the Maintainer role in a project to enable Container Scanning for Registry.
- The project being used must not be empty. If you are utilizing an empty project solely for storing container images, this feature won't function as intended. As a workaround, ensure the project contains an initial commit on the default branch.
- By default there is a limit of `50` scans per project per day.
+- You must [configure container registry notifications](../../../administration/packages/container_registry.md#configure-container-registry-notifications).
### Enabling Container Scanning for Registry
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index 7be73e53d3f..5a3e60723e0 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -709,7 +709,7 @@ To enable the analyzer, either:
- Create a [scan execution policy](../policies/scan_execution_policies.md) that enforces dependency
scanning.
- Edit the `.gitlab-ci.yml` file manually.
-- [Use CI/CD components](#use-cicd-components) (Android projects only)
+- [Use CI/CD components](#use-cicd-components)
#### Use a preconfigured merge request
@@ -771,9 +771,9 @@ Pipelines now include a Dependency Scanning job.
Use [CI/CD components](../../../ci/components/index.md) to perform Dependency Scanning of your
application. For instructions, see the respective component's README file.
-##### Available CI/CD components per language and package manager
+##### Available CI/CD components
-- [Android applications](https://gitlab.com/explore/catalog/components/android-dependency-scanning)
+See
### Running jobs in merge request pipelines
diff --git a/lib/api/entities/group.rb b/lib/api/entities/group.rb
index e65f26825b7..4ddee0dbd9e 100644
--- a/lib/api/entities/group.rb
+++ b/lib/api/entities/group.rb
@@ -44,6 +44,15 @@ module API
expose :uploads_size
end
end
+
+ expose :root_storage_statistics, using: Entities::Namespace::RootStorageStatistics,
+ if: ->(group, opts) {
+ expose_root_storage_statistics?(group, opts)
+ }
+
+ def expose_root_storage_statistics?(group, opts)
+ opts[:statistics] && group.root?
+ end
end
end
end
diff --git a/lib/api/entities/namespace/root_storage_statistics.rb b/lib/api/entities/namespace/root_storage_statistics.rb
new file mode 100644
index 00000000000..c269e26f594
--- /dev/null
+++ b/lib/api/entities/namespace/root_storage_statistics.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class Namespace
+ class RootStorageStatistics < Grape::Entity
+ expose :build_artifacts_size, documentation: { type: 'integer', desc: 'CI artifacts size in bytes.' }
+ expose :container_registry_size, documentation: { type: 'integer', desc: 'Container Registry size in bytes.' }
+ expose :registry_size_estimated,
+ as: :container_registry_size_is_estimated,
+ documentation: { type: 'boolean',
+ desc: 'Indicates whether the deduplicated Container Registry size for ' \
+ 'the namespace is an estimated value or not.' }
+ expose :dependency_proxy_size, documentation: { type: 'integer', desc: 'Dependency Proxy sizes in bytes.' }
+ expose :lfs_objects_size, documentation: { type: 'integer', desc: 'LFS objects size in bytes.' }
+ expose :packages_size, documentation: { type: 'integer', desc: 'Packages size in bytes.' }
+ expose :pipeline_artifacts_size,
+ documentation: { type: 'integer', desc: 'CI pipeline artifacts size in bytes.' }
+ expose :repository_size, documentation: { type: 'integer', desc: 'Git repository size in bytes.' }
+ expose :snippets_size, documentation: { type: 'integer', desc: 'Snippets size in bytes.' }
+ expose :storage_size, documentation: { type: 'integer', desc: 'Total storage in bytes.' }
+ expose :uploads_size, documentation: { type: 'integer', desc: 'Uploads size in bytes.' }
+ expose :wiki_size, documentation: { type: 'integer', desc: 'Wiki size in bytes.' }
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 373aceee5d9..557467e158e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1533,9 +1533,6 @@ msgstr ""
msgid "%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}v*%{code_tag_end} or %{code_tag_start}*-release%{code_tag_end} are supported."
msgstr ""
-msgid "'%{data_pointer}' must be a valid '%{type}'"
-msgstr ""
-
msgid "'%{group_name}' has been scheduled for removal on %{removal_time}."
msgstr ""
@@ -64528,6 +64525,9 @@ msgstr ""
msgid "should have length between 16 to 24 characters."
msgstr ""
+msgid "should use only one of: %{requirements}"
+msgstr ""
+
msgid "show %{count} more"
msgstr ""
@@ -64707,6 +64707,9 @@ msgstr ""
msgid "v%{version} published %{timeAgo}"
msgstr ""
+msgid "value at %{data_pointer} should use only one of: %{requirements}"
+msgstr ""
+
msgid "value for '%{storage}' must be an integer"
msgstr ""
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
index 0147b199040..a6e5b6a2fdd 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
@@ -1,9 +1,11 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import Draggable from 'vuedraggable';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
@@ -18,6 +20,8 @@ import {
workItemByIidResponseFactory,
} from '../../mock_data';
+jest.mock('~/lib/utils/common_utils');
+
describe('WorkItemChildrenWrapper', () => {
let wrapper;
@@ -30,6 +34,8 @@ describe('WorkItemChildrenWrapper', () => {
.mockResolvedValue(changeWorkItemParentMutationResponse);
const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild);
+ const findDraggable = () => wrapper.findComponent(Draggable);
+ const findChildItemsContainer = () => wrapper.findByTestId('child-items-container');
Vue.use(VueApollo);
@@ -38,6 +44,8 @@ describe('WorkItemChildrenWrapper', () => {
confidential = false,
children = childrenWorkItems,
mutationHandler = updateWorkItemMutationHandler,
+ disableContent = false,
+ canUpdate = false,
} = {}) => {
const mockApollo = createMockApollo([
[workItemByIidQuery, getWorkItemQueryHandler],
@@ -62,6 +70,8 @@ describe('WorkItemChildrenWrapper', () => {
workItemIid: '1',
confidential,
children,
+ disableContent,
+ canUpdate,
},
mocks: {
$toast,
@@ -114,6 +124,44 @@ describe('WorkItemChildrenWrapper', () => {
},
);
+ it('does not render draggable component when user is not logged in', () => {
+ createComponent({ canUpdate: true });
+
+ expect(findDraggable().exists()).toBe(false);
+ });
+
+ it('disables list when `disableContent` is true', () => {
+ createComponent({ disableContent: true });
+
+ expect(findChildItemsContainer().classes('disabled-content')).toBe(true);
+ });
+
+ describe('when user is logged in', () => {
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(true);
+ });
+
+ it('renders draggable component without disabling the list', () => {
+ createComponent({ canUpdate: true });
+
+ expect(findDraggable().exists()).toBe(true);
+ expect(findDraggable().classes('disabled-content')).toBe(false);
+ });
+
+ it('does not render draggable component when user has no permission', () => {
+ createComponent({ canUpdate: false });
+
+ expect(findDraggable().exists()).toBe(false);
+ });
+
+ it('disables the list when `disableContent` is true', () => {
+ createComponent({ disableContent: true, canUpdate: true });
+
+ expect(findDraggable().exists()).toBe(true);
+ expect(findDraggable().classes('disabled-content')).toBe(true);
+ });
+ });
+
describe('when removing child work item', () => {
const workItem = { id: 'gid://gitlab/WorkItem/2' };
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
index 57b7175d307..ddfb9b5b5dc 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -152,6 +152,19 @@ describe('WorkItemLinksForm', () => {
);
describe('creating a new work item', () => {
+ const submitForm = ({ title, fullPath }) => {
+ findInput().vm.$emit('input', title);
+
+ if (fullPath) {
+ findProjectSelector().vm.$emit('selectProject', fullPath);
+ }
+
+ // Trigger form submission
+ findForm().vm.$emit('submit', {
+ preventDefault: jest.fn(),
+ });
+ };
+
describe('for project level work items', () => {
beforeEach(async () => {
await createComponent();
@@ -171,11 +184,10 @@ describe('WorkItemLinksForm', () => {
expect(findFormGroup().props('invalidFeedback')).toBe(null);
expect(findInput().props('state')).toBe(true);
- findInput().vm.$emit('input', 'Create task test');
- // Trigger form submission
- findForm().vm.$emit('submit', {
- preventDefault: jest.fn(),
- });
+ submitForm({ title: 'Create task test' });
+
+ expect(wrapper.emitted('update-in-progress')).toEqual([[true]]);
+
await waitForPromises();
expect(findFormGroup().props('state')).toBe(false);
@@ -183,14 +195,14 @@ describe('WorkItemLinksForm', () => {
'Something went wrong when trying to create a child. Please try again.',
);
expect(findInput().props('state')).toBe(false);
+ expect(wrapper.emitted('update-in-progress')[1]).toEqual([false]);
});
it('creates child task in non confidential parent and closes the form', async () => {
- findInput().vm.$emit('input', 'Create task test');
+ submitForm({ title: 'Create task test' });
+
+ expect(wrapper.emitted('update-in-progress')).toEqual([[true]]);
- findForm().vm.$emit('submit', {
- preventDefault: jest.fn(),
- });
await waitForPromises();
expect(createMutationResolver).toHaveBeenCalledWith({
@@ -206,17 +218,18 @@ describe('WorkItemLinksForm', () => {
});
expect(wrapper.emitted('addChild')).toEqual([[]]);
expect(wrapper.emitted('cancel')).toEqual([[]]);
+ expect(wrapper.emitted('update-in-progress')[1]).toEqual([false]);
});
it('creates child task in confidential parent', async () => {
await createComponent({ parentConfidential: true });
- findInput().vm.$emit('input', 'Create confidential task');
+ submitForm({ title: 'Create confidential task' });
+
+ expect(wrapper.emitted('update-in-progress')).toEqual([[true]]);
- findForm().vm.$emit('submit', {
- preventDefault: jest.fn(),
- });
await waitForPromises();
+
expect(wrapper.vm.childWorkItemType).toEqual(workItemTypeIdForTask);
expect(createMutationResolver).toHaveBeenCalledWith({
input: {
@@ -229,6 +242,7 @@ describe('WorkItemLinksForm', () => {
confidential: true,
},
});
+ expect(wrapper.emitted('update-in-progress')[1]).toEqual([false]);
});
});
@@ -250,13 +264,9 @@ describe('WorkItemLinksForm', () => {
});
it('creates child issue in non confidential parent and closes the form', async () => {
- findInput().vm.$emit('input', 'Create issue test');
+ submitForm({ title: 'Create issue test', fullPath: projectData[0].fullPath });
- findProjectSelector().vm.$emit('selectProject', projectData[0].fullPath);
-
- findForm().vm.$emit('submit', {
- preventDefault: jest.fn(),
- });
+ expect(wrapper.emitted('update-in-progress')).toEqual([[true]]);
await waitForPromises();
@@ -272,6 +282,7 @@ describe('WorkItemLinksForm', () => {
},
});
expect(wrapper.emitted('addChild')).toEqual([[]]);
+ expect(wrapper.emitted('update-in-progress')[1]).toEqual([false]);
expect(wrapper.emitted('cancel')).toEqual([[]]);
});
@@ -283,13 +294,9 @@ describe('WorkItemLinksForm', () => {
childrenType: WORK_ITEM_TYPE_ENUM_ISSUE,
});
- findInput().vm.$emit('input', 'Create confidential issue');
+ submitForm({ title: 'Create confidential issue', fullPath: projectData[0].fullPath });
- findProjectSelector().vm.$emit('selectProject', projectData[0].fullPath);
-
- findForm().vm.$emit('submit', {
- preventDefault: jest.fn(),
- });
+ expect(wrapper.emitted('update-in-progress')).toEqual([[true]]);
await waitForPromises();
@@ -304,6 +311,7 @@ describe('WorkItemLinksForm', () => {
confidential: true,
},
});
+ expect(wrapper.emitted('update-in-progress')[1]).toEqual([false]);
});
});
diff --git a/spec/lib/api/entities/group_spec.rb b/spec/lib/api/entities/group_spec.rb
index d12e97d3009..a541630d676 100644
--- a/spec/lib/api/entities/group_spec.rb
+++ b/spec/lib/api/entities/group_spec.rb
@@ -4,21 +4,67 @@ require 'spec_helper'
RSpec.describe API::Entities::Group, feature_category: :groups_and_projects do
let_it_be(:group) do
- base_group = create(:group) { |g| create(:project_statistics, namespace_id: g.id) }
+ base_group = create(:group) do |g|
+ create(:project_statistics, namespace_id: g.id)
+ create(:namespace_root_storage_statistics, namespace_id: g.id)
+ end
Group.with_statistics.find(base_group.id)
end
- subject(:json) { described_class.new(group, { with_custom_attributes: true, statistics: true }).as_json }
+ subject(:json) { described_class.new(group, options).as_json }
- it 'returns expected data' do
- expect(json.keys).to(
- include(
- :organization_id, :path, :description, :visibility, :share_with_group_lock, :require_two_factor_authentication,
- :two_factor_grace_period, :project_creation_level, :auto_devops_enabled,
- :subgroup_creation_level, :emails_disabled, :emails_enabled, :lfs_enabled, :default_branch_protection,
- :default_branch_protection_defaults, :avatar_url, :request_access_enabled, :full_name, :full_path, :created_at,
- :parent_id, :organization_id, :shared_runners_setting, :custom_attributes, :statistics, :default_branch
+ context 'with statistics' do
+ let(:options) { { with_custom_attributes: true, statistics: true } }
+
+ it 'returns expected data' do
+ expect(json.keys).to(
+ include(
+ :organization_id, :path, :description, :visibility, :share_with_group_lock,
+ :require_two_factor_authentication, :two_factor_grace_period, :project_creation_level, :auto_devops_enabled,
+ :subgroup_creation_level, :emails_disabled, :emails_enabled, :lfs_enabled, :default_branch_protection,
+ :default_branch_protection_defaults, :avatar_url, :request_access_enabled, :full_name, :full_path,
+ :created_at, :parent_id, :organization_id, :shared_runners_setting, :custom_attributes, :statistics,
+ :default_branch, :root_storage_statistics
+ )
)
- )
+ end
+
+ context 'on a sub-group' do
+ let(:subgroup) do
+ subgroup = create(:group, parent: group, path: "#{group.path}-subgroup") do |g|
+ create(:project_statistics, namespace_id: g.id)
+ end
+ Group.with_statistics.find(subgroup.id)
+ end
+
+ subject(:json) { described_class.new(subgroup, options).as_json }
+
+ it 'does not expose root storage statistics' do
+ expect(json.keys).not_to(include(:root_storage_statistics))
+ end
+ end
+
+ context 'on a group without root storage statistics' do
+ let(:group_without_root_storage_statistics) do
+ base_group = create(:group) do |g|
+ create(:project_statistics, namespace_id: g.id)
+ end
+ Group.with_statistics.find(base_group.id)
+ end
+
+ subject(:json) { described_class.new(group_without_root_storage_statistics, options).as_json }
+
+ it 'returns nil for root storage statistics' do
+ expect(json[:root_storage_statistics]).to be_nil
+ end
+ end
+ end
+
+ context 'without statistics' do
+ let(:options) { { with_custom_attributes: true, statistics: false } }
+
+ it 'does not expose statistics' do
+ expect(json.keys).not_to(include(:statistics, :root_storage_statistics))
+ end
end
end
diff --git a/spec/lib/api/entities/namespace/root_storage_statistics_spec.rb b/spec/lib/api/entities/namespace/root_storage_statistics_spec.rb
new file mode 100644
index 00000000000..9ce1d3a0794
--- /dev/null
+++ b/spec/lib/api/entities/namespace/root_storage_statistics_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Namespace::RootStorageStatistics, feature_category: :groups_and_projects do
+ let(:root_storage_statistics) { create(:namespace_root_storage_statistics) }
+
+ subject(:entity) { described_class.new(root_storage_statistics).as_json }
+
+ it 'exposes correct attributes' do
+ expect(entity.keys).to(
+ include(
+ :build_artifacts_size,
+ :container_registry_size,
+ :container_registry_size_is_estimated,
+ :dependency_proxy_size,
+ :lfs_objects_size,
+ :packages_size,
+ :pipeline_artifacts_size,
+ :repository_size,
+ :snippets_size,
+ :storage_size,
+ :uploads_size,
+ :wiki_size
+ )
+ )
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb
index 0a82010c20c..acbd9a22c2e 100644
--- a/spec/lib/gitlab/ci/config/entry/image_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb
@@ -136,7 +136,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
it 'raises an error' do
expect(entry).not_to be_valid
expect(entry.errors.first)
- .to match %r{image executor opts '/docker/platform' must be a valid 'string'}
+ .to match %r{image executor opts value at `/docker/platform` is not a string}
end
end
end
@@ -184,7 +184,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
it 'raises an error' do
expect(entry).not_to be_valid
expect(entry.errors.first)
- .to match %r{image executor opts '/docker/user' must be a valid 'string'}
+ .to match %r{image executor opts value at `/docker/user` is not a string}
end
end
end
@@ -194,8 +194,9 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
it 'is not valid' do
expect(entry).not_to be_valid
- expect(entry.errors.first)
- .to match %r{image executor opts '/docker/unknown_key' must be a valid 'schema'}
+ expect(entry.errors.first).to match(
+ %r{image executor opts object property at `/docker/unknown_key` is a disallowed additional property}
+ )
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index e28e01ce7f5..730922ac08f 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -270,14 +270,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_compo
using RSpec::Parameterized::TableSyntax
where(:case_name, :config, :error) do
- 'when only step is used without name' | { stage: 'build',
-run: [{ step: 'some reference' }] } | 'job run \'/0\' must be a valid \'required\''
- 'when only script is used without name' | { stage: 'build',
-run: [{ script: 'echo' }] } | 'job run \'/0\' must be a valid \'required\''
- 'when step and script are used together' | { stage: 'build',
-run: [{ name: 'step1', step: 'some reference', script: 'echo' }] } | 'job run \'/0\' must be a valid \'oneof\''
- 'when a subkey does not exist' | { stage: 'build',
-run: [{ name: 'step1', invalid_key: 'some value' }] } | 'job run \'/0\' must be a valid \'required\''
+ 'when only step is used without name' | {
+ stage: 'build',
+ run: [{ step: 'some reference' }]
+ } | 'job run object at `/0` is missing required properties: name'
+
+ 'when only script is used without name' | {
+ stage: 'build',
+ run: [{ script: 'echo' }]
+ } | 'job run object at `/0` is missing required properties: name'
+
+ 'when step and script are used together' | {
+ stage: 'build',
+ run: [{
+ name: 'step1',
+ step: 'some reference',
+ script: 'echo'
+ }]
+ } | 'job run value at /0 should use only one of: step, script'
+
+ 'when a required subkey is missing' | {
+ stage: 'build',
+ run: [{ name: 'step1' }]
+ } | 'job run object at `/0` is missing required properties: step'
+
+ 'when a subkey is invalid' | {
+ stage: 'build',
+ run: [{ name: 'step1', step: 'some step', invalid_key: 'some value' }]
+ } | 'job run object property at `/0/invalid_key` is a disallowed additional property'
end
with_them do
@@ -292,7 +312,7 @@ run: [{ name: 'step1', invalid_key: 'some value' }] } | 'job run \'/0\' must be
it 'returns error about invalid run' do
expect(entry).not_to be_valid
- expect(entry.errors).to include 'job run \'\' must be a valid \'array\''
+ expect(entry.errors).to include 'job run value at root is not an array'
end
end
@@ -312,7 +332,7 @@ run: [{ name: 'step1', invalid_key: 'some value' }] } | 'job run \'/0\' must be
it 'returns error about invalid env' do
expect(entry).not_to be_valid
- expect(entry.errors).to include 'job run \'/0/env/my_var\' must be a valid \'string\''
+ expect(entry.errors).to include 'job run value at `/0/env/my_var` is not a string'
end
end
@@ -321,7 +341,7 @@ run: [{ name: 'step1', invalid_key: 'some value' }] } | 'job run \'/0\' must be
it 'returns error about invalid run' do
expect(entry).not_to be_valid
- expect(entry.errors).to include 'job run \'/0\' must be a valid \'required\''
+ expect(entry.errors).to include 'job run object at `/0` is missing required properties: step'
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/service_spec.rb b/spec/lib/gitlab/ci/config/entry/service_spec.rb
index 9f4dc3becfd..841d595c8da 100644
--- a/spec/lib/gitlab/ci/config/entry/service_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/service_spec.rb
@@ -204,7 +204,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Service do
it 'is not valid' do
expect(entry).not_to be_valid
expect(entry.errors.first)
- .to match %r{service executor opts '/docker/invalid' must be a valid 'schema'}
+ .to match %r{service executor opts object property at `/docker/invalid` is a disallowed additional property}
end
end
end
@@ -216,7 +216,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Service do
it 'is not valid' do
expect(entry).not_to be_valid
expect(entry.errors.first)
- .to match %r{service executor opts '/docker/platform' must be a valid 'string'}
+ .to match %r{service executor opts value at `/docker/platform` is not a string}
end
end
end
diff --git a/spec/services/ci/create_pipeline_service/run_spec.rb b/spec/services/ci/create_pipeline_service/run_spec.rb
index 6c3f94e7775..32162a31da7 100644
--- a/spec/services/ci/create_pipeline_service/run_spec.rb
+++ b/spec/services/ci/create_pipeline_service/run_spec.rb
@@ -137,7 +137,7 @@ RSpec.describe Ci::CreatePipelineService, :ci_config_feature_flag_correctness,
it 'returns errors for invalid configuration' do
expect(pipeline).not_to be_created_successfully
expect(pipeline.errors.full_messages).to include(
- "jobs:job run '/0' must be a valid 'required'"
+ "jobs:job run object at `/0` is missing required properties: name"
)
end
end
diff --git a/spec/validators/json_schema_validator_spec.rb b/spec/validators/json_schema_validator_spec.rb
index f3a22892407..212bfa31c94 100644
--- a/spec/validators/json_schema_validator_spec.rb
+++ b/spec/validators/json_schema_validator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe JsonSchemaValidator do
+RSpec.describe JsonSchemaValidator, feature_category: :shared do
describe '#validates_each' do
let(:build_report_result) { build(:ci_build_report_result, :with_junit_success) }
@@ -78,10 +78,140 @@ RSpec.describe JsonSchemaValidator do
expect(build_report_result.errors.size).to eq(1)
expect(build_report_result.errors.full_messages).to match_array(
- ["Data '/invalid' must be a valid 'schema'"]
+ ["Data object property at `/invalid` is a disallowed additional property"]
)
end
end
end
+
+ context 'when validating config with oneOf JSON schema' do
+ let(:config) do
+ {
+ run: [
+ {
+ name: 'hello_steps',
+ step: 'gitlab.com/gitlab-org/ci-cd/runner-tools/echo-step',
+ inputs: {
+ echo: 'hello steps!'
+ }
+ }
+ ]
+ }
+ end
+
+ let(:job) { Gitlab::Ci::Config::Entry::Job.new(config, name: :rspec) }
+ let(:errors) { ActiveModel::Errors.new(job) }
+
+ let(:validator) do
+ described_class.new(
+ attributes: [:run],
+ base_directory: 'app/validators/json_schemas',
+ filename: 'run_steps',
+ hash_conversion: true,
+ detail_errors: true
+ )
+ end
+
+ before do
+ job.compose!
+ allow(job).to receive(:errors).and_return(errors)
+ end
+
+ subject { validator.validate(job) }
+
+ context 'when the value is a valid array of hashes' do
+ before do
+ allow(job).to receive(:read_attribute_for_validation).and_return(config[:run])
+ end
+
+ it 'returns no errors' do
+ subject
+
+ expect(job.errors).to be_empty
+ end
+ end
+
+ context 'when a required property is missing' do
+ before do
+ config[:run] = [{ name: 'hello_steps' }]
+ allow(job).to receive(:read_attribute_for_validation).and_return(config[:run])
+ end
+
+ it 'returns an error message' do
+ subject
+
+ expect(job.errors).not_to be_empty
+ expect("#{job.errors.first.attribute} #{job.errors.first.type}").to eq("run object at `/0` is missing required properties: step")
+ end
+ end
+
+ context 'when oneOf validation fails' do
+ before do
+ config[:run] = [nil]
+ allow(job).to receive(:read_attribute_for_validation).and_return(config[:run])
+ end
+
+ it 'returns an error message' do
+ subject
+
+ expect(job.errors).not_to be_empty
+ expect("#{job.errors.first.attribute} #{job.errors.first.type}").to eq(
+ "run value at /0 should use only one of: step, script"
+ )
+ end
+ end
+
+ context 'when there is a general validation error' do
+ before do
+ config[:run] = 'not an array'
+ allow(job).to receive(:read_attribute_for_validation).and_return(config[:run])
+ end
+
+ it 'returns an error message' do
+ subject
+
+ expect(job.errors).not_to be_empty
+ expect("#{job.errors.first.attribute} #{job.errors.first.type}").to eq("run value at root is not an array")
+ end
+ end
+
+ context 'when a non-array value violates oneOf constraint' do
+ let(:schema) do
+ {
+ "type" => "object",
+ "properties" => {
+ "run" => {
+ "oneOf" => [
+ { required: ["step"], title: "step" },
+ { required: ["script"], title: "script" }
+ ]
+ }
+ }
+ }
+ end
+
+ let(:validator) do
+ described_class.new(
+ attributes: [:run],
+ filename: 'test_schema',
+ detail_errors: true
+ )
+ end
+
+ before do
+ config[:run] = 'C'
+ allow(job).to receive(:read_attribute_for_validation).and_return({ run: config[:run] })
+ allow(JSONSchemer).to receive(:schema).and_return(JSONSchemer.schema(schema))
+ allow(File).to receive(:read).with(anything).and_return(schema.to_json)
+ end
+
+ it 'returns an error message for oneOf violation without data pointer' do
+ subject
+
+ expect(job.errors).not_to be_empty
+ expect("#{job.errors.first.attribute} #{job.errors.first.type}").to eq("run should use only one of: step, script")
+ end
+ end
+ end
end
end
diff --git a/workhorse/go.mod b/workhorse/go.mod
index dc1d210b7a2..6df51128a11 100644
--- a/workhorse/go.mod
+++ b/workhorse/go.mod
@@ -25,7 +25,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/goconvey v1.8.1
github.com/stretchr/testify v1.9.0
- gitlab.com/gitlab-org/gitaly/v16 v16.11.7
+ gitlab.com/gitlab-org/gitaly/v16 v16.11.8
gitlab.com/gitlab-org/labkit v1.21.0
go.uber.org/goleak v1.3.0
gocloud.dev v0.38.0
diff --git a/workhorse/go.sum b/workhorse/go.sum
index f19244227cd..0e327f585bc 100644
--- a/workhorse/go.sum
+++ b/workhorse/go.sum
@@ -486,8 +486,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-gitlab.com/gitlab-org/gitaly/v16 v16.11.7 h1:csL3bPTHIEE0147aCo1HFgNLMgmHwlwr1Ns65OlcMuE=
-gitlab.com/gitlab-org/gitaly/v16 v16.11.7/go.mod h1:lJizRUtXRd1SBHjNbbbL9OsGN4TiugvfRBd8bIsdWI0=
+gitlab.com/gitlab-org/gitaly/v16 v16.11.8 h1:bL9F90+rXTlQcsSuZJivn+CIwKGXXc787IJi4g3XQEU=
+gitlab.com/gitlab-org/gitaly/v16 v16.11.8/go.mod h1:lJizRUtXRd1SBHjNbbbL9OsGN4TiugvfRBd8bIsdWI0=
gitlab.com/gitlab-org/labkit v1.21.0 h1:hLmdBDtXjD1yOmZ+uJOac3a5Tlo83QaezwhES4IYik4=
gitlab.com/gitlab-org/labkit v1.21.0/go.mod h1:zeATDAaSBelPcPLbTTq8J3ZJEHyPTLVBM1q3nva+/W4=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=