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 %> +
+
+
+

Jira Configuration

+

Select projects and their done states for tracking releases.

+
+ + <%= form_with model: @config, + url: app_app_config_path(@app), + method: :patch, + data: { turbo_frame: "_top" }, + builder: EnhancedFormHelper::AuthzForm do |f| %> +
+ <% if @jira_data && @jira_data[:projects].present? %> + +
+

Select Projects

+

Choose one or more projects to track tickets from.

+ +
+ <% @jira_data[:projects].each do |project| %> +
+
+ <%= check_box_tag "app_config[jira_config][selected_projects][]", + project['key'], + @current_jira_config&.dig('selected_projects')&.include?(project['key']), + class: "h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary", + data: { + action: "project-selector#toggle", + project_key: project['key'] + }, + id: "project_#{project['key']}" %> + +
+
+ <% end %> +
+
+ + +
+

<%= t('integrations.project_management.jira.release_filters.title') %>

+

<%= t('integrations.project_management.jira.release_filters.help_text') %>

+ +
+ + +
+ <% if (@current_jira_config&.dig('release_filters') || []).any? %> + <% @current_jira_config['release_filters'].each_with_index do |filter, index| %> + <%= render 'jira_integration/release_filter_form', + filter: filter, + index: index, + local_assigns: true %> + <% end %> + <% else %> + <%= render 'jira_integration/release_filter_form', + filter: {}, + index: 0, + local_assigns: true %> + <% end %> +
+ + +
+
+ + +
+ <% @jira_data[:projects].each do |project| %> + <% project_key = project['key'] %> + <% statuses = @jira_data[:project_statuses][project_key] %> + +
+

<%= project['name'] %> Configuration

+ +
+ +
+

Done States

+
+ <% if statuses&.any? %> + <% statuses.each do |status| %> +
+ <%= check_box_tag "app_config[jira_config][project_configs][#{project_key}][done_states][]", + status['name'], + @current_jira_config&.dig('project_configs', project_key, 'done_states')&.include?(status['name']), + class: "h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary", + id: "status_#{project_key}_#{status['name'].parameterize}" %> + +
+ <% end %> + <% else %> +

No status configurations found for this project.

+ <% end %> +
+
+
+
+ <% end %> +
+ <% else %> +
+ No Jira projects found. Please ensure your Jira integration is properly configured. +
+ <% end %> +
+ +
+ <%= f.authz_submit "Update", "plus.svg", size: :xs %> +
+ <% end %> +
+
+<% end %> diff --git a/app/views/jira_integration/_release_filter_form.html.erb b/app/views/jira_integration/_release_filter_form.html.erb new file mode 100644 index 000000000..92b501f0f --- /dev/null +++ b/app/views/jira_integration/_release_filter_form.html.erb @@ -0,0 +1,28 @@ + +<% filter ||= {} %> +<% index ||= 0 %> + +
+ <%= select_tag "app_config[jira_config][release_filters][#{index}][type]", + options_for_select([ + [t("integrations.project_management.jira.release_filters.types.label"), "label"], + [t("integrations.project_management.jira.release_filters.types.fix_version"), "fix_version"] + ], filter['type']), + class: "mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" %> + + <%= text_field_tag "app_config[jira_config][release_filters][#{index}][value]", + filter['value'], + placeholder: "e.g., release-1.0.0", + class: "mt-2 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" %> + + <%= render V2::ButtonComponent.new( + scheme: :naked_icon, + type: :action, + size: :none, + html_options: { + class: "mt-2", + data: { action: "release-filters#remove" } + }) do |b| + b.with_icon("v2/trash.svg", size: :md) + end %> +
diff --git a/app/views/jira_integration/select_organization.html.erb b/app/views/jira_integration/select_organization.html.erb new file mode 100644 index 000000000..5cc30288c --- /dev/null +++ b/app/views/jira_integration/select_organization.html.erb @@ -0,0 +1,47 @@ +
+
+
+
+
+

Select Jira Organization

+
+ + <% if @resources&.any? %> + <%= form_tag jira_set_organization_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :code, params[:code] %> + <%= hidden_field_tag :state, params[:state] %> +
+ <% @resources.each do |org| %> +
+
+ <%= radio_button_tag 'cloud_id', org["id"], false, + class: "h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600", + required: true %> +
+
+ +
+
+ <% end %> +
+ +
+ <%= submit_tag "Continue", class: "ml-auto inline-flex justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> +
+ <% end %> + <% else %> +
+
+

No organizations available

+

Please try again or contact support if the issue persists.

+
+
+ <% end %> +
+
+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index c211e2667..ab4916247 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -384,6 +384,7 @@ en: notification: "Notifications" build_channel: "Submissions" monitoring: "Monitoring and Analytics" + project_management: "Project Management" bitrise_integration: access_token: "Bitrise Personal Access Token" bugsnag_integration: @@ -689,3 +690,30 @@ en: title: "Release stability fixes" scope: "Last 6 releases. Commits after release start per team." help_text: "These are the changes made to the release branch after the release has started by members in these respective teams.\n\nThis is a good indicator of how much the team is involved in the release stability." + + integrations: + project_management: + jira: + connect: + success: "Successfully connected to Jira!" + failure: "Failed to connect to Jira: %{errors}" + configure: + success: "Jira configuration was successfully updated." + failure: "Failed to update Jira configuration: %{errors}" + projects: + select: "Select Jira Projects" + help_text: "Choose the projects you want to track in Tramline" + done_states: + select: "Select Done States" + help_text: "Choose which states represent completed work" + release_filters: + title: "Release Filters" + help_text: "Configure how tickets are associated with releases" + types: + label: "Label" + fix_version: "Fix Version" + add: "Add Filter" + placeholder: + label: "e.g., release-1.0.0" + no_organization: "No Jira organizations available. Please try again." + integration_created: "Integration was successfully created." diff --git a/config/routes.rb b/config/routes.rb index 065e3ba05..85e3e45b8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -307,6 +307,12 @@ get :callback, controller: "integration_listeners/slack", as: :slack_callback end + scope :jira do + get :callback, controller: "integration_listeners/jira", as: :jira_callback + get :select_organization, to: "integration_listeners/jira#select_organization", as: :jira_select_organization + post :set_organization, to: "integration_listeners/jira#set_organization", as: :jira_set_organization + end + get "/rails/active_storage/blobs/redirect/:signed_id/*filename", to: "authorized_blob_redirect#show", as: "blob_redirect" match "/", via: %i[post put patch delete], to: "application#raise_not_found", format: false diff --git a/db/migrate/20241114104939_create_jira_integrations.rb b/db/migrate/20241114104939_create_jira_integrations.rb new file mode 100644 index 000000000..9888a8789 --- /dev/null +++ b/db/migrate/20241114104939_create_jira_integrations.rb @@ -0,0 +1,13 @@ +class CreateJiraIntegrations < ActiveRecord::Migration[7.2] + def change + create_table :jira_integrations, id: :uuid do |t| + t.string :oauth_access_token + t.string :oauth_refresh_token + t.string :cloud_id + + t.timestamps + end + + add_index :jira_integrations, :cloud_id + end +end diff --git a/db/migrate/20241115100841_add_jira_config_to_app_configs.rb b/db/migrate/20241115100841_add_jira_config_to_app_configs.rb new file mode 100644 index 000000000..538d39226 --- /dev/null +++ b/db/migrate/20241115100841_add_jira_config_to_app_configs.rb @@ -0,0 +1,5 @@ +class AddJiraConfigToAppConfigs < ActiveRecord::Migration[7.2] + def change + add_column :app_configs, :jira_config, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index f4f2ac6bd..08d88f8b6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_11_04_165116) do +ActiveRecord::Schema[7.2].define(version: 2024_11_15_100841) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -58,6 +58,7 @@ t.jsonb "bugsnag_android_config" t.string "bitbucket_workspace" t.jsonb "ci_cd_workflows" + t.jsonb "jira_config", default: {}, null: false t.index ["app_id"], name: "index_app_configs_on_app_id", unique: true end @@ -390,6 +391,15 @@ t.index ["sender_id"], name: "index_invites_on_sender_id" end + create_table "jira_integrations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "oauth_access_token" + t.string "oauth_refresh_token" + t.string "cloud_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["cloud_id"], name: "index_jira_integrations_on_cloud_id" + end + create_table "memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id" t.uuid "organization_id" diff --git a/spec/controllers/integration_listeners/jira_controller_spec.rb b/spec/controllers/integration_listeners/jira_controller_spec.rb new file mode 100644 index 000000000..2191f068c --- /dev/null +++ b/spec/controllers/integration_listeners/jira_controller_spec.rb @@ -0,0 +1,138 @@ +require "rails_helper" + +RSpec.describe IntegrationListeners::JiraController do + let(:organization) { create(:organization) } + let(:app) { create(:app, :android, organization: organization) } + let(:user) { create(:user, :with_email_authentication, :as_developer, member_organization: organization) } + let(:state) { + { + app_id: app.id, + user_id: user.id, + organization_id: organization.id + }.to_json.encode + } + let(:code) { "test_code" } + let(:integration) { build(:integration, :jira, integrable: app) } + let(:jira_integration) { build(:jira_integration) } + + before do + sign_in user.email_authentication + allow_any_instance_of(described_class).to receive(:current_user).and_return(user) + allow_any_instance_of(described_class).to receive(:state_user).and_return(user) + allow_any_instance_of(described_class).to receive(:state_app).and_return(app) + allow_any_instance_of(described_class).to receive(:state_organization).and_return(organization) + allow_any_instance_of(described_class).to receive(:build_providable).and_return(jira_integration) + allow_any_instance_of(described_class).to receive(:valid_state?).and_return(true) + end + + describe "GET #callback" do + context "with valid state" do + context "when single organization" do + before do + allow(app.integrations).to receive(:build).and_return(integration) + allow(integration).to receive_messages( + providable: jira_integration, + save!: true, + valid?: true + ) + + allow(jira_integration).to receive_messages( + complete_access: true, + setup: {} + ) + + get :callback, params: {state: state, code: code} + end + + it "creates integration and redirects to app" do + expect(response).to redirect_to(app_path(app)) + expect(flash[:alert]).to be_nil + expect(flash[:notice]).to eq("Integration was successfully created.") + end + end + + context "when multiple organizations" do + let(:resources) { [{"id" => "cloud_1"}, {"id" => "cloud_2"}] } + + before do + allow(app.integrations).to receive(:build).and_return(integration) + allow(integration).to receive_messages( + providable: jira_integration, + valid?: true + ) + + allow(jira_integration).to receive_messages( + complete_access: false, + available_resources: resources + ) + + get :callback, params: {state: state, code: code} + end + + it "shows organization selection page" do + expect(response).to be_successful + expect(response.content_type).to include("text/html") + expect(flash[:alert]).to be_nil + expect(jira_integration).to have_received(:available_resources) + end + end + end + + context "with invalid state" do + before do + allow_any_instance_of(described_class).to receive(:valid_state?).and_return(false) + get :callback, params: {state: state, code: code} + end + + it "redirects with error" do + expect(response).to redirect_to(app_path(app)) + expect(flash[:alert]).to eq("Failed to create the integration, please try again.") + end + end + end + + describe "POST #set_organization" do + let(:cloud_id) { "cloud_123" } + let(:valid_params) do + { + cloud_id: cloud_id, + code: code, + state: state + } + end + + context "with valid parameters" do + before do + allow(app.integrations).to receive(:build).and_return(integration) + allow(integration).to receive_messages( + providable: jira_integration, + save!: true + ) + + allow(jira_integration).to receive_messages( + setup: {} + ) + + post :set_organization, params: valid_params + end + + it "creates integration and redirects to app integrations" do + expect(flash[:notice]).to eq("Integration was successfully created.") + end + end + + context "with invalid parameters" do + before do + allow(app.integrations).to receive(:build).and_return(integration) + allow(integration).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(integration)) + + post :set_organization, params: valid_params + end + + it "redirects to integrations path with error" do + expect(response).to redirect_to(app_integrations_path(app)) + expect(flash[:alert]).to eq("Failed to create the integration, please try again.") + end + end + end +end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 59c13013f..ac704f2cf 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -30,5 +30,15 @@ providable factory: %i[slack_integration without_callbacks_and_validations] category { "notification" } end + + trait :jira do + category { "project_management" } + providable factory: :jira_integration + end + + trait :with_jira do + category { "project_management" } + providable factory: %i[jira_integration with_app_config] + end end end diff --git a/spec/factories/jira_integrations.rb b/spec/factories/jira_integrations.rb new file mode 100644 index 000000000..3c9bd39e8 --- /dev/null +++ b/spec/factories/jira_integrations.rb @@ -0,0 +1,20 @@ +FactoryBot.define do + factory :jira_integration do + oauth_access_token { "test_access_token" } + oauth_refresh_token { "test_refresh_token" } + cloud_id { "cloud_123" } + integration + + trait :with_app_config do + after(:create) do |jira_integration| + app = jira_integration.integration.integrable + app.config.update!(jira_config: { + "release_filters" => [ + {"type" => "label", "value" => "release-1.0"}, + {"type" => "fix_version", "value" => "v1.0.0"} + ] + }) + end + end + end +end diff --git a/spec/libs/installations/jira/api_spec.rb b/spec/libs/installations/jira/api_spec.rb new file mode 100644 index 000000000..2a83a1b31 --- /dev/null +++ b/spec/libs/installations/jira/api_spec.rb @@ -0,0 +1,149 @@ +require "rails_helper" +require "webmock/rspec" + +RSpec.describe Installations::Jira::Api do + let(:oauth_access_token) { "test_token" } + let(:cloud_id) { "test_cloud_id" } + let(:api) { described_class.new(oauth_access_token, cloud_id) } + + describe ".get_accessible_resources" do + let(:code) { "test_code" } + let(:redirect_uri) { "http://example.com/callback" } + + context "when successful" do + let(:resources) { [{"id" => "cloud_1"}] } + let(:tokens) { {"access_token" => "token", "refresh_token" => "refresh"} } + + before do + allow(described_class).to receive(:creds) + .and_return(OpenStruct.new( + integrations: OpenStruct.new( + jira: OpenStruct.new( + client_id: "test_id", + client_secret: "test_secret" + ) + ) + )) + + stub_request(:post, "https://auth.atlassian.com/oauth/token") + .with( + basic_auth: ["test_id", "test_secret"], + body: { + grant_type: "authorization_code", + code: code, + redirect_uri: redirect_uri + } + ) + .to_return(body: tokens.to_json) + + stub_request(:get, "https://api.atlassian.com/oauth/token/accessible-resources") + .with(headers: {"Authorization" => "Bearer #{tokens["access_token"]}"}) + .to_return(body: resources.to_json, status: 200) + end + + it "returns resources and tokens" do + result_resources, result_tokens = described_class.get_accessible_resources(code, redirect_uri) + expect(result_resources).to eq(resources) + expect(result_tokens.access_token).to eq(tokens["access_token"]) + end + end + + context "when HTTP error occurs" do + before do + allow(described_class).to receive(:creds) + .and_return(OpenStruct.new( + integrations: OpenStruct.new( + jira: OpenStruct.new( + client_id: "test_id", + client_secret: "test_secret" + ) + ) + )) + + stub_request(:post, "https://auth.atlassian.com/oauth/token") + .with( + basic_auth: ["test_id", "test_secret"], + body: { + grant_type: "authorization_code", + code: code, + redirect_uri: redirect_uri + } + ) + .to_return(body: tokens.to_json) + + stub_request(:get, "https://api.atlassian.com/oauth/token/accessible-resources") + .to_raise(HTTP::Error.new("Network error")) + end + + let(:tokens) { {"access_token" => "token", "refresh_token" => "refresh"} } + + it "returns empty resources with tokens" do + resources, tokens = described_class.get_accessible_resources(code, redirect_uri) + expect(resources).to be_empty + expect(tokens).to be_present + end + end + end + + describe "#search_tickets_by_filters" do + let(:project_key) { "TEST" } + let(:empty_response) { {"issues" => []} } + + context "when release filters are not configured" do + it "returns empty issues array" do + result = api.search_tickets_by_filters(project_key, []) + expect(result["issues"]).to eq([]) + end + end + + context "with release filters" do + let(:release_filters) do + [ + {"type" => "label", "value" => "release-1.0"}, + {"type" => "fix_version", "value" => "1.0.0"} + ] + end + + let(:mock_response) do + { + "issues" => [ + { + "key" => "TEST-1", + "fields" => { + "summary" => "Test issue", + "status" => {"name" => "Done"}, + "assignee" => {"displayName" => "John Doe"}, + "labels" => ["release-1.0."], + "fixVersions" => [{"name" => "1.0.0"}] + } + } + ] + } + end + + it "returns original response structure" do + allow(api).to receive(:execute).and_return(mock_response) + result = api.search_tickets_by_filters(project_key, release_filters) + expect(result["issues"]).to eq(mock_response["issues"]) + end + + it "builds correct JQL query" do + expected_query = "project = 'TEST' AND labels = 'release-1.0' AND fixVersion = '1.0.0'" + expected_url = "https://api.atlassian.com/ex/jira/#{cloud_id}/rest/api/3/search/jql" + expected_params = { + params: { + jql: expected_query, + fields: described_class::TICKET_SEARCH_FIELDS + } + } + + allow(api).to receive(:execute).and_return({"issues" => []}) + + api.search_tickets_by_filters(project_key, release_filters) + + expect(api).to have_received(:execute) + .with(:get, expected_url, expected_params) + end + end + end +end diff --git a/spec/models/jira_integration_spec.rb b/spec/models/jira_integration_spec.rb new file mode 100644 index 000000000..761db6f02 --- /dev/null +++ b/spec/models/jira_integration_spec.rb @@ -0,0 +1,157 @@ +require "rails_helper" + +RSpec.describe JiraIntegration do + subject(:integration) { build(:jira_integration) } + + let(:sample_release_label) { "release-1.0" } + let(:sample_version) { "v1.0.0" } + + describe "#installation" do + it "returns a new API instance with correct credentials" do + api = integration.installation + expect(api).to be_a(Installations::Jira::Api) + expect(api.oauth_access_token).to eq(integration.oauth_access_token) + expect(api.cloud_id).to eq(integration.cloud_id) + end + end + + describe "#with_api_retries" do + context "when token expired" do + let(:error) { Installations::Jira::Error.new("error" => {"message" => "The access token expired"}) } + let(:integration) { build(:jira_integration) } + + it "retries after refreshing token" do + call_count = 0 + allow(integration).to receive(:reset_tokens!) + + result = integration.send(:with_api_retries) do + call_count += 1 + raise error if call_count == 1 + "success" + end + + expect(integration).to have_received(:reset_tokens!).once + expect(result).to eq("success") + end + end + + context "when max retries exceeded" do + it "raises the error" do + expect do + integration.send(:with_api_retries) { raise Installations::Jira::Error.new({}) } + end.to raise_error(Installations::Jira::Error) + end + end + end + + describe "#fetch_tickets_for_release" do + let(:app) { create(:app, :android) } + let(:integration) { create(:jira_integration, integration: create(:integration, integrable: app)) } + let(:api_response) do + { + "issues" => [ + { + "key" => "PROJ-1", + "fields" => { + "summary" => "Test ticket", + "status" => {"name" => "Done"}, + "assignee" => {"displayName" => "John Doe"}, + "labels" => [sample_release_label], + "fixVersions" => [{"name" => sample_version}] + } + } + ] + } + end + + before do + app.config.update!(jira_config: { + "selected_projects" => ["PROJ"], + "release_filters" => [{"type" => "label", "value" => sample_release_label}] + }) + + allow_any_instance_of(Installations::Jira::Api) + .to receive(:search_tickets_by_filters) + .with("PROJ", [{"type" => "label", "value" => sample_release_label}], any_args) + .and_return(api_response) + end + + it "returns formatted tickets" do + expect(integration.fetch_tickets_for_release).to eq([{ + "key" => "PROJ-1", + "summary" => "Test ticket", + "status" => "Done", + "assignee" => "John Doe", + "labels" => [sample_release_label], + "fix_versions" => [{"name" => sample_version}] + }]) + end + + context "when missing required configuration" do + it "returns empty array when no selected projects" do + app.config.update!(jira_config: { + "release_filters" => [{"type" => "label", "value" => sample_release_label}] + }) + expect(integration.fetch_tickets_for_release).to eq([]) + end + + it "returns empty array when no release filters" do + app.config.update!(jira_config: { + "selected_projects" => ["PROJ"] + }) + expect(integration.fetch_tickets_for_release).to eq([]) + end + end + end + + describe "#validate_release_filters" do + let(:app) { create(:app, :android) } + let(:integration) { build(:jira_integration, integration: create(:integration, integrable: app)) } + + context "with invalid filter type" do + let(:filters) { [{"type" => "invalid", "value" => "test"}] } + + before do + app.config.update!(jira_config: {"release_filters" => filters}) + integration.valid? + end + + it "is invalid" do + expect(integration).not_to be_valid + expect(integration.errors[:release_filters]).to include("must contain valid type and value") + end + end + + context "with empty filter value" do + let(:filters) { [{"type" => "label", "value" => ""}] } + + before do + app.config.update!(jira_config: {"release_filters" => filters}) + integration.valid? + end + + it "is invalid" do + expect(integration).not_to be_valid + expect(integration.errors[:release_filters]).to include("must contain valid type and value") + end + end + + context "with valid filters" do + let(:filters) do + [ + {"type" => "label", "value" => sample_release_label}, + {"type" => "fix_version", "value" => sample_version} + ] + end + + before do + app.config.update!(jira_config: {"release_filters" => filters}) + integration.valid? + end + + it "is valid" do + expect(integration).to be_valid + end + end + end +end