Skip to content

Commit

Permalink
Jira integration for release readiness (#682)
Browse files Browse the repository at this point in the history
At the moment, the integration is disabled because the actual feature isn't ready yet. This is just the integration setup.
  • Loading branch information
kitallis committed Feb 3, 2025
1 parent acf48e8 commit f8b6a0c
Show file tree
Hide file tree
Showing 42 changed files with 1,393 additions and 49 deletions.
1 change: 0 additions & 1 deletion .tool-versions

This file was deleted.

7 changes: 5 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
start:
docker compose up -d
docker compose up -d --remove-orphans

stop:
docker compose down

restart container="web":
docker compose restart {{ container }}
Expand All @@ -12,7 +15,7 @@ spec file="":
fi

lint:
bin/rubocop --autocorrect
docker exec -it tramline-web-1 bin/rubocop --autocorrect

pre-setup:
docker compose run --rm pre-setup
Expand Down
6 changes: 6 additions & 0 deletions app/assets/images/folder_open.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/integrations/logo_jira.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/components/option_cards_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<% options.each do |opt| %>
<li>
<%= form.radio_button(opt[:opt_name], opt[:opt_value], opt[:options]) %>
<label for="<%= opt[:id] %>" class="inline-flex items-center justify-between gap-1 w-full h-full p-3 text-secondary bg-white border border-main-200 rounded-lg cursor-pointer dark:hover:text-main-300 dark:border-main-700 dark:peer-checked:text-blue-700 peer-checked:border-blue-800 peer-checked:text-blue-800 hover:text-main-600 peer-checked:bg-main-100 hover:bg-main-100 dark:text-secondary-50 dark:bg-main-800 dark:peer-checked:bg-main-700 dark:hover:bg-main-700">
<label for="<%= opt[:id] %>" class="inline-flex items-center justify-between gap-6 w-full h-full p-3 text-secondary bg-white border border-main-200 rounded-lg cursor-pointer dark:hover:text-main-300 dark:border-main-700 dark:peer-checked:text-blue-700 peer-checked:border-blue-800 peer-checked:text-blue-800 hover:text-main-600 peer-checked:bg-main-100 hover:bg-main-100 dark:text-secondary-50 dark:bg-main-800 dark:peer-checked:bg-main-700 dark:hover:bg-main-700">
<div class="block">
<div class="w-full text-base font-semibold"><%= opt[:title] %></div>
<div class="w-full"><%= opt[:subtitle] %></div>
Expand Down
2 changes: 1 addition & 1 deletion app/components/option_cards_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 67 additions & 1 deletion app/controllers/app_configs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 = {}

Expand Down
17 changes: 14 additions & 3 deletions app/controllers/integration_listener_controller.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/integration_listeners/jira_controller.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion app/helpers/enhanced_form_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion app/javascript/controllers/character_counter_controller.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Controller } from "@hotwired/stimulus"

const ERROR_CLASS = "text-rose-700"

export default class extends Controller {
Expand Down
5 changes: 3 additions & 2 deletions app/javascript/controllers/dialog_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
3 changes: 3 additions & 0 deletions app/javascript/controllers/nested_form_ext_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions app/javascript/controllers/nested_select_controller.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions app/javascript/controllers/targeted_toggle_controller.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
21 changes: 0 additions & 21 deletions app/javascript/controllers/visibility_controller.js

This file was deleted.

Loading

0 comments on commit f8b6a0c

Please sign in to comment.