Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jira integration for release readiness #682

Merged
merged 29 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7b12725
Jira integration for release readiness
Jan 8, 2025
306ee1d
Merge branch 'main' into TRAB-646
kitallis Jan 17, 2025
2ba7fbf
Merge branch 'main' into TRAB-646
kitallis Jan 17, 2025
4ecc7bd
revive
kitallis Jan 17, 2025
40abb72
Merge branch 'main' into TRAB-646
kitallis Jan 21, 2025
9e2800a
Merge branch 'main' into TRAB-646
kitallis Jan 21, 2025
dec657b
Merge branch 'main' into TRAB-646
kitallis Jan 21, 2025
06afa23
simplify the jira integration setup
kitallis Jan 24, 2025
3064452
minor
kitallis Jan 27, 2025
cab68c6
use standard tramline components
kitallis Jan 28, 2025
0a2f845
Merge branch 'main' into TRAB-646
kitallis Jan 28, 2025
f074834
Merge branch 'main' into TRAB-646
kitallis Jan 28, 2025
d6b3ea8
rewrite the entire jira form partial
kitallis Jan 28, 2025
77581ef
tool updates + lint
kitallis Jan 28, 2025
b6b54b5
allow deleting release filters
kitallis Jan 28, 2025
7992163
lint
kitallis Jan 28, 2025
771b569
Merge branch 'main' into TRAB-646
kitallis Jan 28, 2025
1b76165
specs
kitallis Jan 28, 2025
046c680
lint
kitallis Jan 28, 2025
0f757b8
handle no projects selected empty state
kitallis Feb 3, 2025
8ccbc63
minor formatting
kitallis Feb 3, 2025
85f5973
check for outlet presence
kitallis Feb 3, 2025
122f5b7
minor
kitallis Feb 3, 2025
2b02710
temporarily remove jira
kitallis Feb 3, 2025
c27544d
Update production jira creds
kitallis Feb 3, 2025
d0137e9
lint
kitallis Feb 3, 2025
641d17c
just disable jira from ui
kitallis Feb 3, 2025
94daa1a
lint
kitallis Feb 3, 2025
463ca77
Merge branch 'main' into TRAB-646
kitallis Feb 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
70 changes: 69 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]]
}
)
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,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 = {}

Expand Down
94 changes: 94 additions & 0 deletions app/controllers/integration_listeners/jira_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what scenario is this? afaik we select organization in the jira redirect itself, so why would we need to select again inside Tramline?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Atlassian API endpoint https://api.atlassian.com/oauth/token/accessible-resources returns a list of all resources (organizations) the user has access to after authentication.
This is because:

  • Multiple Resources: The API does not automatically limit the response to the organization selected during the redirect. Instead, it provides all accessible organizations for the user.

  • User Selection in Tramline: To ensure the correct organization is used in Tramline, the app must present the user with the list of accessible organizations returned by the API. The user then selects the desired organization explicitly within the app.

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("://", "")
kitallis marked this conversation as resolved.
Show resolved Hide resolved
)
end

def state
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes to myself: add app config to the state, so that the redirect can show the app.

@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
17 changes: 17 additions & 0 deletions app/javascript/controllers/domain/release_filters_controller.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
5 changes: 5 additions & 0 deletions app/javascript/controllers/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function toggleDisplay(target, condition) {
if (target) {
target.style.display = condition ? "block" : "none";
}
}
37 changes: 37 additions & 0 deletions app/javascript/controllers/project_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Controller } from "@hotwired/stimulus"
kitallis marked this conversation as resolved.
Show resolved Hide resolved
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')
}
}
}
10 changes: 10 additions & 0 deletions app/javascript/controllers/project_states_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Controller } from "@hotwired/stimulus"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes to myself: this controller shouldn't be project states, it should be more general than that, perhaps just even content toggle or something, can I use reveal here?

import { toggleDisplay } from "./helper"

export default class extends Controller {
static targets = ["content"]

toggle(event) {
toggleDisplay(this.contentTarget, event.target.checked)
}
}
Loading
Loading