diff --git a/app/assets/images/integrations/logo_jira.png b/app/assets/images/integrations/logo_jira.png new file mode 100644 index 000000000..a97b51bb2 Binary files /dev/null and b/app/assets/images/integrations/logo_jira.png differ diff --git a/app/controllers/app_configs_controller.rb b/app/controllers/app_configs_controller.rb index 4660968c2..edabfe59c 100644 --- a/app/controllers/app_configs_controller.rb +++ b/app/controllers/app_configs_controller.rb @@ -41,6 +41,7 @@ def pick_category when Integration.categories[:monitoring] then configure_monitoring when Integration.categories[:notification] then configure_notification_channel when Integration.categories[:build_channel] then configure_build_channel + when Integration.categories[:project_management] then configure_project_management else raise "Invalid integration category." end end @@ -69,6 +70,10 @@ def configure_monitoring set_monitoring_projects if further_setup_by_category?.dig(:monitoring, :further_setup) end + def configure_project_management + set_jira_projects if further_setup_by_category?.dig(:project_management, :further_setup) + end + def set_app_config @config = AppConfig.find_or_initialize_by(app: @app) end @@ -86,7 +91,13 @@ def app_config_params :bugsnag_ios_project_id, :bugsnag_android_release_stage, :bugsnag_android_project_id, - :bitbucket_workspace + :bitbucket_workspace, + jira_config: { + selected_projects: [], + project_configs: {}, + release_tracking: [:track_tickets, :auto_transition], + release_filters: [[:type, :value]] + } ) end @@ -98,6 +109,7 @@ def parsed_app_config_params .merge(bugsnag_config(app_config_params.slice(*BUGSNAG_CONFIG_PARAMS))) .merge(firebase_ios_config: app_config_params[:firebase_ios_config]&.safe_json_parse) .merge(firebase_android_config: app_config_params[:firebase_android_config]&.safe_json_parse) + .merge(jira_config: parse_jira_config(app_config_params[:jira_config])) .except(*BUGSNAG_CONFIG_PARAMS) .compact end @@ -133,6 +145,62 @@ def set_integration_category end end + def set_jira_projects + provider = @app.integrations.project_management_provider + @jira_data = provider.setup + + @config.jira_config = {} if @config.jira_config.nil? + + @config.jira_config = { + "selected_projects" => @config.jira_config["selected_projects"] || [], + "project_configs" => @config.jira_config["project_configs"] || {}, + "release_tracking" => @config.jira_config["release_tracking"] || { + "track_tickets" => false, + "auto_transition" => false + }, + "release_filters" => @config.jira_config["release_filters"] || [] + } + + @jira_data[:projects]&.each do |project| + project_key = project["key"] + statuses = @jira_data[:project_statuses][project_key] + + done_states = statuses&.select { |status| status["name"] == "Done" }&.pluck("name") || [] + + @config.jira_config["project_configs"][project_key] ||= { + "done_states" => done_states + } + end + + @config.save! if @config.changed? + @current_jira_config = @config.jira_config.with_indifferent_access + end + + def parse_jira_config(config) + return {} if config.blank? + + { + selected_projects: Array(config[:selected_projects]), + project_configs: config[:project_configs]&.transform_values do |project_config| + { + done_states: Array(project_config[:done_states])&.reject(&:blank?), + custom_done_states: Array(project_config[:custom_done_states])&.reject(&:blank?) + } + end || {}, + release_tracking: { + track_tickets: ActiveModel::Type::Boolean.new.cast(config.dig(:release_tracking, :track_tickets)), + auto_transition: ActiveModel::Type::Boolean.new.cast(config.dig(:release_tracking, :auto_transition)) + }, + release_filters: config[:release_filters]&.values&.filter_map do |filter| + next if filter[:type].blank? || filter[:value].blank? + { + "type" => filter[:type], + "value" => filter[:value] + } + end || [] + } + end + def bugsnag_config(config_params) config = {} diff --git a/app/controllers/integration_listeners/jira_controller.rb b/app/controllers/integration_listeners/jira_controller.rb new file mode 100644 index 000000000..938b556ca --- /dev/null +++ b/app/controllers/integration_listeners/jira_controller.rb @@ -0,0 +1,94 @@ +class IntegrationListeners::JiraController < IntegrationListenerController + using RefinedString + + INTEGRATION_CREATE_ERROR = "Failed to create the integration, please try again." + + def callback + unless valid_state? + redirect_to app_path(state_app), alert: INTEGRATION_CREATE_ERROR + return + end + + begin + @integration = state_app.integrations.build(integration_params) + @integration.providable = build_providable + + if @integration.providable.complete_access + @integration.save! + redirect_to app_path(state_app), + notice: t("integrations.project_management.jira.integration_created") + else + @resources = @integration.providable.available_resources + + if @resources.blank? + redirect_to app_integrations_path(state_app), + alert: t("integrations.project_management.jira.no_organization") + return + end + + render "jira_integration/select_organization" + end + rescue => e + Rails.logger.error("Failed to create Jira integration: #{e.message}") + redirect_to app_integrations_path(state_app), + alert: INTEGRATION_CREATE_ERROR + end + end + + def set_organization + @integration = state_app.integrations.build(integration_params) + @integration.providable = build_providable + @integration.providable.cloud_id = params[:cloud_id] + @integration.providable.code = params[:code] + + if @integration.save! + @integration.providable.setup + redirect_to app_path(@integration.integrable), + notice: t("integrations.project_management.jira.integration_created") + else + @resources = @integration.providable.available_resources + render "jira_integration/select_organization" + end + rescue => e + Rails.logger.error("Failed to create Jira integration: #{e.message}") + redirect_to app_integrations_path(state_app), + alert: INTEGRATION_CREATE_ERROR + end + + protected + + def providable_params + super.merge( + code: code, + callback_url: callback_url + ) + end + + private + + def callback_url + host = request.host_with_port + Rails.application.routes.url_helpers.jira_callback_url( + host: host, + protocol: request.protocol.gsub("://", "") + ) + end + + def state + @state ||= begin + cleaned_state = params[:state].tr(" ", "+") + JSON.parse(cleaned_state.decode).with_indifferent_access + rescue ActiveSupport::MessageEncryptor::InvalidMessage => e + Rails.logger.error "Invalid state parameter: #{e.message}" + {} + end + end + + def error? + params[:error].present? || state.empty? + end + + def state_app + @state_app ||= App.find(state[:app_id]) + end +end diff --git a/app/javascript/controllers/project_selector_controller.js b/app/javascript/controllers/project_selector_controller.js new file mode 100644 index 000000000..94ea2af55 --- /dev/null +++ b/app/javascript/controllers/project_selector_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["config", "filterDivider"] + + connect() { + this.toggleConfigurations() + this.toggleFilterDivider() + } + + toggle(event) { + this.toggleConfigurations() + this.toggleFilterDivider() + } + + toggleConfigurations() { + const configs = document.querySelectorAll('.project-config') + configs.forEach(config => { + const projectKey = config.dataset.project + const checkbox = document.querySelector(`#project_${projectKey}`) + if (checkbox) { + config.style.display = checkbox.checked ? 'block' : 'none' + } + }) + } + + toggleFilterDivider() { + const anyProjectSelected = Array.from(document.querySelectorAll('input[name^="app_config[jira_config][selected_projects]"]')) + .some(checkbox => checkbox.checked) + + if (this.hasFilterDividerTarget) { + this.filterDividerTarget.style.display = anyProjectSelected ? 'block' : 'none' + this.filterDividerTarget.classList.add('border-t') + this.filterDividerTarget.classList.add('border-b') + } + } +} diff --git a/app/javascript/controllers/project_states_controller.js b/app/javascript/controllers/project_states_controller.js new file mode 100644 index 000000000..e93a26dfd --- /dev/null +++ b/app/javascript/controllers/project_states_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["content"] + + toggle(event) { + this.contentTarget.style.display = event.target.checked ? "block" : "none" + } +} diff --git a/app/javascript/controllers/release_filters_controller.js b/app/javascript/controllers/release_filters_controller.js new file mode 100644 index 000000000..933bd6e3f --- /dev/null +++ b/app/javascript/controllers/release_filters_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["template", "container", "filter"] + + add(event) { + event.preventDefault() + const content = this.templateTarget.innerHTML.replace(/__INDEX__/g, this.filterTargets.length) + this.containerTarget.insertAdjacentHTML("beforeend", content) + } + + remove(event) { + event.preventDefault() + const filter = event.target.closest("[data-release-filters-target='filter']") + filter.remove() + } +} diff --git a/app/libs/installations/jira/api.rb b/app/libs/installations/jira/api.rb new file mode 100644 index 000000000..7fde24fa9 --- /dev/null +++ b/app/libs/installations/jira/api.rb @@ -0,0 +1,146 @@ +module Installations + class Jira::Api + include Vaultable + attr_reader :oauth_access_token, :cloud_id + + BASE_URL = "https://api.atlassian.com/ex/jira" + + # API Endpoints + PROJECTS_URL = Addressable::Template.new "#{BASE_URL}/{cloud_id}/rest/api/3/project/search" + PROJECT_STATUSES_URL = Addressable::Template.new "#{BASE_URL}/{cloud_id}/rest/api/3/project/{project_key}/statuses" + SEARCH_URL = Addressable::Template.new "#{BASE_URL}/{cloud_id}/rest/api/3/search/jql" + TICKET_SEARCH_FIELDS = "summary, description, status, assignee, fix_versions, labels" + + class << self + include Vaultable + + OAUTH_ACCESS_TOKEN_URL = "https://auth.atlassian.com/oauth/token" + ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + + def get_accessible_resources(code, redirect_uri) + @tokens ||= oauth_access_token(code, redirect_uri) + return [[], @tokens] unless @tokens + + response = HTTP + .auth("Bearer #{@tokens.access_token}") + .get(ACCESSIBLE_RESOURCES_URL) + + return [[], @tokens] unless response.status.success? + [JSON.parse(response.body.to_s), @tokens] + rescue HTTP::Error => e + Rails.logger.error "Failed to fetch Jira accessible resources: #{e.message}" + [[], @tokens] + end + + def oauth_access_token(code, redirect_uri) + params = { + form: { + grant_type: :authorization_code, + code:, + redirect_uri: + } + } + + get_oauth_token(params) + end + + def oauth_refresh_token(refresh_token, redirect_uri) + params = { + form: { + grant_type: :refresh_token, + redirect_uri:, + refresh_token: + } + } + + get_oauth_token(params) + end + + def get_oauth_token(params) + response = HTTP + .basic_auth(user: creds.integrations.jira.client_id, pass: creds.integrations.jira.client_secret) + .post(OAUTH_ACCESS_TOKEN_URL, params) + + body = JSON.parse(response.body.to_s) + tokens = { + "access_token" => body["access_token"], + "refresh_token" => body["refresh_token"] + } + + return OpenStruct.new(tokens) if tokens.present? + nil + end + end + + def initialize(oauth_access_token, cloud_id) + @oauth_access_token = oauth_access_token + @cloud_id = cloud_id + end + + def projects(transforms = {}) + response = execute(:get, PROJECTS_URL.expand(cloud_id:).to_s) + response["values"] + end + + def project_statuses(project_key, transforms = nil) + execute(:get, PROJECT_STATUSES_URL.expand(cloud_id:, project_key:).to_s) + end + + def search_tickets_by_filters(project_key, release_filters, start_at: 0, max_results: 50) + return {"issues" => []} if release_filters.blank? + + params = { + params: { + jql: build_jql_query(project_key, release_filters), + fields: TICKET_SEARCH_FIELDS + } + } + + response = execute(:get, SEARCH_URL.expand(cloud_id:).to_s, params) + { + "issues" => response["issues"] || [] + } + rescue HTTP::Error => e + Rails.logger.error "Failed to search Jira tickets: #{e.message}" + raise Installations::Error.new("Failed to search Jira tickets", reason: :api_error) + end + + private + + def execute(method, url, params = {}, parse_response = true) + response = HTTP.auth("Bearer #{oauth_access_token}").headers("Accept" => "application/json").public_send(method, url, params) + + parsed_body = parse_response ? JSON.parse(response.body) : response.body + Rails.logger.debug { "Jira API returned #{response.status} for #{url} with body - #{parsed_body}" } + + return parsed_body unless response.status.client_error? + + raise Installations::Error.new("Token expired", reason: :token_expired) if response.status == 401 + raise Installations::Error.new("Resource not found", reason: :not_found) if response.status == 404 + raise Installations::Jira::Error.new(parsed_body) + end + + def build_jql_query(project_key, release_filters) + conditions = ["project = '#{sanitize_jql_value(project_key)}'"] + + release_filters.each do |filter| + value = sanitize_jql_value(filter["value"]) + + case filter["type"] + when "label" + conditions << "labels = '#{value}'" + when "fix_version" + conditions << "fixVersion = '#{value}'" + else + Rails.logger.warn("Unsupported Jira filter type: #{filter["type"]}") + end + end + + conditions.join(" AND ") + end + + def sanitize_jql_value(value) + value.to_s.gsub("'", "\\'").gsub(/[^\w\s\-\.]/, "") + end + end +end diff --git a/app/libs/installations/jira/error.rb b/app/libs/installations/jira/error.rb new file mode 100644 index 000000000..e8b210ed7 --- /dev/null +++ b/app/libs/installations/jira/error.rb @@ -0,0 +1,60 @@ +module Installations + class Jira::Error < Installations::Error + ERRORS = [ + { + message_matcher: /The access token expired/i, + decorated_reason: :token_expired + }, + { + message_matcher: /does not have the required scope/i, + decorated_reason: :insufficient_scope + }, + { + message_matcher: /Project .* does not exist/i, + decorated_reason: :project_not_found + }, + { + message_matcher: /Issue does not exist/i, + decorated_reason: :issue_not_found + }, + { + message_matcher: /Service Unavailable/i, + decorated_reason: :service_unavailable + } + ].freeze + + def initialize(error_body) + @error_body = error_body + log + super(error_message, reason: handle) + end + + def handle + return :unknown_failure if match.nil? + match[:decorated_reason] + end + + private + + attr_reader :error_body + delegate :logger, to: Rails + + def match + @match ||= matched_error + end + + def matched_error + ERRORS.find do |known_error| + known_error[:message_matcher] =~ error_message + end + end + + def error_message + error_body.dig("error", "message") + end + + def log + logger.error(error_message: error_message, error_body: error_body) + end + end +end diff --git a/app/models/app.rb b/app/models/app.rb index d027ce039..cedb1669a 100644 --- a/app/models/app.rb +++ b/app/models/app.rb @@ -63,6 +63,7 @@ class App < ApplicationRecord :ci_cd_provider, :monitoring_provider, :notification_provider, + :project_management_provider, :slack_notifications?, to: :integrations, allow_nil: true def self.allowed_platforms @@ -108,6 +109,10 @@ def bitbucket_connected? integrations.bitbucket_integrations.any? end + def project_management_connected? + integrations.project_management.connected.any? + end + def ready? integrations.ready? and config&.ready? end diff --git a/app/models/app_config.rb b/app/models/app_config.rb index 38720f62a..513b1c629 100644 --- a/app/models/app_config.rb +++ b/app/models/app_config.rb @@ -10,6 +10,7 @@ # code_repository :json # firebase_android_config :jsonb # firebase_ios_config :jsonb +# jira_config :jsonb not null # notification_channel :json # created_at :datetime not null # updated_at :datetime not null @@ -99,6 +100,13 @@ def further_setup_by_category? } end + if integrations.project_management.present? + categories[:project_management] = { + further_setup: integrations.project_management.map(&:providable).any?(&:further_setup?), + ready: project_management_ready? + } + end + categories end @@ -120,6 +128,20 @@ def set_ci_cd_workflows(workflows) update(ci_cd_workflows: workflows) end + def add_jira_release_filter(type:, value:) + return unless JiraIntegration::VALID_FILTER_TYPES.include?(type) + + new_filters = (jira_config&.dig("release_filters") || []).dup + new_filters << {"type" => type, "value" => value} + update!(jira_config: jira_config.merge("release_filters" => new_filters)) + end + + def remove_jira_release_filter(index) + new_filters = (jira_config&.dig("release_filters") || []).dup + new_filters.delete_at(index) + update!(jira_config: jira_config.merge("release_filters" => new_filters)) + end + private def set_bugsnag_config @@ -149,4 +171,16 @@ def configs_ready?(ios, android) return android.present? if app.android? ios.present? && android.present? if app.cross_platform? end + + def project_management_ready? + return false if app.integrations.project_management.blank? + + jira = app.integrations.project_management.find(&:jira_integration?)&.providable + return false unless jira + + jira_config.present? && + jira_config["selected_projects"].present? && + jira_config["selected_projects"].any? && + jira_config["project_configs"].present? + end end diff --git a/app/models/integration.rb b/app/models/integration.rb index a39bddbfa..86852ea55 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -24,7 +24,7 @@ class Integration < ApplicationRecord # self.ignored_columns += %w[app_id] belongs_to :app, optional: true - PROVIDER_TYPES = %w[GithubIntegration GitlabIntegration SlackIntegration AppStoreIntegration GooglePlayStoreIntegration BitriseIntegration GoogleFirebaseIntegration BugsnagIntegration BitbucketIntegration] + PROVIDER_TYPES = %w[GithubIntegration GitlabIntegration SlackIntegration AppStoreIntegration GooglePlayStoreIntegration BitriseIntegration GoogleFirebaseIntegration BugsnagIntegration BitbucketIntegration JiraIntegration] delegated_type :providable, types: PROVIDER_TYPES, autosave: true, validate: false delegated_type :integrable, types: INTEGRABLE_TYPES, autosave: true, validate: false @@ -40,21 +40,24 @@ class Integration < ApplicationRecord "ci_cd" => %w[BitriseIntegration GithubIntegration BitbucketIntegration], "notification" => %w[SlackIntegration], "build_channel" => %w[AppStoreIntegration GoogleFirebaseIntegration], - "monitoring" => %w[BugsnagIntegration] + "monitoring" => %w[BugsnagIntegration], + "project_management" => %w[JiraIntegration] }, android: { "version_control" => %w[GithubIntegration GitlabIntegration BitbucketIntegration], "ci_cd" => %w[BitriseIntegration GithubIntegration BitbucketIntegration], "notification" => %w[SlackIntegration], "build_channel" => %w[GooglePlayStoreIntegration SlackIntegration GoogleFirebaseIntegration], - "monitoring" => %w[BugsnagIntegration] + "monitoring" => %w[BugsnagIntegration], + "project_management" => %w[JiraIntegration] }, cross_platform: { "version_control" => %w[GithubIntegration GitlabIntegration BitbucketIntegration], "ci_cd" => %w[BitriseIntegration GithubIntegration BitbucketIntegration], "notification" => %w[SlackIntegration], "build_channel" => %w[GooglePlayStoreIntegration SlackIntegration GoogleFirebaseIntegration AppStoreIntegration], - "monitoring" => %w[BugsnagIntegration] + "monitoring" => %w[BugsnagIntegration], + "project_management" => %w[JiraIntegration] } }.with_indifferent_access @@ -77,7 +80,8 @@ class Integration < ApplicationRecord ci_cd: "Trigger workflows to create builds and stay up-to-date as they're made available.", notification: "Send release activity notifications at the right time, to the right people.", build_channel: "Send builds to the right deployment service for the right stakeholders.", - monitoring: "Monitor release metrics and stability to make the correct decisions about your release progress." + monitoring: "Monitor release metrics and stability to make the correct decisions about your release progress.", + project_management: "Track tickets and establish release readiness by associating tickets with your releases." }.freeze MULTI_INTEGRATION_CATEGORIES = ["build_channel"].freeze MINIMUM_REQUIRED_SET = [:version_control, :ci_cd, :build_channel].freeze @@ -198,6 +202,10 @@ def firebase_build_channel_provider kept.build_channel.find(&:google_firebase_integration?)&.providable end + def project_management_provider + kept.project_management.first&.providable + end + def existing_integration(app, providable_type) app.integrations.connected.find_by(providable_type: providable_type) end diff --git a/app/models/jira_integration.rb b/app/models/jira_integration.rb new file mode 100644 index 000000000..f16fbdb72 --- /dev/null +++ b/app/models/jira_integration.rb @@ -0,0 +1,257 @@ +# == Schema Information +# +# Table name: jira_integrations +# +# id :uuid not null, primary key +# oauth_access_token :string +# oauth_refresh_token :string +# created_at :datetime not null +# updated_at :datetime not null +# cloud_id :string indexed +# +class JiraIntegration < ApplicationRecord + has_paper_trail + using RefinedHash + include Memery + include Linkable + include Vaultable + include Providable + include Displayable + + encrypts :oauth_access_token, deterministic: true + encrypts :oauth_refresh_token, deterministic: true + + API = Installations::Jira::Api + BASE_INSTALLATION_URL = + Addressable::Template.new("https://auth.atlassian.com/authorize{?params*}") + PUBLIC_ICON = "https://storage.googleapis.com/tramline-public-assets/jira_small.png".freeze + VALID_FILTER_TYPES = %w[label fix_version].freeze + + USER_INFO_TRANSFORMATIONS = { + id: :accountId, + name: :displayName, + email: :emailAddress + }.freeze + + PROJECT_TRANSFORMATIONS = { + id: :id, + key: :key, + name: :name, + description: :description, + url: :self + }.freeze + + STATUS_TRANSFORMATIONS = { + id: :id, + name: :name, + category: [:statusCategory, :key] + }.freeze + + TICKET_TRANSFORMATIONS = { + key: :key, + summary: [:fields, :summary], + status: [:fields, :status, :name], + assignee: [:fields, :assignee, :displayName], + labels: [:fields, :labels], + fix_versions: [:fields, :fixVersions] + }.freeze + + attr_accessor :code, :callback_url, :available_resources + before_validation :complete_access, on: :create + delegate :integrable, to: :integration + delegate :cache, to: Rails + + validates :cloud_id, presence: true + validate :validate_release_filters, if: -> { integrable.config.jira_config&.dig("release_filters").present? } + + def install_path + BASE_INSTALLATION_URL + .expand(params: { + client_id: creds.integrations.jira.client_id, + audience: "api.atlassian.com", + redirect_uri: redirect_uri, + response_type: :code, + prompt: "consent", + scope: "read:jira-work write:jira-work read:jira-user offline_access", + state: integration.installation_state + }).to_s + end + + def complete_access + return false if code.blank? || (callback_url.blank? && redirect_uri.blank?) + + resources, tokens = API.get_accessible_resources(code, callback_url || redirect_uri) + set_tokens(tokens) + + if resources.length == 1 + self.cloud_id = resources.first["id"] + true + else + @available_resources = resources + false + end + end + + def installation + API.new(oauth_access_token, cloud_id) + end + + def to_s = "jira" + + def creatable? = false + + def connectable? = true + + def store? = false + + def project_link = nil + + def further_setup? = true + + def public_icon_img + PUBLIC_ICON + end + + def setup + return {} if cloud_id.blank? + + with_api_retries do + projects_result = fetch_projects + return {} if projects_result[:projects].empty? + + statuses_data = fetch_project_statuses(projects_result[:projects]) + + { + projects: projects_result[:projects], + project_statuses: statuses_data + } + end + rescue => e + Rails.logger.error("Failed to fetch Jira setup data for cloud_id #{cloud_id}: #{e.message}") + {} + end + + def metadata + with_api_retries { installation.user_info(USER_INFO_TRANSFORMATIONS) } + end + + def connection_data + return unless integration.metadata + "Added by user: #{integration.metadata["name"]} (#{integration.metadata["email"]})" + end + + def fetch_tickets_for_release + return [] if integrable.config.jira_config.blank? + + project_key = integrable.config.jira_config["selected_projects"]&.last + release_filters = integrable.config.jira_config["release_filters"] + + return [] if project_key.blank? || release_filters.blank? + + with_api_retries do + response = api.search_tickets_by_filters( + project_key, + release_filters + ) + + return [] if response["issues"].blank? + + Installations::Response::Keys.transform(response["issues"], TICKET_TRANSFORMATIONS) + end + rescue => e + Rails.logger.error("Failed to fetch Jira tickets for release: #{e.message}") + [] + end + + def display + "Jira" + end + + private + + MAX_RETRY_ATTEMPTS = 2 + RETRYABLE_ERRORS = [] + + def with_api_retries(attempt: 0, &) + yield + rescue Installations::Error => ex + raise ex if attempt >= MAX_RETRY_ATTEMPTS + next_attempt = attempt + 1 + + if ex.reason == :token_expired + reset_tokens! + return with_api_retries(attempt: next_attempt, &) + end + + if RETRYABLE_ERRORS.include?(ex.reason) + return with_api_retries(attempt: next_attempt, &) + end + + raise ex + end + + def reset_tokens! + set_tokens(API.oauth_refresh_token(oauth_refresh_token, redirect_uri)) + save! + end + + def set_tokens(tokens) + return unless tokens + + self.oauth_access_token = tokens.access_token + self.oauth_refresh_token = tokens.refresh_token + end + + def redirect_uri + jira_callback_url(link_params) + end + + memoize def api + API.new(oauth_access_token, cloud_id) + end + + def fetch_projects + return {projects: []} if cloud_id.blank? + + with_api_retries do + response = api.projects + projects = Installations::Response::Keys.transform(response, PROJECT_TRANSFORMATIONS) + {projects: projects} + end + rescue => e + Rails.logger.error("Failed to fetch Jira projects for cloud_id #{cloud_id}: #{e.message}") + {projects: []} + end + + def fetch_project_statuses(projects) + return {} if cloud_id.blank? || projects.blank? + + with_api_retries do + statuses = {} + projects.each do |project| + project_statuses = api.project_statuses(project["key"]) + statuses[project["key"]] = extract_unique_statuses(project_statuses) + end + statuses + end + rescue => e + Rails.logger.error("Failed to fetch Jira project statuses for cloud_id #{cloud_id}: #{e.message}") + {} + end + + def extract_unique_statuses(statuses) + statuses.flat_map { |issue_type| issue_type["statuses"] } + .uniq { |status| status["id"] } + .then { |statuses| Installations::Response::Keys.transform(statuses, STATUS_TRANSFORMATIONS) } + end + + def validate_release_filters + return if integrable.config.jira_config&.dig("release_filters").blank? + + integrable.config.jira_config["release_filters"].each do |filter| + unless filter.is_a?(Hash) && VALID_FILTER_TYPES.include?(filter["type"]) && filter["value"].present? + errors.add(:release_filters, "must contain valid type and value") + end + end + end +end diff --git a/app/views/app_configs/project_management.html.erb b/app/views/app_configs/project_management.html.erb new file mode 100644 index 000000000..2cd31171f --- /dev/null +++ b/app/views/app_configs/project_management.html.erb @@ -0,0 +1,127 @@ +<%= render V2::EnhancedTurboFrameComponent.new("#{@integration_category}_config") do %> +
Select projects and their done states for tracking releases.
+Choose one or more projects to track tickets from.
+ +<%= t('integrations.project_management.jira.release_filters.help_text') %>
+ +Done States
+No status configurations found for this project.
+ <% end %> +Please try again or contact support if the issue persists.
+