diff --git a/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql b/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..bdab5fdf052e41043f12b8f58976f63e302c7a44 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql @@ -0,0 +1,26 @@ +mutation promoteModelVersion( + $projectPath: ID! + $modelId: MlModelID! + $version: String! + $description: String + $candidateId: MlCandidateID! +) { + mlModelVersionCreate( + input: { + projectPath: $projectPath + modelId: $modelId + version: $version + description: $description + candidateId: $candidateId + } + ) { + modelVersion { + id + _links { + showPath + importPath + } + } + errors + } +} diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue new file mode 100644 index 0000000000000000000000000000000000000000..c191447c785c7014b41fddca42148a5b43d72734 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue @@ -0,0 +1,215 @@ +<script> +import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { semverRegex } from '~/lib/utils/regexp'; +import PageHeading from '~/vue_shared/components/page_heading.vue'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import createModelVersionMutation from '~/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql'; + +export default { + name: 'PromoteRun', + components: { + PageHeading, + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + MarkdownEditor, + }, + props: { + candidate: { + type: Object, + required: true, + }, + }, + data() { + return { + version: null, + description: '', + errorMessage: null, + versionData: null, + markdownDocPath: helpPagePath('user/markdown'), + markdownEditorRestrictedToolBarItems: ['full-screen'], + }; + }, + computed: { + versionDescription() { + if (this.candidate.latestVersion) { + return sprintf( + s__('MlModelRegistry|Must be a semantic version. Latest version is %{latestVersion}'), + { + latestVersion: this.candidate.latestVersion, + }, + ); + } + return s__('MlModelRegistry|Must be a semantic version.'); + }, + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, + isSemver() { + return semverRegex.test(this.version); + }, + invalidFeedback() { + if (this.version === null) { + return this.versionDescription; + } + if (!this.isSemver) { + return this.$options.i18n.versionInvalid; + } + return null; + }, + modelGid() { + return this.candidate.modelGid; + }, + submitDisabled() { + return this.version === null || !this.isSemver; + }, + }, + methods: { + async createModelVersion() { + const { data } = await this.$apollo.mutate({ + mutation: createModelVersionMutation, + variables: { + projectPath: this.candidate.projectPath, + modelId: this.candidate.modelGid, + version: this.version, + description: this.description, + candidateId: this.candidate.info.gid, + }, + }); + + return data; + }, + async create() { + try { + if (!this.versionData) { + this.versionData = await this.createModelVersion(); + } + const errors = this.versionData?.mlModelVersionCreate?.errors || []; + + if (errors.length) { + this.errorMessage = errors.join(', '); + this.versionData = null; + } else { + const { showPath } = this.versionData.mlModelVersionCreate.modelVersion._links; + visitUrl(showPath); + } + } catch (error) { + Sentry.captureException(error); + this.errorMessage = error; + } + }, + cancel() { + visitUrl(this.modelPath); + }, + hideAlert() { + this.errorMessage = null; + }, + setDescription(newText) { + if (!this.isSubmitting) { + this.description = newText; + } + }, + }, + descriptionFormFieldProps: { + placeholder: s__('MlModelRegistry|Enter a model version description'), + id: 'model-version-description', + name: 'model-version-description', + }, + i18n: { + actionPrimaryText: s__('MlModelRegistry|Promote'), + actionSecondaryText: __('Cancel'), + versionDescription: s__('MlModelRegistry|Enter a semantic version.'), + versionValid: s__('MlModelRegistry|Version is valid semantic version.'), + versionInvalid: s__('MlModelRegistry|Version is not a valid semantic version.'), + versionPlaceholder: s__('MlModelRegistry|For example 1.0.0'), + descriptionPlaceholder: s__('MlModelRegistry|Enter some description'), + title: s__('MlModelRegistry|Promote run'), + description: s__('MlModelRegistry|Complete the form below to promote run to a model version.'), + optionalText: s__('MlModelRegistry|(Optional)'), + versionLabelText: s__('MlModelRegistry|Version'), + versionDescriptionText: s__('MlModelRegistry|Description'), + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="errorMessage" + class="gl-mt-5" + data-testid="create-alert" + variant="danger" + @dismiss="hideAlert" + >{{ errorMessage }} + </gl-alert> + + <page-heading :heading="$options.i18n.title"> + <template #description> + {{ $options.i18n.description }} + </template> + </page-heading> + + <gl-form> + <gl-form-group + data-testid="versionDescriptionId" + :label="$options.i18n.versionLabelText" + label-for="versionId" + :state="isSemver" + :invalid-feedback="!version ? '' : invalidFeedback" + :valid-feedback="isSemver ? $options.i18n.versionValid : ''" + :description="versionDescription" + > + <gl-form-input + id="versionId" + v-model="version" + data-testid="versionId" + type="text" + required + :placeholder="$options.i18n.versionPlaceholder" + autocomplete="off" + /> + </gl-form-group> + <gl-form-group + :label="$options.i18n.versionDescriptionText" + label-for="descriptionId" + class="common-note-form gfm-form js-main-target-form new-note gl-grow" + optional + :optional-text="$options.i18n.optionalText" + > + <markdown-editor + ref="markdownEditor" + data-testid="descriptionId" + :value="description" + enable-autocomplete + :autocomplete-data-sources="autocompleteDataSources" + :enable-content-editor="true" + :form-field-props="$options.descriptionFormFieldProps" + :render-markdown-path="candidate.markdownPreviewPath" + :markdown-docs-path="markdownDocPath" + :disable-attachments="false" + :placeholder="$options.i18n.descriptionPlaceholder" + :restricted-tool-bar-items="markdownEditorRestrictedToolBarItems" + @input="setDescription" + /> + </gl-form-group> + <div class="gl-flex gl-gap-3"> + <gl-button + :disabled="submitDisabled" + data-testid="primary-button" + variant="confirm" + @click="create" + >{{ $options.i18n.actionPrimaryText }} + </gl-button> + <gl-button data-testid="secondary-button" variant="default" @click="cancel" + >{{ $options.i18n.actionSecondaryText }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue index 3e57df46345f1ea9c94f16533b9a7dce0e95c895..48e0dc4424cb4de839c50ae3533b19315e31417b 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlIcon, GlLink } from '@gitlab/ui'; +import { GlBadge, GlButton, GlIcon, GlLink } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import PageHeading from '~/vue_shared/components/page_heading.vue'; @@ -11,6 +11,7 @@ export default { components: { DeleteButton, GlBadge, + GlButton, GlIcon, GlLink, PageHeading, @@ -40,6 +41,7 @@ export default { ), deleteCandidatePrimaryActionLabel: s__('MlExperimentTracking|Delete run'), deleteCandidateModalTitle: s__('MlExperimentTracking|Delete run?'), + promoteText: s__('ExperimentTracking|Promote run'), }, statusVariants: { running: 'success', @@ -80,12 +82,16 @@ export default { </div> </template> </page-heading> - - <delete-button - :delete-path="info.path" - :delete-confirmation-text="$options.i18n.deleteCandidateConfirmationMessage" - :action-primary-text="$options.i18n.deleteCandidatePrimaryActionLabel" - :modal-title="$options.i18n.deleteCandidateModalTitle" - /> + <div class="gl-flex"> + <gl-button v-if="info.canPromote" :href="info.promotePath" variant="confirm" class="gl-mr-3"> + {{ $options.i18n.promoteText }} + </gl-button> + <delete-button + :delete-path="info.path" + :delete-confirmation-text="$options.i18n.deleteCandidateConfirmationMessage" + :action-primary-text="$options.i18n.deleteCandidatePrimaryActionLabel" + :modal-title="$options.i18n.deleteCandidateModalTitle" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js b/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f1e861d936c4c51eacecf46131952bcd5636063f --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import PromoteRun from '~/ml/experiment_tracking/routes/candidates/promote/promote_run.vue'; + +initSimpleApp('#js-promote-ml-candidate', PromoteRun, { withApolloProvider: true }); diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index a6829ddf82cb431f01ae8c67d2f7271e4095b839..60b01b07a2278724e5076b324fbbc29c8f6c5270 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -5,12 +5,14 @@ module Ml class CandidatesController < ApplicationController before_action :set_candidate before_action :check_read, only: [:show] - before_action :check_write, only: [:destroy] + before_action :check_write, only: [:destroy, :promote] feature_category :mlops def show; end + def promote; end + def destroy @experiment = @candidate.experiment @candidate.destroy! diff --git a/app/graphql/mutations/ml/model_versions/create.rb b/app/graphql/mutations/ml/model_versions/create.rb index 86066ad38deae414ca7e2f1a30084ada7b83c7cf..a5a0f6504bcf963c0688a3d2829b61e36b75700c 100644 --- a/app/graphql/mutations/ml/model_versions/create.rb +++ b/app/graphql/mutations/ml/model_versions/create.rb @@ -25,6 +25,10 @@ class Create < BaseMutation required: false, description: 'Description of the model version.' + argument :candidate_id, ::Types::GlobalIDType[::Ml::Candidate], + required: false, + description: 'Global ID of a candidate to promote optionally.' + field :model_version, Types::Ml::ModelVersionType, null: true, @@ -40,6 +44,7 @@ def resolve(**args) { version: args[:version], description: args[:description], + candidate_id: args[:candidate_id], user: current_user } ).execute diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index 99f7008c0c95924a254b7494ce041fc2503455ec..c2d4dfe5a482bc56d5db878381290c67454ca170 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -93,6 +93,12 @@ def with_project_id_and_iid(project_id, iid) find_by(project_id: project_id, internal_id: iid) end + + def with_project_id_and_id(project_id, id) + return unless project_id.present? && id.present? + + find_by(project_id: project_id, id: id) + end end private diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index d4f75cb447ff6b9064adb85775f8f61432f6798a..cb1da18f1ee9b7aa3c579c64b1ccdc2c61cddee4 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -9,12 +9,14 @@ def initialize(candidate, current_user) @current_user = current_user end + # rubocop:disable Metrics/AbcSize -- Monoton complexity def present { candidate: { info: { iid: candidate.iid, eid: candidate.eid, + gid: candidate.to_global_id.to_s, path_to_artifact: link_to_artifact, experiment_name: candidate.experiment.name, path_to_experiment: link_to_experiment, @@ -22,15 +24,23 @@ def present status: candidate.status, ci_job: job_info, created_at: candidate.created_at, - authorWebUrl: candidate.user&.namespace&.web_url, - authorName: candidate.user&.name + author_web_url: candidate.user&.namespace&.web_url, + author_name: candidate.user&.name, + promote_path: promote_project_ml_candidate_path(candidate.project, candidate.iid), + can_promote: candidate.model_version_id.nil? && candidate.experiment.model_id.present? }, params: candidate.params, metrics: candidate.metrics, - metadata: candidate.metadata + metadata: candidate.metadata, + projectPath: candidate.project.full_path, + can_write_model_registry: current_user&.can?(:write_model_registry, candidate.project), + markdown_preview_path: project_preview_markdown_path(candidate.project), + model_gid: candidate.experiment.model&.to_global_id.to_s, + latest_version: candidate.experiment.model&.latest_version&.version } } end + # rubocop:enable Metrics/AbcSize def present_as_json Gitlab::Json.generate(present.deep_transform_keys { |k| k.to_s.camelize(:lower) }) diff --git a/app/services/ml/create_model_version_service.rb b/app/services/ml/create_model_version_service.rb index 882cd0b17485192cecb7e321a2b7a33984de1414..6189de07fc29f66acfc437c9da62950e4c2f2cf5 100644 --- a/app/services/ml/create_model_version_service.rb +++ b/app/services/ml/create_model_version_service.rb @@ -9,6 +9,7 @@ def initialize(model, params = {}) @description = params[:description] @user = params[:user] @metadata = params[:metadata] + @candidate_id = params[:candidate_id] end def execute @@ -17,23 +18,18 @@ def execute error(_("Version must be semantic version")) unless Packages::SemVer.match(@version) - package = @package || find_or_create_package(@model.name, @version) - - error(_("Can't create model version package")) unless package - @model_version = Ml::ModelVersion.new(model: @model, project: @model.project, version: @version, - package: package, description: @description) + description: @description) @model_version.save error(@model_version.errors.full_messages) unless @model_version.persisted? - @model_version.candidate = ::Ml::CreateCandidateService.new( - @model.default_experiment, - { model_version: @model_version } - ).execute + package = find_or_create_candidate + package ||= find_or_create_package(@model.name, @version) + error(_("Can't create model version package")) unless package - error(_("Version must be semantic version")) unless @model_version.candidate + @model_version.update! package: package @model_version.add_metadata(@metadata) @@ -55,6 +51,30 @@ def execute private + def find_or_create_candidate + if @candidate_id + candidate = ::Ml::Candidate.with_project_id_and_id(@model.project_id, @candidate_id.model_id) + error(_("Run not found")) unless candidate + error(_("Run has already a model version")) if candidate.model_version_id + error(_("Run's experiment does not belong to this model")) unless candidate.experiment.model_id == @model.id + + candidate.update! model_version: @model_version + + package = candidate.package + package.update!(name: @model_version.name, version: @model_version.version) if package + else + candidate = ::Ml::CreateCandidateService.new( + @model.default_experiment, + { model_version: @model_version } + ).execute + error(_("Version must be semantic version")) unless candidate + + package = @package + end + + package + end + def find_or_create_package(model_name, model_version) package_params = { name: model_name, diff --git a/app/views/projects/ml/candidates/promote.html.haml b/app/views/projects/ml/candidates/promote.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..10d4642852155e3d77cac0bb2378b7bcc74fd7b9 --- /dev/null +++ b/app/views/projects/ml/candidates/promote.html.haml @@ -0,0 +1,8 @@ +- experiment = @candidate.experiment +- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project) +- add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid) +- add_to_breadcrumbs "Run #{@candidate.iid}", project_ml_candidate_path(@project, @candidate.iid) +- breadcrumb_title "Promote to model version" +- presenter = ::Ml::CandidateDetailsPresenter.new(@candidate, current_user) + +#js-promote-ml-candidate{ data: { view_model: presenter.present_as_json } } diff --git a/config/routes/project.rb b/config/routes/project.rb index 65f1208e29f6db46742584c12892a68458d28092..d3c3929b7fc1ebecef7f795215263e02c9bc8940 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -483,7 +483,11 @@ namespace :ml do resources :experiments, only: [:index, :show, :destroy], controller: 'experiments', param: :iid - resources :candidates, only: [:show, :destroy], controller: 'candidates', param: :iid + resources :candidates, only: [:show, :destroy], controller: 'candidates', param: :iid do + member do + get :promote + end + end resources :models, only: [:index, :show, :edit, :destroy, :new], controller: 'models', param: :model_id do resources :versions, only: [:new], controller: 'model_versions' resources :versions, only: [:show, :edit], controller: 'model_versions', param: :model_version_id diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 161c7c1d07a5844a9bbb3ec6496232d651ed3703..5a098830aacfebc2740db315c453e86081c9a82d 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7723,6 +7723,7 @@ Input type: `MlModelVersionCreateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="mutationmlmodelversioncreatecandidateid"></a>`candidateId` | [`MlCandidateID`](#mlcandidateid) | Global ID of a candidate to promote optionally. | | <a id="mutationmlmodelversioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationmlmodelversioncreatedescription"></a>`description` | [`String`](#string) | Description of the model version. | | <a id="mutationmlmodelversioncreatemodelid"></a>`modelId` | [`MlModelID!`](#mlmodelid) | Global ID of the model the version belongs to. | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0016812822b99a0f409fba7bba57527a38291984..8eb3a815bb70922c38e02a9508b08adf1b72737e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23459,6 +23459,9 @@ msgstr "" msgid "ExperimentTracking|Performance graph will be shown when runs with logged metrics are available" msgstr "" +msgid "ExperimentTracking|Promote run" +msgstr "" + msgid "ExperimentTracking|Run" msgstr "" @@ -35926,6 +35929,9 @@ msgstr "" msgid "MlModelRegistry|CI Info" msgstr "" +msgid "MlModelRegistry|Complete the form below to promote run to a model version." +msgstr "" + msgid "MlModelRegistry|Create" msgstr "" @@ -36157,6 +36163,12 @@ msgstr "" msgid "MlModelRegistry|Performance" msgstr "" +msgid "MlModelRegistry|Promote" +msgstr "" + +msgid "MlModelRegistry|Promote run" +msgstr "" + msgid "MlModelRegistry|Provide a subfolder name to organize your artifacts. Entering an existing subfolder's name will place artifacts in the existing folder" msgstr "" @@ -48037,6 +48049,9 @@ msgstr "" msgid "Run container scanning job whenever a container image with the latest tag is pushed." msgstr "" +msgid "Run has already a model version" +msgstr "" + msgid "Run housekeeping" msgstr "" @@ -48049,6 +48064,9 @@ msgstr "" msgid "Run manual or delayed jobs" msgstr "" +msgid "Run not found" +msgstr "" + msgid "Run pipeline" msgstr "" @@ -48064,6 +48082,9 @@ msgstr "" msgid "Run untagged jobs" msgstr "" +msgid "Run's experiment does not belong to this model" +msgstr "" + msgid "Runner" msgstr "" diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..53a312a754fd2359e14f1775b3270688e6cf9e27 --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js @@ -0,0 +1,240 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { visitUrl } from '~/lib/utils/url_utility'; +import PromoteRun from '~/ml/experiment_tracking/routes/candidates/promote/promote_run.vue'; +import PageHeading from '~/vue_shared/components/page_heading.vue'; +import createModelVersionMutation from '~/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { createModelVersionResponses } from 'jest/ml/model_registry/graphql_mock_data'; +import { newCandidate } from 'jest/ml/model_registry/mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('PromoteRun', () => { + let wrapper; + let apolloProvider; + + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + + afterEach(() => { + apolloProvider = null; + }); + + const createWrapper = ( + createResolver = jest.fn().mockResolvedValue(createModelVersionResponses.success), + ) => { + const requestHandlers = [[createModelVersionMutation, createResolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(PromoteRun, { + propsData: { + candidate: newCandidate(), + }, + apolloProvider, + stubs: { + PageHeading, + }, + }); + }; + + const findDescription = () => wrapper.findByTestId('page-heading-description'); + const findPrimaryButton = () => wrapper.findByTestId('primary-button'); + const findSecondaryButton = () => wrapper.findByTestId('secondary-button'); + const findVersionInput = () => wrapper.findByTestId('versionId'); + const findDescriptionInput = () => wrapper.findByTestId('descriptionId'); + const findGlAlert = () => wrapper.findComponent(GlAlert); + const submitForm = async () => { + findPrimaryButton().vm.$emit('click'); + await waitForPromises(); + }; + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + + describe('Initial state', () => { + beforeEach(() => { + createWrapper(); + }); + + describe('Form', () => { + it('renders the title', () => { + expect(wrapper.findByRole('heading').text()).toBe('Promote run'); + }); + + it('renders the description', () => { + expect(findDescription().text()).toBe( + 'Complete the form below to promote run to a model version.', + ); + }); + + it('renders the version input', () => { + expect(findVersionInput().exists()).toBe(true); + }); + + it('renders the version input label for initial state', () => { + expect(wrapper.findByTestId('versionDescriptionId').attributes().description).toBe( + 'Must be a semantic version. Latest version is 1.0.2', + ); + expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe( + '', + ); + expect(wrapper.findByTestId('versionDescriptionId').attributes('valid-feedback')).toBe(''); + }); + + it('renders the description input', () => { + expect(findDescriptionInput().exists()).toBe(true); + }); + + it('renders the create button', () => { + expect(findPrimaryButton().props()).toMatchObject({ + variant: 'confirm', + disabled: true, + }); + }); + + it('renders the cancel button', () => { + expect(findSecondaryButton().props()).toMatchObject({ + variant: 'default', + disabled: false, + }); + }); + + it('disables the create button in the modal when semver is incorrect', () => { + expect(findPrimaryButton().props()).toMatchObject({ + variant: 'confirm', + disabled: true, + }); + }); + + it('does not render the alert by default', () => { + expect(findGlAlert().exists()).toBe(false); + }); + }); + }); + + describe('Markdown editor', () => { + it('should show markdown editor', () => { + createWrapper(); + + expect(findMarkdownEditor().exists()).toBe(true); + + expect(findMarkdownEditor().props()).toMatchObject({ + enableContentEditor: true, + formFieldProps: { + id: 'model-version-description', + name: 'model-version-description', + placeholder: 'Enter a model version description', + }, + markdownDocsPath: '/help/user/markdown', + renderMarkdownPath: '/markdown-preview', + uploadsPath: '', + }); + }); + }); + + describe('It reacts to semantic version input', () => { + beforeEach(() => { + createWrapper(); + }); + it('renders the version input label for initial state', () => { + expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe(''); + expect(findPrimaryButton().props()).toMatchObject({ + variant: 'confirm', + disabled: true, + }); + }); + it.each(['1.0', '1', 'abc', '1.abc', '1.0.0.0'])( + 'renders the version input label for invalid state', + async (version) => { + findVersionInput().vm.$emit('input', version); + await nextTick(); + expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe( + 'Version is not a valid semantic version.', + ); + expect(findPrimaryButton().props()).toMatchObject({ + variant: 'confirm', + disabled: true, + }); + }, + ); + it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])( + 'renders the version input label for valid state', + async (version) => { + findVersionInput().vm.$emit('input', version); + await nextTick(); + expect(wrapper.findByTestId('versionDescriptionId').attributes('valid-feedback')).toBe( + 'Version is valid semantic version.', + ); + expect(findPrimaryButton().props()).toMatchObject({ + variant: 'confirm', + disabled: false, + }); + }, + ); + }); + + describe('Successful flow', () => { + beforeEach(async () => { + createWrapper(); + findVersionInput().vm.$emit('input', '1.0.0'); + findDescriptionInput().vm.$emit('input', 'My model version description'); + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + + await submitForm(); + }); + + it('Makes a create mutation upon confirm', () => { + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: createModelVersionMutation, + variables: { + modelId: 'gid://gitlab/Ml::Model/1', + projectPath: 'some/project', + version: '1.0.0', + description: 'My model version description', + candidateId: 'gid://gitlab/Ml::Candidate/1', + }, + }), + ); + }); + + it('Visits the model versions page upon successful create mutation', async () => { + createWrapper(); + + await submitForm(); + + expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1'); + }); + + it('clicking on secondary button clears the form', async () => { + createWrapper(); + + await findSecondaryButton().vm.$emit('click'); + + expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1'); + }); + }); + + describe('Failed flow', () => { + it('Displays an alert upon failed create mutation', async () => { + const failedCreateResolver = jest.fn().mockResolvedValue(createModelVersionResponses.failure); + createWrapper(failedCreateResolver); + + findVersionInput().vm.$emit('input', '1.0.0'); + + await submitForm(); + + expect(findGlAlert().text()).toBe('Version is invalid'); + }); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js index a9032d64c9439aa343a6cb9dea6417846b48e81d..1b5dd9fa8c7a334e1866bb6f5f003eeec1ce6da3 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js @@ -1,4 +1,4 @@ -import { GlBadge, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlButton, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CandidateHeader from '~/ml/experiment_tracking/routes/candidates/show/candidate_header.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -24,6 +24,7 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', ( const findPageHeading = () => wrapper.findComponent(PageHeading); const findDeleteButton = () => wrapper.findComponent(DeleteButton); + const findPromoteButton = () => wrapper.findComponent(GlButton); const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); const findBadge = () => wrapper.findComponent(GlBadge); const findAuthorLink = () => wrapper.findByTestId('author-link'); @@ -110,7 +111,7 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', ( }); }); - describe('delete button configuration', () => { + describe('Delete button configuration', () => { beforeEach(() => { wrapper = createWrapper(); }); @@ -126,4 +127,17 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', ( }); }); }); + + describe('Promote button', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('passes correct props to promote button', () => { + expect(findPromoteButton().text()).toBe('Promote run'); + expect(findPromoteButton().attributes()).toMatchObject({ + href: 'promote/path', + }); + }); + }); }); diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js index e30208751b798da40d0b780a9f867501b60a5c89..c6863ec68a8f38632a7b3273f727c8b185e67fef 100644 --- a/spec/frontend/ml/model_registry/mock_data.js +++ b/spec/frontend/ml/model_registry/mock_data.js @@ -17,6 +17,7 @@ export const newCandidate = () => ({ info: { iid: 'candidate_iid', eid: 'abcdefg', + gid: 'gid://gitlab/Ml::Candidate/1', pathToArtifact: 'path_to_artifact', experimentName: 'The Experiment', pathToExperiment: 'path/to/experiment', @@ -40,7 +41,14 @@ export const newCandidate = () => ({ createdAt: '2024-01-01T00:00:00Z', authorName: 'Test User', authorWebUrl: '/test-user', + canPromote: true, + promotePath: 'promote/path', }, + projectPath: 'some/project', + canWriteModelRegistry: true, + markdownPreviewPath: '/markdown-preview', + modelGid: 'gid://gitlab/Ml::Model/1', + latestVersion: '1.0.2', }); const LATEST_VERSION = { diff --git a/spec/models/ml/candidate_spec.rb b/spec/models/ml/candidate_spec.rb index f7ea44e1e97f6da6f913a87b67501a60fcbea45e..1802c4331b7684872d36942b9f59f90555a69b5c 100644 --- a/spec/models/ml/candidate_spec.rb +++ b/spec/models/ml/candidate_spec.rb @@ -256,6 +256,29 @@ end end + describe '#with_project_id_and_id' do + let(:project_id) { candidate.experiment.project_id } + let(:id) { candidate.id } + + subject { described_class.with_project_id_and_id(project_id, id) } + + context 'when internal_id exists', 'and belongs to project' do + it { is_expected.to eq(candidate) } + end + + context 'when id exists and does not belong to project' do + let(:project_id) { non_existing_record_id } + + it { is_expected.to be_nil } + end + + context 'when id does not exist' do + let(:id) { non_existing_record_id } + + it { is_expected.to be_nil } + end + end + describe "#latest_metrics" do let_it_be(:candidate3) { create(:ml_candidates, experiment: candidate.experiment) } let_it_be(:metric1) { create(:ml_candidate_metrics, candidate: candidate3) } diff --git a/spec/presenters/ml/candidate_details_presenter_spec.rb b/spec/presenters/ml/candidate_details_presenter_spec.rb index 05b09d73221efe78ddd70ccf4ac25e551b5171e2..075d5ba5e3a94fd1fa127a6cd024f9cfe3e22187 100644 --- a/spec/presenters/ml/candidate_details_presenter_spec.rb +++ b/spec/presenters/ml/candidate_details_presenter_spec.rb @@ -54,6 +54,7 @@ info: { iid: candidate.iid, eid: candidate.eid, + gid: candidate.to_global_id.to_s, path_to_artifact: "/#{project.full_path}/-/packages/#{candidate.artifact.id}", experiment_name: candidate.experiment.name, path_to_experiment: "/#{project.full_path}/-/ml/experiments/#{experiment.iid}", @@ -75,12 +76,19 @@ } }, created_at: candidate.created_at, - authorWebUrl: nil, - authorName: candidate.user.name + author_web_url: nil, + author_name: candidate.user.name, + promote_path: "/#{project.full_path}/-/ml/candidates/#{candidate.iid}/promote", + can_promote: false }, params: params, metrics: metrics, - metadata: [] + metadata: [], + projectPath: project.full_path, + can_write_model_registry: false, + markdown_preview_path: "/#{project.full_path}/-/preview_markdown", + model_gid: '', + latest_version: nil } } ) diff --git a/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb b/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb index c2a1817f3a8087232821912e6013219db570d568..ff6a60ec36230d2cb09d0f93f073fe2458de61c7 100644 --- a/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb +++ b/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb @@ -8,6 +8,9 @@ let_it_be(:model) { create(:ml_models) } let_it_be(:project) { model.project } let_it_be(:current_user) { project.owner } + let_it_be(:candidate) do + create(:ml_candidates, experiment: model.default_experiment, project: model.project) + end let(:version) { '1.0.0' } let(:description) { 'A description' } @@ -54,4 +57,30 @@ it_behaves_like 'a mutation that returns errors in the response', errors: ["Version must be semantic version"] end end + + context 'when a candidate_id is present' do + let(:input) do + { + project_path: project.full_path, + modelId: model.to_gid, + version: version, + candidate_id: candidate.to_global_id.to_s + } + end + + it 'creates a model' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['modelVersion']).to include( + 'version' => version + ) + end + + context 'when run is not found in the same project' do + let_it_be(:candidate) { create(:ml_candidates) } + + it_behaves_like 'a mutation that returns errors in the response', errors: ["Run not found"] + end + end end diff --git a/spec/requests/projects/ml/candidates_controller_spec.rb b/spec/requests/projects/ml/candidates_controller_spec.rb index 357fe362b65ea34ab62d38a219d39e2d15776a94..14e42d0208b16def51cfc910932382910a2ee392 100644 --- a/spec/requests/projects/ml/candidates_controller_spec.rb +++ b/spec/requests/projects/ml/candidates_controller_spec.rb @@ -72,6 +72,25 @@ it_behaves_like 'requires read_model_experiments' end + describe 'GET promote' do + before do + promote_candidate + end + + it 'renders the template' do + expect(response).to render_template('projects/ml/candidates/promote') + end + + it_behaves_like '404 if candidate does not exist' + describe 'requires write_model_experiments' do + let(:write_model_experiments) { false } + + it 'is 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + describe 'DELETE #destroy' do let_it_be(:candidate_for_deletion) do create(:ml_candidates, project: project, experiment: experiment, user: user) @@ -107,6 +126,10 @@ def show_candidate get project_ml_candidate_path(project, iid: candidate_iid) end + def promote_candidate + get promote_project_ml_candidate_path(project, iid: candidate_iid) + end + def destroy_candidate delete project_ml_candidate_path(project, candidate_iid) end diff --git a/spec/services/ml/create_model_version_service_spec.rb b/spec/services/ml/create_model_version_service_spec.rb index 932069142c62814f08ecba09819f0f3dd37d272e..a8ec4a7ddad1782e6de49c5a6822b7da8bd89505 100644 --- a/spec/services/ml/create_model_version_service_spec.rb +++ b/spec/services/ml/create_model_version_service_spec.rb @@ -51,6 +51,67 @@ end end + context 'when a proper candidate_id without a package is given for promotion' do + let_it_be(:candidate) do + create(:ml_candidates, experiment: model.default_experiment, project: model.project) + end + + let(:params) { { user: user, candidate_id: candidate.to_global_id } } + + it 'creates a model version' do + expect { service }.to change { Ml::ModelVersion.count }.by(1) + .and not_change { Ml::Candidate.count } + .and change { Packages::MlModel::Package.count }.by(1) + + expect(model.reload.latest_version.version).to eq('1.0.1') + expect(service).to be_success + + expect(model.latest_version.package.version).to eq('1.0.1') + expect(model.latest_version.package.name).to eq(model.name) + + expect(Gitlab::InternalEvents).to have_received(:track_event).with( + 'model_registry_ml_model_version_created', + { project: model.project, user: user } + ) + expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context) + end + end + + context 'when a proper candidate_id with a package is given for promotion' do + let_it_be(:candidate) do + create(:ml_candidates, experiment: model.default_experiment, project: model.project, package: package) + end + + let_it_be(:package) do + create(:ml_model_package, name: candidate.package_name, version: candidate.package_version, + project: model.project) + end + + let(:params) { { user: user, candidate_id: candidate.to_global_id } } + + before do + candidate.update!(package: package) + end + + it 'creates a model version' do + expect { service }.to change { Ml::ModelVersion.count }.by(1) + .and not_change { Ml::Candidate.count } + .and not_change { Packages::MlModel::Package.count } + + expect(model.reload.latest_version.version).to eq('1.0.2') + expect(service).to be_success + + expect(model.latest_version.package.version).to eq('1.0.2') + expect(model.latest_version.package.name).to eq(model.name) + + expect(Gitlab::InternalEvents).to have_received(:track_event).with( + 'model_registry_ml_model_version_created', + { project: model.project, user: user } + ) + expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context) + end + end + context 'when a version exist and no value is passed for version' do before do create(:ml_model_versions, model: model, version: '1.2.3')