From 7b1de2c4fa645a952547946e24d5a18153dae81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Tue, 29 Jul 2025 15:31:51 -0400 Subject: [PATCH 1/3] Add Duo developer to start issue_to_mr flow To start the new flow of issue_to_mr, we want the ability to mention the new bot duo_developer so that it can take up the issue and make it a MR. --- app/models/concerns/has_user_type.rb | 7 +- .../finders/ee/autocomplete/users_finder.rb | 4 + ee/app/models/ee/note.rb | 13 ++ ee/app/models/ee/project.rb | 4 + ee/app/models/projects/ai_features.rb | 9 + ee/app/policies/ee/project_policy.rb | 5 + .../services/ee/notes/post_process_service.rb | 20 ++ .../ee/system_notes/issuables_service.rb | 7 + ee/app/services/llm/flows/duo_developer.rb | 163 +++++++++++++++++ .../ai_gateway/completions/duo_developer.rb | 173 ++++++++++++++++++ .../gitlab/llm/utils/ai_features_catalogue.rb | 9 + .../completions/duo_developer_spec.rb | 169 +++++++++++++++++ .../services/llm/flows/duo_developer_spec.rb | 140 ++++++++++++++ lib/users/internal.rb | 15 +- spec/factories/users.rb | 4 + spec/lib/users/internal_spec.rb | 3 + 16 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 ee/app/services/llm/flows/duo_developer.rb create mode 100644 ee/lib/gitlab/llm/ai_gateway/completions/duo_developer.rb create mode 100644 ee/spec/lib/gitlab/llm/ai_gateway/completions/duo_developer_spec.rb create mode 100644 ee/spec/services/llm/flows/duo_developer_spec.rb diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb index d77aa8821b0413..c2924328ee7d83 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_developer_bot, -> { + where(user_type: NON_INTERNAL_USER_TYPES + ['duo_developer_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 88b1d6fca0baec..b99cbdcfa0290b 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 204cf18d761b1a..0e0ce931178eb7 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 d7bf249fd8fe83..adde13d9976217 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 17e1025a44c013..e020f595e37226 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_workflow, + 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 11ac4405ef72bb..c7fc74c648dfe2 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 5397f715631dfd..8bad0e39d2484f 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 7dcdf97e92936a..80b877de69b9ff 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 00000000000000..6a0afd32e88f63 --- /dev/null +++ b/ee/app/services/llm/flows/duo_developer.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module Llm + module Flows + class DuoDeveloper < ::Llm::BaseService + include Gitlab::InternalEventsTracking + + private + + def ai_action + :issue_to_mr + end + + def perform + progress_note = create_note + + # Create and start the workflow using services directly + workflow_response = create_and_start_workflow + + 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 + # Create workflow using the service directly + create_result = create_workflow_service.execute + + return { success: false, error: create_result.message } if create_result.error? + + workflow = create_result[:workflow] + + # Start the workflow using the service directly + start_result = start_workflow_service(workflow).execute + + if start_result.success? + { success: true, workflow_id: workflow.id, workload_id: start_result.payload[:workload_id] } + else + { success: false, error: start_result.message } + end + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project_id: resource.project.id, issue_id: resource.id) + { success: false, error: e.message } + end + + def create_workflow_service + ::Ai::DuoWorkflows::CreateWorkflowService.new( + container: resource.project, + current_user: user, + params: workflow_params + ) + end + + def start_workflow_service(workflow) + ::Ai::DuoWorkflows::StartWorkflowService.new( + workflow: workflow, + params: start_workflow_params(workflow.id) + ) + end + + def workflow_params + { + agent_privileges: [1, 2, 5], + environment: "web", + goal: resource.web_url, + workflow_definition: "issue_to_merge_request" + } + end + + def start_workflow_params(workflow_id) + oauth_token = create_oauth_token + + { + goal: resource.web_url, + workflow_definition: "issue_to_merge_request", + workflow_id: workflow_id, + workflow_oauth_token: oauth_token.plaintext_token, + workflow_service_token: duo_workflow_token[:token], + use_service_account: false, + source_branch: nil, + workflow_metadata: Gitlab::DuoWorkflow::Client.metadata(user).to_json + } + end + + def create_oauth_token + oauth_token_result = ::Ai::DuoWorkflows::CreateOauthAccessTokenService.new( + current_user: user, + organization: ::Current.organization, + workflow_definition: "issue_to_merge_request" + ).execute + + raise StandardError, oauth_token_result.message if oauth_token_result.error? + + oauth_token_result[:oauth_access_token] + end + + def duo_workflow_token + duo_workflow_token_result = ::Ai::DuoWorkflow::DuoWorkflowService::Client.new( + duo_workflow_service_url: Gitlab::DuoWorkflow::Client.url, + current_user: user, + secure: Gitlab::DuoWorkflow::Client.secure? + ).generate_token + + raise StandardError, duo_workflow_token_result.message if duo_workflow_token_result.error? + + duo_workflow_token_result.payload + 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 00000000000000..f62b20b10f4293 --- /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 667fde0b6cf2f4..f468526bd7e962 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 00000000000000..0b27cb3d53cd5f --- /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 00000000000000..0eaaabcf313969 --- /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 bee09537f86597..b4713bdc62762d 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 9cfdc8a7201e3a..5487eaeabf5c50 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 f21e3f8c1deb2a..1d46e528dbf1f3 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 -- GitLab From 5b56b9a33da0b6e514a15a96814eb4d81155cc0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Fri, 1 Aug 2025 15:55:21 -0400 Subject: [PATCH 2/3] Make the bot visible --- app/finders/autocomplete/users_finder.rb | 2 +- app/models/concerns/has_user_type.rb | 4 ++-- ee/app/models/projects/ai_features.rb | 2 +- ee/app/policies/ee/project_policy.rb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index 4c03ce48a15411..8434650fa28f2a 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 c2924328ee7d83..6a625d258ee481 100644 --- a/app/models/concerns/has_user_type.rb +++ b/app/models/concerns/has_user_type.rb @@ -55,8 +55,8 @@ 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_developer_bot, -> { - where(user_type: NON_INTERNAL_USER_TYPES + ['duo_developer_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']) } diff --git a/ee/app/models/projects/ai_features.rb b/ee/app/models/projects/ai_features.rb index e020f595e37226..cabe13580a9bb7 100644 --- a/ee/app/models/projects/ai_features.rb +++ b/ee/app/models/projects/ai_features.rb @@ -21,7 +21,7 @@ def assign_ai_agents_allowed?(user) Ability.allowed?(user, :duo_workflow, project) && ::Gitlab::Llm::FeatureAuthorizer.new( container: project, - feature_name: :duo_workflow, + feature_name: :duo_agent_platform, user: user ).allowed? end diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index c7fc74c648dfe2..d0dc8b60a6f972 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -1297,7 +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? + return ::Gitlab::Access::DEVELOPER if duo_developer_bot? super end -- GitLab From accdb09c30a84c10a00fe29ffadebc14feddfd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Fri, 1 Aug 2025 16:03:22 -0400 Subject: [PATCH 3/3] dedup creation logic --- ee/app/services/llm/flows/duo_developer.rb | 88 +++++----------------- 1 file changed, 19 insertions(+), 69 deletions(-) diff --git a/ee/app/services/llm/flows/duo_developer.rb b/ee/app/services/llm/flows/duo_developer.rb index 6a0afd32e88f63..31922cbc525af5 100644 --- a/ee/app/services/llm/flows/duo_developer.rb +++ b/ee/app/services/llm/flows/duo_developer.rb @@ -1,6 +1,8 @@ # 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 @@ -14,8 +16,8 @@ def ai_action def perform progress_note = create_note - # Create and start the workflow using services directly - workflow_response = create_and_start_workflow + # 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 @@ -49,93 +51,41 @@ def create_note ).duo_developer_started end - def create_and_start_workflow - # Create workflow using the service directly - create_result = create_workflow_service.execute + 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] - # Start the workflow using the service directly - start_result = start_workflow_service(workflow).execute - - if start_result.success? - { success: true, workflow_id: workflow.id, workload_id: start_result.payload[:workload_id] } - else - { success: false, error: start_result.message } - end + # 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 create_workflow_service - ::Ai::DuoWorkflows::CreateWorkflowService.new( - container: resource.project, - current_user: user, - params: workflow_params - ) - end - - def start_workflow_service(workflow) - ::Ai::DuoWorkflows::StartWorkflowService.new( - workflow: workflow, - params: start_workflow_params(workflow.id) - ) - end - - def workflow_params + def workflow_params_with_start_workflow { agent_privileges: [1, 2, 5], environment: "web", - goal: resource.web_url, - workflow_definition: "issue_to_merge_request" - } - end - - def start_workflow_params(workflow_id) - oauth_token = create_oauth_token - - { goal: resource.web_url, workflow_definition: "issue_to_merge_request", - workflow_id: workflow_id, - workflow_oauth_token: oauth_token.plaintext_token, - workflow_service_token: duo_workflow_token[:token], - use_service_account: false, - source_branch: nil, - workflow_metadata: Gitlab::DuoWorkflow::Client.metadata(user).to_json + start_workflow: true } end - def create_oauth_token - oauth_token_result = ::Ai::DuoWorkflows::CreateOauthAccessTokenService.new( - current_user: user, - organization: ::Current.organization, - workflow_definition: "issue_to_merge_request" - ).execute - - raise StandardError, oauth_token_result.message if oauth_token_result.error? - - oauth_token_result[:oauth_access_token] - end - - def duo_workflow_token - duo_workflow_token_result = ::Ai::DuoWorkflow::DuoWorkflowService::Client.new( - duo_workflow_service_url: Gitlab::DuoWorkflow::Client.url, - current_user: user, - secure: Gitlab::DuoWorkflow::Client.secure? - ).generate_token - - raise StandardError, duo_workflow_token_result.message if duo_workflow_token_result.error? - - duo_workflow_token_result.payload - 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) + s_("DuoDeveloper|I encountered an error while trying to \ + implement this issue: %{error}"), error: error_message) ::Notes::CreateService.new( resource.project, -- GitLab