<%= opt[:title] %>
<%= opt[:subtitle] %>
diff --git a/app/components/option_cards_component.rb b/app/components/option_cards_component.rb
index 9a04a3262..4edef8c0a 100644
--- a/app/components/option_cards_component.rb
+++ b/app/components/option_cards_component.rb
@@ -14,7 +14,7 @@ def initialize(form:, options:)
def enhance_opts(opts)
opts.map do |option|
- base_id = "#{form.object_name}_#{option[:opt_name]}"
+ base_id = [form.object_name, option[:opt_name]].compact_blank.join("_")
base_id += "_#{option[:opt_value]}" if option[:opt_value]
option[:id] = base_id
option[:icon] = IconComponent.new(option[:icon], size: :xl)
diff --git a/app/controllers/app_configs_controller.rb b/app/controllers/app_configs_controller.rb
index b42d21279..b8f9d1a4a 100644
--- a/app/controllers/app_configs_controller.rb
+++ b/app/controllers/app_configs_controller.rb
@@ -40,6 +40,7 @@ def pick_category
when Integration.categories[:ci_cd] then configure_ci_cd
when Integration.categories[:monitoring] then configure_monitoring
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
@@ -64,6 +65,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
@@ -80,7 +85,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, :_destroy]]
+ }
)
end
@@ -91,6 +102,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
@@ -122,6 +134,60 @@ 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]).compact_blank,
+ custom_done_states: Array(project_config[:custom_done_states]).compact_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? || filter[:_destroy] == "1"
+ {
+ "type" => filter[:type],
+ "value" => filter[:value]
+ }
+ end || []
+ }
+ end
+
def bugsnag_config(config_params)
config = {}
diff --git a/app/controllers/integration_listener_controller.rb b/app/controllers/integration_listener_controller.rb
index d8e4ae55f..eed05455f 100644
--- a/app/controllers/integration_listener_controller.rb
+++ b/app/controllers/integration_listener_controller.rb
@@ -1,10 +1,11 @@
class IntegrationListenerController < SignedInApplicationController
using RefinedString
before_action :require_write_access!, only: %i[callback]
+ INTEGRATION_CREATE_ERROR = "Failed to create the integration, please try again."
def callback
unless valid_state?
- redirect_to app_path(state_app), alert: "Failed to create the integration, please try again."
+ redirect_to app_path(state_app), alert: INTEGRATION_CREATE_ERROR
return
end
@@ -14,8 +15,11 @@ def callback
if @integration.save
redirect_to app_path(state_app), notice: "Integration was successfully created."
else
- redirect_to app_integrations_path(state_app), alert: "Failed to create the integration, please try again."
+ redirect_to app_integrations_path(state_app), alert: INTEGRATION_CREATE_ERROR
end
+ rescue => e
+ Rails.logger.error("Failed to create integration: #{e.message}")
+ redirect_to app_integrations_path(state_app), alert: INTEGRATION_CREATE_ERROR
end
protected
@@ -27,7 +31,13 @@ def providable_params
private
def state
- @state ||= JSON.parse(params[:state].decode).with_indifferent_access
+ @state ||=
+ begin
+ JSON.parse(params[:state].tr(" ", "+").decode).with_indifferent_access
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage => e
+ Rails.logger.error(e)
+ {}
+ end
end
def installation_id
@@ -59,6 +69,7 @@ def state_organization
def state_app
@state_app ||= @state_organization.apps.find(state[:app_id])
+ @app = @state_app
end
def state_integration_category
diff --git a/app/controllers/integration_listeners/jira_controller.rb b/app/controllers/integration_listeners/jira_controller.rb
new file mode 100644
index 000000000..093e069c9
--- /dev/null
+++ b/app/controllers/integration_listeners/jira_controller.rb
@@ -0,0 +1,46 @@
+class IntegrationListeners::JiraController < IntegrationListenerController
+ using RefinedString
+
+ 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(e)
+ redirect_to app_integrations_path(state_app), alert: INTEGRATION_CREATE_ERROR
+ end
+ end
+
+ protected
+
+ def providable_params
+ super.merge(
+ code: code,
+ cloud_id: params[:cloud_id]
+ )
+ end
+
+ private
+
+ def error?
+ params[:error].present? || state.empty?
+ end
+end
diff --git a/app/helpers/enhanced_form_helper.rb b/app/helpers/enhanced_form_helper.rb
index 6fd57abe2..86f3f2b1d 100644
--- a/app/helpers/enhanced_form_helper.rb
+++ b/app/helpers/enhanced_form_helper.rb
@@ -120,11 +120,16 @@ def labeled_file_field(method, label_text, help_text = nil, options = {})
def labeled_checkbox(method, label_text, options = {})
hopts = {class: field_classes(is_disabled: options[:disabled], classes: CHECK_BOX_CLASSES)}.merge(options)
@template.content_tag(:div, class: "flex items-center") do
- @template.concat check_box(method, hopts)
+ @template.concat checkbox(method, hopts)
@template.concat label_only(method, label_text, type: :side)
end
end
+ def checkbox(method, options = {}, checked_value = "1", unchecked_value = "0")
+ hopts = {class: field_classes(is_disabled: options[:disabled], classes: CHECK_BOX_CLASSES)}.merge(options)
+ check_box(method, hopts, checked_value, unchecked_value)
+ end
+
def labeled_radio_option(method, value, label_text, options = {})
hopts = {class: field_classes(is_disabled: options[:disabled], classes: OPTION_CLASSES)}.merge(options)
@template.content_tag(:div, class: "flex items-center me-4") do
diff --git a/app/javascript/controllers/character_counter_controller.js b/app/javascript/controllers/character_counter_controller.js
index a5c710b6d..3b38120d8 100644
--- a/app/javascript/controllers/character_counter_controller.js
+++ b/app/javascript/controllers/character_counter_controller.js
@@ -1,5 +1,4 @@
import { Controller } from "@hotwired/stimulus"
-
const ERROR_CLASS = "text-rose-700"
export default class extends Controller {
diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js
index d619cf3ba..4536c2f28 100644
--- a/app/javascript/controllers/dialog_controller.js
+++ b/app/javascript/controllers/dialog_controller.js
@@ -21,9 +21,10 @@ export default class extends Controller {
event.preventDefault();
}
- this.dialogTarget.showModal(); // Show the dialog
+ // Show the dialog
+ this.dialogTarget.showModal();
- /* Remove focus from the dialog */
+ // Remove the focus from the dialog
this.dialogTarget.focus();
this.dialogTarget.blur();
diff --git a/app/javascript/controllers/nested_form_ext_controller.js b/app/javascript/controllers/nested_form_ext_controller.js
index 2fc45c36c..6273c72d2 100644
--- a/app/javascript/controllers/nested_form_ext_controller.js
+++ b/app/javascript/controllers/nested_form_ext_controller.js
@@ -2,14 +2,17 @@ import NestedForm from "stimulus-rails-nested-form"
export default class extends NestedForm {
static outlets = ["list-position"]
+
add(e) {
super.add(e)
this.updatePositions()
}
+
remove(e) {
super.remove(e)
this.updatePositions()
}
+
updatePositions() {
if (this.hasListPositionOutlet) {
this.listPositionOutlet.update()
diff --git a/app/javascript/controllers/nested_select_controller.js b/app/javascript/controllers/nested_select_controller.js
index 36eb6bb91..4674de1ea 100644
--- a/app/javascript/controllers/nested_select_controller.js
+++ b/app/javascript/controllers/nested_select_controller.js
@@ -1,3 +1,4 @@
+// TODO: This controller should be deprecated in favor of the multi-level select component
import {Controller} from "@hotwired/stimulus"
export default class extends Controller {
diff --git a/app/javascript/controllers/targeted_toggle_controller.js b/app/javascript/controllers/targeted_toggle_controller.js
new file mode 100644
index 000000000..e48b0829b
--- /dev/null
+++ b/app/javascript/controllers/targeted_toggle_controller.js
@@ -0,0 +1,33 @@
+import {Controller} from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["section", "subsection", "emptyState"]
+ static outlets = ["reveal"]
+
+ connect() {
+ this.sectionTargets.forEach((section) => this.targetedToggle(section))
+ }
+
+ toggle(e) {
+ this.targetedToggle(e.target)
+ }
+
+ targetedToggle(target) {
+ let subsection = this.subsectionTargets.find(t => t.dataset.sectionKey === target.dataset.sectionKey)
+ if (subsection) {
+ subsection.hidden = !target.checked
+ }
+
+ this.updateEmptiness()
+ }
+
+ updateEmptiness() {
+ if (this.hasRevealOutlet) {
+ if (this.subsectionTargets.every(t => t.hidden)) {
+ this.revealOutlet.show()
+ } else {
+ this.revealOutlet.hide()
+ }
+ }
+ }
+}
diff --git a/app/javascript/controllers/visibility_controller.js b/app/javascript/controllers/visibility_controller.js
deleted file mode 100644
index 5ac7be9d0..000000000
--- a/app/javascript/controllers/visibility_controller.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import {Controller} from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = ["visible"]
-
- initialize() {
- if (this.hasVisibleTarget) {
- this.visibleTarget.style.visibility = "hidden"
- }
- }
-
- toggle() {
- if (this.hasVisibleTarget) {
- if (this.visibleTarget.style.visibility === "hidden") {
- this.visibleTarget.style.visibility = "visible";
- } else {
- this.visibleTarget.style.visibility = "hidden";
- }
- }
- }
-}
diff --git a/app/libs/installations/jira/api.rb b/app/libs/installations/jira/api.rb
new file mode 100644
index 000000000..e6314946d
--- /dev/null
+++ b/app/libs/installations/jira/api.rb
@@ -0,0 +1,152 @@
+module Installations
+ class Jira::Api
+ include Vaultable
+ attr_reader :oauth_access_token, :cloud_id
+
+ BASE_URL = "https://api.atlassian.com/ex/jira"
+ DATA = Installations::Response::Keys
+
+ # 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(transformations)
+ response = execute(:get, PROJECTS_URL.expand(cloud_id:).to_s)
+ DATA.transform(response["values"], transformations)
+ end
+
+ def project_statuses(project_key, transformations)
+ response = execute(:get, PROJECT_STATUSES_URL.expand(cloud_id:, project_key:).to_s)
+ extract_unique_statuses(response, transformations)
+ end
+
+ def search_tickets_by_filters(project_key, release_filters, transformations, 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)
+ DATA.transform(response["issues"], transformations)
+ 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 extract_unique_statuses(statuses, transformations)
+ statuses.flat_map { |issue_type| issue_type["statuses"] }
+ .uniq { |status| status["id"] }
+ .then { |statuses| DATA.transform(statuses, transformations) }
+ end
+
+ 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"])
+ filter_condition =
+ case filter["type"]
+ when "label" then "labels = '#{value}'"
+ when "fix_version" then "fixVersion = '#{value}'"
+ else Rails.logger.warn("Unsupported Jira filter type: #{filter["type"]}")
+ end
+ conditions << filter_condition if filter_condition
+ 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 1a95f0293..2da207277 100644
--- a/app/models/app.rb
+++ b/app/models/app.rb
@@ -60,6 +60,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
@@ -109,6 +110,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 d0721d53c..f91b30324 100644
--- a/app/models/app_config.rb
+++ b/app/models/app_config.rb
@@ -10,6 +10,8 @@
# 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
# app_id :uuid not null, indexed
@@ -33,6 +35,7 @@ class AppConfig < ApplicationRecord
validates :firebase_android_config,
allow_blank: true,
json: {message: ->(errors) { errors }, schema: PLATFORM_AWARE_CONFIG_SCHEMA}
+ validate :jira_release_filters, if: -> { jira_config&.dig("release_filters").present? }
after_initialize :set_bugsnag_config, if: :persisted?
@@ -96,6 +99,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
@@ -156,4 +166,24 @@ 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
+
+ def jira_release_filters
+ jira_config["release_filters"].each do |filter|
+ unless filter.is_a?(Hash) && JiraIntegration::VALID_FILTER_TYPES.include?(filter["type"]) && filter["value"].present?
+ errors.add(:jira_config, "release filters must contain valid type and value")
+ end
+ end
+ end
end
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 79addd13d..095a7b0ec 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -24,7 +24,7 @@ class Integration < ApplicationRecord
belongs_to :app, optional: true
- PROVIDER_TYPES = %w[GithubIntegration GitlabIntegration SlackIntegration AppStoreIntegration GooglePlayStoreIntegration BitriseIntegration GoogleFirebaseIntegration BugsnagIntegration BitbucketIntegration CrashlyticsIntegration]
+ PROVIDER_TYPES = %w[GithubIntegration GitlabIntegration SlackIntegration AppStoreIntegration GooglePlayStoreIntegration BitriseIntegration GoogleFirebaseIntegration BugsnagIntegration BitbucketIntegration CrashlyticsIntegration JiraIntegration]
delegated_type :providable, types: PROVIDER_TYPES, autosave: true, validate: false
delegated_type :integrable, types: INTEGRABLE_TYPES, autosave: true, validate: false
@@ -39,21 +39,24 @@ class Integration < ApplicationRecord
"ci_cd" => %w[BitriseIntegration GithubIntegration BitbucketIntegration],
"notification" => %w[SlackIntegration],
"build_channel" => %w[AppStoreIntegration GoogleFirebaseIntegration],
- "monitoring" => %w[BugsnagIntegration CrashlyticsIntegration]
+ "monitoring" => %w[BugsnagIntegration CrashlyticsIntegration],
+ "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 CrashlyticsIntegration]
+ "monitoring" => %w[BugsnagIntegration CrashlyticsIntegration],
+ "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 CrashlyticsIntegration]
+ "monitoring" => %w[BugsnagIntegration CrashlyticsIntegration],
+ "project_management" => %w[JiraIntegration]
}
}.with_indifferent_access
@@ -76,12 +79,14 @@ 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
DEFAULT_CONNECT_STATUS = Integration.statuses[:connected].freeze
DEFAULT_INITIAL_STATUS = Integration.statuses[:disconnected].freeze
+ DISABLED_CATEGORIES = ["project_management"].freeze
# FIXME: Can we make a better External Deployment abstraction?
EXTERNAL_BUILD_INTEGRATION = {
@@ -111,6 +116,8 @@ def by_categories_for(app)
integrations = ALLOWED_INTEGRATIONS_FOR_APP[app.platform]
integrations.each_with_object({}) do |(category, providers), combination|
+ next if DISABLED_CATEGORIES.include?(category)
+
existing_integration = existing_integrations.select { |integration| integration.category.eql?(category) }
combination[category] ||= []
@@ -197,6 +204,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_integrations_across_apps(app, providable_type)
Integration.connected
.where(integrable_id: app.organization.apps, providable_type: providable_type)
diff --git a/app/models/jira_integration.rb b/app/models/jira_integration.rb
new file mode 100644
index 000000000..eb2997889
--- /dev/null
+++ b/app/models/jira_integration.rb
@@ -0,0 +1,234 @@
+# == 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 Linkable
+ include Vaultable
+ include Providable
+ include Displayable
+
+ encrypts :oauth_access_token, deterministic: true
+ encrypts :oauth_refresh_token, deterministic: true
+
+ 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
+ API = Installations::Jira::Api
+
+ 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, :available_resources
+ delegate :app, to: :integration
+ delegate :cache, to: Rails
+ validates :cloud_id, presence: true
+
+ 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
+
+ # if the user has access to only one organization, then set the cloud_id and assume the access is complete
+ # otherwise, set available_resources so that the user can select the right cloud_id and then eventually complete the access
+ def complete_access
+ return false if code.blank? || redirect_uri.blank?
+
+ resources, tokens = API.get_accessible_resources(code, redirect_uri)
+ set_tokens(tokens)
+
+ # access is already complete if cloud_id is already set
+ return true if cloud_id.present?
+
+ 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 = cloud_id
+
+ def connection_data
+ "Cloud ID: #{integration.metadata}" if integration.metadata
+ end
+
+ def fetch_tickets_for_release
+ return [] if app.config.jira_config.blank?
+
+ project_key = app.config.jira_config["selected_projects"]&.last
+ release_filters = app.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,
+ TICKET_TRANSFORMATIONS
+ )
+ return [] if response["issues"].blank?
+
+ response["issues"]
+ 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
+
+ def api
+ @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(PROJECT_TRANSFORMATIONS)
+ {projects: response}
+ end
+ rescue => e
+ Rails.logger.error("Failed to fetch Jira projects for cloud_id #{cloud_id}: #{e}")
+ {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"], STATUS_TRANSFORMATIONS)
+ statuses[project["key"]] = project_statuses
+ end
+ statuses
+ end
+ rescue => e
+ Rails.logger.error("Failed to fetch Jira project statuses for cloud_id #{cloud_id}: #{e}")
+ {}
+ end
+end
diff --git a/app/views/app_configs/_jira_form.html.erb b/app/views/app_configs/_jira_form.html.erb
new file mode 100644
index 000000000..8e29d9fd3
--- /dev/null
+++ b/app/views/app_configs/_jira_form.html.erb
@@ -0,0 +1,25 @@
+<% if jira_data && jira_data[:projects].present? %>
+ <%= render FormComponent.new(model: config,
+ url: app_app_config_path(app),
+ method: :patch,
+ data: { turbo_frame: "_top" },
+ builder: EnhancedFormHelper::AuthzForm,
+ free_form: true) do |f| %>
+ <%= render CardComponent.new(title: "Select Jira Projects",
+ subtitle: "Pick projects, add release filters and done states for tracking releases",
+ separator: false,
+ size: :full) do %>
+ <%= render partial: "jira_integration/project_selection",
+ locals: { form: f.F, jira_data: @jira_data, current_jira_config: @current_jira_config } %>
+ <% f.with_action do %>
+ <%= f.F.authz_submit "Update", "plus.svg", size: :sm %>
+ <% end %>
+ <% end %>
+ <% end %>
+<% else %>
+ <%= render EmptyStateComponent.new(
+ title: "No Jira projects found",
+ text: "Please try loading this page again or check your configured projects.",
+ banner_image: "folder_open.svg",
+ type: :subdued) %>
+<% 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..1198028fc
--- /dev/null
+++ b/app/views/app_configs/project_management.html.erb
@@ -0,0 +1,3 @@
+<%= render EnhancedTurboFrameComponent.new("#{@integration_category}_config") do %>
+ <%= render partial: "jira_form", locals: { config: @config, app: @app, jira_data: @jira_data } %>
+<% end %>
diff --git a/app/views/jira_integration/_project_selection.html.erb b/app/views/jira_integration/_project_selection.html.erb
new file mode 100644
index 000000000..2a945e798
--- /dev/null
+++ b/app/views/jira_integration/_project_selection.html.erb
@@ -0,0 +1,124 @@
+<%# locals: (form:, jira_data:, current_jira_config:) %>
+
+
+
+
Projects
+ <% jira_data[:projects].each do |project| %>
+ <% project_key = project["key"] %>
+
+
+ <%= form.fields_for :jira_config do |sf| %>
+ <% checked =
+ current_jira_config
+ &.dig("selected_projects")
+ &.include?(project["key"]) %>
+
+ <%= sf.checkbox :selected_projects,
+ { multiple: true,
+ include_hidden: false,
+ id: "project_#{project["key"]}",
+ checked:,
+ data: {
+ action: "targeted-toggle#toggle",
+ section_key: project_key,
+ targeted_toggle_target: "section",
+ },
+ },
+ project["key"] %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
Done States
+
+ <% jira_data[:projects].each do |project| %>
+ <% project_key = project["key"] %>
+ <% statuses = jira_data[:project_statuses][project_key] %>
+
+
+
For <%= project["name"] %> (<%= project["key"] %>)
+
+ <% if statuses&.any? %>
+ <% statuses.each do |status| %>
+ <% status_name = "status_#{project_key}_#{status["name"].parameterize}" %>
+
+
+ <%= form.fields_for :jira_config do |sf| %>
+ <%= sf.fields_for :project_configs do |pf| %>
+ <%= pf.fields_for project_key do |pk| %>
+ <% checked =
+ current_jira_config
+ &.dig("project_configs", project_key, "done_states")
+ &.include?(status["name"]) %>
+
+ <%= pk.checkbox :done_states,
+ { multiple: true, include_hidden: false, id: status_name, checked: },
+ status["name"] %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+
+ <% end %>
+ <% else %>
+ <%= render EmptyStateComponent.new(text: "No status configurations found for this project.",
+ banner_image: "list_checks.svg",
+ type: :tiny) %>
+ <% end %>
+
+ <% end %>
+
+
+
+ <%= render EmptyStateComponent.new(text: "Select a project to see the done states.",
+ banner_image: "list_checks.svg",
+ type: :tiny) %>
+
+
+
+
+
Release Filters
+
+
+ <%= render ButtonComponent.new(
+ scheme: :light,
+ type: :action,
+ size: :xs,
+ label: "Add filter",
+ html_options: { data: { action: "nested-form-ext#add" } },
+ arrow: :none) do |b|
+ b.with_icon("plus.svg", rounded: false)
+ end %>
+
+
+
+ <% release_filters = current_jira_config&.dig("release_filters") || [] %>
+
+ <% release_filters.each_with_index do |filter, index| %>
+ -
+ <%= render partial: "jira_integration/release_filter_form", locals: { form:, filter: filter, index: index } %>
+
+ <% end %>
+
+
+ -
+ <%= render partial: "jira_integration/release_filter_form", locals: { form:, index: "NEW_RECORD" } %>
+
+
+
+
+
+
+
+
† Release filters will be applied to all selected projects.
+
+
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..3bed89d7b
--- /dev/null
+++ b/app/views/jira_integration/_release_filter_form.html.erb
@@ -0,0 +1,21 @@
+<%# locals: (form:, filter: {}, index: 0) %>
+
+
+ <%= form.fields_for :jira_config do |sf| %>
+ <%= sf.fields_for :release_filters do |pf| %>
+ <%= pf.fields_for index.to_s do |rf| %>
+ <%= rf.select_without_label :type, options_for_select([["Label", "label"], ["Fix Version", "fix_version"]], filter['type']) %>
+ <%= rf.text_field_without_label :value, "e.g., release-1.0.0", value: filter["value"] %>
+ <%= rf.hidden_field :_destroy %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <%= render ButtonComponent.new(
+ scheme: :naked_icon,
+ type: :action,
+ size: :none,
+ html_options: { data: { action: "nested-form-ext#remove" } }) do |b|
+ b.with_icon("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..32ec231f3
--- /dev/null
+++ b/app/views/jira_integration/select_organization.html.erb
@@ -0,0 +1,36 @@
+
+ <%= render CardComponent.new(title: "Pick your Jira Organization", separator: true, size: :base) do %>
+ <% if @resources&.any? %>
+ <%= render FormComponent.new(url: resend_jira_callback_path, method: :post, free_form: true) do |form| %>
+ <%= form.F.hidden_field :code, value: params[:code] %>
+ <%= form.F.hidden_field :state, value: params[:state] %>
+
+ <% options = @resources.map do |org| %>
+ <%
+ {
+ title: org["name"],
+ subtitle: org["url"],
+ icon: "integrations/logo_jira.png",
+ opt_name: :cloud_id,
+ opt_value: org["id"],
+ options: { checked: true }
+ }
+ %>
+ <% end %>
+
+ <%= render OptionCardsComponent.new(form: form.F, options:) %>
+
+ <% form.with_action do %>
+ <%= form.F.authz_submit "Continue", "archive.svg", size: :sm %>
+ <% end %>
+ <% end %>
+ <% else %>
+
+
+
No organizations available
+
Please try again or contact support if the issue persists.
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/trains/_release_tag_form.html.erb b/app/views/trains/_release_tag_form.html.erb
index fece87d31..da823c41b 100644
--- a/app/views/trains/_release_tag_form.html.erb
+++ b/app/views/trains/_release_tag_form.html.erb
@@ -1,8 +1,8 @@
<%= render Form::SwitchComponent.new(form:,
- field_name: :tag_releases,
- on_label: "Release Tag enabled",
- off_label: "Release Tag disabled",
- hide_child: @train.tag_releases?) do |component| %>
+ field_name: :tag_releases,
+ on_label: "Release Tag enabled",
+ off_label: "Release Tag disabled",
+ hide_child: @train.tag_releases?) do |component| %>
<% component.with_child do %>
{ "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..2ca209a88
--- /dev/null
+++ b/spec/controllers/integration_listeners/jira_controller_spec.rb
@@ -0,0 +1,101 @@
+require "rails_helper"
+
+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
+
+ context "when it behaves as a POST request" do
+ let(:valid_params) do
+ {
+ cloud_id: "cloud_123",
+ code: code,
+ state: state
+ }
+ end
+
+ 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(complete_access: true, setup: {})
+
+ post :callback, params: valid_params
+ end
+
+ it "creates integration and redirects to app integrations" do
+ expect(response).to redirect_to(app_path(app))
+ expect(flash[:notice]).to eq("Integration was successfully created.")
+ end
+ end
+ end
+end
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index 899bc02bb..9cbd5b06e 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -36,5 +36,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..593df5f53
--- /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) }
+ let(:transformations) { JiraIntegration::TICKET_TRANSFORMATIONS }
+
+ 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(
+ 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, [], transformations)
+ 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, transformations)
+ expect(result[0]["key"]).to eq(mock_response["issues"][0]["key"])
+ 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, transformations)
+
+ expect(api).to have_received(:execute)
+ .with(:get, expected_url, expected_params)
+ end
+ end
+ end
+end
diff --git a/spec/models/app_config_spec.rb b/spec/models/app_config_spec.rb
new file mode 100644
index 000000000..424dd5336
--- /dev/null
+++ b/spec/models/app_config_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe AppConfig do
+ describe "#jira_release_filters" do
+ let(:app) { create(:app, :android) }
+ let(:sample_release_label) { "release-1.0" }
+ let(:sample_version) { "v1.0.0" }
+
+ context "with invalid filter type" do
+ let(:filters) { [{"type" => "invalid", "value" => "test"}] }
+
+ before do
+ app.config[:jira_config] = {"release_filters" => filters}
+ app.config.valid?
+ end
+
+ it "is invalid" do
+ expect(app.config).not_to be_valid
+ expect(app.config.errors[:jira_config]).to include("release filters must contain valid type and value")
+ end
+ end
+
+ context "with empty filter value" do
+ let(:filters) { [{"type" => "label", "value" => ""}] }
+
+ before do
+ app.config[:jira_config] = {"release_filters" => filters}
+ app.config.valid?
+ end
+
+ it "is invalid" do
+ expect(app.config).not_to be_valid
+ expect(app.config.errors[:jira_config]).to include("release filters 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[:jira_config] = {"release_filters" => filters}
+ app.config.valid?
+ end
+
+ it "is valid" do
+ expect(app.config).to be_valid
+ 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..3f940bcd6
--- /dev/null
+++ b/spec/models/jira_integration_spec.rb
@@ -0,0 +1,105 @@
+require "rails_helper"
+
+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",
+ "fields" =>
+ {"summary" => "Test ticket",
+ "status" => {"name" => "Done"},
+ "assignee" => {"displayName" => "John Doe"},
+ "labels" => [sample_release_label],
+ "fixVersions" => [{"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
+end