diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 3d81e77f8798adff8910bd39e7939d47943d4ed9..f71a1041068e80874fd3d9862ab7e8ec0510a825 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,9 +1,11 @@ import Vue from 'vue'; + +import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; + import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; import { __ } from '~/locale'; -import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index 6127adc358428475741a763db2f0011594d968a3..79d771ab993f04be25d8154df6daad223ab07ca3 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,7 +1,8 @@ +import { mountMarkdownEditor } from 'ee_else_ce/vue_shared/components/markdown/mount_markdown_editor'; + import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index bf070943fe6aa94d2aa63f0a4001105a2a823f92..758254b3cc065c6d557f28f82fd0092ba00add41 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -13,7 +13,8 @@ import { import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getModifierKey } from '~/constants'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -import { s__, __ } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { s__, __, sprintf } from '~/locale'; import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import { updateText } from '~/lib/utils/text_markdown'; import ToolbarButton from './toolbar_button.vue'; @@ -203,6 +204,23 @@ export default { }); } }, + replaceTextarea(text) { + const { description, descriptionForSha } = this.$options.i18n; + const headSha = document.getElementById('merge_request_diff_head_sha').value; + const textArea = this.$el.closest('.md-area')?.querySelector('textarea'); + const addendum = headSha + ? sprintf(descriptionForSha, { revision: truncateSha(headSha) }) + : description; + + if (textArea) { + updateText({ + textArea, + tag: `${text}\n\n---\n\n_${addendum}_`, + cursorOffset: 0, + wrap: false, + }); + } + }, switchPreview() { if (this.previewMarkdown) { this.hideMarkdownPreview(); @@ -220,8 +238,13 @@ export default { outdent: keysFor(OUTDENT_LINE), }, i18n: { - preview: __('Preview'), + comment: __('This comment was generated by AI'), + description: s__('MergeRequest|This description was generated using AI'), + descriptionForSha: s__( + 'MergeRequest|This description was generated for revision %{revision} using AI', + ), hidePreview: __('Continue editing'), + preview: __('Preview'), }, }; @@ -289,6 +312,7 @@ export default { v-if="editorAiActions.length" :actions="editorAiActions" @input="insertIntoTextarea" + @replace="replaceTextarea" /> Object.assign(facade, props); const autosaveKey = `autosave/${document.location.pathname}/${searchTerm}/description`; + if (options.useApollo || options.apolloProvider) { + let { apolloProvider } = options; + + if (!apolloProvider) { + apolloProvider = new VueApollo({ defaultClient: createApolloClient() }); + } + + componentConfiguration.apolloProvider = apolloProvider; + } + // eslint-disable-next-line no-new new Vue({ el, @@ -114,6 +132,7 @@ export function mountMarkdownEditor() { }, }); }, + ...componentConfiguration, }); mountAutosaveClearOnSubmit(autosaveKey); diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 063813156141d5ab9fbaf977c106b326b2a09a65..b9b2199b303dc52f67d066b1ed8ca8df04cee4c1 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -11,6 +11,12 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] + before_action only: [:new] do + if can?(current_user, :fill_in_merge_request_template, project) + push_frontend_feature_flag(:fill_in_mr_template, project) + end + end + urgency :low, [ :new, :create, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 4d9cbe889fb355e4191d84abd804d559c89478f7..568b5fe05bc49643ebc044699f5ca6e204b62ecd 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -53,6 +53,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:ci_job_failures_in_mr, project) end + before_action only: [:edit] do + if can?(current_user, :fill_in_merge_request_template, project) + push_frontend_feature_flag(:fill_in_mr_template, project) + end + end + around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions] after_action :log_merge_request_show, only: [:show, :diffs] diff --git a/ee/app/assets/javascripts/ai/components/ai_actions_dropdown.vue b/ee/app/assets/javascripts/ai/components/ai_actions_dropdown.vue index 602d8501d38e50629020fc68a956649a1b8fecdd..3d654fe5f2ac72ebfd59df5c281edb4cf623c359 100644 --- a/ee/app/assets/javascripts/ai/components/ai_actions_dropdown.vue +++ b/ee/app/assets/javascripts/ai/components/ai_actions_dropdown.vue @@ -5,6 +5,10 @@ import { __ } from '~/locale'; import { createAlert } from '~/alert'; import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; +const EVENTS = { + replace: 'replace', +}; + export default { components: { GlDisclosureDropdown, @@ -22,6 +26,7 @@ export default { return { loading: false, errorAlert: null, + method: undefined, }; }, computed: { @@ -91,13 +96,16 @@ export default { }, methods: { insertResponse(response) { - this.$emit('input', response); + const event = this.method ? EVENTS[this.method] : 'input'; + this.$emit(event, response); }, - beforeAction() { + beforeAction(item) { if (this.loading) { return false; } + this.method = item.method; + this.errorAlert?.dismiss(); this.loading = true; @@ -107,17 +115,17 @@ export default { this.loading = false; }, onAbstractAction(item) { - if (!this.beforeAction()) return; + if (!this.beforeAction(item)) return; item .handler() .then((response) => { - this.insertResponse(response); + this.insertResponse(response, item); }) .catch(this.handleError) .finally(this.afterAction); }, onApolloAction(item) { - if (!this.beforeAction()) return; + if (!this.beforeAction(item)) return; this.$apollo .mutate(item.apolloMutation()) .then(({ data: { aiAction } }) => { diff --git a/ee/app/assets/javascripts/ai/graphql/fill_mr_description.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/fill_mr_description.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..cd93fa925a28107fc94f2793fa4e573d6fe18620 --- /dev/null +++ b/ee/app/assets/javascripts/ai/graphql/fill_mr_description.mutation.graphql @@ -0,0 +1,23 @@ +mutation fillInMergeRequestTemplate( + $sourceProjectGqlId: ID + $targetProjectGqlId: AiModelID! + $source: String! + $target: String! + $mrTitle: String! + $templateContent: String! +) { + aiAction( + input: { + fillInMergeRequestTemplate: { + resourceId: $targetProjectGqlId + sourceProjectId: $sourceProjectGqlId + sourceBranch: $source + targetBranch: $target + title: $mrTitle + content: $templateContent + } + } + ) { + errors + } +} diff --git a/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js new file mode 100644 index 0000000000000000000000000000000000000000..fcfbf77217cc441ef241809d7374583d4a6a9457 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -0,0 +1,67 @@ +import { __ } from '~/locale'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import aiFillDescriptionMutation from 'ee/ai/graphql/fill_mr_description.mutation.graphql'; +import { + mountMarkdownEditor as mountCEMarkdownEditor, + MR_SOURCE_BRANCH, + MR_TARGET_BRANCH, +} from '~/vue_shared/components/markdown/mount_markdown_editor'; + +export function mountMarkdownEditor() { + const provideEEAiActions = []; + + if (window.gon?.features?.fillInMrTemplate) { + provideEEAiActions.push({ + title: __('Fill in merge request template'), + description: __('Replace current template with filled in placeholders'), + method: 'replace', + subscriptionVariables() { + const projectGqlId = convertToGraphQLId( + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + 'Project', + document.getElementById('merge_request_source_project_id').value, + ); + return { + userId: convertToGraphQLId(TYPENAME_USER, gon.current_user_id), + resourceId: projectGqlId, + }; + }, + apolloMutation() { + /* eslint-disable @gitlab/require-i18n-strings */ + const projectGqlId = convertToGraphQLId( + 'Project', + document.getElementById('merge_request_source_project_id').value, + ); + const targetProjectGqlId = convertToGraphQLId( + 'Project', + document.getElementById('merge_request_target_project_id').value, + ); + /* eslint-enable @gitlab/require-i18n-strings */ + const sourceBranch = document.querySelector(`[name="${MR_SOURCE_BRANCH}"]`).value; + const targetBranch = document.querySelector(`[name="${MR_TARGET_BRANCH}"]`).value; + const mrTitle = document.getElementById('merge_request_title').value; + const mrDescription = document.getElementById('merge_request_description').value; + + return { + mutation: aiFillDescriptionMutation, + variables: { + source: sourceBranch, + target: targetBranch, + templateContent: mrDescription, + mrTitle, + projectGqlId, + targetProjectGqlId, + }, + }; + }, + }); + } + + return mountCEMarkdownEditor({ + useApollo: true, + provide: { + editorAiActions: provideEEAiActions, + }, + }); +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f754d32864709adbaa1527aabe35fd3ea2d60fca..61c7262b5f0b164d4477ff64abc6f7a004b309c6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19285,6 +19285,9 @@ msgstr "" msgid "Files, directories, and submodules in the path %{path} for commit reference %{ref}" msgstr "" +msgid "Fill in merge request template" +msgstr "" + msgid "Fill in the fields below, turn on %{strong_open}Enable SAML authentication for this group%{strong_close}, and press %{strong_open}Save changes%{strong_close}" msgstr "" @@ -28804,6 +28807,12 @@ msgstr "" msgid "MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)" msgstr "" +msgid "MergeRequest|This description was generated for revision %{revision} using AI" +msgstr "" + +msgid "MergeRequest|This description was generated using AI" +msgstr "" + msgid "MergeTopics|%{sourceTopic} will be removed" msgstr "" @@ -38541,6 +38550,9 @@ msgstr "" msgid "Replace audio" msgstr "" +msgid "Replace current template with filled in placeholders" +msgstr "" + msgid "Replace file" msgstr ""