From e31e8a7f4f0ada2ea9ea6e7d8a1a1f0e95de2cb7 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:01:38 +0200 Subject: [PATCH 01/86] Add Subject and Description automation actions and a strict mode The Automations update service drops actions that fail validation and applies the rest. Callers that need all-or-nothing pass strict: true. Co-Authored-By: Claude Opus 5.5 --- app/models/automations/actions/base.rb | 4 + app/models/automations/actions/comment.rb | 43 +++++++++++ app/models/automations/actions/description.rb | 41 ++++++++++ app/models/automations/actions/subject.rb | 45 +++++++++++ app/models/automations/register.rb | 3 + .../update_work_package_service.rb | 12 ++- app/views/automations/_form.html.erb | 6 +- .../automations/actions/description_spec.rb | 69 +++++++++++++++++ .../automations/actions/subject_spec.rb | 75 +++++++++++++++++++ .../update_work_package_service_spec.rb | 55 +++++++++++++- 10 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 app/models/automations/actions/comment.rb create mode 100644 app/models/automations/actions/description.rb create mode 100644 app/models/automations/actions/subject.rb create mode 100644 spec/models/automations/actions/description_spec.rb create mode 100644 spec/models/automations/actions/subject_spec.rb diff --git a/app/models/automations/actions/base.rb b/app/models/automations/actions/base.rb index ea78ba237067..1c3ad304d1be 100644 --- a/app/models/automations/actions/base.rb +++ b/app/models/automations/actions/base.rb @@ -96,6 +96,10 @@ def priority DEFAULT_PRIORITY end + # Called once the work package is saved with the changes of every action. Actions with + # an effect outside the work package (a notification, a call, a run) do it here. + def after_success(_work_package, _user); end + private def deconstruct_keys(*) diff --git a/app/models/automations/actions/comment.rb b/app/models/automations/actions/comment.rb new file mode 100644 index 000000000000..5b8c50af257c --- /dev/null +++ b/app/models/automations/actions/comment.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Adds a comment to the work package. It appends to a comment that another action of the +# same automation (Notify) already set. +class Automations::Actions::Comment < Automations::Actions::Base + include Automations::Actions::Strategies::Text + + def self.key + :comment + end + + def apply(work_package) + work_package.journal_notes = [work_package.journal_notes.presence, values.first].compact.join("\n\n") + end +end diff --git a/app/models/automations/actions/description.rb b/app/models/automations/actions/description.rb new file mode 100644 index 000000000000..d9683146b198 --- /dev/null +++ b/app/models/automations/actions/description.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Automations::Actions::Description < Automations::Actions::Base + include Automations::Actions::Strategies::Text + + def self.key + :description + end + + def apply(work_package) + work_package.description = values.first + end +end diff --git a/app/models/automations/actions/subject.rb b/app/models/automations/actions/subject.rb new file mode 100644 index 000000000000..a2e3440d615f --- /dev/null +++ b/app/models/automations/actions/subject.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Automations::Actions::Subject < Automations::Actions::Base + include Automations::Actions::Strategies::String + + def self.key + :subject + end + + def apply(work_package) + work_package.subject = values.first + end + + def required? + true + end +end diff --git a/app/models/automations/register.rb b/app/models/automations/register.rb index 7c3265b17ee7..050d5d1ef3de 100644 --- a/app/models/automations/register.rb +++ b/app/models/automations/register.rb @@ -39,6 +39,9 @@ def actions Automations::Actions::CustomField, Automations::Actions::Type, Automations::Actions::Project, + Automations::Actions::Subject, + Automations::Actions::Description, + Automations::Actions::Comment, Automations::Actions::Notify, Automations::Actions::DoneRatio, Automations::Actions::EstimatedHours, diff --git a/app/services/automations/update_work_package_service.rb b/app/services/automations/update_work_package_service.rb index 03e5d6e8f004..c93b3cae4395 100644 --- a/app/services/automations/update_work_package_service.rb +++ b/app/services/automations/update_work_package_service.rb @@ -33,11 +33,15 @@ class Automations::UpdateWorkPackageService include Contracted attr_accessor :user, - :action + :action, + :strict - def initialize(action:, user:) + # In strict mode, an action that fails validation fails the whole call, + # instead of being dropped while the other actions apply. + def initialize(action:, user:, strict: false) self.action = action self.user = user + self.strict = strict self.contract_class = ::WorkPackages::UpdateContract end @@ -49,6 +53,8 @@ def call(work_package:, &) model: work_package) .call + action.actions.each { |a| a.after_success(result.result, user) } if result.success? + block_with_result(result, &) end @@ -59,7 +65,7 @@ def apply_actions(work_package, actions) apply_actions_sorted(work_package, actions) success, errors = validate(work_package, user) - retry_apply_actions(work_package, actions, errors, changes_before) unless success + retry_apply_actions(work_package, actions, errors, changes_before) unless success || strict end def retry_apply_actions(work_package, actions, errors, changes_before) diff --git a/app/views/automations/_form.html.erb b/app/views/automations/_form.html.erb index de5c3c8b9dad..3f96dac7af77 100644 --- a/app/views/automations/_form.html.erb +++ b/app/views/automations/_form.html.erb @@ -166,10 +166,14 @@ See COPYRIGHT and LICENSE files for more details. <%= angular_component_tag "opce-custom-date-action-admin", data: { "field-value": date.try(:iso8601) || date, "field-name": input_name } %> - <% elsif %i(string_property text_property).include?(action.type) %> + <% elsif action.type == :string_property %>
<%= styled_text_field_tag input_name, action.values, container_class: "-slim" %>
+ <% elsif action.type == :text_property %> +
+ <%= styled_text_area_tag input_name, action.values.first, rows: 5 %> +
<% elsif action.type == :link_property %>
<%= styled_url_field_tag input_name, action.values, container_class: "-slim" %> diff --git a/spec/models/automations/actions/description_spec.rb b/spec/models/automations/actions/description_spec.rb new file mode 100644 index 000000000000..2c711ab7d145 --- /dev/null +++ b/spec/models/automations/actions/description_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_expectations" + +RSpec.describe Automations::Actions::Description do + let(:key) { :description } + let(:type) { :text_property } + let(:value) { "First line\n\nSecond paragraph" } + + it_behaves_like "base custom action" do + describe "#apply" do + let(:work_package) { build_stubbed(:work_package) } + + it "sets the description to the action's value" do + instance.values = ["First line\n\nSecond paragraph"] + + instance.apply(work_package) + + expect(work_package.description) + .to eql "First line\n\nSecond paragraph" + end + + it "clears the description without a value" do + instance.values = [] + + instance.apply(work_package) + + expect(work_package.description) + .to be_nil + end + end + + describe "#required?" do + it "is false" do + expect(instance) + .not_to be_required + end + end + end +end diff --git a/spec/models/automations/actions/subject_spec.rb b/spec/models/automations/actions/subject_spec.rb new file mode 100644 index 000000000000..3eee63d6325f --- /dev/null +++ b/spec/models/automations/actions/subject_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_expectations" + +RSpec.describe Automations::Actions::Subject do + let(:key) { :subject } + let(:type) { :string_property } + let(:value) { "A better subject" } + + it_behaves_like "base custom action" do + describe "#apply" do + let(:work_package) { build_stubbed(:work_package) } + + it "sets the subject to the action's value" do + instance.values = ["A better subject"] + + instance.apply(work_package) + + expect(work_package.subject) + .to eql "A better subject" + end + end + + describe "#required?" do + it "is true" do + expect(instance) + .to be_required + end + end + + describe "#validate" do + let(:errors) do + build_stubbed(:automation).errors + end + + it "is invalid without a value" do + instance.values = [] + + instance.validate(errors) + + expect(errors.symbols_for(:actions)) + .to include(:empty) + end + end + end +end diff --git a/spec/services/automations/update_work_package_service_spec.rb b/spec/services/automations/update_work_package_service_spec.rb index b93c7ac33ebf..b85e18413966 100644 --- a/spec/services/automations/update_work_package_service_spec.rb +++ b/spec/services/automations/update_work_package_service_spec.rb @@ -41,7 +41,7 @@ action end let(:alter_action1) do - action = instance_double(Automations::Actions::Base, key: "abc", priority: 100) + action = instance_double(Automations::Actions::Base, key: "abc", priority: 100, after_success: nil) allow(action) .to receive(:apply) @@ -52,7 +52,7 @@ action end let(:alter_action2) do - action = instance_double(Automations::Actions::Base, key: "def", priority: 10) + action = instance_double(Automations::Actions::Base, key: "def", priority: 10, after_success: nil) allow(action) .to receive(:apply) @@ -135,6 +135,12 @@ expect([alter_action1, alter_action2]).to all(have_received(:apply).with(work_package)) end + it "tells each action that the work package was saved" do + call + + expect([alter_action1, alter_action2]).to all(have_received(:after_success).with(work_package, user)) + end + it "calls the registered actions based on their priority" do call_order = [] @@ -193,6 +199,51 @@ end end + context "on validation error in strict mode" do + let(:instance) { described_class.new(action: automation, user:, strict: true) } + let(:result) do + ServiceResult.failure(result: work_package) + end + # The lambda runs as the update service stub, so it must assert at call time + # rubocop:disable-next RSpec/ExpectInLet + let(:update_service_call_implementation) do + -> do + # check that the invalid change is not dropped, so the update fails as a whole + expect(work_package.subject) + .to eql "" + + expect(work_package.status_id) + .to be 100 + + result + end + end + + before do + allow(contract) + .to receive(:validate) do + work_package.subject.present? + end + + allow(contract) + .to receive(:errors) + .and_return(instance_double(ActiveModel::Errors, attribute_names: [alter_action1.key])) + + subject + end + + it "is failure" do + expect(subject) + .to be_failure + end + + it "applies each action only once" do + expect(alter_action1) + .to have_received(:apply) + .once + end + end + context "on unfixable validation error" do let(:result) do ServiceResult.failure(result: work_package) From 114e077132f883d1427d28a525a2be9a28518cc2 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:03:56 +0200 Subject: [PATCH 02/86] Add the ai_assistance flag, permission and journal cause Co-Authored-By: Claude Opus 5.5 --- app/models/journal.rb | 6 +++ .../journal/caused_by_assistant_skill.rb | 45 +++++++++++++++++++ app/seeders/common.yml | 1 + config/initializers/feature_decisions.rb | 4 ++ config/initializers/permissions.rb | 9 ++++ config/locales/en.yml | 3 ++ lib/open_project/journal_formatter/cause.rb | 8 ++++ .../journal_formatter/cause_spec.rb | 25 +++++++++++ 8 files changed, 101 insertions(+) create mode 100644 app/models/journal/caused_by_assistant_skill.rb diff --git a/app/models/journal.rb b/app/models/journal.rb index 0e7f516832e3..dafba31adc5f 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -91,6 +91,11 @@ class Journal < ApplicationRecord status_changes meeting_id source_meeting_id + source + depth + run_id + skill_key + assistant_name ], prefix: true @@ -103,6 +108,7 @@ class Journal < ApplicationRecord ].freeze VALID_CAUSE_TYPES = (%w[ + assistant_skill default_attribute_written import progress_mode_changed_to_status_based diff --git a/app/models/journal/caused_by_assistant_skill.rb b/app/models/journal/caused_by_assistant_skill.rb new file mode 100644 index 000000000000..66c784262664 --- /dev/null +++ b/app/models/journal/caused_by_assistant_skill.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# A change applied from an assistant's proposal. "source" and "depth" are the fields that +# loop protection for automations reads: an automation does not react to its own changes, +# and changes caused by changes stop at a fixed depth. +class Journal::CausedByAssistantSkill < CauseOfChange::Base + def initialize(run:, depth: 0) + additional = { + "source" => "assistant_skill", + "depth" => depth, + "run_id" => run.id, + "skill_key" => run.skill.skill_key, + "assistant_name" => run.skill.assistant.name + } + super("assistant_skill", additional) + end +end diff --git a/app/seeders/common.yml b/app/seeders/common.yml index a0bf87fc2b20..1a687417ab32 100644 --- a/app/seeders/common.yml +++ b/app/seeders/common.yml @@ -319,6 +319,7 @@ project_roles: position: 3 permissions: - :view_work_packages + - :use_ai_assistance - :export_work_packages - :add_work_packages - :move_work_packages diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index b3d844fa9bba..c6b9b350785f 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -68,6 +68,10 @@ description: "Enables project settings for the unit of velocity, effort and capacity. " \ "See AGILE-198 for details." +OpenProject::FeatureDecisions.add :ai_assistance, + description: "Enables the AI Assistance menu on work packages, " \ + "with skills such as the health check." + OpenProject::FeatureDecisions.add :llm_connection, description: "Enables the administration page connecting OpenProject to an " \ "OpenAI-API-compatible LLM server, and the AI features built on it." diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 0edb1aafad62..7ec70a4e0a61 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -368,6 +368,15 @@ dependencies: :view_work_packages, contract_actions: { work_packages: %i[update] } + wpt.permission :use_ai_assistance, + { + "work_packages/ai_assistance/menus": %i[show items], + "work_packages/ai_assistance/runs": %i[index show create poll], + "work_packages/ai_assistance/findings": %i[apply dismiss] + }, + permissible_on: %i[work_package project], + dependencies: :view_work_packages + wpt.permission :move_work_packages, { "work_packages/moves": %i[new create] }, permissible_on: :project, diff --git a/config/locales/en.yml b/config/locales/en.yml index fb1e8b87c0f9..3ea4aa473571 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3837,6 +3837,7 @@ en: Work values of child work packages are ignored. total_percent_complete_mode_changed_to_work_weighted_average: >- Child work packages without Work are ignored. + assistant_skill: proposed by %{assistant_name} and applied by the user unaccessable_work_package_changed: by changes to a related work package work_package_children_changed_times: by changes to child %{link} work_package_duplicate_closed: The status was automatically updated by the duplicated work package %{link} @@ -3853,6 +3854,7 @@ en: non_working: "%{day} is now non-working" working: "%{day} is now working" caused_changes: + assistant_skill: "AI Assistance:" budget_deleted: "Budget has been deleted" dates_changed: "Dates changed" default_attribute_written: "Read-only attributes written" @@ -5713,6 +5715,7 @@ en: permission_select_project_phases: "Select project phases" permission_select_project_phases_explanation: "Activate/Deactivate the phases in a project. Enables the user to select the life cycle appropriate for the project as inactive phases will not be visible in the project overview page nor the project list." permission_share_work_packages: "Share work packages" + permission_use_ai_assistance: "Use AI assistance" permission_view_all_principals: "View all users and groups" permission_view_all_principals_explanation: > Allows users to see all users and groups in the system, even if they are not members of any joined projects or groups. diff --git a/lib/open_project/journal_formatter/cause.rb b/lib/open_project/journal_formatter/cause.rb index d888af99b2a8..f0e3146acf42 100644 --- a/lib/open_project/journal_formatter/cause.rb +++ b/lib/open_project/journal_formatter/cause.rb @@ -101,11 +101,19 @@ def cause_description import_message when "work_package_parent_deleted" I18n.t("journals.cause_descriptions.work_package_parent_deleted") + when "assistant_skill" + assistant_skill_message else related_work_package_changed_message end end + def assistant_skill_message + name = cause["assistant_name"].to_s + I18n.t("journals.cause_descriptions.assistant_skill", + assistant_name: html? ? ERB::Util.html_escape(name) : name) + end + def system_update_message feature = cause["feature"] feature = "progress_calculation_adjusted" if feature == "progress_calculation_changed" diff --git a/spec/lib/open_project/journal_formatter/cause_spec.rb b/spec/lib/open_project/journal_formatter/cause_spec.rb index c1787098b8e9..42c1c6001f4c 100644 --- a/spec/lib/open_project/journal_formatter/cause_spec.rb +++ b/spec/lib/open_project/journal_formatter/cause_spec.rb @@ -303,6 +303,31 @@ def render(cause, html:) end end + context "when the change was applied from an AI assistant proposal" do + subject(:cause) do + { + "type" => "assistant_skill", + "run_id" => 1, + "skill_key" => "health_check", + "assistant_name" => "QA " + } + end + + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.assistant_skill')} " \ + "#{I18n.t('journals.cause_descriptions.assistant_skill', assistant_name: 'QA <assistant>')}" + ) + end + + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.assistant_skill')} " \ + "#{I18n.t('journals.cause_descriptions.assistant_skill', assistant_name: 'QA ')}" + ) + end + end + context "when the change was caused by working day changes" do subject(:cause) do { From 758fe294240e198d932e5ea604f5f260f5db13eb Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:07:35 +0200 Subject: [PATCH 03/86] Add AI assistant, skill, run and finding models Co-Authored-By: Claude Opus 5.5 --- app/models/ai/assistant.rb | 45 +++++++ app/models/ai/assistant_run.rb | 90 +++++++++++++ app/models/ai/assistant_run_finding.rb | 61 +++++++++ app/models/ai/assistant_skill.rb | 76 +++++++++++ app/models/concerns/ai/proposal.rb | 94 +++++++++++++ app/seeders/basic_data/ai_assistant_seeder.rb | 54 ++++++++ app/seeders/basic_data_seeder.rb | 3 +- app/seeders/common.yml | 8 ++ .../20261012120000_create_ai_assistants.rb | 125 ++++++++++++++++++ spec/factories/ai_assistant_factory.rb | 76 +++++++++++ spec/models/ai/assistant_run_finding_spec.rb | 86 ++++++++++++ spec/models/ai/assistant_run_spec.rb | 75 +++++++++++ spec/models/ai/assistant_spec.rb | 59 +++++++++ .../journal/caused_by_assistant_skill_spec.rb | 46 +++++++ .../basic_data/ai_assistant_seeder_spec.rb | 68 ++++++++++ 15 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 app/models/ai/assistant.rb create mode 100644 app/models/ai/assistant_run.rb create mode 100644 app/models/ai/assistant_run_finding.rb create mode 100644 app/models/ai/assistant_skill.rb create mode 100644 app/models/concerns/ai/proposal.rb create mode 100644 app/seeders/basic_data/ai_assistant_seeder.rb create mode 100644 db/migrate/20261012120000_create_ai_assistants.rb create mode 100644 spec/factories/ai_assistant_factory.rb create mode 100644 spec/models/ai/assistant_run_finding_spec.rb create mode 100644 spec/models/ai/assistant_run_spec.rb create mode 100644 spec/models/ai/assistant_spec.rb create mode 100644 spec/models/journal/caused_by_assistant_skill_spec.rb create mode 100644 spec/seeders/basic_data/ai_assistant_seeder_spec.rb diff --git a/app/models/ai/assistant.rb b/app/models/ai/assistant.rb new file mode 100644 index 000000000000..63dd29ff1d42 --- /dev/null +++ b/app/models/ai/assistant.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class Assistant < ApplicationRecord + has_many :skills, + -> { order(:position, :id) }, + class_name: "AI::AssistantSkill", + inverse_of: :assistant, + dependent: :destroy + + validates :name, presence: true, uniqueness: true, length: { maximum: 255 } + validates :icon, length: { maximum: 255 } + + scope :active, -> { where(active: true) } + scope :ordered, -> { order(:position, :id) } + end +end diff --git a/app/models/ai/assistant_run.rb b/app/models/ai/assistant_run.rb new file mode 100644 index 000000000000..601dcfb75132 --- /dev/null +++ b/app/models/ai/assistant_run.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class AssistantRun < ApplicationRecord + TERMINAL_STATUSES = %w[finished failed].freeze + + belongs_to :skill, class_name: "AI::AssistantSkill", inverse_of: :runs + belongs_to :user + belongs_to :work_package + belongs_to :automation, optional: true + has_many :findings, + -> { order(:id) }, + class_name: "AI::AssistantRunFinding", + inverse_of: :run, + dependent: :delete_all + + # running: the rules run. ai_running: the rules are done and the AI judgement job runs. + enum :status, { + running: "running", + ai_running: "ai_running", + finished: "finished", + failed: "failed" + }, default: "running", validate: true + + enum :trigger, { + menu: "menu", + automation: "automation", + schedule: "schedule" + }, default: "menu", validate: true, prefix: :triggered_by + + attribute :uuid, default: -> { SecureRandom.uuid } + + validates :locale, presence: true + + before_save :stamp_finished_at, if: -> { status_changed? && terminal? } + + scope :for, ->(user, work_package) { where(user:, work_package:) } + scope :newest_first, -> { order(created_at: :desc, id: :desc) } + + def terminal? + TERMINAL_STATUSES.include?(status) + end + + # Appends one entry in a single statement, so a job and a request that both write + # events never overwrite each other. + def log_event(kind, **data) + entry = { "at" => Time.current.iso8601(3), "kind" => kind.to_s, "data" => data.deep_stringify_keys } + self.class.where(id:).update_all(["events = events || ?::jsonb", [entry].to_json]) + events << entry + end + + def add_not_checked(area, reason) + not_checked << { "area" => area.to_s, "reason" => reason.to_s } + end + + private + + def stamp_finished_at + self.finished_at ||= Time.current + end + end +end diff --git a/app/models/ai/assistant_run_finding.rb b/app/models/ai/assistant_run_finding.rb new file mode 100644 index 000000000000..978d74621fdc --- /dev/null +++ b/app/models/ai/assistant_run_finding.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class AssistantRunFinding < ApplicationRecord + include AI::Proposal + + belongs_to :run, class_name: "AI::AssistantRun", inverse_of: :findings + + enum :source, { rule: "rule", ai: "ai" }, validate: true + enum :severity, { high: "high", medium: "medium", low: "low" }, validate: true + enum :state, { open: "open", applied: "applied", dismissed: "dismissed" }, default: "open", validate: true + enum :dismiss_reason, { + wrong: "wrong", + not_relevant: "not_relevant", + already_known: "already_known" + }, validate: { allow_nil: true }, prefix: :dismissed_as + + validates :area, :text, presence: true + validates :evidence, presence: true + + scope :by_severity, -> { unscope(:order).in_order_of(:severity, severities.values).order(:id) } + + def proposal_work_package = run.work_package + + def proposal_user = run.user + + def proposal_changeable_attributes = run.skill.changeable_attributes + + def proposal_journal_cause = Journal::CausedByAssistantSkill.new(run:) + + def proposal_siblings = run.findings.open + end +end diff --git a/app/models/ai/assistant_skill.rb b/app/models/ai/assistant_skill.rb new file mode 100644 index 000000000000..12dd07de801a --- /dev/null +++ b/app/models/ai/assistant_skill.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + class AssistantSkill < ApplicationRecord + belongs_to :assistant, class_name: "AI::Assistant", inverse_of: :skills + has_many :runs, + class_name: "AI::AssistantRun", + foreign_key: :skill_id, + inverse_of: :skill, + dependent: :destroy + + validates :skill_key, presence: true + validates :label, :template_ref, length: { maximum: 255 } + + scope :active, -> { where(active: true) } + scope :ordered, -> { order(:position, :id) } + + def skill_type + AI::AssistantSkills::Registry.fetch(skill_key) + end + + # The attributes this skill may propose changes to: none when read-only, otherwise what + # its type supports. + def changeable_attributes + read_only? ? [] : (skill_type&.changeable_attributes || []) + end + + # The admin's label or description, else the text of the skill type. + def display_label + label.presence || skill_type&.label || skill_key + end + + def display_description + description.presence || skill_type&.description + end + + # The system prompt for a run, or nil when no instructions are set. The output rules + # may use {{locale}}; other placeholders stay as they are. + def system_prompt(locale:) + return if prompt.blank? + + rules = output_rules.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) do |match| + Regexp.last_match(1) == "locale" ? locale.to_s : match + end + [prompt, rules].compact_blank.join("\n\n") + end + end +end diff --git a/app/models/concerns/ai/proposal.rb b/app/models/concerns/ai/proposal.rb new file mode 100644 index 000000000000..c6ae4cec4cf9 --- /dev/null +++ b/app/models/concerns/ai/proposal.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + # A proposed change to a work package, in the vocabulary of Automations: a target and a + # list of actions with values. Findings of a run and answers in the chat both carry one, + # and AI::AssistantRuns::ApplyProposalService applies either. + # + # Including classes implement: + # - proposal_work_package, proposal_user + # - proposal_changeable_attributes: the action keys allowed now + # - proposal_journal_cause + # - proposal_siblings: the other open proposals made from the same work package version + module Proposal + extend ActiveSupport::Concern + + # Only "self" is a target for now; parent, children and related come later. + CHANGE_TARGETS = %w[self].freeze + + def self.change_set_for(actions, target: "self") + { "target" => target, "actions" => actions.map { |key, values| { "key" => key.to_s, "values" => Array(values) } } } + end + + included do + validate :change_set_shape + end + + class_methods do + def change_set_for(...) = AI::Proposal.change_set_for(...) + end + + def change_set? + change_set.present? + end + + def change_target + change_set&.fetch("target", nil) + end + + def change_actions + Array(change_set&.fetch("actions", nil)) + end + + def change_action_keys + change_actions.pluck("key") + end + + def change_value_for(key) + change_actions.find { |action| action["key"] == key.to_s }&.dig("values", 0) + end + + private + + def change_set_shape + return if change_set.nil? + + errors.add(:change_set, :invalid) unless valid_change_set? + end + + def valid_change_set? + change_set.is_a?(Hash) && + CHANGE_TARGETS.include?(change_set["target"]) && + change_set["actions"].is_a?(Array) && change_set["actions"].any? && + change_set["actions"].all? { |a| a.is_a?(Hash) && a["key"].is_a?(String) && a["values"].is_a?(Array) } + end + end +end diff --git a/app/seeders/basic_data/ai_assistant_seeder.rb b/app/seeders/basic_data/ai_assistant_seeder.rb new file mode 100644 index 000000000000..1072f9e78216 --- /dev/null +++ b/app/seeders/basic_data/ai_assistant_seeder.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module BasicData + class AiAssistantSeeder < ModelSeeder + self.model_class = AI::Assistant + self.seed_data_model_key = "ai_assistants" + self.attribute_names_for_lookups = %i[name] + + def create_model!(model_data) + super.tap do |assistant| + Array(model_data["skills"]).each_with_index do |skill_key, index| + assistant.skills.create!(skill_key:, position: index + 1) + end + end + end + + def model_attributes(assistant_data) + { + name: assistant_data["name"], + description: assistant_data["description"], + icon: assistant_data["icon"], + position: assistant_data["position"] + } + end + end +end diff --git a/app/seeders/basic_data_seeder.rb b/app/seeders/basic_data_seeder.rb index 127eddf35d08..a5b265b320e3 100644 --- a/app/seeders/basic_data_seeder.rb +++ b/app/seeders/basic_data_seeder.rb @@ -49,7 +49,8 @@ def data_seeder_classes ::BasicData::SettingSeeder, ::BasicData::ProjectCustomFieldSectionSeeder, ::BasicData::UserCustomFieldSectionSeeder, - ::BasicData::AiTextTransformActionSeeder + ::BasicData::AiTextTransformActionSeeder, + ::BasicData::AiAssistantSeeder ] end diff --git a/app/seeders/common.yml b/app/seeders/common.yml index 1a687417ab32..2c56dffc07d5 100644 --- a/app/seeders/common.yml +++ b/app/seeders/common.yml @@ -479,3 +479,11 @@ ai_text_transform_actions: 6. Every section of the template has italic instruction text (lines starting with _). This instruction text must appear in your output for every section, whether the section received content or not. Content is added below the instruction text. 7. Do not invent content. If the user did not mention something, keep only the section heading and instruction text. 8. Every section heading from the template must appear in your output. +ai_assistants: + - reference: :default_ai_assistant_qa + t_name: QA assistance + t_description: Checks work packages for inconsistencies and proposes changes. + icon: checklist + position: 1 + skills: + - health_check diff --git a/db/migrate/20261012120000_create_ai_assistants.rb b/db/migrate/20261012120000_create_ai_assistants.rb new file mode 100644 index 000000000000..d13da872aa63 --- /dev/null +++ b/db/migrate/20261012120000_create_ai_assistants.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateAIAssistants < ActiveRecord::Migration[8.1] + def change + create_assistants + create_skills + create_runs + create_findings + end + + private + + def create_assistants + create_table :ai_assistants do |t| + t.string :name, null: false, index: { unique: true } + t.text :description + t.string :icon + # Not a foreign key: syncing the models of an LLM connection replaces the llm_models rows. + t.string :model_id + t.integer :position + t.boolean :active, null: false, default: true + + t.timestamps + end + end + + def create_skills + create_table :ai_assistant_skills do |t| + t.references :assistant, null: false, foreign_key: { to_table: :ai_assistants, on_delete: :cascade }, index: false + t.string :skill_key, null: false + # Shown in the menu; empty uses the text of the skill type. + t.string :label + t.text :description + # The prompt is data, not code: the instructions and the output rules. Templates for + # them come from a registry outside OpenProject; template_ref names the one a + # skill was created from. + t.text :prompt + t.text :output_rules + t.string :template_ref + # A read-only skill reports findings only. Otherwise it may propose changes to the + # attributes its type supports; the workflow and permissions decide the values. + t.boolean :read_only, null: false, default: false + t.integer :position + t.boolean :active, null: false, default: true + + t.timestamps + + # Not unique: one assistant can have several variants of a skill type, each with + # its own label, prompt and attributes. + t.index %i[assistant_id skill_key] + end + end + + def create_runs + create_table :ai_assistant_runs do |t| + t.uuid :uuid, null: false, default: -> { "gen_random_uuid()" }, index: { unique: true } + t.references :skill, null: false, foreign_key: { to_table: :ai_assistant_skills, on_delete: :cascade } + t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false + t.references :work_package, null: false, foreign_key: { on_delete: :cascade } + t.integer :lock_version, null: false + # Why the run started: "menu" now; "automation" and "schedule" once Automations can run skills. + t.string :trigger, null: false, default: "menu" + t.references :automation, foreign_key: { on_delete: :nullify }, index: false + t.string :status, null: false, default: "running" + t.string :locale, null: false + t.jsonb :not_checked, null: false, default: [] + # What happened during the run, shown in the run dialog: [{at, kind, data}]. + t.jsonb :events, null: false, default: [] + t.string :error_message + t.datetime :finished_at + + t.timestamps + + t.index %i[user_id work_package_id created_at] + end + end + + def create_findings + create_table :ai_assistant_run_findings do |t| + t.references :run, null: false, foreign_key: { to_table: :ai_assistant_runs, on_delete: :cascade }, index: false + t.string :source, null: false + t.string :rule_key + t.string :severity, null: false + t.string :area, null: false + t.text :text, null: false + t.jsonb :evidence, null: false, default: [] + t.jsonb :change_set + t.integer :base_lock_version + t.string :state, null: false, default: "open" + t.string :dismiss_reason + + t.timestamps + + t.index %i[run_id id] + end + end +end diff --git a/spec/factories/ai_assistant_factory.rb b/spec/factories/ai_assistant_factory.rb new file mode 100644 index 000000000000..6228c25be16a --- /dev/null +++ b/spec/factories/ai_assistant_factory.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :ai_assistant, class: "AI::Assistant" do + sequence(:name) { |n| "Assistant #{n}" } + description { "Checks work packages." } + icon { "checklist" } + sequence(:position) + end + + factory :ai_assistant_skill, class: "AI::AssistantSkill" do + assistant { association :ai_assistant } + skill_key { "health_check" } + prompt { "Check the work package for contradictions." } + output_rules { "Write in the language with the locale code {{locale}}." } + sequence(:position) + end + + factory :ai_assistant_run, class: "AI::AssistantRun" do + skill { association :ai_assistant_skill } + user + work_package + lock_version { work_package.lock_version } + locale { "en" } + + trait :finished do + status { "finished" } + finished_at { Time.current } + end + end + + factory :ai_assistant_run_finding, class: "AI::AssistantRunFinding" do + run { association :ai_assistant_run } + source { "rule" } + rule_key { "closed_with_open_descendants" } + severity { "high" } + area { "tree" } + text { "This work package is closed, but 1 descendant is still open." } + evidence { [{ "text" => "#1", "work_package_id" => 1 }] } + + trait :with_change_set do + source { "ai" } + rule_key { nil } + change_set { AI::AssistantRunFinding.change_set_for({ "description" => "A better description" }) } + base_lock_version { run.lock_version } + end + end +end diff --git a/spec/models/ai/assistant_run_finding_spec.rb b/spec/models/ai/assistant_run_finding_spec.rb new file mode 100644 index 000000000000..bbb0982be8e0 --- /dev/null +++ b/spec/models/ai/assistant_run_finding_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::AssistantRunFinding do + subject(:finding) { build(:ai_assistant_run_finding) } + + it "is valid with the factory defaults" do + expect(finding).to be_valid + end + + it "is invalid without evidence" do + finding.evidence = [] + + expect(finding).not_to be_valid + expect(finding.errors.symbols_for(:evidence)).to include(:blank) + end + + it "accepts a change set as a list of Automations actions on the work package itself" do + finding.change_set = described_class.change_set_for({ "subject" => "New subject" }) + + expect(finding).to be_valid + expect(finding.change_target).to eq("self") + expect(finding.change_action_keys).to eq(["subject"]) + expect(finding.change_value_for(:subject)).to eq("New subject") + end + + it "rejects a change set for another target" do + finding.change_set = described_class.change_set_for({ "subject" => "New subject" }, target: "parent") + + expect(finding).not_to be_valid + expect(finding.errors.symbols_for(:change_set)).to include(:invalid) + end + + it "rejects a change set without actions" do + finding.change_set = { "target" => "self", "actions" => [] } + + expect(finding).not_to be_valid + end + + it "rejects an unknown dismiss reason" do + finding.dismiss_reason = "bored" + + expect(finding).not_to be_valid + expect(finding.errors.symbols_for(:dismiss_reason)).to include(:inclusion) + end + + describe ".by_severity" do + let(:run) { create(:ai_assistant_run) } + let!(:low) { create(:ai_assistant_run_finding, run:, severity: "low") } + let!(:high) { create(:ai_assistant_run_finding, run:, severity: "high") } + let!(:medium) { create(:ai_assistant_run_finding, run:, severity: "medium") } + + it "orders high, medium, low" do + expect(run.findings.by_severity).to eq([high, medium, low]) + end + end +end diff --git a/spec/models/ai/assistant_run_spec.rb b/spec/models/ai/assistant_run_spec.rb new file mode 100644 index 000000000000..10b4c1c0c13a --- /dev/null +++ b/spec/models/ai/assistant_run_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::AssistantRun do + subject(:run) { create(:ai_assistant_run) } + + it "starts as running" do + expect(run).to be_running + expect(run).not_to be_terminal + end + + it "stamps finished_at when it finishes" do + run.update!(status: "finished") + + expect(run.finished_at).to be_present + end + + it "records what was not checked" do + run.add_not_checked(:pull_requests, :module_disabled) + run.save! + + expect(run.reload.not_checked).to eq([{ "area" => "pull_requests", "reason" => "module_disabled" }]) + end + + describe ".for" do + let!(:other_users_run) { create(:ai_assistant_run, work_package: run.work_package) } + + it "returns only the runs of that user on that work package" do + expect(described_class.for(run.user, run.work_package)).to eq([run]) + end + end + + it "is deleted with its user" do + run + run.user.destroy + + expect(described_class.exists?(run.id)).to be(false) + end + + it "is deleted with its work package" do + run + run.work_package.destroy + + expect(described_class.exists?(run.id)).to be(false) + end +end diff --git a/spec/models/ai/assistant_spec.rb b/spec/models/ai/assistant_spec.rb new file mode 100644 index 000000000000..7f53eafd99c2 --- /dev/null +++ b/spec/models/ai/assistant_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::Assistant do + subject(:assistant) { build(:ai_assistant) } + + it "requires a unique name" do + create(:ai_assistant, name: "QA assistance") + assistant.name = "QA assistance" + + expect(assistant).not_to be_valid + expect(assistant.errors.symbols_for(:name)).to include(:taken) + end + + it "destroys its skills" do + skill = create(:ai_assistant_skill, assistant: create(:ai_assistant)) + + skill.assistant.destroy + + expect(AI::AssistantSkill.exists?(skill.id)).to be(false) + end + + it "allows several variants of one skill type on an assistant" do + skill = create(:ai_assistant_skill) + variant = build(:ai_assistant_skill, assistant: skill.assistant, skill_key: skill.skill_key, + label: "Check scope") + + expect(variant).to be_valid + end +end diff --git a/spec/models/journal/caused_by_assistant_skill_spec.rb b/spec/models/journal/caused_by_assistant_skill_spec.rb new file mode 100644 index 000000000000..1c0eb5a19410 --- /dev/null +++ b/spec/models/journal/caused_by_assistant_skill_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Journal::CausedByAssistantSkill do + let(:run) { build_stubbed(:ai_assistant_run, skill: build_stubbed(:ai_assistant_skill)) } + + it "names the run, the skill and the assistant, with the fields loop protection reads" do + expect(described_class.new(run:).to_hash).to include( + "type" => "assistant_skill", + "source" => "assistant_skill", + "depth" => 0, + "run_id" => run.id, + "skill_key" => "health_check", + "assistant_name" => run.skill.assistant.name + ) + end +end diff --git a/spec/seeders/basic_data/ai_assistant_seeder_spec.rb b/spec/seeders/basic_data/ai_assistant_seeder_spec.rb new file mode 100644 index 000000000000..1db47b62b70a --- /dev/null +++ b/spec/seeders/basic_data/ai_assistant_seeder_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe BasicData::AiAssistantSeeder do + include_context "with basic seed data" + + subject(:seeder) { described_class.new(seed_data) } + + let(:seed_data) { basic_seed_data.merge(Source::SeedData.new(data_hash)) } + let(:data_hash) do + YAML.load <<~SEEDING_DATA_YAML + ai_assistants: + - reference: :ai_assistant_qa + name: QA assistance + description: Checks work packages. + icon: checklist + position: 1 + skills: + - health_check + SEEDING_DATA_YAML + end + + before do + seeder.seed! + end + + it "creates the assistant with its skills" do + assistant = AI::Assistant.find_by!(name: "QA assistance") + + expect(assistant).to have_attributes(icon: "checklist", active: true, position: 1) + expect(assistant.skills.map(&:skill_key)).to eq(["health_check"]) + end + + it "does not seed a second time" do + described_class.new(basic_seed_data.merge(Source::SeedData.new(data_hash))).seed! + + expect(AI::Assistant.count).to eq(1) + end +end From 36c3c5a62e950bd579b62ee9b113bf7a26de6907 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:09:53 +0200 Subject: [PATCH 04/86] Add the skill registry and the health check tree rules Co-Authored-By: Claude Opus 5.5 --- app/services/ai/assistant_skills/base.rb | 61 ++++++++++++++ app/services/ai/assistant_skills/finding.rb | 40 +++++++++ .../assistant_skills/health_check/context.rb | 79 ++++++++++++++++++ .../ai/assistant_skills/health_check/rule.rb | 83 +++++++++++++++++++ .../closed_descendant_with_open_below.rb | 70 ++++++++++++++++ .../rules/closed_with_open_descendants.rb | 46 ++++++++++ .../rules/open_with_all_descendants_closed.rb | 46 ++++++++++ .../rules/stale_open_descendants.rb | 50 +++++++++++ .../ai/assistant_skills/health_check/skill.rb | 56 +++++++++++++ app/services/ai/assistant_skills/registry.rb | 52 ++++++++++++ config/locales/en.yml | 21 +++++ .../closed_descendant_with_open_below_spec.rb | 66 +++++++++++++++ .../closed_with_open_descendants_spec.rb | 65 +++++++++++++++ .../open_with_all_descendants_closed_spec.rb | 61 ++++++++++++++ .../rules/stale_open_descendants_spec.rb | 64 ++++++++++++++ .../health_check/shared_context.rb | 51 ++++++++++++ .../ai/assistant_skills/registry_spec.rb | 52 ++++++++++++ 17 files changed, 963 insertions(+) create mode 100644 app/services/ai/assistant_skills/base.rb create mode 100644 app/services/ai/assistant_skills/finding.rb create mode 100644 app/services/ai/assistant_skills/health_check/context.rb create mode 100644 app/services/ai/assistant_skills/health_check/rule.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/stale_open_descendants.rb create mode 100644 app/services/ai/assistant_skills/health_check/skill.rb create mode 100644 app/services/ai/assistant_skills/registry.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/stale_open_descendants_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/shared_context.rb create mode 100644 spec/services/ai/assistant_skills/registry_spec.rb diff --git a/app/services/ai/assistant_skills/base.rb b/app/services/ai/assistant_skills/base.rb new file mode 100644 index 000000000000..e3dcee919382 --- /dev/null +++ b/app/services/ai/assistant_skills/base.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + class Base + class << self + def key + raise SubclassResponsibilityError + end + + def icon + raise SubclassResponsibilityError + end + + def label + I18n.t("ai_assistance.skills.#{key}.label") + end + + def description + I18n.t("ai_assistance.skills.#{key}.description") + end + + def rules + [] + end + + def available?(_work_package, _user) + true + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/finding.rb b/app/services/ai/assistant_skills/finding.rb new file mode 100644 index 000000000000..36233f22f084 --- /dev/null +++ b/app/services/ai/assistant_skills/finding.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + # What a rule reports, before it is stored as an AI::AssistantRunFinding. + Finding = Data.define(:rule_key, :severity, :area, :text, :evidence) do + def to_record_attributes + { source: "rule", rule_key:, severity:, area:, text:, evidence: } + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/context.rb b/app/services/ai/assistant_skills/health_check/context.rb new file mode 100644 index 000000000000..c05ae9912d38 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/context.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + # The only way the rules read data. Everything goes through what the user can see, + # so evidence never names a work package the user cannot open. + class Context + MAX_DESCENDANTS = 200 + STALE_AFTER = 60.days + + attr_reader :work_package, :user, :not_checked + + def initialize(work_package:, user:) + @work_package = work_package + @user = user + @not_checked = [] + end + + def closed? + work_package.closed? + end + + def descendants + @descendants ||= begin + scope = work_package.descendants.visible(user).includes(:status).order(:id) + records = scope.limit(MAX_DESCENDANTS + 1).to_a + if records.size > MAX_DESCENDANTS + add_not_checked(:tree, :too_many_descendants) + records = records.first(MAX_DESCENDANTS) + end + records + end + end + + def open_descendants + descendants.reject(&:closed?) + end + + def stale?(record) + record.updated_at < STALE_AFTER.ago + end + + def add_not_checked(area, reason) + entry = { "area" => area.to_s, "reason" => reason.to_s } + @not_checked << entry unless @not_checked.include?(entry) + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rule.rb b/app/services/ai/assistant_skills/health_check/rule.rb new file mode 100644 index 000000000000..80139981d638 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rule.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + class Rule + MAX_EVIDENCE = 5 + + def self.call(context) + new(context).call + end + + def self.key + name.demodulize.underscore + end + + attr_reader :context + + delegate :work_package, :user, to: :context + + def initialize(context) + @context = context + end + + def call + raise SubclassResponsibilityError + end + + private + + def finding(severity:, area:, evidence:, **text_args) + Finding.new(rule_key: self.class.key, + severity: severity.to_s, + area: area.to_s, + text: I18n.t("ai_assistance.health_check.rules.#{self.class.key}", **text_args), + evidence:) + end + + def evidence_for(work_packages) + shown = work_packages.first(MAX_EVIDENCE).map { |wp| work_package_evidence(wp) } + rest = work_packages.size - shown.size + shown << { "text" => I18n.t("ai_assistance.health_check.and_more", count: rest) } if rest.positive? + shown + end + + def work_package_evidence(record) + { + "text" => "#{record.formatted_id} #{record.subject} (#{record.status&.name})", + "work_package_id" => record.id + } + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below.rb b/app/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below.rb new file mode 100644 index 000000000000..1aa0ec5ba0ef --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + # A closed descendant that still has open work packages below it. + class ClosedDescendantWithOpenBelow < Rule + MAX_FINDINGS = 5 + + def call + closed_ancestors_of_open_nodes + .first(MAX_FINDINGS) + .map do |closed| + finding(severity: :medium, + area: :tree, + evidence: [work_package_evidence(closed)], + id: closed.formatted_id) + end + end + + private + + def closed_ancestors_of_open_nodes + by_id = context.descendants.index_by(&:id) + found = {} + + context.open_descendants.each do |node| + parent = by_id[node.parent_id] + while parent + found[parent.id] = parent if parent.closed? + parent = by_id[parent.parent_id] + end + end + + found.values.sort_by(&:id) + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants.rb b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants.rb new file mode 100644 index 000000000000..d11b807fe6be --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class ClosedWithOpenDescendants < Rule + def call + open = context.open_descendants + return [] unless context.closed? && open.any? + + [finding(severity: :high, area: :tree, evidence: evidence_for(open), count: open.size)] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed.rb b/app/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed.rb new file mode 100644 index 000000000000..bf2d02af4d71 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class OpenWithAllDescendantsClosed < Rule + def call + descendants = context.descendants + return [] if context.closed? || descendants.empty? || descendants.any? { |d| !d.closed? } + + [finding(severity: :low, area: :status, evidence: evidence_for(descendants), count: descendants.size)] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/stale_open_descendants.rb b/app/services/ai/assistant_skills/health_check/rules/stale_open_descendants.rb new file mode 100644 index 000000000000..73a29ab9a56e --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/stale_open_descendants.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class StaleOpenDescendants < Rule + def call + stale = context.open_descendants.select { |d| context.stale?(d) } + return [] if stale.empty? + + [finding(severity: :low, + area: :status, + evidence: evidence_for(stale), + count: stale.size, + days: Context::STALE_AFTER.in_days.to_i)] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/skill.rb b/app/services/ai/assistant_skills/health_check/skill.rb new file mode 100644 index 000000000000..8ecb865385e6 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/skill.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + class Skill < AI::AssistantSkills::Base + class << self + def key + "health_check" + end + + def icon + "pulse" + end + + def rules + [ + Rules::ClosedWithOpenDescendants, + Rules::OpenWithAllDescendantsClosed, + Rules::ClosedDescendantWithOpenBelow, + Rules::StaleOpenDescendants + ] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/registry.rb b/app/services/ai/assistant_skills/registry.rb new file mode 100644 index 000000000000..fba1ee380841 --- /dev/null +++ b/app/services/ai/assistant_skills/registry.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + # Skills are defined in code. Admins attach them to assistants by key. + module Registry + class << self + def skills + [ + AI::AssistantSkills::HealthCheck::Skill + ] + end + + def keys + skills.map(&:key) + end + + def fetch(key) + skills.find { |skill| skill.key == key.to_s } + end + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3ea4aa473571..15e2e11d2eea 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2328,6 +2328,27 @@ en: author: "Additional transitions available to the work package's author." menu_label: "Transitions shown" workflow_button: "Workflow" + ai_assistance: + button: "AI Assistance" + skills: + health_check: + label: "Check health" + description: "Finds where this work package does not agree with its tree, relations and pull requests." + health_check: + and_more: + one: "and 1 more" + other: "and %{count} more" + rules: + closed_with_open_descendants: + one: "This work package is closed, but 1 descendant is still open." + other: "This work package is closed, but %{count} descendants are still open." + open_with_all_descendants_closed: + one: "The only descendant is closed, but this work package is still open." + other: "All %{count} descendants are closed, but this work package is still open." + closed_descendant_with_open_below: "Descendant %{id} is closed, but a work package below it is still open." + stale_open_descendants: + one: "1 open descendant did not change for %{days} days." + other: "%{count} open descendants did not change for %{days} days." ai: text_transform: errors: diff --git a/spec/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below_spec.rb new file mode 100644 index 000000000000..1e502a4de16e --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/closed_descendant_with_open_below_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::ClosedDescendantWithOpenBelow do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + context "when a closed child has an open grandchild" do + let!(:closed_child) { child_of(work_package, status: closed_status) } + + before { child_of(child_of(closed_child, status: closed_status), status: open_status) } + + it "reports each closed node above the open one" do + expect(findings.map { |f| f.evidence.first["work_package_id"] }) + .to eq([closed_child.id, closed_child.children.first.id]) + expect(findings).to all(have_attributes(severity: "medium", area: "tree")) + end + end + + context "when more than five closed nodes are affected" do + before do + 7.times { child_of(child_of(work_package, status: closed_status), status: open_status) } + end + + it "reports at most five" do + expect(findings.size).to eq(5) + end + end + + context "when every open node is directly below the work package" do + before { child_of(work_package, status: open_status) } + + it { is_expected.to be_empty } + end +end diff --git a/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants_spec.rb new file mode 100644 index 000000000000..2d5b7e2264db --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_descendants_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::ClosedWithOpenDescendants do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + context "when the work package is closed and a grandchild is open" do + let(:work_package_status) { closed_status } + let!(:grandchild) { child_of(child_of(work_package, status: closed_status), status: open_status) } + + it "reports one high finding with the open descendant as evidence" do + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes(severity: "high", area: "tree", rule_key: "closed_with_open_descendants") + expect(findings.first.evidence.pluck("work_package_id")).to eq([grandchild.id]) + end + end + + context "when the work package is open" do + before { child_of(work_package, status: open_status) } + + it { is_expected.to be_empty } + end + + context "when the only open descendant is in a project the user cannot see" do + let(:work_package_status) { closed_status } + + before { child_of(work_package, status: open_status, project: other_project) } + + it "reports nothing, so the descendant does not leak" do + expect(findings).to be_empty + end + end +end diff --git a/spec/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed_spec.rb new file mode 100644 index 000000000000..2e5b27a1dd55 --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/open_with_all_descendants_closed_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::OpenWithAllDescendantsClosed do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + context "when every descendant is closed" do + before { 2.times { child_of(work_package, status: closed_status) } } + + it "reports a low finding" do + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes(severity: "low", area: "status") + expect(findings.first.text).to include("All 2 descendants") + end + end + + context "when one descendant is open" do + before do + child_of(work_package, status: closed_status) + child_of(work_package, status: open_status) + end + + it { is_expected.to be_empty } + end + + context "without descendants" do + it { is_expected.to be_empty } + end +end diff --git a/spec/services/ai/assistant_skills/health_check/rules/stale_open_descendants_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/stale_open_descendants_spec.rb new file mode 100644 index 000000000000..d832571f9601 --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/stale_open_descendants_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::StaleOpenDescendants do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + context "with an open descendant that did not change for 90 days" do + let!(:stale) { child_of(work_package, status: open_status, updated_at: 90.days.ago) } + + before do + child_of(work_package, status: open_status) + child_of(work_package, status: closed_status, updated_at: 90.days.ago) + end + + it "reports only the stale open one" do + expect(findings.size).to eq(1) + expect(findings.first.evidence.pluck("work_package_id")).to eq([stale.id]) + expect(findings.first.text).to include("60 days") + end + end + + context "with more than five stale descendants" do + before { 7.times { child_of(work_package, status: open_status, updated_at: 90.days.ago) } } + + it "shows five and counts the rest" do + evidence = findings.first.evidence + + expect(evidence.size).to eq(6) + expect(evidence.last).to eq("text" => "and 2 more") + end + end +end diff --git a/spec/services/ai/assistant_skills/health_check/shared_context.rb b/spec/services/ai/assistant_skills/health_check/shared_context.rb new file mode 100644 index 000000000000..f9ace1683c7d --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/shared_context.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +RSpec.shared_context "with a health check context" do + shared_let(:project) { create(:project) } + shared_let(:other_project) { create(:project) } + shared_let(:open_status) { create(:status, is_closed: false) } + shared_let(:closed_status) { create(:status, is_closed: true) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }) + end + + let(:work_package_status) { open_status } + let(:work_package) { create(:work_package, project:, status: work_package_status) } + let(:context) do + AI::AssistantSkills::HealthCheck::Context.new(work_package: work_package.reload, user:) + end + + def child_of(parent, status:, project: parent.project, updated_at: nil) + create(:work_package, project:, parent:, status:).tap do |wp| + wp.update_column(:updated_at, updated_at) if updated_at + end + end +end diff --git a/spec/services/ai/assistant_skills/registry_spec.rb b/spec/services/ai/assistant_skills/registry_spec.rb new file mode 100644 index 000000000000..fd40da5d0393 --- /dev/null +++ b/spec/services/ai/assistant_skills/registry_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::AssistantSkills::Registry do + it "lists the health check" do + expect(described_class.keys).to include("health_check") + end + + it "fetches a skill by key" do + expect(described_class.fetch(:health_check)).to eq(AI::AssistantSkills::HealthCheck::Skill) + end + + it "returns nil for an unknown key" do + expect(described_class.fetch("unknown")).to be_nil + end + + it "has a label and an icon for each skill" do + described_class.skills.each do |skill| + expect(skill.label).not_to include("translation missing") + expect(skill.icon).to be_present + end + end +end From 18fcca94c180dc2d73b677e15d9f62d92bd09644 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:11:12 +0200 Subject: [PATCH 05/86] Add the health check relation and staleness rules Co-Authored-By: Claude Opus 5.5 --- .../assistant_skills/health_check/context.rb | 19 +++++ .../rules/closed_with_open_prerequisites.rb | 56 ++++++++++++++ .../rules/open_duplicate_of_closed.rb | 52 +++++++++++++ .../health_check/rules/stale_work_package.rb | 50 +++++++++++++ .../ai/assistant_skills/health_check/skill.rb | 5 +- config/locales/en.yml | 4 + .../closed_with_open_prerequisites_spec.rb | 75 +++++++++++++++++++ .../rules/open_duplicate_of_closed_spec.rb | 55 ++++++++++++++ .../rules/stale_work_package_spec.rb | 60 +++++++++++++++ 9 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 app/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/stale_work_package.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed_spec.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/stale_work_package_spec.rb diff --git a/app/services/ai/assistant_skills/health_check/context.rb b/app/services/ai/assistant_skills/health_check/context.rb index c05ae9912d38..d779e1365876 100644 --- a/app/services/ai/assistant_skills/health_check/context.rb +++ b/app/services/ai/assistant_skills/health_check/context.rb @@ -65,6 +65,25 @@ def open_descendants descendants.reject(&:closed?) end + # [relation type seen from this work package, other work package], for every relation + # to a work package the user can see. + def relations + @relations ||= begin + all = Relation.of_work_package(work_package).includes(from: :status, to: :status).to_a + visible_ids = WorkPackage.visible(user) + .where(id: all.map { |r| r.other_work_package(work_package).id }) + .pluck(:id) + all.filter_map do |relation| + other = relation.other_work_package(work_package) + [relation.relation_type_for(work_package), other] if visible_ids.include?(other.id) + end + end + end + + def last_activity_at + [work_package.journals.maximum(:created_at), work_package.updated_at].compact.max + end + def stale?(record) record.updated_at < STALE_AFTER.ago end diff --git a/app/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites.rb b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites.rb new file mode 100644 index 000000000000..ea681c0d4f54 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + # Closed, while a work package it waits for is still open. + class ClosedWithOpenPrerequisites < Rule + PREREQUISITE_TYPES = [Relation::TYPE_BLOCKED, Relation::TYPE_FOLLOWS, Relation::TYPE_REQUIRES].freeze + + def call + return [] unless context.closed? + + context.relations + .select { |type, other| PREREQUISITE_TYPES.include?(type) && !other.closed? } + .map do |type, other| + finding(severity: :medium, + area: :relations, + evidence: [work_package_evidence(other)], + id: other.formatted_id, + relation: I18n.t(Relation::TYPES[type][:name])) + end + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed.rb b/app/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed.rb new file mode 100644 index 000000000000..1dd0a2e69d7a --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class OpenDuplicateOfClosed < Rule + def call + return [] if context.closed? + + context.relations + .select { |type, other| type == Relation::TYPE_DUPLICATES && other.closed? } + .map do |_type, other| + finding(severity: :medium, + area: :relations, + evidence: [work_package_evidence(other)], + id: other.formatted_id) + end + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/stale_work_package.rb b/app/services/ai/assistant_skills/health_check/rules/stale_work_package.rb new file mode 100644 index 000000000000..ee8094f62171 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/stale_work_package.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class StaleWorkPackage < Rule + def call + last_activity = context.last_activity_at + return [] if context.closed? || last_activity >= Context::STALE_AFTER.ago + + [finding(severity: :low, + area: :status, + evidence: [{ "text" => I18n.t("ai_assistance.health_check.last_activity", + date: I18n.l(last_activity.to_date)) }], + days: (Time.current.to_date - last_activity.to_date).to_i)] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/skill.rb b/app/services/ai/assistant_skills/health_check/skill.rb index 8ecb865385e6..186761bff410 100644 --- a/app/services/ai/assistant_skills/health_check/skill.rb +++ b/app/services/ai/assistant_skills/health_check/skill.rb @@ -46,7 +46,10 @@ def rules Rules::ClosedWithOpenDescendants, Rules::OpenWithAllDescendantsClosed, Rules::ClosedDescendantWithOpenBelow, - Rules::StaleOpenDescendants + Rules::StaleOpenDescendants, + Rules::ClosedWithOpenPrerequisites, + Rules::OpenDuplicateOfClosed, + Rules::StaleWorkPackage ] end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 15e2e11d2eea..d546ef9d1809 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2335,6 +2335,7 @@ en: label: "Check health" description: "Finds where this work package does not agree with its tree, relations and pull requests." health_check: + last_activity: "Last activity on %{date}" and_more: one: "and 1 more" other: "and %{count} more" @@ -2349,6 +2350,9 @@ en: stale_open_descendants: one: "1 open descendant did not change for %{days} days." other: "%{count} open descendants did not change for %{days} days." + closed_with_open_prerequisites: "This work package is closed, but %{id} is still open (%{relation})." + open_duplicate_of_closed: "This work package duplicates %{id}, which is closed, but it is still open." + stale_work_package: "This work package is open, but nothing changed for %{days} days." ai: text_transform: errors: diff --git a/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites_spec.rb new file mode 100644 index 000000000000..ac84d812f28f --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/closed_with_open_prerequisites_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::ClosedWithOpenPrerequisites do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + let(:work_package_status) { closed_status } + let(:other) { create(:work_package, project:, status: open_status) } + + context "when an open work package blocks it" do + before { create(:relation, from: other, to: work_package, relation_type: Relation::TYPE_BLOCKS) } + + it "reports a medium finding" do + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes(severity: "medium", area: "relations") + expect(findings.first.text).to include("blocked by") + end + end + + context "when it follows an open work package" do + before { create(:relation, from: work_package, to: other, relation_type: Relation::TYPE_FOLLOWS) } + + it "reports a finding" do + expect(findings.first.evidence.first["work_package_id"]).to eq(other.id) + end + end + + context "when it blocks an open work package" do + before { create(:relation, from: work_package, to: other, relation_type: Relation::TYPE_BLOCKS) } + + it "reports nothing, because the other one waits for this one" do + expect(findings).to be_empty + end + end + + context "when the open blocker is in a project the user cannot see" do + let(:other) { create(:work_package, project: other_project, status: open_status) } + + before { create(:relation, from: other, to: work_package, relation_type: Relation::TYPE_BLOCKS) } + + it { is_expected.to be_empty } + end +end diff --git a/spec/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed_spec.rb new file mode 100644 index 000000000000..a6ef022761b3 --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/open_duplicate_of_closed_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::OpenDuplicateOfClosed do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + let(:original) { create(:work_package, project:, status: closed_status) } + + context "when it duplicates a closed work package" do + before { create(:relation, from: work_package, to: original, relation_type: Relation::TYPE_DUPLICATES) } + + it "reports a medium finding" do + expect(findings.size).to eq(1) + expect(findings.first.evidence.first["work_package_id"]).to eq(original.id) + end + end + + context "when a closed work package duplicates it" do + before { create(:relation, from: original, to: work_package, relation_type: Relation::TYPE_DUPLICATES) } + + it { is_expected.to be_empty } + end +end diff --git a/spec/services/ai/assistant_skills/health_check/rules/stale_work_package_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/stale_work_package_spec.rb new file mode 100644 index 000000000000..4887f35107fc --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/stale_work_package_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe AI::AssistantSkills::HealthCheck::Rules::StaleWorkPackage do + include_context "with a health check context" + + subject(:findings) { described_class.call(context) } + + context "when nothing changed for 90 days" do + before do + work_package.journals.update_all(created_at: 90.days.ago) + work_package.update_column(:updated_at, 90.days.ago) + end + + it "reports a low finding" do + expect(findings.size).to eq(1) + expect(findings.first.text).to include("90 days") + end + + context "and the work package is closed" do + let(:work_package_status) { closed_status } + + it { is_expected.to be_empty } + end + end + + context "when it changed recently" do + it { is_expected.to be_empty } + end +end From d27e52a3b303728575ccdad31e5299f52e9da523 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:12:21 +0200 Subject: [PATCH 06/86] Add the health check rules for linked pull requests Co-Authored-By: Claude Opus 5.5 --- .../assistant_skills/health_check/context.rb | 30 +++++ .../health_check/linked_pull_request.rb | 54 +++++++++ .../rules/closed_with_open_pull_request.rb | 46 ++++++++ .../merged_pull_request_default_status.rb | 46 ++++++++ .../ai/assistant_skills/health_check/skill.rb | 4 +- config/locales/en.yml | 2 + .../rules/pull_request_rules_spec.rb | 107 ++++++++++++++++++ 7 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 app/services/ai/assistant_skills/health_check/linked_pull_request.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/closed_with_open_pull_request.rb create mode 100644 app/services/ai/assistant_skills/health_check/rules/merged_pull_request_default_status.rb create mode 100644 spec/services/ai/assistant_skills/health_check/rules/pull_request_rules_spec.rb diff --git a/app/services/ai/assistant_skills/health_check/context.rb b/app/services/ai/assistant_skills/health_check/context.rb index d779e1365876..1633e2572696 100644 --- a/app/services/ai/assistant_skills/health_check/context.rb +++ b/app/services/ai/assistant_skills/health_check/context.rb @@ -80,10 +80,40 @@ def relations end end + # The pull requests of every integration that is enabled in the project. An integration + # that the user may not read is listed as not checked. + def linked_pull_requests + @linked_pull_requests ||= pull_request_sources.flat_map do |module_name, permission, association, builder| + next [] unless work_package.project.module_enabled?(module_name) + + unless user.allowed_in_work_package?(permission, work_package) + add_not_checked(:pull_requests, :"no_permission_#{module_name}") + next [] + end + + work_package.public_send(association).map { |record| LinkedPullRequest.public_send(builder, record) } + end + end + + def default_status? + work_package.status&.is_default? + end + def last_activity_at [work_package.journals.maximum(:created_at), work_package.updated_at].compact.max end + def pull_request_sources + sources = [] + if work_package.respond_to?(:github_pull_requests) + sources << [:github, :show_github_content, :github_pull_requests, :from_github] + end + if work_package.respond_to?(:gitlab_merge_requests) + sources << [:gitlab, :show_gitlab_content, :gitlab_merge_requests, :from_gitlab] + end + sources + end + def stale?(record) record.updated_at < STALE_AFTER.ago end diff --git a/app/services/ai/assistant_skills/health_check/linked_pull_request.rb b/app/services/ai/assistant_skills/health_check/linked_pull_request.rb new file mode 100644 index 000000000000..6f093dff3631 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/linked_pull_request.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + # A GitHub pull request or a GitLab merge request, reduced to what the rules read. + LinkedPullRequest = Data.define(:number, :title, :repository, :url, :merged, :open) do + def self.from_github(record) + new(number: record.number, title: record.title, repository: record.repository, + url: record.github_html_url, merged: record.merged == true, + open: record.open? && !record.draft) + end + + def self.from_gitlab(record) + new(number: record.number, title: record.title, repository: record.repository, + url: record.gitlab_html_url, merged: record.merged? || record.merged == true, + open: record.opened? && !record.draft) + end + + def evidence + { "text" => "#{repository}##{number}: #{title}", "url" => url } + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/closed_with_open_pull_request.rb b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_pull_request.rb new file mode 100644 index 000000000000..f41bd34c9969 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/closed_with_open_pull_request.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class ClosedWithOpenPullRequest < Rule + def call + open = context.linked_pull_requests.select(&:open) + return [] unless context.closed? && open.any? + + [finding(severity: :medium, area: :pull_requests, evidence: open.first(MAX_EVIDENCE).map(&:evidence))] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/rules/merged_pull_request_default_status.rb b/app/services/ai/assistant_skills/health_check/rules/merged_pull_request_default_status.rb new file mode 100644 index 000000000000..3a6e3ea94560 --- /dev/null +++ b/app/services/ai/assistant_skills/health_check/rules/merged_pull_request_default_status.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantSkills + module HealthCheck + module Rules + class MergedPullRequestDefaultStatus < Rule + def call + merged = context.linked_pull_requests.select(&:merged) + return [] unless context.default_status? && merged.any? + + [finding(severity: :high, area: :pull_requests, evidence: merged.first(MAX_EVIDENCE).map(&:evidence))] + end + end + end + end + end +end diff --git a/app/services/ai/assistant_skills/health_check/skill.rb b/app/services/ai/assistant_skills/health_check/skill.rb index 186761bff410..adcceca4ca7e 100644 --- a/app/services/ai/assistant_skills/health_check/skill.rb +++ b/app/services/ai/assistant_skills/health_check/skill.rb @@ -49,7 +49,9 @@ def rules Rules::StaleOpenDescendants, Rules::ClosedWithOpenPrerequisites, Rules::OpenDuplicateOfClosed, - Rules::StaleWorkPackage + Rules::StaleWorkPackage, + Rules::MergedPullRequestDefaultStatus, + Rules::ClosedWithOpenPullRequest ] end end diff --git a/config/locales/en.yml b/config/locales/en.yml index d546ef9d1809..766ff932dfc6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2353,6 +2353,8 @@ en: closed_with_open_prerequisites: "This work package is closed, but %{id} is still open (%{relation})." open_duplicate_of_closed: "This work package duplicates %{id}, which is closed, but it is still open." stale_work_package: "This work package is open, but nothing changed for %{days} days." + merged_pull_request_default_status: "A linked pull request is merged, but the status is still the initial one." + closed_with_open_pull_request: "This work package is closed, but a linked pull request is still open." ai: text_transform: errors: diff --git a/spec/services/ai/assistant_skills/health_check/rules/pull_request_rules_spec.rb b/spec/services/ai/assistant_skills/health_check/rules/pull_request_rules_spec.rb new file mode 100644 index 000000000000..be5b55b51593 --- /dev/null +++ b/spec/services/ai/assistant_skills/health_check/rules/pull_request_rules_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "health check pull request rules" do # rubocop:disable RSpec/DescribeClass + include_context "with a health check context" + + shared_let(:default_status) { create(:default_status) } + shared_let(:github_project) { create(:project, enabled_module_names: %w[work_package_tracking github gitlab]) } + shared_let(:github_user) do + create(:user, member_with_permissions: { + github_project => %i[view_work_packages show_github_content show_gitlab_content] + }) + end + + let(:project) { github_project } + let(:user) { github_user } + let(:merged) { AI::AssistantSkills::HealthCheck::Rules::MergedPullRequestDefaultStatus.call(context) } + let(:closed_open) { AI::AssistantSkills::HealthCheck::Rules::ClosedWithOpenPullRequest.call(context) } + + context "with a merged GitHub pull request and the default status" do + let(:work_package_status) { default_status } + + before { work_package.github_pull_requests << create(:github_pull_request, state: "closed", merged: true) } + + it "reports a high finding with a link to the pull request" do + expect(merged.size).to eq(1) + expect(merged.first).to have_attributes(severity: "high", area: "pull_requests") + expect(merged.first.evidence.first["url"]).to start_with("https://github.com/") + end + end + + context "with a merged GitLab merge request and the default status" do + let(:work_package_status) { default_status } + + before { work_package.gitlab_merge_requests << create(:gitlab_merge_request, :closed_merged) } + + it "reports a high finding" do + expect(merged.size).to eq(1) + end + end + + context "when closed with an open pull request" do + let(:work_package_status) { closed_status } + + before do + work_package.github_pull_requests << create(:github_pull_request, state: "open") + work_package.github_pull_requests << create(:github_pull_request, state: "open", draft: true) + end + + it "reports one medium finding for the pull request that is not a draft" do + expect(closed_open.size).to eq(1) + expect(closed_open.first.evidence.size).to eq(1) + end + end + + context "when the user may not see GitHub content" do + let(:user) { create(:user, member_with_permissions: { github_project => %i[view_work_packages show_gitlab_content] }) } + let(:work_package_status) { default_status } + + before { work_package.github_pull_requests << create(:github_pull_request, state: "closed", merged: true) } + + it "reports nothing and lists GitHub as not checked" do + expect(merged).to be_empty + expect(context.not_checked).to include("area" => "pull_requests", "reason" => "no_permission_github") + end + end + + context "when the project does not use the integrations" do + let(:project) { create(:project, enabled_module_names: %w[work_package_tracking]) } + let(:user) { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "reports nothing and lists nothing as not checked" do + expect(merged).to be_empty + expect(context.not_checked).to be_empty + end + end +end From bba4f133e328afa5ebeb58ef9e3b85f0d1fa7769 Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:21:24 +0200 Subject: [PATCH 07/86] Add the run service, the run controllers and the run dialog Co-Authored-By: Claude Opus 5.5 --- .../ai/assistance/finding_component.html.erb | 70 +++++++++ .../ai/assistance/finding_component.rb | 92 ++++++++++++ .../history_dialog_component.html.erb | 52 +++++++ .../ai/assistance/history_dialog_component.rb | 55 +++++++ .../ai/assistance/run_component.html.erb | 61 ++++++++ app/components/ai/assistance/run_component.rb | 63 ++++++++ .../assistance/run_dialog_component.html.erb | 54 +++++++ .../ai/assistance/run_dialog_component.rb | 58 +++++++ .../work_packages/ai_assistance/scoped.rb | 80 ++++++++++ .../ai_assistance/findings_controller.rb | 53 +++++++ .../ai_assistance/runs_controller.rb | 65 ++++++++ app/services/ai/assistant_runs/run_service.rb | 76 ++++++++++ app/services/ai/assistant_skills/base.rb | 4 + .../ai/assistant_skills/health_check/skill.rb | 4 + config/initializers/zeitwerk.rb | 1 + config/locales/en.yml | 38 +++++ config/routes.rb | 15 ++ .../20261012120000_create_ai_assistants.rb | 2 +- .../ai/assistance/finding_component_spec.rb | 68 +++++++++ .../ai/assistance/run_component_spec.rb | 68 +++++++++ spec/factories/ai_assistant_factory.rb | 4 +- .../work_packages/ai_assistance/runs_spec.rb | 141 ++++++++++++++++++ .../ai/assistant_runs/run_service_spec.rb | 71 +++++++++ 23 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 app/components/ai/assistance/finding_component.html.erb create mode 100644 app/components/ai/assistance/finding_component.rb create mode 100644 app/components/ai/assistance/history_dialog_component.html.erb create mode 100644 app/components/ai/assistance/history_dialog_component.rb create mode 100644 app/components/ai/assistance/run_component.html.erb create mode 100644 app/components/ai/assistance/run_component.rb create mode 100644 app/components/ai/assistance/run_dialog_component.html.erb create mode 100644 app/components/ai/assistance/run_dialog_component.rb create mode 100644 app/controllers/concerns/work_packages/ai_assistance/scoped.rb create mode 100644 app/controllers/work_packages/ai_assistance/findings_controller.rb create mode 100644 app/controllers/work_packages/ai_assistance/runs_controller.rb create mode 100644 app/services/ai/assistant_runs/run_service.rb create mode 100644 spec/components/ai/assistance/finding_component_spec.rb create mode 100644 spec/components/ai/assistance/run_component_spec.rb create mode 100644 spec/requests/work_packages/ai_assistance/runs_spec.rb create mode 100644 spec/services/ai/assistant_runs/run_service_spec.rb diff --git a/app/components/ai/assistance/finding_component.html.erb b/app/components/ai/assistance/finding_component.html.erb new file mode 100644 index 000000000000..8aac2b8501c3 --- /dev/null +++ b/app/components/ai/assistance/finding_component.html.erb @@ -0,0 +1,70 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= component_wrapper(data: { test_selector: "ai-assistance-finding" }) do %> + <%= flex_layout(align_items: :flex_start) do |row| %> + <% row.with_column(flex: 1) do %> + <%= render(Primer::Beta::Label.new(scheme: severity_scheme, mr: 2)) { severity_label } %> + <%= render(Primer::Beta::Text.new(color: :muted, font_size: :small)) { area_label } %> + <%= render(Primer::Beta::Text.new(tag: :p, mt: 1, mb: 1, color: text_color)) { finding.text } %> +
    + <% finding.evidence.each do |item| %> +
  • + <% if evidence_href(item) %> + <%= render(Primer::Beta::Link.new(**evidence_link_arguments(item))) { item["text"] } %> + <% else %> + <%= render(Primer::Beta::Text.new(color: :muted)) { item["text"] } %> + <% end %> +
  • + <% end %> +
+ <% end %> + <% row.with_column do %> + <% if finding.open? %> + <%= render(Primer::Alpha::ActionMenu.new(anchor_align: :end)) do |menu| %> + <% menu.with_show_button(size: :small, scheme: :invisible, data: { test_selector: "ai-assistance-dismiss" }) do |button| %> + <% button.with_trailing_action_icon(icon: :"triangle-down") %> + <%= t("ai_assistance.finding.dismiss") %> + <% end %> + <% menu.with_item(label: t("ai_assistance.finding.dismiss"), href: dismiss_path, form_arguments: { method: :post }) %> + <% menu.with_divider %> + <% dismiss_reasons.each do |reason| %> + <% menu.with_item(label: t("ai_assistance.finding.dismiss_reasons.#{reason}"), href: dismiss_path(reason), form_arguments: { method: :post }) %> + <% end %> + <% end %> + <% elsif finding.dismissed? %> + <%= render(Primer::Beta::Label.new(scheme: :secondary)) { t("ai_assistance.finding.dismissed") } %> + <% elsif finding.applied? %> + <%= render(Primer::Beta::Label.new(scheme: :success)) { t("ai_assistance.finding.applied") } %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/ai/assistance/finding_component.rb b/app/components/ai/assistance/finding_component.rb new file mode 100644 index 000000000000..5bcd37b0a188 --- /dev/null +++ b/app/components/ai/assistance/finding_component.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + class FindingComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + SEVERITY_SCHEMES = { "high" => :danger, "medium" => :attention, "low" => :secondary }.freeze + + attr_reader :finding + + def initialize(finding:) + super + @finding = finding + end + + def wrapper_key + "ai-assistance-finding-#{finding.id}" + end + + def severity_scheme + SEVERITY_SCHEMES.fetch(finding.severity, :secondary) + end + + def severity_label + I18n.t("ai_assistance.severities.#{finding.severity}") + end + + def area_label + I18n.t("ai_assistance.areas.#{finding.area}", default: finding.area.to_s.humanize) + end + + def evidence_href(item) + if item["work_package_id"] + url_helpers.work_package_path(item["work_package_id"]) + elsif item["url"].to_s.start_with?("https://", "http://") + item["url"] + end + end + + def evidence_link_arguments(item) + arguments = { href: evidence_href(item) } + arguments.merge!(target: "_blank", rel: "noopener noreferrer") if item["work_package_id"].nil? + arguments + end + + def text_color + finding.dismissed? ? :muted : :default + end + + def dismiss_path(reason = nil) + url_helpers.dismiss_work_package_ai_assistance_run_finding_path(finding.run.work_package, + finding.run, + finding, + reason:) + end + + def dismiss_reasons + AI::AssistantRunFinding.dismiss_reasons.keys + end + end + end +end diff --git a/app/components/ai/assistance/history_dialog_component.html.erb b/app/components/ai/assistance/history_dialog_component.html.erb new file mode 100644 index 000000000000..a38ad6c55019 --- /dev/null +++ b/app/components/ai/assistance/history_dialog_component.html.erb @@ -0,0 +1,52 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title: t("ai_assistance.history.title"), size: :medium, data: { test_selector: "ai-assistance-history-dialog" })) do |dialog| %> + <% dialog.with_header(variant: :large) %> + <% dialog.with_body do %> + <% if runs.empty? %> + <%= render(Primer::Beta::Blankslate.new) do |slate| %> + <% slate.with_heading(tag: :h3).with_content(t("ai_assistance.history.empty")) %> + <% end %> + <% else %> + <%= render(Primer::Beta::BorderBox.new) do |box| %> + <% runs.each do |run| %> + <% box.with_row do %> + <%= render(Primer::Beta::Link.new(href: run_path(run), data: { controller: "async-dialog", turbo_stream: true })) { run_label(run) } %> + <%= render(Primer::Beta::Text.new(color: :muted, font_size: :small, ml: 2)) do %> + <%= helpers.format_time(run.created_at) %> · <%= t("ai_assistance.history.findings", count: run.findings.size) %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/ai/assistance/history_dialog_component.rb b/app/components/ai/assistance/history_dialog_component.rb new file mode 100644 index 000000000000..1b199b4f5a8d --- /dev/null +++ b/app/components/ai/assistance/history_dialog_component.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + class HistoryDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "ai-assistance-history-dialog" + + attr_reader :work_package, :runs + + def initialize(work_package:, runs:) + super + @work_package = work_package + @runs = runs + end + + def run_path(run) + url_helpers.work_package_ai_assistance_run_path(work_package, run) + end + + def run_label(run) + "#{run.skill.assistant.name} · #{run.skill.display_label}" + end + end + end +end diff --git a/app/components/ai/assistance/run_component.html.erb b/app/components/ai/assistance/run_component.html.erb new file mode 100644 index 000000000000..c83b4fc9fba0 --- /dev/null +++ b/app/components/ai/assistance/run_component.html.erb @@ -0,0 +1,61 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= component_wrapper(style: "position: relative;") do %> + <%= render(Primer::Beta::Text.new(tag: :p, color: :muted, font_size: :small, mb: 3)) do %> + <%= t("ai_assistance.run.started_at", time: started_at) %> + <% end %> + + <% if findings.empty? %> + <%= render(Primer::Beta::Blankslate.new(border: true, data: { test_selector: "ai-assistance-no-findings" })) do |slate| %> + <% slate.with_visual_icon(icon: :"check-circle") %> + <% slate.with_heading(tag: :h3).with_content(t("ai_assistance.run.no_findings")) %> + <% slate.with_description { t("ai_assistance.run.no_findings_description") } %> + <% end %> + <% else %> + <%= render(Primer::Beta::BorderBox.new(data: { test_selector: "ai-assistance-findings" })) do |box| %> + <% findings.each do |finding| %> + <% box.with_row do %> + <%= render(AI::Assistance::FindingComponent.new(finding:)) %> + <% end %> + <% end %> + <% end %> + <% end %> + + <% if not_checked.any? %> + <%= render(Primer::Beta::Heading.new(tag: :h4, font_size: :normal, mt: 4, mb: 2)) { t("ai_assistance.run.not_checked") } %> +
    + <% not_checked.each do |line| %> +
  • <%= render(Primer::Beta::Text.new(color: :muted)) { line } %>
  • + <% end %> +
+ <% end %> +<% end %> diff --git a/app/components/ai/assistance/run_component.rb b/app/components/ai/assistance/run_component.rb new file mode 100644 index 000000000000..b1865cee641d --- /dev/null +++ b/app/components/ai/assistance/run_component.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + class RunComponent < ApplicationComponent + include OpTurbo::Streamable + + attr_reader :run + + def initialize(run:) + super + @run = run + end + + def wrapper_key + "ai-assistance-run-#{run.id}" + end + + def findings + @findings ||= run.findings.by_severity.to_a + end + + def not_checked + run.not_checked.map do |entry| + I18n.t("ai_assistance.not_checked.#{entry['reason']}", + default: I18n.t("ai_assistance.not_checked.default", area: entry["area"])) + end + end + + def started_at + helpers.format_time(run.created_at) + end + end + end +end diff --git a/app/components/ai/assistance/run_dialog_component.html.erb b/app/components/ai/assistance/run_dialog_component.html.erb new file mode 100644 index 000000000000..1f4474051aaa --- /dev/null +++ b/app/components/ai/assistance/run_dialog_component.html.erb @@ -0,0 +1,54 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :xlarge, data: { test_selector: "ai-assistance-run-dialog", "keep-open-on-submit": true })) do |dialog| %> + <% dialog.with_header(variant: :large) %> + <% dialog.with_body do %> + <%= render(AI::Assistance::RunComponent.new(run:)) %> + <% end %> + <% dialog.with_footer(show_divider: true) do %> + <%= render(Primer::Beta::Button.new(tag: :a, + href: runs_path, + scheme: :invisible, + data: { controller: "async-dialog", turbo_stream: true })) do |button| %> + <% button.with_leading_visual_icon(icon: :history) %> + <%= t("ai_assistance.history.title") %> + <% end %> + <%= primer_form_with(url: runs_path, method: :post) do %> + <%= hidden_field_tag :skill_id, run.skill_id %> + <%= render(Primer::Beta::Button.new(type: :submit, data: { test_selector: "ai-assistance-run-again" })) do |button| %> + <% button.with_leading_visual_icon(icon: :sync) %> + <%= t("ai_assistance.run.run_again") %> + <% end %> + <% end %> + <%= render(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) { t(:button_close) } %> + <% end %> +<% end %> diff --git a/app/components/ai/assistance/run_dialog_component.rb b/app/components/ai/assistance/run_dialog_component.rb new file mode 100644 index 000000000000..87e0796490e3 --- /dev/null +++ b/app/components/ai/assistance/run_dialog_component.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + class RunDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "ai-assistance-run-dialog" + + attr_reader :run + + def initialize(run:) + super + @run = run + end + + def title + "#{run.skill.assistant.name} · #{run.skill.display_label}" + end + + def skill_type + AI::AssistantSkills::Registry.fetch(run.skill.skill_key) + end + + def runs_path + url_helpers.work_package_ai_assistance_runs_path(run.work_package) + end + end + end +end diff --git a/app/controllers/concerns/work_packages/ai_assistance/scoped.rb b/app/controllers/concerns/work_packages/ai_assistance/scoped.rb new file mode 100644 index 000000000000..f1e856c621f9 --- /dev/null +++ b/app/controllers/concerns/work_packages/ai_assistance/scoped.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Shared by the AI Assistance controllers: the feature flag, the work package and the +# permission. Every run is looked up through the current user, so nobody sees another +# user's run. +module WorkPackages::AIAssistance::Scoped + extend ActiveSupport::Concern + + included do + before_action :require_feature + before_action :find_work_package + before_action :authorize + end + + private + + def require_feature + render_404 unless OpenProject::FeatureDecisions.ai_assistance_active? + end + + def find_work_package + @work_package = WorkPackage.visible.find(params[:work_package_id]) + @project = @work_package.project + rescue ActiveRecord::RecordNotFound + render_404 + end + + def runs + AI::AssistantRun.for(current_user, @work_package) + end + + def find_run + @run = runs.find(params[:ai_assistance_run_id] || params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def available_skills + AI::AssistantSkill + .active + .joins(:assistant) + .merge(AI::Assistant.active) + .includes(:assistant) + .order("ai_assistants.position", "ai_assistants.id", :position, :id) + .select { |skill| skill_available?(skill) } + end + + def skill_available?(skill) + skill_type = AI::AssistantSkills::Registry.fetch(skill.skill_key) + skill_type&.available?(@work_package, current_user) + end +end diff --git a/app/controllers/work_packages/ai_assistance/findings_controller.rb b/app/controllers/work_packages/ai_assistance/findings_controller.rb new file mode 100644 index 000000000000..1c2529b9a730 --- /dev/null +++ b/app/controllers/work_packages/ai_assistance/findings_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackages::AIAssistance::FindingsController < ApplicationController + include OpTurbo::ComponentStream + include WorkPackages::AIAssistance::Scoped + + before_action :find_run + before_action :find_finding + + def dismiss + reason = params[:reason].presence_in(AI::AssistantRunFinding.dismiss_reasons.keys) + @finding.update!(state: "dismissed", dismiss_reason: reason) if @finding.open? + + replace_via_turbo_stream(component: AI::Assistance::FindingComponent.new(finding: @finding)) + respond_with_turbo_streams + end + + private + + def find_finding + @finding = @run.findings.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end +end diff --git a/app/controllers/work_packages/ai_assistance/runs_controller.rb b/app/controllers/work_packages/ai_assistance/runs_controller.rb new file mode 100644 index 000000000000..2d5968addcd0 --- /dev/null +++ b/app/controllers/work_packages/ai_assistance/runs_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackages::AIAssistance::RunsController < ApplicationController + include OpTurbo::ComponentStream + include WorkPackages::AIAssistance::Scoped + + before_action :find_run, only: %i[show] + + # Both open a dialog. A plain page request (a link opened in a new tab) goes back to + # the work package instead of failing. + def index + respond_with_dialog AI::Assistance::HistoryDialogComponent.new(work_package: @work_package, + runs: runs.newest_first.includes(skill: :assistant)) do |format| + format.html { redirect_to work_package_path(@work_package) } + end + end + + def show + respond_with_dialog AI::Assistance::RunDialogComponent.new(run: @run) do |format| + format.html { redirect_to work_package_path(@work_package) } + end + end + + def create + skill = available_skills.find { |p| p.id.to_s == params[:skill_id].to_s } + return render_404 unless skill + + call = AI::AssistantRuns::RunService.new(skill:, work_package: @work_package, user: current_user).call + + if call.success? + respond_with_dialog AI::Assistance::RunDialogComponent.new(run: call.result) + else + render_error_flash_message_via_turbo_stream(message: call.message) + respond_with_turbo_streams(status: :unprocessable_entity) + end + end +end diff --git a/app/services/ai/assistant_runs/run_service.rb b/app/services/ai/assistant_runs/run_service.rb new file mode 100644 index 000000000000..e21871425956 --- /dev/null +++ b/app/services/ai/assistant_runs/run_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module AssistantRuns + # Starts a run of a skill on a work package for a user. The rules run at once, + # in the user's locale; the run and its findings are stored for that user only. + class RunService + attr_reader :skill, :work_package, :user + + def initialize(skill:, work_package:, user:) + @skill = skill + @work_package = work_package + @user = user + end + + def call + skill_type = AI::AssistantSkills::Registry.fetch(skill.skill_key) + return ServiceResult.failure(message: I18n.t("ai_assistance.errors.unknown_skill")) unless skill_type + + run = AI::AssistantRun.transaction { create_run(skill_type) } + ServiceResult.success(result: run) + end + + private + + def create_run(skill_type) + run = AI::AssistantRun.create!(skill:, work_package:, user:, + work_package_lock_version: work_package.lock_version, + locale: locale.to_s) + + I18n.with_locale(locale) do + context = skill_type.build_context(work_package:, user:) + skill_type.rules.flat_map { |rule| rule.call(context) }.each do |finding| + run.findings.create!(finding.to_record_attributes.merge(base_lock_version: run.work_package_lock_version)) + end + run.not_checked = context.not_checked + end + + run.update!(status: "finished") + run + end + + def locale + @locale ||= user.language.presence&.to_sym.then { |l| I18n.available_locales.include?(l) ? l : I18n.default_locale } + end + end + end +end diff --git a/app/services/ai/assistant_skills/base.rb b/app/services/ai/assistant_skills/base.rb index e3dcee919382..6826049ff71d 100644 --- a/app/services/ai/assistant_skills/base.rb +++ b/app/services/ai/assistant_skills/base.rb @@ -52,6 +52,10 @@ def rules [] end + def build_context(work_package:, user:) + raise SubclassResponsibilityError + end + def available?(_work_package, _user) true end diff --git a/app/services/ai/assistant_skills/health_check/skill.rb b/app/services/ai/assistant_skills/health_check/skill.rb index adcceca4ca7e..0adbbaa37552 100644 --- a/app/services/ai/assistant_skills/health_check/skill.rb +++ b/app/services/ai/assistant_skills/health_check/skill.rb @@ -41,6 +41,10 @@ def icon "pulse" end + def build_context(work_package:, user:) + Context.new(work_package:, user:) + end + def rules [ Rules::ClosedWithOpenDescendants, diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index ef7161e794d2..b7de04b4bb0f 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -88,6 +88,7 @@ OpenProject::Inflector.inflection( "ai" => "AI", + "ai_assistance" => "AIAssistance", "rss" => "RSS", "sha1" => "SHA1", "sso" => "SSO", diff --git a/config/locales/en.yml b/config/locales/en.yml index 766ff932dfc6..a6557dc48e45 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2330,6 +2330,44 @@ en: workflow_button: "Workflow" ai_assistance: button: "AI Assistance" + areas: + content: "Content" + pull_requests: "Pull requests" + relations: "Relations" + status: "Status" + tree: "Hierarchy" + errors: + unknown_skill: "This skill is not available." + finding: + applied: "Applied" + dismiss: "Dismiss" + dismiss_reasons: + already_known: "Dismiss: already known" + not_relevant: "Dismiss: not relevant" + wrong: "Dismiss: wrong" + dismissed: "Dismissed" + history: + empty: "You did not run an assistant on this work package yet." + findings: + one: "1 finding" + other: "%{count} findings" + zero: "No findings" + title: "History" + not_checked: + default: "%{area} was not checked." + no_permission_github: "Linked GitHub pull requests: you do not have permission to see them." + no_permission_gitlab: "Linked GitLab merge requests: you do not have permission to see them." + too_many_descendants: "Only the first 200 descendants were checked." + run: + no_findings: "No findings" + no_findings_description: "Everything that was checked is consistent." + not_checked: "Not checked" + run_again: "Run again" + started_at: "Checked at %{time}. Only you can see this result." + severities: + high: "High" + low: "Low" + medium: "Medium" skills: health_check: label: "Check health" diff --git a/config/routes.rb b/config/routes.rb index d2fdd65d9cba..7f601b0cdf64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1268,6 +1268,21 @@ end end + resource :ai_assistance_menu, path: "ai_assistance/menu", controller: "work_packages/ai_assistance/menus", + only: :show do + get :items + end + resources :ai_assistance_runs, path: "ai_assistance/runs", controller: "work_packages/ai_assistance/runs", + only: %i[index show create] do + get :poll, on: :member + resources :findings, controller: "work_packages/ai_assistance/findings", only: [] do + member do + post :apply + post :dismiss + end + end + end + resources :hierarchy_relations, only: %i[new create destroy], controller: "work_package_hierarchy_relations" resources :children, only: %i[new create], controller: "work_package_children" do diff --git a/db/migrate/20261012120000_create_ai_assistants.rb b/db/migrate/20261012120000_create_ai_assistants.rb index d13da872aa63..2ab74c22ebca 100644 --- a/db/migrate/20261012120000_create_ai_assistants.rb +++ b/db/migrate/20261012120000_create_ai_assistants.rb @@ -85,7 +85,7 @@ def create_runs t.references :skill, null: false, foreign_key: { to_table: :ai_assistant_skills, on_delete: :cascade } t.references :user, null: false, foreign_key: { on_delete: :cascade }, index: false t.references :work_package, null: false, foreign_key: { on_delete: :cascade } - t.integer :lock_version, null: false + t.integer :work_package_lock_version, null: false # Why the run started: "menu" now; "automation" and "schedule" once Automations can run skills. t.string :trigger, null: false, default: "menu" t.references :automation, foreign_key: { on_delete: :nullify }, index: false diff --git a/spec/components/ai/assistance/finding_component_spec.rb b/spec/components/ai/assistance/finding_component_spec.rb new file mode 100644 index 000000000000..727a371ef53a --- /dev/null +++ b/spec/components/ai/assistance/finding_component_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe AI::Assistance::FindingComponent, type: :component do + let(:run) { create(:ai_assistant_run, :finished) } + let(:finding) do + create(:ai_assistant_run_finding, + run:, + text: " is wrong", + evidence: [{ "text" => "#1", "work_package_id" => run.work_package_id }, + { "text" => "PR", "url" => "javascript:alert(1)" }]) + end + + subject(:rendered) { render_inline(described_class.new(finding:)) } + + it "escapes the text and the evidence" do + expect(rendered.to_html).not_to include(" is wrong") + end + + it "links work package evidence and does not link unsafe URLs" do + expect(rendered).to have_link("#1", href: "/work_packages/#{run.work_package_id}") + expect(rendered).to have_no_link("PR") + end + + it "offers to dismiss an open finding" do + expect(rendered).to have_test_selector("ai-assistance-dismiss") + end + + context "when dismissed" do + before { finding.update!(state: "dismissed") } + + it "shows the state and no actions" do + expect(rendered).to have_text("Dismissed") + expect(rendered).not_to have_test_selector("ai-assistance-dismiss") + end + end +end diff --git a/spec/components/ai/assistance/run_component_spec.rb b/spec/components/ai/assistance/run_component_spec.rb new file mode 100644 index 000000000000..fa0e197a41b2 --- /dev/null +++ b/spec/components/ai/assistance/run_component_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe AI::Assistance::RunComponent, type: :component do + let(:run) { create(:ai_assistant_run, :finished) } + + subject(:rendered) { render_inline(described_class.new(run:)) } + + context "without findings" do + it "says that there are no findings" do + expect(rendered).to have_test_selector("ai-assistance-no-findings") + end + end + + context "with findings" do + before do + create(:ai_assistant_run_finding, run:, severity: "low", text: "A low finding") + create(:ai_assistant_run_finding, run:, severity: "high", text: "A high finding") + end + + it "lists them by severity" do + texts = rendered.css("[data-test-selector='ai-assistance-finding'] p").map(&:text) + + expect(texts).to eq(["A high finding", "A low finding"]) + end + end + + context "with inputs that were not checked" do + before do + run.add_not_checked(:pull_requests, :no_permission_github) + run.save! + end + + it "lists them" do + expect(rendered).to have_css("[data-test-selector='ai-assistance-not-checked'] li", + text: "Linked GitHub pull requests") + end + end +end diff --git a/spec/factories/ai_assistant_factory.rb b/spec/factories/ai_assistant_factory.rb index 6228c25be16a..6e87550160f4 100644 --- a/spec/factories/ai_assistant_factory.rb +++ b/spec/factories/ai_assistant_factory.rb @@ -48,7 +48,7 @@ skill { association :ai_assistant_skill } user work_package - lock_version { work_package.lock_version } + work_package_lock_version { work_package.lock_version } locale { "en" } trait :finished do @@ -70,7 +70,7 @@ source { "ai" } rule_key { nil } change_set { AI::AssistantRunFinding.change_set_for({ "description" => "A better description" }) } - base_lock_version { run.lock_version } + base_lock_version { run.work_package_lock_version } end end end diff --git a/spec/requests/work_packages/ai_assistance/runs_spec.rb b/spec/requests/work_packages/ai_assistance/runs_spec.rb new file mode 100644 index 000000000000..b0ca1a9379a9 --- /dev/null +++ b/spec/requests/work_packages/ai_assistance/runs_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe "AI Assistance runs", :skip_csrf, type: :rails_request, with_flag: { ai_assistance: true } do + shared_let(:project) { create(:project) } + shared_let(:skill) { create(:ai_assistant_skill) } + + let(:permissions) { %i[view_work_packages use_ai_assistance] } + let(:user) { create(:user, member_with_permissions: { project => permissions }) } + let(:work_package) { create(:work_package, project:) } + + current_user { user } + + describe "POST create" do + subject(:request) do + post "/work_packages/#{work_package.id}/ai_assistance/runs", params: { skill_id: skill.id }, as: :turbo_stream + end + + it "runs the skill and opens the run dialog" do + request + + expect(response).to have_http_status(:ok) + expect(response).to have_turbo_stream(action: "dialog") + expect(AI::AssistantRun.for(user, work_package).count).to eq(1) + end + + context "without the permission" do + let(:permissions) { %i[view_work_packages] } + + it "is forbidden and stores nothing" do + request + + expect(response).to have_http_status(:forbidden) + expect(AI::AssistantRun.count).to eq(0) + end + end + + context "when the skill is inactive" do + before { skill.update!(active: false) } + + after { skill.update!(active: true) } + + it "responds with not found" do + request + + expect(response).to have_http_status(:not_found) + end + end + + context "with the feature flag off", with_flag: { ai_assistance: false } do + it "responds with not found" do + request + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET show" do + let(:run) { create(:ai_assistant_run, :finished, skill:, user:, work_package:) } + + it "opens the run dialog" do + get "/work_packages/#{work_package.id}/ai_assistance/runs/#{run.id}", as: :turbo_stream + + expect(response).to have_turbo_stream(action: "dialog") + end + + context "for another user's run" do + let(:run) { create(:ai_assistant_run, :finished, skill:, work_package:) } + + it "responds with not found" do + get "/work_packages/#{work_package.id}/ai_assistance/runs/#{run.id}", as: :turbo_stream + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET index" do + before do + create(:ai_assistant_run, :finished, skill:, user:, work_package:) + create(:ai_assistant_run, :finished, skill:, work_package:) + end + + it "lists only the user's runs" do + get "/work_packages/#{work_package.id}/ai_assistance/runs", as: :turbo_stream + + expect(response).to have_turbo_stream(action: "dialog") + expect(response.body.scan("/ai_assistance/runs/").size).to eq(1) + end + end + + describe "POST dismiss" do + let(:run) { create(:ai_assistant_run, :finished, skill:, user:, work_package:) } + let(:finding) { create(:ai_assistant_run_finding, run:) } + + it "dismisses the finding with the reason" do + post "/work_packages/#{work_package.id}/ai_assistance/runs/#{run.id}/findings/#{finding.id}/dismiss", + params: { reason: "wrong" }, as: :turbo_stream + + expect(finding.reload).to have_attributes(state: "dismissed", dismiss_reason: "wrong") + expect(response).to have_turbo_stream(action: "replace") + end + + it "ignores an unknown reason" do + post "/work_packages/#{work_package.id}/ai_assistance/runs/#{run.id}/findings/#{finding.id}/dismiss", + params: { reason: "bored" }, as: :turbo_stream + + expect(finding.reload).to have_attributes(state: "dismissed", dismiss_reason: nil) + end + end +end diff --git a/spec/services/ai/assistant_runs/run_service_spec.rb b/spec/services/ai/assistant_runs/run_service_spec.rb new file mode 100644 index 000000000000..108faf7df791 --- /dev/null +++ b/spec/services/ai/assistant_runs/run_service_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe AI::AssistantRuns::RunService do + shared_let(:project) { create(:project) } + shared_let(:open_status) { create(:status) } + shared_let(:closed_status) { create(:closed_status) } + shared_let(:user) { create(:user, language: "de", member_with_permissions: { project => %i[view_work_packages] }) } + shared_let(:skill) { create(:ai_assistant_skill) } + + let(:work_package) { create(:work_package, project:, status: closed_status) } + + subject(:call) { described_class.new(skill:, work_package: work_package.reload, user:).call } + + before { create(:work_package, project:, parent: work_package, status: open_status) } + + it "stores a finished run with the rule findings" do + run = call.result + + expect(call).to be_success + expect(run).to be_finished + expect(run).to have_attributes(user:, work_package:, work_package_lock_version: work_package.reload.lock_version) + expect(run.findings.map(&:rule_key)).to include("closed_with_open_descendants") + expect(run.findings).to all(have_attributes(source: "rule", change_set: nil)) + end + + it "writes the findings in the user's language" do + expect(call.result.locale).to eq("de") + expect(call.result.findings.first.text).to eq( + I18n.t("ai_assistance.health_check.rules.closed_with_open_descendants", count: 1, locale: :de) + ) + end + + context "with a skill key that is not registered" do + let(:skill) { create(:ai_assistant_skill, skill_key: "unknown") } + + it "fails and stores nothing" do + expect(call).to be_failure + expect(AI::AssistantRun.count).to eq(0) + end + end +end From 9a75fd83832ee25d0b444c8744a3d68f619e218e Mon Sep 17 00:00:00 2001 From: Tomas Hykel Date: Mon, 12 Oct 2026 19:24:22 +0200 Subject: [PATCH 08/86] Add the AI Assistance menu to the work package toolbars Co-Authored-By: Claude Opus 5.5 --- .../assistance/menu_button_component.html.erb | 49 +++++++++ .../ai/assistance/menu_button_component.rb | 55 +++++++++ .../assistance/menu_items_component.html.erb | 52 +++++++++ .../ai/assistance/menu_items_component.rb | 55 +++++++++ .../ai_assistance/menus_controller.rb | 58 ++++++++++ .../wp-ai-assistance-slot.component.html | 3 + .../wp-ai-assistance-slot.component.ts | 104 ++++++++++++++++++ .../wp-details/wp-details-toolbar.html | 2 + .../openproject-work-packages.module.ts | 4 + .../routing/wp-full-view/wp-full-view.html | 3 + .../work_packages/ai_assistance/menus_spec.rb | 89 +++++++++++++++ 11 files changed, 474 insertions(+) create mode 100644 app/components/ai/assistance/menu_button_component.html.erb create mode 100644 app/components/ai/assistance/menu_button_component.rb create mode 100644 app/components/ai/assistance/menu_items_component.html.erb create mode 100644 app/components/ai/assistance/menu_items_component.rb create mode 100644 app/controllers/work_packages/ai_assistance/menus_controller.rb create mode 100644 frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html create mode 100644 frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts create mode 100644 spec/requests/work_packages/ai_assistance/menus_spec.rb diff --git a/app/components/ai/assistance/menu_button_component.html.erb b/app/components/ai/assistance/menu_button_component.html.erb new file mode 100644 index 000000000000..33c9e4f2ecc7 --- /dev/null +++ b/app/components/ai/assistance/menu_button_component.html.erb @@ -0,0 +1,49 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= content_tag("turbo-frame", id: FRAME_ID) do %> + <% unless empty? %> + <%= render(Primer::Alpha::ActionMenu.new(menu_id: MENU_ID, + src: items_path, + anchor_align: :end, + data: { test_selector: "wp-ai-assistance-menu" })) do |menu| %> + <% if compact? %> + <% menu.with_show_button(icon: :sparkle, scheme: :default, "aria-label": t("ai_assistance.button"), tooltip_direction: :s) %> + <% else %> + <% menu.with_show_button(scheme: :default) do |button| %> + <% button.with_leading_visual_icon(icon: :sparkle) %> + <% button.with_trailing_action_icon(icon: :"triangle-down") %> + <%= t("ai_assistance.button") %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/ai/assistance/menu_button_component.rb b/app/components/ai/assistance/menu_button_component.rb new file mode 100644 index 000000000000..e98e5343596d --- /dev/null +++ b/app/components/ai/assistance/menu_button_component.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + class MenuButtonComponent < ApplicationComponent + FRAME_ID = "wp-ai-assistance-menu" + MENU_ID = "wp-ai-assistance-menu-list" + + attr_reader :work_package + + def initialize(work_package:, compact: false, empty: false) + super + @work_package = work_package + @compact = compact + @empty = empty + end + + def empty? = @empty + + def compact? = @compact + + def items_path + url_helpers.items_work_package_ai_assistance_menu_path(work_package) + end + end + end +end diff --git a/app/components/ai/assistance/menu_items_component.html.erb b/app/components/ai/assistance/menu_items_component.html.erb new file mode 100644 index 000000000000..311164df1985 --- /dev/null +++ b/app/components/ai/assistance/menu_items_component.html.erb @@ -0,0 +1,52 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= render(Primer::Alpha::ActionMenu::List.new(menu_id:)) do |list| %> + <% skills.each do |skill| %> + <% type = skill_type(skill) %> + <% list.with_item(label: skill.display_label, + href: runs_path, + form_arguments: { method: :post, inputs: [{ name: "skill_id", value: skill.id }] }, + data: { test_selector: "wp-ai-assistance-skill-#{skill.skill_key}" }) do |item| %> + <% item.with_leading_visual_icon(icon: type.icon) %> + <% item.with_trailing_visual_label(scheme: :secondary) { skill.assistant.name } %> + <% if skill.display_description.present? %> + <% item.with_description { skill.display_description } %> + <% end %> + <% end %> + <% end %> + <% list.with_divider %> + <% list.with_item(label: t("ai_assistance.history.title"), + href: runs_path, + content_arguments: { data: { controller: "async-dialog", turbo_stream: true } }) do |item| %> + <% item.with_leading_visual_icon(icon: :history) %> + <% end %> +<% end %> diff --git a/app/components/ai/assistance/menu_items_component.rb b/app/components/ai/assistance/menu_items_component.rb new file mode 100644 index 000000000000..37d87ac77b31 --- /dev/null +++ b/app/components/ai/assistance/menu_items_component.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module AI + module Assistance + # The deferred list of the AI Assistance menu: one flat item per skill, with the + # assistant's name as an inline label, and the user's history at the end. + class MenuItemsComponent < ApplicationComponent + attr_reader :work_package, :skills + + def initialize(work_package:, skills:) + super + @work_package = work_package + @skills = skills + end + + def menu_id = MenuButtonComponent::MENU_ID + + def runs_path + url_helpers.work_package_ai_assistance_runs_path(work_package) + end + + def skill_type(skill) + AI::AssistantSkills::Registry.fetch(skill.skill_key) + end + end + end +end diff --git a/app/controllers/work_packages/ai_assistance/menus_controller.rb b/app/controllers/work_packages/ai_assistance/menus_controller.rb new file mode 100644 index 000000000000..98bf5034147a --- /dev/null +++ b/app/controllers/work_packages/ai_assistance/menus_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackages::AIAssistance::MenusController < ApplicationController + include WorkPackages::AIAssistance::Scoped + + # The toolbar loads the menu into a turbo frame on every work package. A user who may + # not use it gets an empty frame, not an error inside the toolbar. + skip_before_action :authorize + before_action :authorize_or_render_empty + + def show + render AI::Assistance::MenuButtonComponent.new(work_package: @work_package, + compact: params[:compact] == "true"), + layout: false + end + + def items + render AI::Assistance::MenuItemsComponent.new(work_package: @work_package, + skills: available_skills), + layout: false + end + + private + + def authorize_or_render_empty + return if current_user.allowed_in_work_package?(:use_ai_assistance, @work_package) && available_skills.any? + + render AI::Assistance::MenuButtonComponent.new(work_package: @work_package, empty: true), layout: false + end +end diff --git a/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html new file mode 100644 index 000000000000..a4d30e6da888 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html @@ -0,0 +1,3 @@ +@if (enabled) { + +} diff --git a/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts new file mode 100644 index 000000000000..8d139ae4e740 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts @@ -0,0 +1,104 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + ChangeDetectionStrategy, + Component, + inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; + +// WorkPackagesController::UPDATED_EVENT_NAME, dispatched once an applied proposal changed the work package. +const WORK_PACKAGE_UPDATED_EVENT = 'op-dispatched:work-packages:updated'; + +/** + * Places the Rails-rendered AI Assistance menu into the work package toolbar, and reloads + * the work package after a proposal from that menu changed it, so the next inline edit does + * not conflict. + */ +@Component({ + selector: 'op-wp-ai-assistance-slot', + templateUrl: './wp-ai-assistance-slot.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class WorkPackageAiAssistanceSlotComponent implements OnInit, OnDestroy { + private readonly pathHelper = inject(PathHelperService); + private readonly configuration = inject(ConfigurationService); + private readonly apiV3Service = inject(ApiV3Service); + private readonly halEvents = inject(HalEventsService); + + @Input() public workPackage:WorkPackageResource; + + @Input() public compact = false; + + public enabled = false; + + public turboFrameSrc:string; + + private readonly onUpdated = (event:Event):void => { + const detail = (event as CustomEvent<{ work_package_id?:number|string }>).detail; + if (String(detail?.work_package_id) !== String(this.workPackage.id)) { + return; + } + + void this.apiV3Service + .work_packages + .id(this.workPackage.id!) + .refresh() + .then((workPackage) => this.halEvents.push(workPackage, { eventType: 'updated' })); + }; + + ngOnInit():void { + this.enabled = this.configuration.activeFeatureFlags.includes('aiAssistance'); + if (!this.enabled) { + return; + } + + const url = new URL( + `${this.pathHelper.staticBase}/work_packages/${this.workPackage.id}/ai_assistance/menu`, + window.location.origin, + ); + if (this.compact) { + url.searchParams.set('compact', 'true'); + } + this.turboFrameSrc = url.toString(); + + document.addEventListener(WORK_PACKAGE_UPDATED_EVENT, this.onUpdated); + } + + ngOnDestroy():void { + document.removeEventListener(WORK_PACKAGE_UPDATED_EVENT, this.onUpdated); + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html index 3706b4ff49dd..4e3006188aa3 100644 --- a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html +++ b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html @@ -17,6 +17,8 @@ /> } + +