diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 4c03ce48a154110432fdb2ae6460b8db145e6c97..8434650fa28f2abd07d5cb66c73c419962b75b80 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -62,7 +62,7 @@ def limited_users # reorder_by_name will break the ORDER BY applied in optionally_search(). find_users .where(state: states) - .with_duo_code_review_bot + .with_duo_bots .reorder_by_name .optionally_search(search, use_minimum_char_limit: use_minimum_char_limit) .limit_to_todo_authors( diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index d77aa8821b0413105a108a85ad7dbc41a0432308..6a625d258ee481232d5eeefddf4824bff7c9b859 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -21,7 +21,8 @@ module HasUserType llm_bot: 14, placeholder: 15, duo_code_review_bot: 16, - import_user: 17 + import_user: 17, + duo_developer_bot: 18 }.with_indifferent_access.freeze BOT_USER_TYPES = %w[ @@ -38,6 +39,7 @@ module HasUserType service_account llm_bot duo_code_review_bot + duo_developer_bot ].freeze # `service_account` allows instance/namespaces to configure a user for external integrations/automations @@ -53,6 +55,9 @@ module HasUserType scope :without_bots, -> { where(user_type: USER_TYPES.keys - BOT_USER_TYPES) } scope :non_internal, -> { where(user_type: NON_INTERNAL_USER_TYPES) } scope :with_duo_code_review_bot, -> { where(user_type: NON_INTERNAL_USER_TYPES + ['duo_code_review_bot']) } + scope :with_duo_bots, -> { + where(user_type: NON_INTERNAL_USER_TYPES + %w[duo_developer_bot duo_code_review_bot]) + } scope :without_ghosts, -> { where(user_type: USER_TYPES.keys - ['ghost']) } scope :without_project_bot, -> { where(user_type: USER_TYPES.keys - ['project_bot']) } scope :without_humans, -> { where(user_type: USER_TYPES.keys - ['human']) } diff --git a/ee/app/finders/ee/autocomplete/users_finder.rb b/ee/app/finders/ee/autocomplete/users_finder.rb index 88b1d6fca0baeced4e39ae990c78662a75ac7073..b99cbdcfa0290b2fb5f3a47f8efede6f72f10f80 100644 --- a/ee/app/finders/ee/autocomplete/users_finder.rb +++ b/ee/app/finders/ee/autocomplete/users_finder.rb @@ -15,6 +15,10 @@ def project_users users = users.union_with_user(::Users::Internal.duo_code_review_bot) end + if project.ai_duo_developer_allowed?(current_user) + users = users.union_with_user(::Users::Internal.duo_developer_bot) + end + users end end diff --git a/ee/app/models/ee/note.rb b/ee/app/models/ee/note.rb index 204cf18d761b1aaa6a02f5b32bc17f44e4609f97..0e0ce931178eb76fac6ca5685622f1407bb3548b 100644 --- a/ee/app/models/ee/note.rb +++ b/ee/app/models/ee/note.rb @@ -165,6 +165,19 @@ def duo_bot_mentioned? mentioned_users.include?(duo_code_review_bot) end + def authored_by_duo_developer_bot? + author == ::Users::Internal.duo_developer_bot + end + + def duo_developer_bot_mentioned? + duo_bot = ::Users::Internal.duo_developer_bot + + # We don't want the bot to talk to itself + return false if authored_by_duo_developer_bot? + + mentioned_users.include?(duo_bot) + end + override :human_max_access def human_max_access return super unless project.blank? && for_group_wiki? diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index d7bf249fd8fe838d0654374ceefb8a88260db2d6..adde13d99762174b49be796bda9699e6d4b5e3e1 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -874,6 +874,10 @@ def ai_review_merge_request_allowed?(user) ::Projects::AiFeatures.new(self).review_merge_request_allowed?(user) end + def ai_duo_developer_allowed?(user) + ::Projects::AiFeatures.new(self).assign_ai_agents_allowed?(user) + end + override :add_import_job def add_import_job # custom_project_template job is a special case that doesn't use `#add_import_job` diff --git a/ee/app/models/projects/ai_features.rb b/ee/app/models/projects/ai_features.rb index 17e1025a44c0131484503e06f57ed83a46ab74db..cabe13580a9bb7d297ee4f531bb79d47d0447dce 100644 --- a/ee/app/models/projects/ai_features.rb +++ b/ee/app/models/projects/ai_features.rb @@ -16,5 +16,14 @@ def review_merge_request_allowed?(user) user: user ).allowed? end + + def assign_ai_agents_allowed?(user) + Ability.allowed?(user, :duo_workflow, project) && + ::Gitlab::Llm::FeatureAuthorizer.new( + container: project, + feature_name: :duo_agent_platform, + user: user + ).allowed? + end end end diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index 11ac4405ef72bbc1b51fd71cb2af054898d49dbc..d0dc8b60a6f972a944d958e1642081c386907e59 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -812,6 +812,10 @@ module ProjectPolicy @user.duo_code_review_bot? end + condition(:duo_developer_bot) do + @user.duo_developer_bot? + end + condition(:ip_enforcement_prevents_access, scope: :subject) do !::Gitlab::IpRestriction::Enforcer.new(subject.group).allows_current_ip? if subject.group end @@ -1293,6 +1297,7 @@ def lookup_access_level! return ::Gitlab::Access::NO_ACCESS if needs_new_sso_session? return ::Gitlab::Access::REPORTER if security_bot? return ::Gitlab::Access::DEVELOPER if duo_code_review_bot? + return ::Gitlab::Access::DEVELOPER if duo_developer_bot? super end diff --git a/ee/app/services/ee/notes/post_process_service.rb b/ee/app/services/ee/notes/post_process_service.rb index 5397f715631dfd2f69c7a42c38f498731cb4fbaa..8bad0e39d2484f74f7845fa2c0229e521780465e 100644 --- a/ee/app/services/ee/notes/post_process_service.rb +++ b/ee/app/services/ee/notes/post_process_service.rb @@ -14,6 +14,7 @@ def execute log_audit_event if note.author.project_bot? process_duo_code_review_chat + process_duo_developer_mention end private @@ -50,6 +51,25 @@ def process_duo_code_review_chat ::MergeRequests::DuoCodeReviewChatWorker.perform_async(note.id) end + + def process_duo_developer_mention + author = note.author + + # Duo Software Developer should respond to issue notes when mentioned + return unless note.for_issue? + + # We don't want the bot to talk to itself + return if note.authored_by_duo_developer_bot? + + return unless note.duo_developer_bot_mentioned? + + # Execute the service directly (could also use a worker if needed) + ::Llm::Flows::DuoDeveloper.new( + user: author, + resource: note.noteable, + options: {} + ).execute + end end end end diff --git a/ee/app/services/ee/system_notes/issuables_service.rb b/ee/app/services/ee/system_notes/issuables_service.rb index 7dcdf97e92936a7f2c669f290f39779de5c1d3d6..80b877de69b9ff8c3c09978e45acd3d01ea3ed08 100644 --- a/ee/app/services/ee/system_notes/issuables_service.rb +++ b/ee/app/services/ee/system_notes/issuables_service.rb @@ -249,6 +249,13 @@ def change_work_item_status(status) create_note(NoteSummary.new(noteable, project, author, body, action: 'work_item_status')) end + def duo_developer_started + create_note( + NoteSummary.new(noteable, project, author, + s_("DuoDeveloper|is implementing this issue in a merge request. It will let you know when it's done")) + ) + end + private def block_message(issuable_type, noteable_reference, type) diff --git a/ee/app/services/llm/flows/duo_developer.rb b/ee/app/services/llm/flows/duo_developer.rb new file mode 100644 index 0000000000000000000000000000000000000000..31922cbc525af54f9ec791a6f668cd9c8ed0206b --- /dev/null +++ b/ee/app/services/llm/flows/duo_developer.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# rubocop:disable Gitlab/BoundedContexts -- Llm is the same module as Duo review +module Llm + # rubocop:enable Gitlab/BoundedContexts + module Flows + class DuoDeveloper < ::Llm::BaseService + include Gitlab::InternalEventsTracking + + private + + def ai_action + :issue_to_mr + end + + def perform + progress_note = create_note + + # Use the existing workflow API with start_workflow: true + workflow_response = create_and_start_workflow_via_api + + if workflow_response[:success] + # Track successful workflow start + track_internal_event( + 'duo_developer_workflow_started', + user: user, + project: resource.project + ) + + # Schedule completion worker to track workflow progress and update the note + schedule_completion_worker( + progress_note_id: progress_note.id, + workflow_id: workflow_response[:workflow_id], + workload_id: workflow_response[:workload_id] + ) + else + # Handle error - update the progress note with error message + update_progress_note_with_error(workflow_response[:error]) + end + end + + def valid? + super && resource.is_a?(Issue) && resource.project.present? + end + + def create_note + ::SystemNotes::IssuablesService.new( + noteable: resource, + container: resource.project, + author: duo_developer_bot + ).duo_developer_started + end + + def create_and_start_workflow_via_api + # Use the existing CreateWorkflowService with start_workflow: true + # This eliminates the duplicate workflow creation and starting logic + create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new( + container: resource.project, + current_user: user, + params: workflow_params_with_start_workflow + ).execute + + return { success: false, error: create_result.message } if create_result.error? + + workflow = create_result[:workflow] + + # The workflow is already started when start_workflow: true is used + # We just need to extract the workload_id from the response + { success: true, workflow_id: workflow.id, workload_id: nil } + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project_id: resource.project.id, issue_id: resource.id) + { success: false, error: e.message } + end + + def workflow_params_with_start_workflow + { + agent_privileges: [1, 2, 5], + environment: "web", + goal: resource.web_url, + workflow_definition: "issue_to_merge_request", + start_workflow: true + } + end + + def update_progress_note_with_error(error_message) + error_note_body = format( + s_("DuoDeveloper|I encountered an error while trying to \ + implement this issue: %{error}"), error: error_message) + + ::Notes::CreateService.new( + resource.project, + duo_developer_bot, + noteable: resource, + note: error_note_body + ).execute + + # Track the error + track_internal_event( + 'duo_developer_workflow_error', + user: user, + project: resource.project, + additional_properties: { + error: error_message + } + ) + end + + def duo_developer_bot + Users::Internal.duo_developer_bot + end + end + end +end diff --git a/ee/lib/gitlab/llm/ai_gateway/completions/duo_developer.rb b/ee/lib/gitlab/llm/ai_gateway/completions/duo_developer.rb new file mode 100644 index 0000000000000000000000000000000000000000..f62b20b10f42933cf3b2f6d76d31ac181cedf406 --- /dev/null +++ b/ee/lib/gitlab/llm/ai_gateway/completions/duo_developer.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module AiGateway + module Completions + class DuoDeveloper < Base + include Gitlab::InternalEventsTracking + + UNIT_PRIMITIVE = 'duo_developer' + + class << self + def success_msg + s_("DuoDeveloper|I have successfully implemented this issue in a merge request. You can review the changes and merge when ready.") + end + + def error_msg + s_("DuoDeveloper|I encountered an error while implementing this issue. Please try again later.") + end + + def in_progress_msg + s_("DuoDeveloper|I'm working on implementing this issue. This may take a few minutes...") + end + end + + def execute + @progress_note = find_progress_note + + unless progress_note.present? + Gitlab::ErrorTracking.track_exception( + StandardError.new("Unable to perform Duo Developer workflow: progress_note not found"), + unit_primitive: UNIT_PRIMITIVE + ) + return + end + + workflow_id = options[:workflow_id] + workload_id = options[:workload_id] + + unless workflow_id.present? + update_progress_note(self.class.error_msg) + return + end + + # Check workflow status and update note accordingly + check_workflow_status(workflow_id, workload_id) + + rescue StandardError => error + Gitlab::ErrorTracking.track_exception(error, unit_primitive: UNIT_PRIMITIVE) + + track_internal_event( + 'duo_developer_workflow_error', + user: user, + project: resource&.project, + additional_properties: { error: error.message } + ) + + update_progress_note(self.class.error_msg) if progress_note.present? + end + + private + + attr_reader :progress_note + + def user + prompt_message.user + end + + def check_workflow_status(workflow_id, workload_id) + workflow = find_workflow(workflow_id) + + unless workflow.present? + update_progress_note(self.class.error_msg) + return + end + + case workflow.status + when 'finished' + handle_workflow_completion(workflow) + when 'failed', 'stopped' + handle_workflow_failure(workflow) + else + # Workflow is still in progress, reschedule check + schedule_status_check(workflow_id, workload_id) + end + end + + def find_workflow(workflow_id) + ::Ai::DuoWorkflows::Workflow.find_by(id: workflow_id) + end + + def handle_workflow_completion(workflow) + # Look for any merge requests created by the workflow + merge_requests = find_related_merge_requests(workflow) + + if merge_requests.any? + mr_links = merge_requests.map { |mr| "[!#{mr.iid}](#{mr.web_url})" }.join(', ') + success_message = format( + s_("DuoDeveloper|I have successfully implemented this issue in merge request %{mr_links}. You can review the changes and merge when ready."), + mr_links: mr_links + ) + update_progress_note(success_message) + else + update_progress_note(self.class.success_msg) + end + + track_internal_event( + 'duo_developer_workflow_completed', + user: user, + project: resource&.project + ) + end + + def handle_workflow_failure(workflow) + update_progress_note(self.class.error_msg) + + track_internal_event( + 'duo_developer_workflow_failed', + user: user, + project: resource&.project + ) + end + + def find_related_merge_requests(workflow) + return [] unless workflow.project.present? && resource.is_a?(Issue) + + # Look for merge requests that reference this issue + # First, try to find MRs that close this issue + closing_mrs = resource.closed_by_merge_requests.where(author: duo_developer_bot) + return closing_mrs if closing_mrs.any? + + # If no closing MRs, look for MRs that mention this issue + # created around the time of the workflow by the duo bot + time_range = (workflow.created_at - 30.minutes)..(Time.current + 5.minutes) + + workflow.project.merge_requests + .created_during(time_range) + .where(author: duo_developer_bot) + .limit(5) # Reasonable limit to avoid performance issues + end + + def schedule_status_check(workflow_id, workload_id) + # Reschedule this completion worker to check again in a few minutes + ::Llm::CompletionWorker.perform_in( + 2.minutes, + ::Llm::CompletionWorker.serialize_message(prompt_message), + options.merge( + workflow_id: workflow_id, + workload_id: workload_id, + progress_note_id: progress_note.id + ).as_json + ) + end + + def update_progress_note(note_content) + return unless progress_note.present? + + # Update the existing progress note instead of creating a new one + progress_note.update!(note: note_content, updated_at: Time.current) + end + + def find_progress_note + Note.find_by_id(options[:progress_note_id]) + end + + def duo_developer_bot + Users::Internal.duo_developer_bot + end + end + end + end + end +end diff --git a/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb b/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb index 667fde0b6cf2f4874a864916d5631efce4ab8280..f468526bd7e962844059df3cf9f71654e9174bb7 100644 --- a/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb +++ b/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb @@ -234,6 +234,15 @@ class AiFeaturesCatalogue maturity: :ga, self_managed: true, internal: true + }, + issue_to_mr: { + service_class: ::Gitlab::Llm::AiGateway::Completions::DuoDeveloper, + prompt_class: nil, + feature_category: :duo_workflow, + execute_method: ::Llm::Flows::DuoDeveloper, + maturity: :experimental, + self_managed: true, + internal: true } }.freeze diff --git a/ee/spec/lib/gitlab/llm/ai_gateway/completions/duo_developer_spec.rb b/ee/spec/lib/gitlab/llm/ai_gateway/completions/duo_developer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b27cb3d53cd5ff5dd1221fed27bcebed134f130 --- /dev/null +++ b/ee/spec/lib/gitlab/llm/ai_gateway/completions/duo_developer_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::AiGateway::Completions::DuoDeveloper, feature_category: :duo_workflow do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:progress_note) { create(:note, project: project, noteable: issue) } + let_it_be(:workflow) { create(:duo_workflows_workflow, project: project, user: user) } + + let(:prompt_message) do + build(:ai_message, user: user, resource: issue, ai_action: :issue_to_mr) + end + + let(:options) do + { + progress_note_id: progress_note.id, + workflow_id: workflow.id, + workload_id: 123 + } + end + + let(:completion) { described_class.new(prompt_message, nil, options) } + + describe '#execute' do + context 'when progress note is not found' do + let(:options) { { progress_note_id: 999999 } } + + it 'logs error and returns early' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(an_instance_of(StandardError), unit_primitive: 'duo_developer') + + completion.execute + end + end + + context 'when workflow is finished' do + before do + workflow.update!(status: 'finished') + end + + it 'handles workflow completion' do + expect(completion).to receive(:handle_workflow_completion).with(workflow) + + completion.execute + end + end + + context 'when workflow is failed' do + before do + workflow.update!(status: 'failed') + end + + it 'handles workflow failure' do + expect(completion).to receive(:handle_workflow_failure).with(workflow) + + completion.execute + end + end + + context 'when workflow is still running' do + before do + workflow.update!(status: 'running') + end + + it 'schedules status check' do + expect(completion).to receive(:schedule_status_check).with(workflow.id, 123) + + completion.execute + end + end + end + + describe '#handle_workflow_completion' do + let(:merge_request) { create(:merge_request, source_project: project, author: Users::Internal.duo_developer_bot) } + + before do + allow(completion).to receive(:find_related_merge_requests).with(workflow).and_return([merge_request]) + end + + it 'updates progress note with merge request link' do + expected_message = format( + described_class.success_msg.gsub('%{mr_links}', "[!#{merge_request.iid}](#{merge_request.web_url})") + ) + + expect(progress_note).to receive(:update!).with( + note: expected_message, + updated_at: an_instance_of(ActiveSupport::TimeWithZone) + ) + + completion.send(:handle_workflow_completion, workflow) + end + + it 'tracks completion event' do + allow(progress_note).to receive(:update!) + + expect(completion).to receive(:track_internal_event).with( + 'duo_developer_workflow_completed', + user: user, + project: project + ) + + completion.send(:handle_workflow_completion, workflow) + end + end + + describe '#handle_workflow_failure' do + it 'updates progress note with error message' do + expect(progress_note).to receive(:update!).with( + note: described_class.error_msg, + updated_at: an_instance_of(ActiveSupport::TimeWithZone) + ) + + completion.send(:handle_workflow_failure, workflow) + end + + it 'tracks failure event' do + allow(progress_note).to receive(:update!) + + expect(completion).to receive(:track_internal_event).with( + 'duo_developer_workflow_failed', + user: user, + project: project + ) + + completion.send(:handle_workflow_failure, workflow) + end + end + + describe '#find_related_merge_requests' do + let(:duo_bot) { Users::Internal.duo_developer_bot } + + context 'when there are closing merge requests' do + let(:closing_mr) { create(:merge_request, source_project: project, author: duo_bot) } + + before do + allow(issue).to receive(:closed_by_merge_requests).and_return( + double(where: [closing_mr]) + ) + end + + it 'returns the closing merge requests' do + result = completion.send(:find_related_merge_requests, workflow) + + expect(result).to eq([closing_mr]) + end + end + + context 'when there are no closing merge requests' do + let(:recent_mr) { create(:merge_request, source_project: project, author: duo_bot) } + + before do + allow(issue).to receive(:closed_by_merge_requests).and_return( + double(where: []) + ) + allow(project.merge_requests).to receive(:created_during).and_return( + double(where: double(limit: [recent_mr])) + ) + end + + it 'returns recent merge requests by duo bot' do + result = completion.send(:find_related_merge_requests, workflow) + + expect(result).to eq([recent_mr]) + end + end + end +end \ No newline at end of file diff --git a/ee/spec/services/llm/flows/duo_developer_spec.rb b/ee/spec/services/llm/flows/duo_developer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0eaaabcf31396903e3ae2a066041b0b49d7db379 --- /dev/null +++ b/ee/spec/services/llm/flows/duo_developer_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Llm::Flows::DuoDeveloper, feature_category: :duo_workflow do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + + let(:service) { described_class.new(user, issue) } + + describe '#valid?' do + context 'when resource is an issue with a project' do + it 'returns true if base validation passes' do + allow(service).to receive(:ai_integration_enabled?).and_return(true) + allow(service).to receive(:user_can_send_to_ai?).and_return(true) + allow_next_instance_of(Gitlab::Llm::Utils::Authorizer) do |authorizer| + allow(authorizer).to receive(:allowed?).and_return(true) + end + + expect(service.valid?).to be true + end + end + + context 'when resource is not an issue' do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:service) { described_class.new(user, merge_request) } + + it 'returns false' do + allow(service).to receive(:ai_integration_enabled?).and_return(true) + allow(service).to receive(:user_can_send_to_ai?).and_return(true) + allow_next_instance_of(Gitlab::Llm::Utils::Authorizer) do |authorizer| + allow(authorizer).to receive(:allowed?).and_return(true) + end + + expect(service.valid?).to be false + end + end + end + + describe '#perform' do + let(:progress_note) { create(:note, project: project, noteable: issue) } + + before do + allow(service).to receive(:valid?).and_return(true) + allow(service).to receive(:create_note).and_return(progress_note) + end + + context 'when workflow creation and start succeeds' do + let(:workflow) { create(:duo_workflows_workflow, project: project, user: user) } + let(:workflow_response) { { success: true, workflow_id: workflow.id, workload_id: 123 } } + + before do + allow(service).to receive(:create_and_start_workflow).and_return(workflow_response) + end + + it 'tracks the workflow started event' do + expect(service).to receive(:track_internal_event).with( + 'duo_developer_workflow_started', + user: user, + project: project + ) + + service.execute + end + + it 'schedules the completion worker' do + expect(service).to receive(:schedule_completion_worker).with( + progress_note_id: progress_note.id, + workflow_id: workflow.id, + workload_id: 123 + ) + + service.execute + end + end + + context 'when workflow creation fails' do + let(:workflow_response) { { success: false, error: 'Something went wrong' } } + + before do + allow(service).to receive(:create_and_start_workflow).and_return(workflow_response) + end + + it 'updates the progress note with error' do + expect(service).to receive(:update_progress_note_with_error).with('Something went wrong') + + service.execute + end + end + end + + describe '#create_and_start_workflow' do + let(:create_service) { instance_double(Ai::DuoWorkflows::CreateWorkflowService) } + let(:start_service) { instance_double(Ai::DuoWorkflows::StartWorkflowService) } + let(:workflow) { create(:duo_workflows_workflow, project: project, user: user) } + + before do + allow(service).to receive(:create_workflow_service).and_return(create_service) + allow(service).to receive(:start_workflow_service).and_return(start_service) + end + + context 'when both services succeed' do + let(:create_result) { ServiceResponse.success(workflow: workflow) } + let(:start_result) { ServiceResponse.success(payload: { workload_id: 123 }) } + + before do + allow(create_service).to receive(:execute).and_return(create_result) + allow(start_service).to receive(:execute).and_return(start_result) + end + + it 'returns success with workflow and workload IDs' do + result = service.send(:create_and_start_workflow) + + expect(result).to eq({ + success: true, + workflow_id: workflow.id, + workload_id: 123 + }) + end + end + + context 'when workflow creation fails' do + let(:create_result) { ServiceResponse.error(message: 'Creation failed') } + + before do + allow(create_service).to receive(:execute).and_return(create_result) + end + + it 'returns error' do + result = service.send(:create_and_start_workflow) + + expect(result).to eq({ + success: false, + error: 'Creation failed' + }) + end + end + end +end \ No newline at end of file diff --git a/lib/users/internal.rb b/lib/users/internal.rb index bee09537f865979395a19991d505276ba5359d11..b4713bdc62762d59e25becfe7301a7ea4ea3ef0e 100644 --- a/lib/users/internal.rb +++ b/lib/users/internal.rb @@ -9,7 +9,7 @@ class << self def_delegators :new, :bot_avatar, :ghost, :support_bot, :alert_bot, :migration_bot, :security_bot, :automation_bot, :llm_bot, - :duo_code_review_bot, :admin_bot + :duo_code_review_bot, :admin_bot, :duo_developer_bot def for_organization(organization) new(organization: organization) @@ -134,6 +134,19 @@ def duo_code_review_bot end end + def duo_developer_bot + email_pattern = "gitlab-duo-developer-%s@#{Settings.gitlab.host}" + + unique_internal(User.where(user_type: :duo_developer_bot), 'GitLabDuoDeveloper', + email_pattern) do |u| + u.bio = 'GitLab Duo bot specifically made to handle turning issues into Merge Requests.' + u.name = 'GitLab Duo Developer' + u.avatar = bot_avatar(image: 'duo-bot.png') + u.confirmed_at = Time.zone.now + u.private_profile = true + end + end + def admin_bot email_pattern = "admin-bot%s@#{Settings.gitlab.host}" diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 9cfdc8a7201e3ac8459ab57257d3e7cacef28abd..5487eaeabf5c501546ad43a4f02c7743a5c7de02 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -149,6 +149,10 @@ user_type { :duo_code_review_bot } end + trait :duo_developer_bot do + user_type { :duo_developer_bot } + end + trait :placeholder do user_type { :placeholder } end diff --git a/spec/lib/users/internal_spec.rb b/spec/lib/users/internal_spec.rb index f21e3f8c1deb2a163db042428d65a1064d05869f..1d46e528dbf1f30bd1414ee72c6d24a5f070172e 100644 --- a/spec/lib/users/internal_spec.rb +++ b/spec/lib/users/internal_spec.rb @@ -200,6 +200,8 @@ it_behaves_like 'bot users', :automation_bot, 'automation-bot', 'automation@example.com' it_behaves_like 'bot users', :llm_bot, 'GitLab-Llm-Bot', 'llm-bot@example.com' it_behaves_like 'bot users', :duo_code_review_bot, 'GitLabDuo', 'gitlab-duo@example.com' + it_behaves_like 'bot users', :duo_developer_bot, 'GitLabDuoDeveloper', + 'gitlab-duo-developer-@example.com' it_behaves_like 'bot users', :admin_bot, 'GitLab-Admin-Bot', 'admin-bot@example.com' it_behaves_like 'bot user avatars', :alert_bot, 'alert-bot.png' @@ -208,6 +210,7 @@ it_behaves_like 'bot user avatars', :automation_bot, 'support-bot.png' it_behaves_like 'bot user avatars', :llm_bot, 'support-bot.png' it_behaves_like 'bot user avatars', :duo_code_review_bot, 'duo-bot.png' + it_behaves_like 'bot user avatars', :duo_developer_bot, 'duo-bot.png' it_behaves_like 'bot user avatars', :admin_bot, 'admin-bot.png' context 'when bot is the support_bot' do