Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub e55d089f01 Merge branch 'develop' into feat/github-integration 2025-10-28 19:27:09 +05:30
Muhsin KelothandGitHub d0e84373ee Merge branch 'develop' into feat/github-integration 2025-10-06 15:29:09 +05:30
Muhsin KelothandGitHub 62152321bc Merge branch 'develop' into feat/github-integration 2025-09-25 16:25:58 +05:30
Muhsin KelothandGitHub c6714b20c2 Merge branch 'develop' into feat/github-integration 2025-09-22 11:19:35 +05:30
Muhsin KelothandGitHub 0f2263ec94 Merge branch 'develop' into feat/github-integration 2025-09-12 17:10:29 +05:30
Muhsin KelothandGitHub 784e0468c3 Merge branch 'develop' into feat/github-integration 2025-08-27 11:44:46 +05:30
Muhsin KelothandGitHub 75c9b38099 Merge branch 'develop' into feat/github-integration 2025-08-22 00:44:13 +05:30
Muhsin KelothandGitHub f8c155dd9e Merge branch 'develop' into feat/github-integration 2025-08-12 14:48:17 +05:30
Muhsin KelothandGitHub b9cf410b1f Merge branch 'develop' into feat/github-integration 2025-08-11 23:31:49 +05:30
Muhsin KelothandGitHub e6d96b14ac Merge branch 'develop' into feat/github-integration 2025-08-11 16:22:11 +05:30
Muhsin KelothandGitHub f0b3f025ff Merge branch 'develop' into feat/github-integration 2025-08-07 18:29:25 +05:30
Muhsin KelothandGitHub 5b32761d72 Merge branch 'develop' into feat/github-integration 2025-08-07 09:47:27 +05:30
Muhsin KelothandGitHub 7146471270 Merge branch 'develop' into feat/github-integration 2025-08-05 16:22:18 +05:30
Muhsin KelothandGitHub 8d6ea64848 Merge branch 'develop' into feat/github-integration 2025-08-04 12:36:38 +04:00
Muhsin KelothandGitHub 27b0a22019 Merge branch 'develop' into feat/github-integration 2025-07-29 12:50:05 +04:00
Muhsin KelothandGitHub 611635d3d7 Merge branch 'develop' into feat/github-integration 2025-07-23 11:42:04 +04:00
Muhsin KelothandGitHub 02178eb930 Merge branch 'develop' into feat/github-integration 2025-07-22 15:28:13 +04:00
Muhsin KelothandGitHub a1413d1656 Merge branch 'develop' into feat/github-integration 2025-07-21 21:48:28 +04:00
Muhsin KelothandGitHub a1747eeb62 Merge branch 'develop' into feat/github-integration 2025-07-17 09:26:59 +05:30
Muhsin Keloth f90a324ddf chore: update allowed config mappings for integrations 2025-07-17 09:26:15 +05:30
Muhsin Keloth 3f5d4f46f8 chore: add whatsApp and google oauth config options 2025-07-17 09:25:28 +05:30
Muhsin Keloth 7ae916e2fc chore: delete feature.yml 2025-07-17 09:23:40 +05:30
Muhsin KelothandGitHub c623d6079b Merge branch 'develop' into feat/github-integration 2025-07-14 19:23:13 +05:30
Muhsin KelothandGitHub f0876e9877 Merge branch 'develop' into feat/github-integration 2025-07-14 11:32:05 +05:30
Muhsin KelothandGitHub 92b0c2d4f5 Merge branch 'develop' into feat/github-integration 2025-07-11 11:48:12 +05:30
Muhsin KelothandGitHub 0a80076387 Merge branch 'develop' into feat/github-integration 2025-07-10 20:14:01 +05:30
Muhsin Keloth a59f53d00d chore: add more specs 2025-07-04 23:32:12 +05:30
Muhsin Keloth 143f394a18 chore: add specs 2025-07-04 17:10:11 +05:30
Muhsin Keloth 8e98fd26b5 chore: fix account id 2025-07-04 16:52:02 +05:30
Muhsin Keloth 4c85a1c6c0 chore: add github installation steps 2025-07-04 16:43:59 +05:30
Muhsin KelothandGitHub 6e28c4fcf8 Merge branch 'develop' into feat/github-integration 2025-07-04 14:38:18 +05:30
Muhsin KelothandGitHub 7f9d711845 Merge branch 'develop' into feat/github-integration 2025-07-02 22:42:41 +05:30
Muhsin KelothandGitHub 31436412fb Merge branch 'develop' into feat/github-integration 2025-07-01 16:52:46 +05:30
Muhsin KelothandGitHub 84d2135a04 Merge branch 'develop' into feat/github-integration 2025-07-01 11:34:35 +05:30
Muhsin KelothandGitHub 5bd739739e Merge branch 'develop' into feat/github-integration 2025-07-01 08:54:23 +05:30
Muhsin KelothandGitHub 3b42b6c3bb Merge branch 'develop' into feat/github-integration 2025-06-30 20:55:13 +05:30
Muhsin Keloth d3b8edd522 chore: add github basic apis 2025-06-30 12:54:50 +05:30
Muhsin Keloth ac7cac33a8 chore: update oauth url 2025-06-30 12:47:33 +05:30
Muhsin Keloth 4d98b1871a chore: fix route 2025-06-30 12:44:26 +05:30
Muhsin Keloth 4f214dd77a chore: fix route 2025-06-30 12:41:58 +05:30
Muhsin KelothandGitHub 94640628eb Merge branch 'develop' into feat/github-integration 2025-06-30 12:35:17 +05:30
Muhsin Keloth c9c9a011fe chore: enable GitHub Integration feature 2025-06-24 20:52:20 +05:30
Muhsin KelothandGitHub 747a856181 Merge branch 'develop' into feat/github-integration 2025-06-24 15:59:16 +05:30
Muhsin KelothandGitHub be5248ad68 Merge branch 'develop' into feat/github-integration 2025-06-23 11:54:12 +05:30
Muhsin KelothandGitHub a9ead61a89 Merge branch 'develop' into feat/github-integration 2025-06-19 11:52:06 +05:30
Muhsin KelothandGitHub c5b72239c6 Merge branch 'develop' into feat/github-integration 2025-06-18 11:31:31 +05:30
Muhsin KelothandGitHub 824b5a8113 Merge branch 'develop' into feat/github-integration 2025-06-17 09:24:16 +05:30
Muhsin KelothandGitHub 913ff2821a Merge branch 'develop' into feat/github-integration 2025-06-13 10:54:32 +05:30
Muhsin KelothandGitHub 05c004a3cb Merge branch 'develop' into feat/github-integration 2025-06-12 17:20:13 +05:30
Muhsin Keloth bd4c572aea chore: add octokit 2025-06-12 14:36:22 +05:30
Muhsin Keloth 7e672e5c71 Update app.rb 2025-06-12 14:18:26 +05:30
Muhsin Keloth 1622ba1b7f chore: fix configs 2025-06-12 14:00:47 +05:30
Muhsin Keloth d59f2d7bd1 chore: fix cyclomatic complexity 2025-06-12 13:34:39 +05:30
Muhsin Keloth 959f7447c3 Update app.rb 2025-06-12 12:56:29 +05:30
Muhsin Keloth 39a6485c2a Update app.rb 2025-06-12 12:55:04 +05:30
Muhsin Keloth bcaca9d3d3 Update app.rb 2025-06-12 12:54:08 +05:30
Muhsin Keloth 4de56468b7 chore: add state 2025-06-12 12:52:59 +05:30
Muhsin KelothandGitHub 6f84899beb Merge branch 'develop' into feat/github-integration 2025-06-12 12:19:04 +05:30
Muhsin Keloth bf78dde488 chore: delete github hook 2025-04-21 20:04:39 +05:30
Muhsin Keloth 1df0159f65 chore: add oauth flow 2025-04-21 19:50:08 +05:30
Muhsin Keloth b2a1e3282c chore: add basic config 2025-04-21 19:43:11 +05:30
26 changed files with 874 additions and 271 deletions
+1
View File
@@ -21,6 +21,7 @@ gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'octokit'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+7
View File
@@ -591,6 +591,9 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -817,6 +820,9 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
scout_apm (5.3.3)
parser
scss_lint (0.60.0)
@@ -1057,6 +1063,7 @@ DEPENDENCIES
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
octokit
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
@@ -0,0 +1,155 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :fetch_hook
before_action :ensure_hook_exists, except: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def repositories
repositories = github_service.repositories
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def search_repositories
repositories = github_service.search_repositories(params[:q])
filtered_repos = repositories.map do |repo|
{
full_name: repo[:full_name] || repo.full_name,
private: repo[:private] || repo.private
}
end
render json: filtered_repos
end
def assignees
assignees = github_service.assignees(repo_full_name)
filtered_assignees = assignees.map do |assignee|
{
login: assignee.login,
avatar_url: assignee.avatar_url
}
end
render json: filtered_assignees
end
def labels
labels = github_service.labels(repo_full_name)
filtered_labels = labels.map do |label|
{
name: label.name,
color: label.color
}
end
render json: filtered_labels
end
def search_issues
issues = github_service.search_issues(repo_full_name, params[:q])
filtered_issues = issues.map do |issue|
{
number: issue.number,
title: issue.title,
html_url: issue.html_url
}
end
render json: filtered_issues
end
def create_issue
issue_data = github_service.create_issue(
repo_full_name,
params[:title],
params[:body],
issue_options
)
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def link_issue
issue_data = github_service.issue(repo_full_name, params[:issue_number])
github_issue = create_linked_issue(issue_data)
render json: issue_response(github_issue), status: :created
end
def linked_issues
issues = GithubIssue.where(conversation_id: params[:conversation_id])
render json: issues.map do |issue|
{
id: issue.id,
issue_number: issue.issue_number,
title: issue.issue_title,
html_url: issue.html_url,
repo_full_name: issue.repo_full_name,
linked_by: {
id: issue.linked_by.id,
name: issue.linked_by.name
},
created_at: issue.created_at
}
end
end
def unlink_issue
github_issue = GithubIssue.find(params[:id])
github_issue.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
end
def ensure_hook_exists
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
end
def github_service
@github_service ||= Github::GithubService.new(hook: @hook)
end
def repo_full_name
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
end
def issue_options
options = {}
options[:assignees] = params[:assignees] if params[:assignees].present?
options[:labels] = params[:labels] if params[:labels].present?
options
end
def create_linked_issue(issue_data)
GithubIssue.create!(
conversation_id: params[:conversation_id],
account: Current.account,
repo_full_name: repo_full_name,
issue_number: issue_data.number,
issue_title: issue_data.title,
html_url: issue_data.html_url,
linked_by: Current.user
)
end
def issue_response(github_issue)
{
id: github_issue.id,
issue_number: github_issue.issue_number,
title: github_issue.issue_title,
html_url: github_issue.html_url,
repo_full_name: github_issue.repo_full_name
}
end
end
@@ -0,0 +1,160 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
def show
# Validate account context early for all flows that require it
account if params[:code].present?
if params[:installation_id].present? && params[:code].present?
# Both installation and OAuth code present - handle both
handle_installation_with_oauth
elsif params[:installation_id].present?
# Only installation_id present - redirect to OAuth
handle_installation
else
# Only OAuth code present - handle authorization
handle_authorization
end
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
private
def handle_installation_with_oauth
# Handle both installation and OAuth in one go
installation_id = params[:installation_id]
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response(installation_id)
end
def handle_installation
if params[:setup_action] == 'install'
installation_id = params[:installation_id]
redirect_to build_oauth_url(installation_id)
else
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
redirect_to github_integration_settings_url
end
end
def handle_authorization
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/github/callback"
)
handle_response
end
def build_oauth_url(installation_id)
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# Store installation_id in session for later use
session[:github_installation_id] = installation_id
# For now, redirect to a page that will initiate OAuth with proper account context
# This is a temporary solution until we have a proper account-agnostic setup
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
end
def oauth_client
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://github.com',
token_url: '/login/oauth/access_token',
authorize_url: '/login/oauth/authorize'
}
)
end
def handle_response(installation_id = nil)
settings = build_hook_settings(installation_id)
hook = create_integration_hook(settings)
hook.save!
cleanup_session_data
redirect_to github_redirect_uri
rescue StandardError => e
Rails.logger.error("Github callback error: #{e.message}")
redirect_to fallback_redirect_uri
end
def build_hook_settings(installation_id)
settings = {
token_type: parsed_body['token_type'],
scope: parsed_body['scope']
}
settings[:installation_id] = installation_id || session[:github_installation_id]
settings.compact
end
def create_integration_hook(settings)
account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'github',
settings: settings
)
end
def cleanup_session_data
session.delete(:github_installation_id)
end
def account
@account ||= account_from_state
end
def account_from_state
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
# Try signed GlobalID first (installation flow)
account = GlobalID::Locator.locate_signed(params[:state])
return account if account
# Fallback to JWT token (direct OAuth flow)
account_id = verify_github_token(params[:state])
return Account.find(account_id) if account_id
raise 'Invalid or expired state'
rescue StandardError
raise ActionController::BadRequest, 'Invalid account context'
end
def github_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
end
def github_integration_settings_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
end
def fallback_redirect_uri
github_redirect_uri
rescue StandardError
# Fallback if no account context available
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -42,7 +42,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
+47
View File
@@ -0,0 +1,47 @@
module Github::IntegrationHelper
# Generates a signed JWT token for Github integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_github_token(account_id)
return if github_client_secret.blank?
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Github token: #{e.message}")
nil
end
def github_token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Github JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_github_token(token)
return if token.blank? || github_client_secret.blank?
github_decode_token(token, github_client_secret)
end
private
def github_client_secret
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
end
def github_decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
nil
end
end
@@ -15,6 +15,20 @@
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"GITHUB": {
"TITLE": "Connect Github",
"LABEL": "Github Client ID",
"PLACEHOLDER": "Enter your Github Client ID",
"HELP": "Enter your Github Client ID",
"CANCEL": "Cancel",
"SUBMIT": "Connect",
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
},
"HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
@@ -338,6 +352,7 @@
}
}
},
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
@@ -0,0 +1,57 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const initializeGithubIntegration = async () => {
await store.dispatch('integrations/get', 'github');
integrationLoaded.value = true;
};
onMounted(() => {
initializeGithubIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
routes: [
{
@@ -100,6 +100,16 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
props: route => ({ error: route.query.error }),
},
{
path: 'shopify',
name: 'settings_integrations_shopify',
+32 -3
View File
@@ -1,5 +1,6 @@
class Integrations::App
include Linear::IntegrationHelper
include Github::IntegrationHelper
attr_accessor :params
def initialize(params)
@@ -30,10 +31,15 @@ class Integrations::App
params[:fields]
end
# There is no way to get the account_id from the linear callback
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
# There is no way to get the account_id from the linear/github callback
# so we are using the generate token method to generate a token and encode it in the state parameter
def encode_state
generate_linear_token(Current.account.id)
case params[:id]
when 'linear'
generate_linear_token(Current.account.id)
when 'github'
generate_github_token(Current.account.id)
end
end
def action
@@ -43,6 +49,8 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
when 'github'
build_github_action
else
params[:action]
end
@@ -54,6 +62,8 @@ class Integrations::App
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'github'
github_enabled?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
@@ -78,6 +88,17 @@ class Integrations::App
].join('&')
end
def build_github_action
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
# For GitHub Apps, redirect to installation page
# The standard /installations/new URL should show org/user selection if the app is properly configured
# Include state parameter with signed account ID for account context
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
state = Current.account.to_signed_global_id(expires_in: 1.hour)
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
end
def enabled?(account)
case params[:id]
when 'webhook'
@@ -101,6 +122,10 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
@@ -119,6 +144,10 @@ class Integrations::App
private
def github_enabled?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
+60
View File
@@ -0,0 +1,60 @@
class Github::GithubService
attr_reader :hook
def initialize(hook:)
@hook = hook
end
def repositories
client.repositories(nil, type: 'all')
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
raise_api_error(e)
end
def assignees(repo_full_name)
client.repository_assignees(repo_full_name)
rescue Octokit::Error => e
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
raise_api_error(e)
end
def search_repositories(query)
return repositories if query.blank?
# Get all user's repositories first
user_repos = repositories
# Filter repositories by query string (case-insensitive)
user_repos.select do |repo|
repo.full_name.downcase.include?(query.downcase) ||
(repo.description&.downcase&.include?(query.downcase))
end
rescue Octokit::Error => e
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
raise_api_error(e)
end
private
def client
@client ||= Octokit::Client.new(
access_token: hook.access_token,
auto_paginate: true,
per_page: 100
)
end
def raise_api_error(error)
case error.response_status
when 401
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
when 403
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
when 404
raise CustomExceptions::Base.new('GitHub resource not found', 404)
else
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
end
end
end
@@ -169,7 +169,11 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+3
View File
@@ -176,6 +176,9 @@
- name: notion_integration
display_name: Notion Integration
enabled: false
- name: github_integration
display_name: Github Integration
enabled: false
- name: captain_integration_v2
display_name: Captain V2
enabled: false
+19
View File
@@ -451,3 +451,22 @@
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##
# ------- Github Integration Config ------- #
- name: GITHUB_CLIENT_ID
display_title: 'Github Client ID'
value:
locked: false
description: 'Github client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'Github Client Secret'
value:
locked: false
description: 'Github client secret'
type: secret
- name: GITHUB_APP_NAME
display_title: 'Github App Name'
value:
locked: false
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
## ------ End of Github Integration Config ------- #
+8
View File
@@ -284,3 +284,11 @@ leadsquared:
'conversation_activity_score',
'transcript_activity_score',
]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
action: https://github.com/login/oauth/authorize
allow_multiple_hooks: false
+4
View File
@@ -312,6 +312,10 @@ en:
name: 'LeadSquared'
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
github:
name: 'GitHub'
short_description: 'Create and manage GitHub issues directly from conversations.'
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
captain:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
+12
View File
@@ -302,6 +302,14 @@ Rails.application.routes.draw do
delete :destroy
end
end
resource :github, controller: 'github', only: [] do
collection do
delete :destroy
get :repositories
get :search_repositories
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
end
end
end
resources :working_hours, only: [:update]
@@ -549,6 +557,10 @@ Rails.application.routes.draw do
end
end
namespace :github do
get :callback, to: 'callbacks#show'
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
@@ -18,15 +18,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
handle_error(e)
ensure
# Process handoff OUTSIDE the transaction so after_commit callbacks work properly
# This is because within a transaction, each save operation overrides previous_changes
# There's a separate gem to handle this: https://github.com/Shopify/ar_transaction_changes/
# But for us, moving the handoff outside the transaction works well enough
# This ensures Rails' dirty tracking (previous_changes, saved_change_to_*?) works correctly
process_handoff if @handoff_requested
Current.executed_by = nil
Current.handoff_requested = nil
end
private
@@ -44,10 +36,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
)
end
if handoff_requested? || Current.handoff_requested
@handoff_requested = true
return
end
return process_action('handoff') if handoff_requested?
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
@@ -84,10 +73,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
@response['response'] == 'conversation_handoff'
end
def process_handoff
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
def process_action(action)
case action
when 'handoff'
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
end
end
end
@@ -122,7 +114,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def handle_error(error)
log_error(error)
@handoff_requested = true
process_action('handoff')
true
end
+13 -5
View File
@@ -23,11 +23,19 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
private
def trigger_handoff(_conversation, _reason)
# Signal to ResponseBuilderJob that handoff is requested
# The job will handle the actual bot_handoff! call outside the transaction
# to ensure after_commit callbacks work correctly (see PR.md for details)
Current.handoff_requested = true
def trigger_handoff(conversation, reason)
# post the reason as a private note
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: @assistant,
account: conversation.account,
inbox: conversation.inbox,
content: reason
)
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
end
# TODO: Future enhancement - Add team assignment capability
-2
View File
@@ -4,7 +4,6 @@ module Current
thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by
thread_mattr_accessor :contact
thread_mattr_accessor :handoff_requested
def self.reset
Current.user = nil
@@ -12,6 +11,5 @@ module Current
Current.account_user = nil
Current.executed_by = nil
Current.contact = nil
Current.handoff_requested = nil
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -0,0 +1,165 @@
require 'rails_helper'
RSpec.describe Github::CallbacksController, type: :controller do
let(:account) { create(:account) }
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
before do
# Clear any existing GitHub hooks for the account
account.hooks.where(app_id: 'github').destroy_all
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
end
describe 'route accessibility' do
it 'is accessible via the github callback route' do
expect(get: '/github/callback').to route_to(
controller: 'github/callbacks',
action: 'show'
)
end
end
describe 'helper methods' do
describe '#account' do
it 'resolves account from valid signed global id' do
controller.params = { state: signed_account_id }
expect(controller.send(:account)).to eq(account)
end
it 'raises error for missing state' do
controller.params = {}
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
it 'raises error for invalid state' do
controller.params = { state: 'invalid_state' }
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
end
end
describe '#oauth_client' do
it 'creates OAuth2 client with correct configuration' do
oauth_client = controller.send(:oauth_client)
expect(oauth_client).to be_a(OAuth2::Client)
expect(oauth_client.id).to eq('test_client_id')
expect(oauth_client.secret).to eq('test_client_secret')
expect(oauth_client.site).to eq('https://github.com')
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
end
end
describe '#github_redirect_uri' do
before do
allow(controller).to receive(:account).and_return(account)
end
it 'generates correct GitHub redirect URI' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:github_redirect_uri)
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
end
end
end
describe '#fallback_redirect_uri' do
it 'generates fallback redirect URI when account is unavailable' do
allow(controller).to receive(:account).and_raise(StandardError)
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
uri = controller.send(:fallback_redirect_uri)
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
end
end
end
describe '#build_hook_settings' do
let(:mock_parsed_body) do
{
'access_token' => 'test_token',
'token_type' => 'bearer',
'scope' => 'repo,read:org'
}
end
before do
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
end
it 'builds hook settings with installation_id' do
settings = controller.send(:build_hook_settings, '12345')
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
)
end
it 'builds hook settings without installation_id when nil' do
settings = controller.send(:build_hook_settings, nil)
expect(settings).to include(
token_type: 'bearer',
scope: 'repo,read:org'
)
expect(settings).not_to have_key(:installation_id)
end
it 'removes nil installation_id values' do
settings = controller.send(:build_hook_settings, nil)
expect(settings.keys).not_to include(:installation_id)
end
end
describe '#create_integration_hook' do
let(:settings) do
{
token_type: 'bearer',
scope: 'repo,read:org',
installation_id: '12345'
}
end
before do
allow(controller).to receive(:account).and_return(account)
allow(controller).to receive(:parsed_body).and_return({
'access_token' => 'test_token'
})
end
it 'creates integration hook with correct attributes' do
hook = controller.send(:create_integration_hook, settings)
expect(hook.access_token).to eq('test_token')
expect(hook.status).to eq('enabled')
expect(hook.app_id).to eq('github')
expect(hook.settings).to eq(settings.stringify_keys)
expect(hook.account).to eq(account)
end
end
describe '#build_oauth_url' do
it 'builds OAuth URL with installation context' do
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
url = controller.send(:build_oauth_url, '12345')
expect(url).to include('github?setup_action=install&installation_id=12345')
expect(controller.session[:github_installation_id]).to eq('12345')
end
end
end
describe '#base_url' do
it 'returns FRONTEND_URL when set' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
end
end
it 'returns default localhost when FRONTEND_URL not set' do
with_modified_env FRONTEND_URL: nil do
expect(controller.send(:base_url)).to eq('http://localhost:3000')
end
end
end
end
end
@@ -220,189 +220,6 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
end
end
describe 'handoff flow' do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
before do
create(:message, conversation: conversation, content: 'I need human help', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(inbox).to receive(:captain_active?).and_return(true)
end
context 'when response requests handoff' do
before do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'conversation_handoff' })
end
it 'triggers handoff and changes conversation status' do
described_class.perform_now(conversation, assistant)
conversation.reload
expect(conversation.status).to eq('open')
end
it 'creates handoff message' do
described_class.perform_now(conversation, assistant)
handoff_message = conversation.messages.outgoing.last
expect(handoff_message).not_to be_nil
expect(handoff_message.content).to include(I18n.t('conversations.captain.handoff'))
end
it 'does not increment response usage' do
described_class.perform_now(conversation, assistant)
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
end
context 'when tool requests handoff via Current.handoff_requested' do
before do
allow(mock_llm_chat_service).to receive(:generate_response) do
# Simulate tool setting the flag during response generation
Current.handoff_requested = true
{ 'response' => 'Processing your request...' }
end
end
it 'triggers handoff after transaction completes' do
described_class.perform_now(conversation, assistant)
conversation.reload
expect(conversation.status).to eq('open')
end
it 'creates handoff message' do
described_class.perform_now(conversation, assistant)
handoff_message = conversation.messages.outgoing.last
expect(handoff_message).not_to be_nil
expect(handoff_message.content).to include(I18n.t('conversations.captain.handoff'))
end
it 'does not create regular response message' do
described_class.perform_now(conversation, assistant)
# Should only have the handoff message, not the regular response
outgoing_messages = conversation.messages.outgoing
expect(outgoing_messages.count).to eq(1)
expect(outgoing_messages.last.content).not_to include('Processing your request...')
end
it 'does not increment response usage' do
described_class.perform_now(conversation, assistant)
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
end
context 'when error occurs' do
let(:error) { StandardError.new('Something went wrong') }
before do
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
end
it 'triggers handoff in ensure block' do
described_class.perform_now(conversation, assistant)
conversation.reload
expect(conversation.status).to eq('open')
end
it 'creates handoff message' do
described_class.perform_now(conversation, assistant)
handoff_message = conversation.messages.outgoing.last
expect(handoff_message).not_to be_nil
expect(handoff_message.content).to include(I18n.t('conversations.captain.handoff'))
end
it 'logs the error' do
expect(ChatwootExceptionTracker).to receive(:new)
.with(error, account: account)
.and_call_original
described_class.perform_now(conversation, assistant)
end
end
context 'when custom handoff message is configured' do
let(:custom_message) { 'Custom handoff message from config' }
before do
assistant.config = { 'handoff_message' => custom_message }
assistant.save!
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'conversation_handoff' })
end
it 'uses custom handoff message' do
described_class.perform_now(conversation, assistant)
handoff_message = conversation.messages.outgoing.last
expect(handoff_message.content).to eq(custom_message)
end
end
context 'when handoff happens outside transaction' do
before do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'conversation_handoff' })
end
it 'ensures bot_handoff! is called outside transaction' do
# This verifies that bot_handoff! is called outside the transaction
# so that after_commit callbacks can see the status change correctly
allow(conversation).to receive(:bot_handoff!).and_call_original
described_class.perform_now(conversation, assistant)
# Verify status changed
expect(conversation.reload.status).to eq('open')
end
it 'maintains Current.executed_by during handoff' do
# Capture the executed_by value during bot_handoff! call
captured_executed_by = nil
allow(conversation).to receive(:bot_handoff!) do
captured_executed_by = Current.executed_by
conversation.status = :open
conversation.save!
end
described_class.perform_now(conversation, assistant)
expect(captured_executed_by).to eq(assistant)
end
end
context 'when Current values are cleaned up' do
before do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'conversation_handoff' })
end
it 'resets Current.executed_by after job completes' do
described_class.perform_now(conversation, assistant)
expect(Current.executed_by).to be_nil
end
it 'resets Current.handoff_requested after job completes' do
Current.handoff_requested = true
described_class.perform_now(conversation, assistant)
expect(Current.handoff_requested).to be_nil
end
end
end
describe 'job configuration' do
it 'has retry_on configuration for retryable errors' do
expect(described_class).to respond_to(:retry_on)
@@ -10,12 +10,6 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
after do
# Clean up Current values after each test
Current.executed_by = nil
Current.handoff_requested = nil
end
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further')
@@ -35,36 +29,38 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
describe '#perform' do
context 'when conversation exists' do
context 'with reason provided' do
before { conversation.update!(status: :pending) }
it 'returns success message with reason' do
it 'creates a private note with reason and hands off conversation' do
reason = 'Customer needs specialized support'
result = tool.perform(tool_context, reason: reason)
expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})")
end
it 'does not create any messages immediately' do
# Messages are created by ResponseBuilderJob, not the tool
expect do
tool.perform(tool_context, reason: 'Test reason')
end.not_to change(Message, :count)
result = tool.perform(tool_context, reason: reason)
expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})")
end.to change(Message, :count).by(1)
end
it 'sets Current.handoff_requested flag' do
it 'creates message with correct attributes' do
reason = 'Customer needs specialized support'
tool.perform(tool_context, reason: reason)
created_message = Message.last
expect(created_message.content).to eq(reason)
expect(created_message.message_type).to eq('outgoing')
expect(created_message.private).to be true
expect(created_message.sender).to eq(assistant)
expect(created_message.account).to eq(account)
expect(created_message.inbox).to eq(inbox)
expect(created_message.conversation).to eq(conversation)
end
it 'triggers bot handoff on conversation' do
# The tool finds the conversation by ID, so we need to mock the found conversation
found_conversation = Conversation.find(conversation.id)
scoped_conversations = Conversation.where(account_id: assistant.account_id)
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
expect(found_conversation).to receive(:bot_handoff!)
tool.perform(tool_context, reason: 'Test reason')
expect(Current.handoff_requested).to be true
end
it 'does not change conversation status immediately' do
conversation.update(status: :pending)
expect(conversation.pending?).to be true
tool.perform(tool_context, reason: 'Customer needs specialized support')
conversation.reload
# Status should remain pending until ResponseBuilderJob processes the handoff
expect(conversation.pending?).to be true
end
it 'logs tool usage with reason' do
@@ -79,24 +75,14 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
end
context 'without reason provided' do
before { conversation.update!(status: :pending) }
it 'returns success message without reason' do
result = tool.perform(tool_context)
expect(result).to eq('Conversation handed off to human support team')
end
it 'does not create any messages immediately' do
# Messages are created by ResponseBuilderJob, not the tool
it 'creates a private note with nil content and hands off conversation' do
expect do
tool.perform(tool_context)
end.not_to change(Message, :count)
end
result = tool.perform(tool_context)
expect(result).to eq('Conversation handed off to human support team')
end.to change(Message, :count).by(1)
it 'sets Current.handoff_requested flag' do
tool.perform(tool_context)
expect(Current.handoff_requested).to be true
created_message = Message.last
expect(created_message.content).to be_nil
end
it 'logs tool usage with default reason' do
@@ -109,33 +95,31 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
end
end
context 'when an error occurs' do
it 'returns error message' do
# Mock an error in trigger_handoff
allow(Current).to receive(:handoff_requested=).and_raise(StandardError, 'Unexpected error')
context 'when handoff fails' do
before do
# Mock the conversation lookup and handoff failure
found_conversation = Conversation.find(conversation.id)
scoped_conversations = Conversation.where(account_id: assistant.account_id)
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error')
exception_tracker = instance_double(ChatwootExceptionTracker)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
allow(exception_tracker).to receive(:capture_exception)
end
it 'returns error message' do
result = tool.perform(tool_context, reason: 'Test')
expect(result).to eq('Failed to handoff conversation')
# Unstub to allow cleanup
allow(Current).to receive(:handoff_requested=).and_call_original
end
it 'captures exception' do
exception_tracker = instance_double(ChatwootExceptionTracker)
allow(Current).to receive(:handoff_requested=).and_raise(StandardError, 'Unexpected error')
expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker)
expect(exception_tracker).to receive(:capture_exception)
tool.perform(tool_context, reason: 'Test')
# Unstub to allow cleanup
allow(Current).to receive(:handoff_requested=).and_call_original
end
end
end
+47
View File
@@ -38,6 +38,25 @@ RSpec.describe Integrations::App do
end
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns the GitHub App installation URL with state parameter' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
expect(app.action).to include('state=')
end
end
it 'uses default app name when GITHUB_APP_NAME is not set' do
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
end
end
end
end
describe '#active?' do
@@ -87,6 +106,34 @@ RSpec.describe Integrations::App do
end
end
context 'when the app is github' do
let(:app_name) { 'github' }
it 'returns false if github credentials are not present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_id is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
expect(app.active?(account)).to be false
end
it 'returns false if only client_secret is present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be false
end
it 'returns true if both client_id and client_secret are present' do
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
expect(app.active?(account)).to be true
end
end
context 'when other apps are queried' do
let(:app_name) { 'webhook' }