From 2e57030482ad4f0358440a04fedb8364a40185da Mon Sep 17 00:00:00 2001 From: Pavel Balashou Date: Tue, 10 Feb 2026 11:39:14 +0100 Subject: [PATCH] Use state_machine to manage jira_import state. --- Gemfile | 3 + Gemfile.lock | 3 + .../import/jira/import_runs/row_component.rb | 2 +- .../import_runs/status_badge_component.rb | 15 ++- ...ard_step_confirm_import_component.html.erb | 12 +-- .../wizard_step_fetch_data_component.html.erb | 10 +- ...izard_step_import_scope_component.html.erb | 14 +-- .../wizard_step_review_component.html.erb | 18 ++-- .../import/jira/import_runs_controller.rb | 42 +++----- .../jira/import_runs/component_streams.rb | 2 +- app/models/jira_import.rb | 85 ++++----------- app/models/jira_import_state_machine.rb | 95 ++++++++++++++++ app/models/jira_import_transition.rb | 13 +++ .../import/jira/import_runs/history.html.erb | 101 ++++++++++++++++++ .../import/jira/import_runs/show.html.erb | 2 +- .../jira_fetch_and_import_projects_job.rb | 5 +- app/workers/jira_instance_meta_data_job.rb | 6 +- app/workers/jira_projects_meta_data_job.rb | 8 +- app/workers/jira_revert_jira_import_job.rb | 4 +- config/initializers/statesman.rb | 4 + config/locales/en.yml | 1 + config/routes.rb | 1 + ...04161727_create_jira_import_transitions.rb | 32 ++++++ ...0260209131615_remove_jira_import_status.rb | 7 ++ 24 files changed, 350 insertions(+), 135 deletions(-) create mode 100644 app/models/jira_import_state_machine.rb create mode 100644 app/models/jira_import_transition.rb create mode 100644 app/views/admin/import/jira/import_runs/history.html.erb create mode 100644 config/initializers/statesman.rb create mode 100644 db/migrate/20260204161727_create_jira_import_transitions.rb create mode 100644 db/migrate/20260209131615_remove_jira_import_status.rb diff --git a/Gemfile b/Gemfile index cea6d80f8ca..62fca078546 100644 --- a/Gemfile +++ b/Gemfile @@ -167,6 +167,9 @@ gem "meta-tags", "~> 2.22.2" gem "paper_trail", "~> 17.0.0" +# State machine with audit trail +gem "statesman", "~> 13.1.0" + gem "op-clamav-client", "~> 3.4", require: "clamav" # Global ID for polymorphic associations diff --git a/Gemfile.lock b/Gemfile.lock index 829c7f04ded..0080866315b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1405,6 +1405,7 @@ GEM sprockets (>= 3.0.0) ssrf_filter (1.0.8) stackprof (0.2.27) + statesman (13.1.0) store_attribute (2.0.1) activerecord (>= 6.1) stringex (2.8.6) @@ -1727,6 +1728,7 @@ DEPENDENCIES sprockets (~> 3.7.2) sprockets-rails (~> 3.5.1) stackprof + statesman (~> 13.1.0) store_attribute (~> 2.0) stringex (~> 2.8.5) structured_warnings (~> 0.5.0) @@ -2226,6 +2228,7 @@ CHECKSUMS sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e ssrf_filter (1.0.8) sha256=03f49f54837e407d43ee93ec733a8a94dc1bcf8185647ac61606e63aaedaa0db stackprof (0.2.27) sha256=aff6d28656c852e74cf632cc2046f849033dc1dedffe7cb8c030d61b5745e80c + statesman (13.1.0) sha256=3ecb78466dfd2682e433f335a7722aa0e5b8c6853d72d83e460151b8af17a84e store_attribute (2.0.1) sha256=643655e4800655b58379e8b01bd524f5586093a9d88698483ac8762cda25b5ab stringex (2.8.6) sha256=c7b382d2b2a47a1e1646f256df201c48d487d6296fbb289d76802f67f5e929c4 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 diff --git a/app/components/admin/import/jira/import_runs/row_component.rb b/app/components/admin/import/jira/import_runs/row_component.rb index 40b8f318923..155a9b093ab 100644 --- a/app/components/admin/import/jira/import_runs/row_component.rb +++ b/app/components/admin/import/jira/import_runs/row_component.rb @@ -42,7 +42,7 @@ module Admin::Import::Jira::ImportRuns end def status - render(Admin::Import::Jira::ImportRuns::StatusBadgeComponent.new(model.status)) + render(Admin::Import::Jira::ImportRuns::StatusBadgeComponent.new(model.current_state)) end def last_changed diff --git a/app/components/admin/import/jira/import_runs/status_badge_component.rb b/app/components/admin/import/jira/import_runs/status_badge_component.rb index de4cb6a1133..0c39c924ced 100644 --- a/app/components/admin/import/jira/import_runs/status_badge_component.rb +++ b/app/components/admin/import/jira/import_runs/status_badge_component.rb @@ -41,13 +41,18 @@ module Admin::Import::Jira::ImportRuns def status_color_scheme(status) case status - when JiraImport::IMPORT_ERROR, JiraImport::REVERT_ERROR, - JiraImport::INSTANCE_META_ERROR, JiraImport::PROJECTS_META_ERROR + when JiraImportStateMachine::IMPORT_ERROR, + JiraImportStateMachine::REVERT_ERROR, + JiraImportStateMachine::INSTANCE_META_ERROR, + JiraImportStateMachine::PROJECTS_META_ERROR :danger - when JiraImport::COMPLETED, JiraImport::REVERTED + when JiraImportStateMachine::COMPLETED, + JiraImportStateMachine::REVERTED :success - when JiraImport::INSTANCE_META_FETCHING, JiraImport::PROJECTS_META_FETCHING, - JiraImport::IMPORTING, JiraImport::REVERTING + when JiraImportStateMachine::INSTANCE_META_FETCHING, + JiraImportStateMachine::PROJECTS_META_FETCHING, + JiraImportStateMachine::IMPORTING, + JiraImportStateMachine::REVERTING :accent else :attention diff --git a/app/components/admin/import/jira/import_runs/wizard_step_confirm_import_component.html.erb b/app/components/admin/import/jira/import_runs/wizard_step_confirm_import_component.html.erb index 19c47956f28..29fe8ba35c4 100644 --- a/app/components/admin/import/jira/import_runs/wizard_step_confirm_import_component.html.erb +++ b/app/components/admin/import/jira/import_runs/wizard_step_confirm_import_component.html.erb @@ -32,17 +32,17 @@ See COPYRIGHT and LICENSE files for more details. concat(render(Primer::Beta::Text.new(font_weight: :semibold, tag: :div)) { I18n.t(:"admin.jira.run.wizard.sections.confirm_import.title") }) - if model.status_before?(JiraImport::PROJECTS_META_DONE) + if model.status_before?(JiraImportStateMachine::PROJECTS_META_DONE) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.confirm_import.caption") }) - elsif model.status_equal_or_after?(JiraImport::IMPORTED) + elsif model.status_equal_or_after?(JiraImportStateMachine::IMPORTED) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.confirm_import.caption_done") }) end end - if model.status?(JiraImport::PROJECTS_META_DONE, JiraImport::IMPORT_ERROR) + if model.in_state?(JiraImportStateMachine::PROJECTS_META_DONE, JiraImportStateMachine::IMPORT_ERROR) box.with_row(mb: 3) do render(Primer::Beta::Text.new) { I18n.t(:"admin.jira.run.wizard.sections.confirm_import.description") @@ -54,7 +54,7 @@ See COPYRIGHT and LICENSE files for more details. list: import_selection )) end - if model.status?(JiraImport::PROJECTS_META_DONE) + if model.in_state?(JiraImportStateMachine::PROJECTS_META_DONE) box.with_row do render(Primer::Beta::Button.new( scheme: :primary, @@ -66,12 +66,12 @@ See COPYRIGHT and LICENSE files for more details. I18n.t(:"admin.jira.run.wizard.sections.confirm_import.button_start") } end - elsif model.status?(JiraImport::IMPORT_ERROR) + elsif model.in_state?(JiraImportStateMachine::IMPORT_ERROR) box.with_row do render(Admin::Import::Jira::ImportRuns::ErrorBannerComponent.new(model, 'import')) end end - elsif model.status?(JiraImport::IMPORTING) + elsif model.in_state?(JiraImportStateMachine::IMPORTING) box.with_row(mt: 3, mb: 3) do render(Admin::Import::Jira::ImportRuns::ProgressBoxComponent.new( I18n.t(:"admin.jira.run.wizard.sections.confirm_import.label_progress") diff --git a/app/components/admin/import/jira/import_runs/wizard_step_fetch_data_component.html.erb b/app/components/admin/import/jira/import_runs/wizard_step_fetch_data_component.html.erb index ea2fd1ef33b..66ac35868a4 100644 --- a/app/components/admin/import/jira/import_runs/wizard_step_fetch_data_component.html.erb +++ b/app/components/admin/import/jira/import_runs/wizard_step_fetch_data_component.html.erb @@ -34,13 +34,13 @@ See COPYRIGHT and LICENSE files for more details. concat(render(Primer::Beta::Text.new(font_weight: :semibold, tag: :div)) { I18n.t(:"admin.jira.run.wizard.sections.fetch_data.title") }) - if model.status_equal_or_after?(JiraImport::INSTANCE_META_DONE) + if model.status_equal_or_after?(JiraImportStateMachine::INSTANCE_META_DONE) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.fetch_data.caption_done") }) end end - if model.status?(JiraImport::INSTANCE_META_DONE, JiraImport::CONFIGURING) + if model.in_state?(:instance_meta_done, :configuring) row.with_column do render( Primer::Beta::IconButton.new( @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details. end end end - if model.status?(JiraImport::INITIAL) + if model.in_state?(JiraImportStateMachine::INITIAL) box.with_row(mb: 3) do render(Primer::Beta::Text.new) { I18n.t(:"admin.jira.run.wizard.sections.fetch_data.description") @@ -73,11 +73,11 @@ See COPYRIGHT and LICENSE files for more details. I18n.t(:"admin.jira.run.wizard.sections.fetch_data.button_fetch") end end - elsif model.status?(JiraImport::INSTANCE_META_ERROR) + elsif model.in_state?(:instance_meta_error) box.with_row(mt: 3) do render(Admin::Import::Jira::ImportRuns::ErrorBannerComponent.new(model, 'fetch')) end - elsif model.status?(JiraImport::INSTANCE_META_FETCHING) + elsif model.in_state?(:instance_meta_fetching) box.with_row(mt: 3) do render(Admin::Import::Jira::ImportRuns::ProgressBoxComponent.new( I18n.t(:"admin.jira.run.wizard.sections.fetch_data.label_progress") diff --git a/app/components/admin/import/jira/import_runs/wizard_step_import_scope_component.html.erb b/app/components/admin/import/jira/import_runs/wizard_step_import_scope_component.html.erb index ccb4b942e45..042cff16476 100644 --- a/app/components/admin/import/jira/import_runs/wizard_step_import_scope_component.html.erb +++ b/app/components/admin/import/jira/import_runs/wizard_step_import_scope_component.html.erb @@ -32,17 +32,17 @@ See COPYRIGHT and LICENSE files for more details. concat(render(Primer::Beta::Text.new(font_weight: :semibold, tag: :div)) { I18n.t(:"admin.jira.run.wizard.sections.import_scope.title") }) - if model.status_before?(JiraImport::INSTANCE_META_DONE) + if model.status_before?(JiraImportStateMachine::INSTANCE_META_DONE) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.import_scope.caption") }) - elsif model.status_equal_or_after?(JiraImport::PROJECTS_META_DONE) + elsif model.status_equal_or_after?(JiraImportStateMachine::PROJECTS_META_DONE) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.import_scope.caption_done") }) end end - if model.status?(JiraImport::INSTANCE_META_DONE) + if model.in_state?(JiraImportStateMachine::INSTANCE_META_DONE) box.with_row(mb: 3, mt: 3) do render(Primer::Alpha::Banner.new(scheme: :warning, icon: :alert)) { I18n.t(:"admin.jira.run.wizard.sections.import_scope.label_info") @@ -82,7 +82,7 @@ See COPYRIGHT and LICENSE files for more details. I18n.t(:"admin.jira.run.wizard.sections.import_scope.button_continue") } end - elsif model.status?(JiraImport::CONFIGURING, JiraImport::PROJECTS_META_ERROR) + elsif model.in_state?(JiraImportStateMachine::CONFIGURING, JiraImportStateMachine::PROJECTS_META_ERROR) box.with_row(mb: 3, mt: 3) do render(Primer::Beta::Text.new) { I18n.t(:"admin.jira.run.wizard.sections.import_scope.label_import") @@ -99,7 +99,7 @@ See COPYRIGHT and LICENSE files for more details. I18n.t(:"admin.jira.run.wizard.sections.import_scope.button_select") end end - if model.status?(JiraImport::CONFIGURING) + if model.in_state?(JiraImportStateMachine::CONFIGURING) box.with_row do render(Primer::Beta::Button.new( scheme: :primary, @@ -112,12 +112,12 @@ See COPYRIGHT and LICENSE files for more details. I18n.t(:"admin.jira.run.wizard.sections.import_scope.button_continue") } end - elsif model.status?(JiraImport::PROJECTS_META_ERROR) + elsif model.in_state?(JiraImportStateMachine::PROJECTS_META_ERROR) box.with_row do render(Admin::Import::Jira::ImportRuns::ErrorBannerComponent.new(model, 'stats')) end end - elsif model.status?(JiraImport::PROJECTS_META_FETCHING) + elsif model.in_state?(JiraImportStateMachine::PROJECTS_META_FETCHING) box.with_row(mt: 3) do render(Admin::Import::Jira::ImportRuns::ProgressBoxComponent.new( I18n.t(:"admin.jira.run.wizard.sections.import_scope.label_progress") diff --git a/app/components/admin/import/jira/import_runs/wizard_step_review_component.html.erb b/app/components/admin/import/jira/import_runs/wizard_step_review_component.html.erb index 94349ab458e..9e6fdecfb09 100644 --- a/app/components/admin/import/jira/import_runs/wizard_step_review_component.html.erb +++ b/app/components/admin/import/jira/import_runs/wizard_step_review_component.html.erb @@ -28,14 +28,14 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= flex_layout do |box| - if model.status?(JiraImport::REVERTING, JiraImport::REVERTED, JiraImport::REVERT_ERROR) + if model.in_state?(JiraImportStateMachine::REVERTING, JiraImportStateMachine::REVERTED, JiraImportStateMachine::REVERT_ERROR) box.with_row do concat(render(Primer::Beta::Text.new(font_weight: :semibold, tag: :div)) { I18n.t(:"admin.jira.run.wizard.sections.import_result.label_revert") }) end - case model.status - when JiraImport::REVERTING + case model.current_state + when JiraImportStateMachine::REVERTING box.with_row(mt: 3) do render(Primer::Box.new(border: true, border_color: :accent, border_radius: 2, bg: :accent, p: 3)) do concat render(Primer::Beta::Spinner.new(size: :small)) @@ -44,13 +44,13 @@ See COPYRIGHT and LICENSE files for more details. } end end - when JiraImport::REVERTED + when JiraImportStateMachine::REVERTED box.with_row(mt: 3) do render(Primer::Alpha::Banner.new(scheme: :success, icon: :"check-circle")) { I18n.t(:"admin.jira.run.wizard.sections.import_result.label_reverted") } end - when JiraImport::REVERT_ERROR + when JiraImportStateMachine::REVERT_ERROR box.with_row(mt: 3) do render(Admin::Import::Jira::ImportRuns::ErrorBannerComponent.new(model, 'revert')) end @@ -60,20 +60,20 @@ See COPYRIGHT and LICENSE files for more details. concat(render(Primer::Beta::Text.new(font_weight: :semibold, tag: :div)) { I18n.t(:"admin.jira.run.wizard.sections.import_result.title") }) - if model.status_before?(JiraImport::IMPORTED) + if model.status_before?(JiraImportStateMachine::IMPORTED) concat(render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) { I18n.t(:"admin.jira.run.wizard.sections.import_result.caption") }) end end - if model.status?(JiraImport::IMPORTED) + if model.in_state?(JiraImportStateMachine::IMPORTED) box.with_row(mt: 3) do render(Primer::Alpha::Banner.new(scheme: :success, icon: :"check-circle")) { I18n.t(:"admin.jira.run.wizard.sections.import_result.info") } end end - if model.status?(JiraImport::IMPORTED, JiraImport::COMPLETED) + if model.in_state?(JiraImportStateMachine::IMPORTED, JiraImportStateMachine::COMPLETED) box.with_row(mt: 3, mb: 3) do render(Admin::Import::Jira::ImportRuns::InfoListBoxComponent.new( title: I18n.t(:"admin.jira.run.wizard.sections.import_result.label_results"), @@ -81,7 +81,7 @@ See COPYRIGHT and LICENSE files for more details. )) end end - if model.status?(JiraImport::IMPORTED) + if model.in_state?(JiraImportStateMachine::IMPORTED) box.with_row(mb: 3) do render(Primer::Alpha::Banner.new(scheme: :default, icon: :info)) { I18n.t(:"admin.jira.run.wizard.sections.import_result.preview_description") diff --git a/app/controllers/admin/import/jira/import_runs_controller.rb b/app/controllers/admin/import/jira/import_runs_controller.rb index 54cea3e3c65..5b3f471eb62 100644 --- a/app/controllers/admin/import/jira/import_runs_controller.rb +++ b/app/controllers/admin/import/jira/import_runs_controller.rb @@ -48,13 +48,13 @@ module Admin::Import::Jira menu_item :jira_import before_action :require_admin - before_action :find_jira_and_jira_import, only: %i[show continue remove revert_modal] + before_action :find_jira_and_jira_import, only: %i[show continue remove revert_modal history] def show; end def new jira = Jira.find(params[:jira_id]) - jira_import = JiraImport.create!(author_id: current_user.id, jira_id: jira.id, status: JiraImport::INITIAL) + jira_import = JiraImport.create!(author_id: current_user.id, jira_id: jira.id) redirect_to(admin_import_jira_run_path(jira_id: jira.id, id: jira_import.id)) end @@ -76,6 +76,10 @@ module Admin::Import::Jira redirect_to admin_import_jira_path(@jira), status: :see_other end + def history + @history = @jira_import.history + end + private def change_step(step) @@ -90,7 +94,7 @@ module Admin::Import::Jira def handle_error(error) respond_to do |format| format.turbo_stream do - render_error_flash_message_via_turbo_stream(message: error.message) + render_error_flash_message_via_turbo_stream(message: "#{error.message}\n#{error.backtrace}") respond_with_turbo_streams end format.html do @@ -101,49 +105,31 @@ module Admin::Import::Jira end def init - return unless @jira_import.status_equal_or_before?(JiraImport::CONFIGURING) - - @jira_import.update!(status: JiraImport::INITIAL) + @jira_import.transition_to!(:initial) end def fetch_instance_meta - return unless @jira_import.status_equal_or_before?(JiraImport::CONFIGURING) - - job = JiraInstanceMetaDataJob.perform_later(@jira_import.id) - @jira_import.update!(status: JiraImport::INSTANCE_META_FETCHING, job_id: job.job_id) + @jira_import.transition_to!(:instance_meta_fetching, job_id: "JOOOOOOOO") end def fetch_projects_meta - return unless @jira_import.status?(JiraImport::CONFIGURING, JiraImport::PROJECTS_META_ERROR) - - job = JiraProjectsMetaDataJob.perform_later(@jira_import.id) - @jira_import.update!(status: JiraImport::PROJECTS_META_FETCHING, job_id: job.job_id) + @jira_import.transition_to!(:projects_meta_fetching) end def import - return unless @jira_import.status?(JiraImport::IMPORT_ERROR, JiraImport::PROJECTS_META_DONE) - - job = JiraFetchAndImportProjectsJob.perform_later(@jira_import.id) - @jira_import.update!(status: JiraImport::IMPORTING, job_id: job.job_id) + @jira_import.transition_to!(:importing) end def configure - return unless @jira_import.status?(JiraImport::INSTANCE_META_DONE) - - @jira_import.update!(status: JiraImport::CONFIGURING) + @jira_import.transition_to!(:configuring) end def revert - return unless @jira_import.status?(JiraImport::REVERT_ERROR, JiraImport::IMPORTED) - - job = JiraRevertJiraImportJob.perform_later(@jira_import.id) - @jira_import.update!(status: JiraImport::REVERTING, job_id: job.job_id) + @jira_import.transition_to!(:reverting) end def finalize - return unless @jira_import.status?(JiraImport::IMPORTED) - - @jira_import.update!(status: JiraImport::COMPLETED) + @jira_import.transition_to!(:completed) end def find_jira_and_jira_import diff --git a/app/controllers/concerns/admin/import/jira/import_runs/component_streams.rb b/app/controllers/concerns/admin/import/jira/import_runs/component_streams.rb index 04b55848edf..654613233e6 100644 --- a/app/controllers/concerns/admin/import/jira/import_runs/component_streams.rb +++ b/app/controllers/concerns/admin/import/jira/import_runs/component_streams.rb @@ -43,7 +43,7 @@ module Admin::Import::Jira::ImportRuns method: "morph" ) update_via_turbo_stream( - component: ::Admin::Import::Jira::ImportRuns::StreamableStatusBadgeComponent.new(@jira_import.status), + component: ::Admin::Import::Jira::ImportRuns::StreamableStatusBadgeComponent.new(@jira_import.current_state), method: "morph" ) render turbo_stream: turbo_streams diff --git a/app/models/jira_import.rb b/app/models/jira_import.rb index 53e9501647c..b2bf0d98d60 100644 --- a/app/models/jira_import.rb +++ b/app/models/jira_import.rb @@ -32,72 +32,31 @@ class JiraImport < ApplicationRecord belongs_to :jira belongs_to :author, class_name: "User" - INITIAL = "initial" - INSTANCE_META_FETCHING = "instance-meta-fetching" - INSTANCE_META_ERROR = "instance-meta-error" - INSTANCE_META_DONE = "instance-meta-done" - CONFIGURING = "configuring" - PROJECTS_META_FETCHING = "projects-meta-fetching" - PROJECTS_META_ERROR = "projects-meta-error" - PROJECTS_META_DONE = "projects-meta-done" - IMPORTING = "importing" - IMPORT_ERROR = "import-error" - IMPORTED = "imported" - COMPLETED = "completed" - REVERTING = "reverting" - REVERT_ERROR = "revert-error" - REVERTED = "reverted" + has_many :transitions, class_name: "JiraImportTransition", autosave: false - STATUSES = [ - INITIAL, - INSTANCE_META_FETCHING, - INSTANCE_META_ERROR, - INSTANCE_META_DONE, - CONFIGURING, - PROJECTS_META_FETCHING, - PROJECTS_META_ERROR, - PROJECTS_META_DONE, - IMPORTING, - IMPORT_ERROR, - IMPORTED, - COMPLETED, - REVERTING, - REVERT_ERROR, - REVERTED - ].freeze - - def status_equal_or_after?(check_status) - STATUSES.index(status) >= STATUSES.index(check_status) + def state_machine + @state_machine ||= JiraImportStateMachine.new( + self, + transition_class: JiraImportTransition, + association_name: :transitions + ) end - def status_equal_or_before?(check_status) - STATUSES.index(status) <= STATUSES.index(check_status) - end - - def status_before?(check_status) - STATUSES.index(status) < STATUSES.index(check_status) - end - - def status_after?(check_status) - STATUSES.index(status) > STATUSES.index(check_status) - end - - def status?(*check_statuses) - check_statuses.include?(status) - end - - def deletable? - !status_running? && !status?(IMPORTED, IMPORT_ERROR, REVERT_ERROR) - end - - def status_running? - [ - INSTANCE_META_FETCHING, - PROJECTS_META_FETCHING, - IMPORTING, - REVERTING - ].include?(status) - end + delegate :can_transition_to?, + :current_state, + :history, + :last_transition, + :last_transition_to, + :transition_to!, + :transition_to, + :in_state?, + :status_running?, + :status_equal_or_after?, + :status_equal_or_before?, + :status_after?, + :status_before?, + :deletable?, + to: :state_machine def project_ids (projects || []).pluck("id") diff --git a/app/models/jira_import_state_machine.rb b/app/models/jira_import_state_machine.rb new file mode 100644 index 00000000000..2ee0a7f5260 --- /dev/null +++ b/app/models/jira_import_state_machine.rb @@ -0,0 +1,95 @@ +class JiraImportStateMachine + include Statesman::Machine + + state :initial, initial: true + state :instance_meta_fetching + state :instance_meta_error + state :instance_meta_done + state :configuring + state :projects_meta_fetching + state :projects_meta_error + state :projects_meta_done + state :importing + state :import_error + state :imported + state :completed + state :reverting + state :revert_error + state :reverted + + STATES_ORDER = [ + INITIAL, + INSTANCE_META_FETCHING, + INSTANCE_META_ERROR, + INSTANCE_META_DONE, + CONFIGURING, + PROJECTS_META_FETCHING, + PROJECTS_META_ERROR, + PROJECTS_META_DONE, + IMPORTING, + IMPORT_ERROR, + IMPORTED, + COMPLETED, + REVERTING, + REVERT_ERROR, + REVERTED + ].freeze + + transition from: INITIAL, to: [INSTANCE_META_FETCHING] + transition from: INSTANCE_META_FETCHING, to: [INSTANCE_META_DONE, INSTANCE_META_ERROR] + transition from: INSTANCE_META_ERROR, to: [INSTANCE_META_FETCHING] + transition from: INSTANCE_META_DONE, to: [CONFIGURING] + transition from: CONFIGURING, to: [PROJECTS_META_FETCHING] + transition from: PROJECTS_META_FETCHING, to: [PROJECTS_META_DONE, PROJECTS_META_ERROR] + transition from: PROJECTS_META_ERROR, to: [PROJECTS_META_FETCHING] + transition from: PROJECTS_META_DONE, to: [IMPORTING] + transition from: IMPORTING, to: [IMPORTED, IMPORT_ERROR] + transition from: IMPORT_ERROR, to: [IMPORTING] + transition from: IMPORTED, to: [COMPLETED, REVERTING] + transition from: REVERTING, to: [REVERTED, REVERT_ERROR] + + after_transition(to: :instance_meta_fetching) do |jira_import, transition| + JiraInstanceMetaDataJob.perform_later(jira_import.id) + end + + after_transition(to: :projects_meta_fetching) do |jira_import, transition| + JiraProjectsMetaDataJob.perform_later(jira_import.id) + end + + after_transition(to: :importing) do |jira_import, transition| + JiraFetchAndImportProjectsJob.perform_later(jira_import.id) + end + + after_transition(to: :reverting) do |jira_import, transition| + JiraRevertJiraImportJob.perform_later(jira_import.id) + end + + def status_running? + [ + INSTANCE_META_FETCHING, + PROJECTS_META_FETCHING, + IMPORTING, + REVERTING + ].include?(current_state) + end + + def status_equal_or_after?(check_status) + STATES_ORDER.index(current_state) >= STATES_ORDER.index(check_status) + end + + def status_equal_or_before?(check_status) + STATES_ORDER.index(current_state) <= STATES_ORDER.index(check_status) + end + + def status_before?(check_status) + STATES_ORDER.index(current_state) < STATES_ORDER.index(check_status) + end + + def status_after?(check_status) + STATES_ORDER.index(current_state) > STATES_ORDER.index(check_status) + end + + def deletable? + !status_running? && !in_state?(IMPORTED, IMPORT_ERROR, REVERT_ERROR) + end +end diff --git a/app/models/jira_import_transition.rb b/app/models/jira_import_transition.rb new file mode 100644 index 00000000000..f1c3e7be021 --- /dev/null +++ b/app/models/jira_import_transition.rb @@ -0,0 +1,13 @@ +class JiraImportTransition < ApplicationRecord + belongs_to :jira_import, inverse_of: :transitions + + after_destroy :update_most_recent, if: :most_recent? + + private + + def update_most_recent + last_transition = jira_import.jira_import_transitions.order(:sort_key).last + return unless last_transition.present? + last_transition.update_column(:most_recent, true) + end +end diff --git a/app/views/admin/import/jira/import_runs/history.html.erb b/app/views/admin/import/jira/import_runs/history.html.erb new file mode 100644 index 00000000000..f36f356efe1 --- /dev/null +++ b/app/views/admin/import/jira/import_runs/history.html.erb @@ -0,0 +1,101 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), JiraImport.model_name, @jira_import.id %> +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title do + concat(render(Primer::Beta::Text.new(mr: 2)) { + "#{I18n.t(:"admin.jira.run.title")} #{@jira_import.id.to_s}" + }) + concat(render(Admin::Import::Jira::ImportRuns::StreamableStatusBadgeComponent.new(@jira_import.current_state))) + end + header.with_breadcrumbs( + [ + { href: admin_index_path, text: t(:label_administration) }, + { href: admin_import_path, text: t(:"admin.import.title") }, + { href: admin_import_jira_index_path, text: I18n.t(:"admin.jira.title") }, + { href: admin_import_jira_path(@jira), text: @jira.name || @jira.url }, + { href: admin_import_jira_run_path(id: @jira_import.id), text: "Run: #{@jira_import.id}" }, + I18n.t(:"admin.jira.run.history"), + ] + ) + end +%> +<%= + table = Class.new(OpPrimer::BorderBoxTableComponent) do + columns :from_state, :to_state, :metadata + + def self.name + "HistoryTable" + end + + def mobile_title + "History Table" + end + + def row_class + @row_class ||= Class.new(OpPrimer::BorderBoxRowComponent) do + def self.name + "HistoryRow" + end + + def button_links + [] + end + + def from_state + model.from_state + end + + def to_state + model.to_state + end + + def metadata + model.metadata + end + end + end + + def has_actions? + false + end + + def headers + [ + [:from_state, { caption: "From state" }], + [:to_state, { caption: "To state" }], + [:metadata, { caption: "Metadata" }] + ] + end + end + + render(table.new(rows: @history)) +%> diff --git a/app/views/admin/import/jira/import_runs/show.html.erb b/app/views/admin/import/jira/import_runs/show.html.erb index ca0abec9ebb..a60d1b62f91 100644 --- a/app/views/admin/import/jira/import_runs/show.html.erb +++ b/app/views/admin/import/jira/import_runs/show.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. concat(render(Primer::Beta::Text.new(mr: 2)) { "#{I18n.t(:"admin.jira.run.title")} #{@jira_import.id.to_s}" }) - concat(render(Admin::Import::Jira::ImportRuns::StreamableStatusBadgeComponent.new(@jira_import.status))) + concat(render(Admin::Import::Jira::ImportRuns::StreamableStatusBadgeComponent.new(@jira_import.current_state))) end header.with_breadcrumbs( [ diff --git a/app/workers/jira_fetch_and_import_projects_job.rb b/app/workers/jira_fetch_and_import_projects_job.rb index 0e178c69454..ace2a9f1758 100644 --- a/app/workers/jira_fetch_and_import_projects_job.rb +++ b/app/workers/jira_fetch_and_import_projects_job.rb @@ -6,8 +6,9 @@ class JiraFetchAndImportProjectsJob < ApplicationJob JiraFetchProjectsJob.perform_now(jira_import_id) JiraImportProjectsJob.perform_now(jira_import_id) - jira_import.update!(status: JiraImport::IMPORTED, job_id: nil) + jira_import.transition_to!(:imported) rescue StandardError => e - jira_import.update!(status: JiraImport::IMPORT_ERROR, job_id: nil, error: e.message) + jira_import.transition_to!(:import_error, error: e.message) + jira_import.update!(job_id: nil, error: e.message) end end diff --git a/app/workers/jira_instance_meta_data_job.rb b/app/workers/jira_instance_meta_data_job.rb index be4d5d971ba..330a4ae10d5 100644 --- a/app/workers/jira_instance_meta_data_job.rb +++ b/app/workers/jira_instance_meta_data_job.rb @@ -47,9 +47,11 @@ class JiraInstanceMetaDataJob < ApplicationJob jira = jira_import.jira client = JiraClient.new(url: jira.url, personal_access_token: jira.personal_access_token) available = collect_metadata(client) - jira_import.update!(status: JiraImport::INSTANCE_META_DONE, job_id: nil, available:, error: nil) + jira_import.update!(job_id: nil, available:, error: nil) + jira_import.transition_to!(:instance_meta_done) rescue StandardError => e - jira_import.update!(status: JiraImport::INSTANCE_META_ERROR, job_id: nil, error: e.message) + jira_import.transition_to!(:instance_meta_error, error: e.message) + jira_import.update!(job_id: nil, error: e.message) end def collect_metadata(client) diff --git a/app/workers/jira_projects_meta_data_job.rb b/app/workers/jira_projects_meta_data_job.rb index 7f146c29636..99f5102e4ec 100644 --- a/app/workers/jira_projects_meta_data_job.rb +++ b/app/workers/jira_projects_meta_data_job.rb @@ -41,15 +41,17 @@ class JiraProjectsMetaDataJob < ApplicationJob def perform(jira_import_id) jira_import = JiraImport.find(jira_import_id) get_meta(jira_import) + rescue StandardError => e + jira_import.transition_to!(:projects_meta_error, error: e.message) + jira_import.update!(job_id: nil, error: e.message) end def get_meta(jira_import) jira = jira_import.jira client = JiraClient.new(url: jira.url, personal_access_token: jira.personal_access_token) selected = collect_metadata(client, jira_import.project_ids) - jira_import.update!(status: JiraImport::PROJECTS_META_DONE, job_id: nil, selected:, error: nil) - rescue StandardError => e - jira_import.update!(status: JiraImport::PROJECTS_META_ERROR, job_id: nil, error: e.message) + jira_import.transition_to!(:projects_meta_done, selected:) + jira_import.update!(job_id: nil, selected:, error: nil) end def collect_metadata(client, project_ids) diff --git a/app/workers/jira_revert_jira_import_job.rb b/app/workers/jira_revert_jira_import_job.rb index 2911117044f..c357ccb0998 100644 --- a/app/workers/jira_revert_jira_import_job.rb +++ b/app/workers/jira_revert_jira_import_job.rb @@ -65,9 +65,9 @@ class JiraRevertJiraImportJob < ApplicationJob OpenProjectJiraReference.where(jira_import_id: jira_import.id).delete_all - jira_import.update!(status: JiraImport::REVERTED, job_id: nil) + jira_import.transition_to!(:reverted) end rescue StandardError => e - jira_import.update!(status: JiraImport::REVERT_ERROR, job_id: nil, error: e.message) + jira_import.transition_to!(:revert_error, job_id: nil, error: e.message) end end diff --git a/config/initializers/statesman.rb b/config/initializers/statesman.rb new file mode 100644 index 00000000000..a94bf3406c0 --- /dev/null +++ b/config/initializers/statesman.rb @@ -0,0 +1,4 @@ +# config/initializers/statesman.rb +Statesman.configure do + storage_adapter(Statesman::Adapters::ActiveRecord) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index fb622733b2b..e9a3035fdbd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -164,6 +164,7 @@ en: label_ago: "%{amount} ago" run: title: "Import run" + history: "History" remove_error: "A Jira import cannot be removed while it is running" blank: title: "No import runs set up yet" diff --git a/config/routes.rb b/config/routes.rb index 1412cb8fcf6..7424e92a856 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -742,6 +742,7 @@ Rails.application.routes.draw do delete :remove get :revert_modal + get :history end resource :select_projects, diff --git a/db/migrate/20260204161727_create_jira_import_transitions.rb b/db/migrate/20260204161727_create_jira_import_transitions.rb new file mode 100644 index 00000000000..a481daec353 --- /dev/null +++ b/db/migrate/20260204161727_create_jira_import_transitions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateJiraImportTransitions < ActiveRecord::Migration[8.0] + def change + create_table :jira_import_transitions do |t| + t.string :from_state, null: false + t.string :to_state, null: false + t.jsonb :metadata, default: {} + t.integer :sort_key, null: false + t.integer :jira_import_id, null: false + t.boolean :most_recent, null: false + + # If you decide not to include an updated timestamp column in your transition + # table, you'll need to configure the `updated_timestamp_column` setting in your + # migration class. + t.timestamps null: false + end + + # Foreign keys are optional, but highly recommended + add_foreign_key :jira_import_transitions, :jira_imports + + add_index(:jira_import_transitions, + %i(jira_import_id sort_key), + unique: true, + name: "index_jira_import_transitions_parent_sort") + add_index(:jira_import_transitions, + %i(jira_import_id most_recent), + unique: true, + where: "most_recent", + name: "index_jira_import_transitions_parent_most_recent") + end +end diff --git a/db/migrate/20260209131615_remove_jira_import_status.rb b/db/migrate/20260209131615_remove_jira_import_status.rb new file mode 100644 index 00000000000..2d3c105b3bf --- /dev/null +++ b/db/migrate/20260209131615_remove_jira_import_status.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveJiraImportStatus < ActiveRecord::Migration[8.0] + def change + remove_column :jira_imports, :status, :string + end +end