From 7b12725d836b74c5fc8d5b0dae252690a18b09a6 Mon Sep 17 00:00:00 2001 From: gitstart_bot Date: Wed, 8 Jan 2025 12:13:21 +0000 Subject: [PATCH 01/20] Jira integration for release readiness --- app/assets/images/integrations/logo_jira.png | Bin 0 -> 757 bytes app/controllers/app_configs_controller.rb | 70 ++++- .../integration_listeners/jira_controller.rb | 94 +++++++ .../domain/release_filters_controller.js | 17 ++ app/javascript/controllers/helper.js | 5 + .../project_selector_controller.js | 37 +++ .../controllers/project_states_controller.js | 10 + app/libs/installations/jira/api.rb | 150 +++++++++++ app/libs/installations/jira/error.rb | 60 +++++ app/models/app.rb | 5 + app/models/app_config.rb | 35 +++ app/models/integration.rb | 18 +- app/models/jira_integration.rb | 245 ++++++++++++++++++ app/views/app_configs/_jira_form.html.erb | 29 +++ .../app_configs/project_management.html.erb | 7 + .../_project_selection.html.erb | 98 +++++++ .../_release_filter_form.html.erb | 28 ++ .../select_organization.html.erb | 47 ++++ config/locales/en.yml | 28 ++ config/routes.rb | 6 + ...20241114104939_create_jira_integrations.rb | 13 + ...15100841_add_jira_config_to_app_configs.rb | 5 + db/schema.rb | 10 + .../jira_controller_spec.rb | 138 ++++++++++ spec/factories/integrations.rb | 10 + spec/factories/jira_integrations.rb | 20 ++ spec/libs/installations/jira/api_spec.rb | 149 +++++++++++ spec/models/jira_integration_spec.rb | 156 +++++++++++ 28 files changed, 1484 insertions(+), 6 deletions(-) create mode 100644 app/assets/images/integrations/logo_jira.png create mode 100644 app/controllers/integration_listeners/jira_controller.rb create mode 100644 app/javascript/controllers/domain/release_filters_controller.js create mode 100644 app/javascript/controllers/helper.js create mode 100644 app/javascript/controllers/project_selector_controller.js create mode 100644 app/javascript/controllers/project_states_controller.js create mode 100644 app/libs/installations/jira/api.rb create mode 100644 app/libs/installations/jira/error.rb create mode 100644 app/models/jira_integration.rb create mode 100644 app/views/app_configs/_jira_form.html.erb create mode 100644 app/views/app_configs/project_management.html.erb create mode 100644 app/views/jira_integration/_project_selection.html.erb create mode 100644 app/views/jira_integration/_release_filter_form.html.erb create mode 100644 app/views/jira_integration/select_organization.html.erb create mode 100644 db/migrate/20241114104939_create_jira_integrations.rb create mode 100644 db/migrate/20241115100841_add_jira_config_to_app_configs.rb create mode 100644 spec/controllers/integration_listeners/jira_controller_spec.rb create mode 100644 spec/factories/jira_integrations.rb create mode 100644 spec/libs/installations/jira/api_spec.rb create mode 100644 spec/models/jira_integration_spec.rb diff --git a/app/assets/images/integrations/logo_jira.png b/app/assets/images/integrations/logo_jira.png new file mode 100644 index 0000000000000000000000000000000000000000..a97b51bb2b6ece5f493cf2a0f873b0007f1d3a5e GIT binary patch literal 757 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0*XmQK~zXft&~kn z6G0TmH^hL5A~Y(Y0h_vRi6@R8_vpckh8RsqCD_D5NNKQ|Duxca3*J0>?9rn~k3D)p zBx)=NLK|#CNl5FVQUu~W=S^pq-RV|Z(tq-je!Ta7Gw)-RRunR6JY)hiYnn-yMQC#x zF+lf9^gk1!&1onFUF=jjuSAVZgf=hLq&Hcqs$R*wOoX+j>n4n92F-dUlud-V6(DdC z>y4(8BR%l4qWcrSbYHxv_eKkP_r!+YIsRSuJv<#PLcc6djC-h8iK-c&W=(x8A%H7Y^p}F0#GsZHJvR-lt)I|n)@!N*SLigpKeA z*R2n;aDw$oP$8IaVBza+S@Pll5fGB{TvzF3j;N4Lni$xYH3 zUMC&*zY=9^l_+;sqRPVzU$$$+f|NLcrt1&BqHA=XGrkTfgV7b^xK z=zk@#YXe-8-xqI4!3jw-pSPBO4RQefWW^hR;;VlWWV!$sA~`r4&ZfOyRh8^F2B7%% zV#E2U(Tw(*W`YX`+cKmll1rn%d<%r~M6D>aMALYEC!}w>zyYY*GS-&s)Bp@dh&}hS zJ2t=)KH*pa@wIue)7dYi4S80s1Dsmed<$?4*=rvF#hU)n2??eS+Y1Q2fdGRXzb~vW n;;;6pHp28B8_jHM07dx+96b@nbj5h`00000NkvXXu0mjf60uSQ literal 0 HcmV?d00001 diff --git a/app/controllers/app_configs_controller.rb b/app/controllers/app_configs_controller.rb index e2bc46990..11eb719b8 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]] + } ) 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,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/domain/release_filters_controller.js b/app/javascript/controllers/domain/release_filters_controller.js new file mode 100644 index 000000000..25ac4e3af --- /dev/null +++ b/app/javascript/controllers/domain/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-domain--release-filters-target='filter']") + filter.remove() + } +} diff --git a/app/javascript/controllers/helper.js b/app/javascript/controllers/helper.js new file mode 100644 index 000000000..9d915b762 --- /dev/null +++ b/app/javascript/controllers/helper.js @@ -0,0 +1,5 @@ +export function toggleDisplay(target, condition) { + if (target) { + target.style.display = condition ? "block" : "none"; + } +} diff --git a/app/javascript/controllers/project_selector_controller.js b/app/javascript/controllers/project_selector_controller.js new file mode 100644 index 000000000..2a474474e --- /dev/null +++ b/app/javascript/controllers/project_selector_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" +import { toggleDisplay } from "./helper" + +export default class extends Controller { + static targets = [ "projectCheckbox", "config", "filterDivider"] + + connect() { + this.toggleConfigurations() + this.toggleFilterDivider() + } + + toggle() { + 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) { + toggleDisplay(config, checkbox.checked) + } + }) + } + + toggleFilterDivider() { + const anyProjectSelected = this.projectCheckboxTargets.some(checkbox => checkbox.checked) + + if (this.hasFilterDividerTarget) { + toggleDisplay(this.filterDividerTarget, anyProjectSelected) + 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..fa86268e9 --- /dev/null +++ b/app/javascript/controllers/project_states_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus" +import { toggleDisplay } from "./helper" + +export default class extends Controller { + static targets = ["content"] + + toggle(event) { + toggleDisplay(this.contentTarget, event.target.checked) + } +} diff --git a/app/libs/installations/jira/api.rb b/app/libs/installations/jira/api.rb new file mode 100644 index 000000000..249c2405b --- /dev/null +++ b/app/libs/installations/jira/api.rb @@ -0,0 +1,150 @@ +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(transformations) + response = execute(:get, PROJECTS_URL.expand(cloud_id:).to_s) + transform_data(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) + transform_data(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| transform_data(statuses, transformations) } + end + + def transform_data(data, transformations) + Installations::Response::Keys.transform(data, 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 fe36beec6..0b92da854 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..38b196123 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 @@ -96,6 +98,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 @@ -127,6 +136,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 @@ -156,4 +179,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 b3360cdd1..d541e3e88 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,7 +79,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 @@ -197,6 +201,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..5c06c8553 --- /dev/null +++ b/app/models/jira_integration.rb @@ -0,0 +1,245 @@ +# == 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 + + 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 :app, to: :integration + delegate :cache, to: Rails + + validates :cloud_id, presence: true + validate :validate_release_filters, if: -> { app.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 = Installations::Jira::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 + Installations::Jira::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 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(Installations::Jira::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 ||= Installations::Jira::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.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"], 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.message}") + {} + end + + def validate_release_filters + return if app.config.jira_config&.dig("release_filters").blank? + + app.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/_jira_form.html.erb b/app/views/app_configs/_jira_form.html.erb new file mode 100644 index 000000000..3b4676485 --- /dev/null +++ b/app/views/app_configs/_jira_form.html.erb @@ -0,0 +1,29 @@ +
+
+
+

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? %> + + <%= render 'jira_integration/project_selection' %> + <% else %> +
+ No Jira projects found. Please ensure your Jira integration is properly configured. +
+ <% end %> +
+ +
+ <%= f.authz_submit "Update", "plus.svg", size: :xs %> +
+ <% 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..6a33b7fe0 --- /dev/null +++ b/app/views/app_configs/project_management.html.erb @@ -0,0 +1,7 @@ +<%= render V2::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..40098ce0c --- /dev/null +++ b/app/views/jira_integration/_project_selection.html.erb @@ -0,0 +1,98 @@ +
+

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'], + project_selector_target: "projectCheckbox" + }, + 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 %> +
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..0bc08cbb6 --- /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: "domain--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 c060ded79..2e192469d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -405,6 +405,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: @@ -701,3 +702,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 fb26eefe9..4237801c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -268,6 +268,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 fe24a6bdf..3b7691862 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -60,6 +60,7 @@ t.jsonb "ci_cd_workflows" t.jsonb "firebase_crashlytics_ios_config" t.jsonb "firebase_crashlytics_android_config" + t.jsonb "jira_config", default: {}, null: false t.index ["app_id"], name: "index_app_configs_on_app_id", unique: true end @@ -399,6 +400,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 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/jira_integration_spec.rb b/spec/models/jira_integration_spec.rb new file mode 100644 index 000000000..496f8d58c --- /dev/null +++ b/spec/models/jira_integration_spec.rb @@ -0,0 +1,156 @@ +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", + "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 + + 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 From 4ecc7bd6544c9192a9c8d195626a68c9e1c4a7f2 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Fri, 17 Jan 2025 14:49:12 +0530 Subject: [PATCH 02/20] revive --- app/views/app_configs/project_management.html.erb | 2 +- app/views/jira_integration/_release_filter_form.html.erb | 4 ++-- config/credentials.yml.enc | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/app_configs/project_management.html.erb b/app/views/app_configs/project_management.html.erb index 6a33b7fe0..02f8b088f 100644 --- a/app/views/app_configs/project_management.html.erb +++ b/app/views/app_configs/project_management.html.erb @@ -1,4 +1,4 @@ -<%= render V2::EnhancedTurboFrameComponent.new("#{@integration_category}_config") do %> +<%= render EnhancedTurboFrameComponent.new("#{@integration_category}_config") do %> <%= render partial: "jira_form", locals: { config: @config, app: @app, diff --git a/app/views/jira_integration/_release_filter_form.html.erb b/app/views/jira_integration/_release_filter_form.html.erb index 0bc08cbb6..96379c1b4 100644 --- a/app/views/jira_integration/_release_filter_form.html.erb +++ b/app/views/jira_integration/_release_filter_form.html.erb @@ -15,7 +15,7 @@ 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( + <%= render ButtonComponent.new( scheme: :naked_icon, type: :action, size: :none, @@ -23,6 +23,6 @@ class: "mt-2", data: { action: "domain--release-filters#remove" } }) do |b| - b.with_icon("v2/trash.svg", size: :md) + b.with_icon("trash.svg", size: :md) end %> diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 25fb89aab..d494e22b6 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -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--yryFVhjV2gsmHUKR--CNr8jveivL9GtLj7i2w5Tw== \ No newline at end of file +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--7TF2RCQNwICYRZrZ--3EMUkidT7luRJwFoiO2g7w== \ No newline at end of file From 06afa23221dcd926092fb9f8f72c98e55e760e65 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Fri, 24 Jan 2025 23:15:08 +0530 Subject: [PATCH 03/20] simplify the jira integration setup --- app/controllers/app_configs_controller.rb | 2 - .../integration_listener_controller.rb | 17 ++++- .../integration_listeners/jira_controller.rb | 60 ++------------- app/models/app_config.rb | 13 +++- app/models/jira_integration.rb | 41 ++++------- app/views/app_configs/_jira_form.html.erb | 50 ++++++------- .../select_organization.html.erb | 73 ++++++++----------- config/routes.rb | 2 +- 8 files changed, 103 insertions(+), 155 deletions(-) diff --git a/app/controllers/app_configs_controller.rb b/app/controllers/app_configs_controller.rb index 3f5dd07d7..0a7f657e7 100644 --- a/app/controllers/app_configs_controller.rb +++ b/app/controllers/app_configs_controller.rb @@ -139,7 +139,6 @@ def set_jira_projects @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"] || {}, @@ -153,7 +152,6 @@ def set_jira_projects @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] ||= { 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 index 938b556ca..093e069c9 100644 --- a/app/controllers/integration_listeners/jira_controller.rb +++ b/app/controllers/integration_listeners/jira_controller.rb @@ -1,8 +1,6 @@ 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 @@ -13,82 +11,36 @@ def callback @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") + 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") + 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 + Rails.logger.error(e) + 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 + cloud_id: params[:cloud_id] ) 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/models/app_config.rb b/app/models/app_config.rb index 38b196123..37362c0af 100644 --- a/app/models/app_config.rb +++ b/app/models/app_config.rb @@ -35,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? @@ -139,13 +140,13 @@ def set_ci_cd_workflows(workflows) 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 = (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 = (jira_config.dig("release_filters") || []).dup new_filters.delete_at(index) update!(jira_config: jira_config.merge("release_filters" => new_filters)) end @@ -191,4 +192,12 @@ def project_management_ready? 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/jira_integration.rb b/app/models/jira_integration.rb index 5c06c8553..c1181484c 100644 --- a/app/models/jira_integration.rb +++ b/app/models/jira_integration.rb @@ -20,10 +20,10 @@ class JiraIntegration < ApplicationRecord encrypts :oauth_access_token, deterministic: true encrypts :oauth_refresh_token, deterministic: true - BASE_INSTALLATION_URL = - Addressable::Template.new("https://auth.atlassian.com/authorize{?params*}") + 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, @@ -54,13 +54,10 @@ class JiraIntegration < ApplicationRecord fix_versions: [:fields, :fixVersions] }.freeze - attr_accessor :code, :callback_url, :available_resources - before_validation :complete_access, on: :create + attr_accessor :code, :available_resources delegate :app, to: :integration delegate :cache, to: Rails - validates :cloud_id, presence: true - validate :validate_release_filters, if: -> { app.config.jira_config&.dig("release_filters").present? } def install_path BASE_INSTALLATION_URL @@ -75,12 +72,17 @@ def install_path }).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? || (callback_url.blank? && redirect_uri.blank?) + return false if code.blank? || redirect_uri.blank? - resources, tokens = Installations::Jira::Api.get_accessible_resources(code, callback_url || redirect_uri) + 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 @@ -91,7 +93,7 @@ def complete_access end def installation - Installations::Jira::Api.new(oauth_access_token, cloud_id) + API.new(oauth_access_token, cloud_id) end def to_s = "jira" @@ -129,13 +131,10 @@ def setup {} end - def metadata - with_api_retries { installation.user_info(USER_INFO_TRANSFORMATIONS) } - end + def metadata = cloud_id def connection_data - return unless integration.metadata - "Added by user: #{integration.metadata["name"]} (#{integration.metadata["email"]})" + "Cloud ID: #{integration.metadata}" if integration.metadata end def fetch_tickets_for_release @@ -188,7 +187,7 @@ def with_api_retries(attempt: 0, &) end def reset_tokens! - set_tokens(Installations::Jira::Api.oauth_refresh_token(oauth_refresh_token, redirect_uri)) + set_tokens(API.oauth_refresh_token(oauth_refresh_token, redirect_uri)) save! end @@ -204,7 +203,7 @@ def redirect_uri end def api - @api ||= Installations::Jira::Api.new(oauth_access_token, cloud_id) + @api ||= API.new(oauth_access_token, cloud_id) end def fetch_projects @@ -232,14 +231,4 @@ def fetch_project_statuses(projects) Rails.logger.error("Failed to fetch Jira project statuses for cloud_id #{cloud_id}: #{e.message}") {} end - - def validate_release_filters - return if app.config.jira_config&.dig("release_filters").blank? - - app.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/_jira_form.html.erb b/app/views/app_configs/_jira_form.html.erb index 3b4676485..258c24ce3 100644 --- a/app/views/app_configs/_jira_form.html.erb +++ b/app/views/app_configs/_jira_form.html.erb @@ -1,29 +1,29 @@
-
-
-

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? %> - - <%= render 'jira_integration/project_selection' %> - <% else %> -
- No Jira projects found. Please ensure your Jira integration is properly configured. -
- <% end %> +
+
+

Jira Configuration

+

Select projects and their done states for tracking releases.

-
- <%= f.authz_submit "Update", "plus.svg", size: :xs %> -
- <% end %> -
+ <%= 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? %> + + <%= render 'jira_integration/project_selection' %> + <% else %> +
+ No Jira projects found. Please ensure your Jira integration is properly configured. +
+ <% end %> +
+ +
+ <%= f.authz_submit "Update", "plus.svg", size: :xs %> +
+ <% end %> +
diff --git a/app/views/jira_integration/select_organization.html.erb b/app/views/jira_integration/select_organization.html.erb index 5cc30288c..32ec231f3 100644 --- a/app/views/jira_integration/select_organization.html.erb +++ b/app/views/jira_integration/select_organization.html.erb @@ -1,47 +1,36 @@ -
-
-
-
-
-

Select Jira Organization

-
+
+ <%= 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 %> - <% 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 %> -
+ <%= render OptionCardsComponent.new(form: form.F, options:) %> -
- <%= 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.

-
-
+ <% 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/config/routes.rb b/config/routes.rb index ac2e17f1a..34bf1eda3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -269,8 +269,8 @@ scope :jira do get :callback, controller: "integration_listeners/jira", as: :jira_callback + post :callback, controller: "integration_listeners/jira", as: :resend_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", From 3064452b0cee48ced7885a1927cd4f23b22df6d5 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Mon, 27 Jan 2025 18:17:06 +0530 Subject: [PATCH 04/20] minor --- app/assets/images/integrations/logo_jira.png | Bin 757 -> 32471 bytes .../option_cards_component.html.erb | 2 +- app/components/option_cards_component.rb | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/images/integrations/logo_jira.png b/app/assets/images/integrations/logo_jira.png index a97b51bb2b6ece5f493cf2a0f873b0007f1d3a5e..72dfa728e983f1261c2566f56cb38b359accb51e 100644 GIT binary patch literal 32471 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
P^R9M7^Zpz|tDDBd^NaDV3> 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 diff --git a/app/components/option_cards_component.html.erb b/app/components/option_cards_component.html.erb index 1bbaca22b..085ebbaf9 100644 --- a/app/components/option_cards_component.html.erb +++ b/app/components/option_cards_component.html.erb @@ -2,7 +2,7 @@ <% options.each do |opt| %>
  • <%= form.radio_button(opt[:opt_name], opt[:opt_value], opt[:options]) %> -