diff --git a/.gitignore b/.gitignore index 94a5ebb..23a0311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Github/Database/.DS_Store Jira/* +vendor \ No newline at end of file diff --git a/Github/Config/config.php b/Config/config.php similarity index 100% rename from Github/Config/config.php rename to Config/config.php diff --git a/Console/InstallCommand.php b/Console/InstallCommand.php new file mode 100644 index 0000000..a3598aa --- /dev/null +++ b/Console/InstallCommand.php @@ -0,0 +1,70 @@ +error('The GitHub module is not registered.'); + + return 1; + } + + $this->info('Running migrations for the GitHub module...'); + + $parameters = ['module' => $moduleName]; + + $force = $this->option('force'); + + if ($force) { + $parameters['--force'] = true; + } + + try { + $exitCode = $this->call('module:migrate', $parameters); + } catch (\Throwable $exception) { + $this->error('Running GitHub module migrations failed: '.$exception->getMessage()); + Helper::logException($exception, '[GitHub] install command'); + + return 1; + } + + if ($exitCode === 0) { + $this->info('GitHub module migrations completed successfully.'); + } else { + $this->error('GitHub module migrations finished with errors.'); + } + + return $exitCode; + } +} + diff --git a/Github/Database/Migrations/2024_01_01_000001_create_github_issues_table.php b/Database/Migrations/2024_01_01_000001_create_github_issues_table.php similarity index 100% rename from Github/Database/Migrations/2024_01_01_000001_create_github_issues_table.php rename to Database/Migrations/2024_01_01_000001_create_github_issues_table.php diff --git a/Github/Database/Migrations/2024_01_01_000002_create_github_issue_conversation_table.php b/Database/Migrations/2024_01_01_000002_create_github_issue_conversation_table.php similarity index 100% rename from Github/Database/Migrations/2024_01_01_000002_create_github_issue_conversation_table.php rename to Database/Migrations/2024_01_01_000002_create_github_issue_conversation_table.php diff --git a/Github/Database/Migrations/2024_01_01_000003_create_github_label_mappings_table.php b/Database/Migrations/2024_01_01_000003_create_github_label_mappings_table.php similarity index 100% rename from Github/Database/Migrations/2024_01_01_000003_create_github_label_mappings_table.php rename to Database/Migrations/2024_01_01_000003_create_github_label_mappings_table.php diff --git a/Github/Entities/GithubIssue.php b/Entities/GithubIssue.php similarity index 100% rename from Github/Entities/GithubIssue.php rename to Entities/GithubIssue.php diff --git a/Github/Entities/GithubIssueConversation.php b/Entities/GithubIssueConversation.php similarity index 100% rename from Github/Entities/GithubIssueConversation.php rename to Entities/GithubIssueConversation.php diff --git a/Github/Entities/GithubLabelMapping.php b/Entities/GithubLabelMapping.php similarity index 100% rename from Github/Entities/GithubLabelMapping.php rename to Entities/GithubLabelMapping.php diff --git a/Github/Http/Controllers/GithubController.php b/Http/Controllers/GithubController.php similarity index 84% rename from Github/Http/Controllers/GithubController.php rename to Http/Controllers/GithubController.php index c66edaa..43ca663 100644 --- a/Github/Http/Controllers/GithubController.php +++ b/Http/Controllers/GithubController.php @@ -10,9 +10,42 @@ use Modules\Github\Entities\GithubIssue; use Modules\Github\Entities\GithubLabelMapping; use App\Conversation; +use Modules\Github\Support\RepositoryCache; class GithubController extends Controller { + /** + * Normalize request values to boolean for older Laravel versions. + * + * @param mixed $value + * @param bool $default + * @return bool + */ + private function toBoolean($value, bool $default = false): bool + { + if (is_null($value)) { + return $default; + } + + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $value = strtolower(trim($value)); + if ($value === '') { + return $default; + } + return in_array($value, ['1', 'true', 'on', 'yes'], true); + } + + if (is_numeric($value)) { + return (int) $value === 1; + } + + return $default; + } + /** * Test GitHub connection */ @@ -56,25 +89,33 @@ public function testConnection(Request $request) public function getRepositories(Request $request) { try { - $result = GithubApiClient::getRepositories(); - - \Helper::log('github_debug', 'Repository fetch result: ' . json_encode([ - 'status' => $result['status'], - 'data_count' => isset($result['data']) ? count($result['data']) : 'no data', - 'message' => $result['message'] ?? 'no message' - ])); - + if ($this->toBoolean($request->input('refresh'))) { + RepositoryCache::clear(); + } + + $result = RepositoryCache::getRepositories(true); + if ($result['status'] === 'success') { return response()->json([ 'status' => 'success', - 'repositories' => $result['data'] + 'repositories' => $result['repositories'], + 'source' => $result['source'] ?? 'cache', + 'fetched_at' => $result['fetched_at'] ?? null, ]); - } else { + } + + if ($result['status'] === 'throttled') { return response()->json([ - 'status' => 'error', - 'message' => $result['message'] - ]); + 'status' => 'throttled', + 'message' => $result['message'], + 'retry_after' => $result['retry_after'] ?? 60, + ], 429); } + + return response()->json([ + 'status' => 'error', + 'message' => $result['message'] ?? 'Failed to load repositories', + ], 400); } catch (\Exception $e) { \Helper::logException($e, '[GitHub] Get Repositories Error'); return response()->json([ @@ -84,6 +125,89 @@ public function getRepositories(Request $request) } } + /** + * Refresh repository cache. + */ + public function refreshRepositories(): \Illuminate\Http\JsonResponse + { + try { + RepositoryCache::clear(); + + return response()->json([ + 'status' => 'success', + 'message' => 'Repository cache cleared.', + ]); + } catch (\Exception $e) { + \Helper::logException($e, '[GitHub] Refresh Repositories Error'); + + return response()->json([ + 'status' => 'error', + 'message' => 'Failed to clear repository cache.', + ], 500); + } + } + + /** + * Search repositories with cache + throttled API fallback. + */ + public function searchRepositories(Request $request): \Illuminate\Http\JsonResponse + { + try { + $query = (string) $request->input('q', $request->input('term', '')); + $limit = (int) $request->input('limit', 20); + + $result = RepositoryCache::search($query, $limit); + + if ($result['status'] === 'success') { + $repositories = $result['repositories']; + + $formatted = array_map(function ($repo) { + return [ + 'id' => $repo['full_name'], + 'text' => $repo['full_name'], + 'name' => $repo['name'] ?? $repo['full_name'], + 'private' => $repo['private'] ?? false, + 'has_issues' => $repo['has_issues'] ?? true, + ]; + }, $repositories); + + return response()->json([ + 'status' => 'success', + 'results' => $formatted, + 'meta' => [ + 'count' => count($formatted), + 'source' => $result['source'] ?? 'cache', + 'fetched_at' => $result['fetched_at'] ?? null, + 'throttled' => $result['throttled'] ?? false, + 'retry_after' => $result['retry_after'] ?? null, + ], + ]); + } + + if ($result['status'] === 'throttled') { + return response()->json([ + 'status' => 'throttled', + 'message' => $result['message'], + 'retry_after' => $result['retry_after'] ?? 60, + ], 429); + } + + $message = $result['message'] ?? 'Failed to search repositories.'; + + return response()->json([ + 'status' => 'error', + 'message' => $message, + ], 400); + } catch (\Exception $e) { + \Helper::logException($e, '[GitHub] Search Repositories Error'); + + return response()->json([ + 'status' => 'error', + 'message' => 'Failed to search repositories: ' . $e->getMessage(), + ], 500); + } + } + /** * Get repository labels */ @@ -550,16 +674,39 @@ public function saveLabelMappings(Request $request) { $request->validate([ 'repository' => 'required|string', - 'mappings' => 'required|array', - 'mappings.*.freescout_tag' => 'required|string', - 'mappings.*.github_label' => 'required|string', + 'mappings' => 'nullable|array', + 'mappings.*.freescout_tag' => 'required_with:mappings|string', + 'mappings.*.github_label' => 'required_with:mappings|string', 'mappings.*.confidence_threshold' => 'nullable|numeric|min:0|max:1' ]); $repository = $request->get('repository'); - $mappings = $request->get('mappings'); + $mappings = collect($request->get('mappings', [])) + ->map(function ($mapping) { + return [ + 'freescout_tag' => trim($mapping['freescout_tag'] ?? ''), + 'github_label' => trim($mapping['github_label'] ?? ''), + 'confidence_threshold' => isset($mapping['confidence_threshold']) + ? (float) $mapping['confidence_threshold'] + : 0.80, + ]; + }) + ->filter(function ($mapping) { + return !empty($mapping['freescout_tag']) && !empty($mapping['github_label']); + }) + ->values(); try { + $activeTags = $mappings->pluck('freescout_tag')->unique()->all(); + + if (empty($activeTags)) { + GithubLabelMapping::where('repository', $repository)->delete(); + } else { + GithubLabelMapping::where('repository', $repository) + ->whereNotIn('freescout_tag', $activeTags) + ->delete(); + } + foreach ($mappings as $mapping) { GithubLabelMapping::createOrUpdateMapping( $mapping['freescout_tag'], @@ -571,7 +718,8 @@ public function saveLabelMappings(Request $request) return response()->json([ 'status' => 'success', - 'message' => 'Label mappings saved successfully' + 'message' => 'Label mappings saved successfully', + 'data' => GithubLabelMapping::getRepositoryMappings($repository) ]); } catch (\Exception $e) { diff --git a/Github/Http/routes.php b/Http/routes.php similarity index 89% rename from Github/Http/routes.php rename to Http/routes.php index 2e79f32..fb46251 100644 --- a/Github/Http/routes.php +++ b/Http/routes.php @@ -22,6 +22,8 @@ // Settings routes Route::post('/github/test-connection', 'GithubController@testConnection')->name('github.test_connection'); Route::post('/github/repositories', 'GithubController@getRepositories')->name('github.repositories'); + Route::get('/github/repositories/search', 'GithubController@searchRepositories')->name('github.repositories.search'); + Route::post('/github/repositories/refresh', 'GithubController@refreshRepositories')->name('github.repositories.refresh'); Route::get('/github/labels/{repository}', 'GithubController@getLabels')->name('github.labels')->where('repository', '.*'); Route::post('/github/save-settings', 'GithubController@saveSettings')->name('github.save_settings'); diff --git a/Github/Providers/GithubServiceProvider.php b/Providers/GithubServiceProvider.php similarity index 83% rename from Github/Providers/GithubServiceProvider.php rename to Providers/GithubServiceProvider.php index d8adf85..a384341 100644 --- a/Github/Providers/GithubServiceProvider.php +++ b/Providers/GithubServiceProvider.php @@ -3,7 +3,7 @@ namespace Modules\Github\Providers; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Facades\Route; +use Modules\Github\Console\InstallCommand; // Module alias define('GITHUB_MODULE', 'github'); @@ -23,6 +23,10 @@ public function boot() $this->loadMigrations(); $this->registerHooks(); $this->loadAssets(); + + if ($this->app->runningInConsole()) { + $this->registerCommands(); + } } /** @@ -88,14 +92,30 @@ protected function registerHooks() { // Add module's CSS file to the application layout \Eventy::addFilter('stylesheets', function($styles) { - $styles[] = \Module::getPublicPath(GITHUB_MODULE).'/css/module.css'; + $cssPath = \Module::getPublicPath(GITHUB_MODULE).'/css/module.css'; + if (file_exists(public_path($cssPath))) { + $styles[] = $cssPath; + } else { + \Log::warning('[GitHub] Public CSS asset not found: '.public_path($cssPath)); + } return $styles; }); // Add module's JS file to the application layout \Eventy::addFilter('javascripts', function($javascripts) { - $javascripts[] = \Module::getPublicPath(GITHUB_MODULE).'/js/laroute.js'; - $javascripts[] = \Module::getPublicPath(GITHUB_MODULE).'/js/module.js'; + $jsFiles = [ + \Module::getPublicPath(GITHUB_MODULE).'/js/laroute.js', + \Module::getPublicPath(GITHUB_MODULE).'/js/module.js', + ]; + + foreach ($jsFiles as $jsPath) { + if (file_exists(public_path($jsPath))) { + $javascripts[] = $jsPath; + } else { + \Log::warning('[GitHub] Public JS asset not found: '.public_path($jsPath)); + } + } + return $javascripts; }); @@ -187,4 +207,16 @@ protected function loadAssets() { // Assets are loaded via Eventy filters in registerHooks() } + + /** + * Register Artisan commands provided by the module. + * + * @return void + */ + protected function registerCommands() + { + $this->commands([ + InstallCommand::class, + ]); + } } \ No newline at end of file diff --git a/Github/Public/css/module.css b/Public/css/module.css similarity index 97% rename from Github/Public/css/module.css rename to Public/css/module.css index ff64aa4..c56efa8 100644 --- a/Github/Public/css/module.css +++ b/Public/css/module.css @@ -385,6 +385,22 @@ flex-shrink: 0; } +.label-mapping-row.has-error input { + border-color: #d9534f; +} + +.label-mapping-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} + +#label-mapping-status { + min-height: 18px; +} + /* Connection status styles */ .github-connection-status { display: flex; diff --git a/Github/Public/js/laroute.js b/Public/js/laroute.js similarity index 88% rename from Github/Public/js/laroute.js rename to Public/js/laroute.js index e12e995..f836ff6 100644 --- a/Github/Public/js/laroute.js +++ b/Public/js/laroute.js @@ -8,6 +8,14 @@ "uri": "github\/repositories", "name": "github.repositories" }, + { + "uri": "github\/repositories\/search", + "name": "github.repositories.search" + }, + { + "uri": "github\/repositories\/refresh", + "name": "github.repositories.refresh" + }, { "uri": "github\/labels\/{repository}", "name": "github.labels" diff --git a/Github/Public/js/module.js b/Public/js/module.js similarity index 63% rename from Github/Public/js/module.js rename to Public/js/module.js index f30a82e..2d0cc95 100644 --- a/Github/Public/js/module.js +++ b/Public/js/module.js @@ -10,24 +10,165 @@ var GitHub = { maxSearchResults: 10 }, cache: { - repositories: null + repositories: null, + loading: false, + loadingCallbacks: [], + repoSearchTimers: {}, + activeRepoRequests: {}, + lastThrottleNotice: 0 }, - warningsShown: [] // Track warnings to prevent duplicates + warningsShown: [], // Track warnings to prevent duplicates + state: { + currentRepository: null, + labelMappingsSaving: false + } }; +$(document).on('click', '.github-issue-action', githubHandleIssueAction); + +function githubHandleIssueAction(e) { + var $trigger = $(this); + var action = $trigger.data('action'); + var issueId = $trigger.data('issue-id'); + + if (!action || !issueId) { + return; + } + + e.preventDefault(); + + if (action === 'unlink') { + if (confirm('Are you sure you want to unlink this issue?')) { + githubUnlinkIssue(issueId); + } + + return; + } + + if (action === 'refresh') { + githubRefreshIssue(issueId); + } +} + +function githubGetCurrentTokenHash() { + try { + var $tokenField = $('#github_token'); + if ($tokenField.length === 0) { + return null; + } + + var token = $tokenField.val(); + if (!token) { + return null; + } + + return window.btoa(token).slice(-8); + } catch (e) { + return null; + } +} + +function githubPersistRepositoryCache(repositories) { + if (!Array.isArray(repositories)) { + return; + } + + try { + var cacheData = { + repositories: repositories, + token_hash: githubGetCurrentTokenHash(), + stored_at: Date.now() + }; + + localStorage.setItem('github_repositories_cache', JSON.stringify(cacheData)); + } catch (e) { + console.warn('GitHub: Failed to persist repository cache', e); + } +} + +function githubClearLocalRepositoryCache() { + try { + localStorage.removeItem('github_repositories_cache'); + } catch (e) { + console.warn('GitHub: Failed to clear repository cache', e); + } +} + +function githubEnsureRepositoryCache(callback) { + if (typeof callback !== 'function') { + callback = null; + } + + if (!Array.isArray(GitHub.cache.loadingCallbacks)) { + GitHub.cache.loadingCallbacks = []; + } + + if (GitHub.cache.repositories && GitHub.cache.repositories.length > 0) { + if (callback) { + callback(GitHub.cache.repositories); + } + return; + } + + var cachedRepos = githubGetCachedRepositories(); + if (cachedRepos && cachedRepos.length > 0) { + GitHub.cache.repositories = cachedRepos; + if (callback) { + callback(GitHub.cache.repositories); + } + return; + } + + if (callback) { + GitHub.cache.loadingCallbacks.push(callback); + } + + if (GitHub.cache.loading) { + return; + } + + GitHub.cache.loading = true; + + githubLoadRepositories({ + skipCache: true, + onSuccess: function(response) { + if (response && Array.isArray(response.repositories)) { + GitHub.cache.repositories = response.repositories; + } + }, + onComplete: function() { + GitHub.cache.loading = false; + + var callbacks = Array.isArray(GitHub.cache.loadingCallbacks) ? GitHub.cache.loadingCallbacks.slice() : []; + GitHub.cache.loadingCallbacks = []; + + var repos = GitHub.cache.repositories; + if (!repos || repos.length === 0) { + repos = githubGetCachedRepositories() || []; + if (repos.length > 0) { + GitHub.cache.repositories = repos; + } + } + + $.each(callbacks, function(_, cb) { + if (typeof cb === 'function') { + cb(repos); + } + }); + } + }); +} + function githubInitSettings() { $(document).ready(function() { // Show/hide OpenAI model dropdown based on AI service selection function toggleOpenAIModel() { var selectedService = $('#github_ai_service').val(); - console.log('AI Service selected:', selectedService); // Debug log if (selectedService === 'openai') { $('#openai_model_group').show(); - console.log('Showing OpenAI model group'); // Debug log } else { $('#openai_model_group').hide(); - console.log('Hiding OpenAI model group'); // Debug log } } @@ -66,6 +207,7 @@ function githubInitSettings() { if (isAjaxSuccess(response)) { githubShowConnectionResult(response); if (response.repositories) { + githubPersistRepositoryCache(response.repositories); githubPopulateRepositories(response.repositories); } } else { @@ -80,7 +222,7 @@ function githubInitSettings() { // Refresh repositories button $("#refresh-repositories").click(function(e) { e.preventDefault(); - githubLoadRepositories(); + githubRefreshRepositoryCache(); }); // Refresh allowed labels button @@ -92,13 +234,14 @@ function githubInitSettings() { // Repository change handler $("#github_default_repository").change(function() { var repository = $(this).val(); + GitHub.state.currentRepository = repository || null; + githubUpdateLabelMappingSection(repository); + if (repository) { githubLoadLabelMappings(repository); - $('#label-mapping-section').show(); - // Also load allowed labels when repository changes githubLoadAllowedLabels(); } else { - $('#label-mapping-section').hide(); + githubResetLabelMappingsUI(); } }); @@ -115,8 +258,13 @@ function githubInitSettings() { // Load allowed labels on page load if repository is already selected var defaultRepo = $("#github_default_repository").val(); + GitHub.state.currentRepository = defaultRepo || null; + githubUpdateLabelMappingSection(defaultRepo); if (defaultRepo) { githubLoadAllowedLabels(); + githubLoadLabelMappings(defaultRepo); + } else { + githubResetLabelMappingsUI(); } // Add label mapping @@ -125,30 +273,16 @@ function githubInitSettings() { githubAddLabelMappingRow(); }); - // Remove label mapping - $(document).on('click', '.remove-mapping', function(e) { + // Persist label mappings + $("#save-label-mappings").click(function(e) { e.preventDefault(); - $(this).closest('.label-mapping-row').remove(); + githubSaveLabelMappings(); }); - }); -} -function githubInitConversation() { - $(document).ready(function() { - // Issue actions - $(document).on('click', '.github-issue-action', function(e) { + // Remove label mapping + $(document).on('click', '.remove-mapping', function(e) { e.preventDefault(); - var link = $(this); - var action = link.data('action'); - var issueId = link.data('issue-id'); - - if (action === 'unlink') { - if (confirm('Are you sure you want to unlink this issue?')) { - githubUnlinkIssue(issueId); - } - } else if (action === 'refresh') { - githubRefreshIssue(issueId); - } + $(this).closest('.label-mapping-row').remove(); }); }); } @@ -161,26 +295,7 @@ function githubInitModals() { if ($(this).parent().get(0) !== document.body) { $(this).detach().appendTo('body'); } - - // Only populate repositories if we don't have a default repository - // Most users will use the default repository, so avoid unnecessary API calls - var defaultRepo = GitHub.defaultRepository; - if (defaultRepo) { - // Just populate with the default repository to avoid API call - githubPopulateRepositories([{full_name: defaultRepo, name: defaultRepo.split('/')[1], has_issues: true}]); - } else { - // Only load all repositories if no default is set - if (GitHub.cache.repositories && GitHub.cache.repositories.length > 0) { - githubPopulateRepositories(GitHub.cache.repositories); - } else { - var cachedRepos = githubGetCachedRepositories(); - if (cachedRepos) { - githubPopulateRepositories(cachedRepos); - } else { - githubLoadRepositories(); - } - } - } + $('#github-create-issue-form')[0].reset(); // Initialize labels multiselect with Select2 @@ -200,14 +315,22 @@ function githubInitModals() { } // Restore default repository after form reset - setTimeout(function() { - githubSetDefaultRepository('#github-repository'); - - // Auto-generate content if fields are empty - if (!$('#github-issue-title').val() && !$('#github-issue-body').val()) { - githubGenerateIssueContent(); - } - }, 100); + githubEnsureRepositoryCache(function() { + githubSetupRepositorySelect('#github-repository', '#github-create-issue-modal'); + + setTimeout(function() { + if (GitHub.defaultRepository) { + githubSetDefaultRepository('#github-repository'); + } else { + $('#github-repository').val(null).trigger('change'); + } + + // Auto-generate content if fields are empty + if (!$('#github-issue-title').val() && !$('#github-issue-body').val()) { + githubGenerateIssueContent(); + } + }, 100); + }); }); // Link issue modal @@ -216,32 +339,21 @@ function githubInitModals() { if ($(this).parent().get(0) !== document.body) { $(this).detach().appendTo('body'); } - - // Only populate repositories if we don't have a default repository - // Most users will use the default repository, so avoid unnecessary API calls - var defaultRepo = GitHub.defaultRepository; - if (defaultRepo) { - // Just populate with the default repository to avoid API call - githubPopulateRepositories([{full_name: defaultRepo, name: defaultRepo.split('/')[1], has_issues: true}]); - } else { - // Only load all repositories if no default is set - if (GitHub.cache.repositories && GitHub.cache.repositories.length > 0) { - githubPopulateRepositories(GitHub.cache.repositories); - } else { - var cachedRepos = githubGetCachedRepositories(); - if (cachedRepos) { - githubPopulateRepositories(cachedRepos); - } else { - githubLoadRepositories(); - } - } - } + $('#github-link-issue-form')[0].reset(); $('#github-search-results').hide(); // Restore default repository after form reset - setTimeout(function() { - githubSetDefaultRepository('#github-link-repository'); - }, 10); + githubEnsureRepositoryCache(function() { + githubSetupRepositorySelect('#github-link-repository', '#github-link-issue-modal'); + + setTimeout(function() { + if (GitHub.defaultRepository) { + githubSetDefaultRepository('#github-link-repository'); + } else { + $('#github-link-repository').val(null).trigger('change'); + } + }, 10); + }); }); // Repository change in create modal @@ -356,61 +468,291 @@ function githubInitModals() { }); } -function githubLoadRepositories() { +function githubLoadRepositories(options) { + options = options || {}; + var skipCache = options.skipCache || false; + var onSuccess = typeof options.onSuccess === 'function' ? options.onSuccess : null; + var onComplete = typeof options.onComplete === 'function' ? options.onComplete : null; var $loadingDiv = $('#github-repositories-loading'); var $refreshBtn = $('#refresh-repositories'); - - // Show loading indicator + $loadingDiv.show(); - $refreshBtn.find('.glyphicon').addClass('glyphicon-spin'); - - fsAjax({}, - laroute.route('github.repositories'), - function(response) { - if (isAjaxSuccess(response)) { - githubPopulateRepositories(response.repositories); - - // Cache repositories in localStorage with timestamp - var cacheData = { - repositories: response.repositories, - timestamp: Date.now(), - token_hash: $('#github_token').val() ? btoa($('#github_token').val()).slice(-8) : null // Last 8 chars of token for validation - }; - localStorage.setItem('github_repositories_cache', JSON.stringify(cacheData)); - } else { - showFloatingAlert('error', 'Failed to load repositories: ' + (response.message || 'Unknown error')); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').addClass('glyphicon-spin'); + } + + if (!skipCache) { + var cachedRepos = githubGetCachedRepositories(); + if (cachedRepos && cachedRepos.length > 0) { + githubPopulateRepositories(cachedRepos); + $loadingDiv.hide(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); + } + + if (onSuccess) { + onSuccess({ + status: 'success', + source: 'local', + repositories: cachedRepos + }); + } + + GitHub.cache.loading = false; + + if (onComplete) { + onComplete(); + } + + return; + } + } + + GitHub.cache.loading = true; + fsAjax({}, + laroute.route('github.repositories'), + function(response) { + if (response.status === 'success' && response.repositories) { + githubPersistRepositoryCache(response.repositories); + githubPopulateRepositories(response.repositories); + + if (onSuccess) { + onSuccess({ + status: 'success', + source: response.source || 'api', + repositories: response.repositories + }); + } + } else if (response.status === 'throttled') { + showFloatingAlert('warning', response.message || 'Repository refresh is temporarily throttled. Please try again later.'); + } else { + showFloatingAlert('error', 'Failed to load repositories: ' + (response.message || 'Unknown error')); + } + + $loadingDiv.hide(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); + } + + GitHub.cache.loading = false; + + if (onComplete) { + onComplete(); + } + }, + true, + function() { + $loadingDiv.hide(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); + } + showFloatingAlert('error', 'Failed to load repositories'); + + GitHub.cache.loading = false; + + if (onComplete) { + onComplete(); + } + } + ); +} + +function githubRefreshRepositoryCache() { + var $loadingDiv = $('#github-repositories-loading'); + var $refreshBtn = $('#refresh-repositories'); + + $loadingDiv.show(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').addClass('glyphicon-spin'); + } + + fsAjax({}, + laroute.route('github.repositories.refresh'), + function(response) { + githubClearLocalRepositoryCache(); + GitHub.cache.repositories = null; + + if (response.status !== 'success') { + showFloatingAlert('warning', response.message || 'Repository cache cleared.'); + } + + $loadingDiv.hide(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); + } + + githubLoadRepositories({ skipCache: true }); + }, + true, + function() { + $loadingDiv.hide(); + if ($refreshBtn.length > 0) { + $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); + } + showFloatingAlert('error', 'Failed to refresh repositories'); + } + ); +} + +function githubSetupRepositorySelect(selector, modalSelector) { + var $select = $(selector); + if ($select.length === 0) { + return; + } + + if ($select.hasClass('select2-hidden-accessible')) { + return; + } + + var placeholder = $select.data('placeholder') || Lang.get("messages.select_repository"); + $select.select2({ + placeholder: placeholder, + allowClear: false, + width: '100%', + dropdownParent: $(modalSelector), + dropdownCssClass: 'github-select2-dropdown', + minimumInputLength: 0, + ajax: { + delay: 0, + cache: true, + url: laroute.route('github.repositories.search'), + dataType: 'json', + data: function(params) { + return { + q: params.term || '', + limit: GitHub.config.maxSearchResults + }; + }, + transport: function(params, success, failure) { + var requestKey = selector; + + if (!GitHub.cache.repoSearchTimers) { + GitHub.cache.repoSearchTimers = {}; + } + if (!GitHub.cache.activeRepoRequests) { + GitHub.cache.activeRepoRequests = {}; + } + + if (GitHub.cache.repoSearchTimers[requestKey]) { + clearTimeout(GitHub.cache.repoSearchTimers[requestKey]); + } + + var timeoutId = setTimeout(function() { + delete GitHub.cache.repoSearchTimers[requestKey]; + + if (GitHub.cache.activeRepoRequests[requestKey]) { + GitHub.cache.activeRepoRequests[requestKey].abort(); + } + + var jqXHR = $.ajax(params) + .done(function(data) { + success(data); + }) + .fail(function(xhr) { + if (xhr && xhr.statusText === 'abort') { + return; + } + + var response = (xhr && xhr.responseJSON) ? xhr.responseJSON : {}; + if (response.status === 'throttled') { + showFloatingAlert('warning', response.message || 'Repository search is temporarily throttled. Please try again later.'); + } else { + showFloatingAlert('error', response.message || 'Failed to search repositories.'); + } + failure(response); + }) + .always(function() { + if (GitHub.cache.activeRepoRequests[requestKey] === jqXHR) { + delete GitHub.cache.activeRepoRequests[requestKey]; + } + }); + + GitHub.cache.activeRepoRequests[requestKey] = jqXHR; + }, GitHub.config.debounceDelay); + + GitHub.cache.repoSearchTimers[requestKey] = timeoutId; + + return { + abort: function() { + if (GitHub.cache.repoSearchTimers[requestKey]) { + clearTimeout(GitHub.cache.repoSearchTimers[requestKey]); + delete GitHub.cache.repoSearchTimers[requestKey]; + } + + if (GitHub.cache.activeRepoRequests[requestKey]) { + GitHub.cache.activeRepoRequests[requestKey].abort(); + delete GitHub.cache.activeRepoRequests[requestKey]; + } + } + }; + }, + processResults: function(data) { + if (data.status === 'success' && data.results) { + if (data.meta && data.meta.source === 'api') { + githubClearLocalRepositoryCache(); + GitHub.cache.repositories = null; + if (!GitHub.cache.loading) { + githubLoadRepositories({ skipCache: true }); + } + } + + if (data.meta && data.meta.throttled) { + var now = Date.now(); + var minInterval = GitHub.config.debounceDelay * 4; + if (!GitHub.cache.lastThrottleNotice || (now - GitHub.cache.lastThrottleNotice) > minInterval) { + var retryAfter = parseInt(data.meta.retry_after, 10); + if (isNaN(retryAfter) || retryAfter < 0) { + retryAfter = 30; + } + showFloatingAlert('info', 'Using cached repositories. You can retry in about ' + retryAfter + 's.'); + GitHub.cache.lastThrottleNotice = now; + } + } + + return { + results: $.map(data.results, function(repo) { + return { + id: repo.id, + text: repo.text || repo.id, + data: repo + }; + }) + }; + } + + if (data.status === 'throttled') { + showFloatingAlert('warning', data.message || 'Repository search is temporarily throttled. Please try again later.'); + } + + return { results: [] }; + } } - $loadingDiv.hide(); - $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); - }, true, function() { - // Error callback - $loadingDiv.hide(); - $refreshBtn.find('.glyphicon').removeClass('glyphicon-spin'); - showFloatingAlert('error', 'Failed to load repositories'); }); } -// Check if we have cached repositories function githubGetCachedRepositories() { try { var cached = localStorage.getItem('github_repositories_cache'); if (!cached) return null; - + var cacheData = JSON.parse(cached); - var currentTokenHash = $('#github_token').val() ? btoa($('#github_token').val()).slice(-8) : null; - - // Check if cache is less than 1 hour old and token matches - var maxAge = 60 * 60 * 1000; // 1 hour - var isValid = (Date.now() - cacheData.timestamp) < maxAge && - cacheData.token_hash === currentTokenHash && - cacheData.repositories && cacheData.repositories.length > 0; - - if (isValid) { - return cacheData.repositories; - } else { + + if (!cacheData || !Array.isArray(cacheData.repositories)) { localStorage.removeItem('github_repositories_cache'); return null; } + + var currentTokenHash = githubGetCurrentTokenHash(); + if (currentTokenHash && cacheData.token_hash && cacheData.token_hash !== currentTokenHash) { + localStorage.removeItem('github_repositories_cache'); + return null; + } + + if (cacheData.repositories.length === 0) { + return null; + } + + return cacheData.repositories; } catch (e) { localStorage.removeItem('github_repositories_cache'); return null; @@ -424,6 +766,12 @@ function githubSetDefaultRepository(selectId) { // Check for backend default first if (GitHub.defaultRepository && selectId !== '#github_default_repository') { + if (select.find('option[value="' + GitHub.defaultRepository + '"]').length === 0) { + var option = $('') + .attr('value', GitHub.defaultRepository) + .text(GitHub.defaultRepository); + select.append(option); + } select.val(GitHub.defaultRepository).trigger('change'); return; } @@ -451,6 +799,8 @@ function githubPopulateRepositories(repositories) { // Use GitHub.defaultRepository if available and we're not in settings var backendDefault = (selectId !== '#github_default_repository' && GitHub.defaultRepository) ? GitHub.defaultRepository : ''; + + var usesAjax = select.data('select2Search') === true || select.data('select2Search') === 'true'; // For settings page, preserve any manually entered value if (selectId === '#github_default_repository' && currentValue) { @@ -460,12 +810,29 @@ function githubPopulateRepositories(repositories) { $(this).remove(); } }); - } else { + } else if (!usesAjax) { select.empty().append(''); } // Determine which value should be selected (priority: current -> backend default -> template default) var valueToSelect = currentValue || backendDefault || defaultValue; + + if (usesAjax) { + if (valueToSelect && select.find('option[value="' + valueToSelect + '"]').length === 0) { + var ajaxOption = $('') + .attr('value', valueToSelect) + .text(valueToSelect); + select.append(ajaxOption); + } + + if (!valueToSelect) { + select.val('').trigger('change'); + } else { + select.val(valueToSelect).trigger('change'); + } + + return; + } // Add repositories that have issues enabled var foundRepository = false; @@ -502,7 +869,6 @@ function githubPopulateRepositories(repositories) { function githubLoadAllowedLabels() { var repository = $("#github_default_repository").val(); if (!repository) { - console.log('No repository selected for loading allowed labels'); return; } @@ -528,7 +894,7 @@ function githubLoadAllowedLabels() { } } } catch (e) { - console.log('No current allowed labels found, will select all by default'); + // Ignore parsing errors and fall back to defaults } // Use laroute to generate URL with encoded parameter @@ -629,7 +995,6 @@ function githubPopulateAllowedLabels(labels, currentAllowedLabels) { } }); - console.log('Populated allowed labels with Select2:', labels.length, 'labels, selected:', $select.val()); } function githubLoadRepositoryLabels(repository) { @@ -653,7 +1018,6 @@ function githubLoadRepositoryLabels(repository) { function githubPopulateLabels(labels) { var select = $('#github-issue-labels'); - console.log('Populating labels:', labels); // Debug log // Destroy existing Select2 if it exists if (select.hasClass('select2-hidden-accessible')) { @@ -675,7 +1039,6 @@ function githubPopulateLabels(labels) { select.append(option); }); - console.log('Options added, initializing Select2'); // Debug log // Initialize Select2 for multiselect with custom styling for labels select.select2({ @@ -725,53 +1088,258 @@ function githubPopulateLabels(labels) { } }); - console.log('Select2 initialized, options count:', select.find('option').length); // Debug log } function githubLoadLabelMappings(repository) { + var $container = $('#label-mappings-container'); + if (!$container.length) { + return; + } + + if (!repository) { + githubResetLabelMappingsUI(); + return; + } + + githubSetLabelMappingStatus('muted', 'Loading label mappings…'); + githubToggleLabelMappingControls(true); + $container.html('

Loading label mappings…

'); + $.ajax({ url: laroute.route('github.label_mappings'), type: 'GET', data: { repository: repository }, success: function(response) { + githubToggleLabelMappingControls(false); + if (response.status === 'success') { - githubRenderLabelMappings(response.data); + githubRenderLabelMappings(response.data || []); + $('#save-label-mappings').prop('disabled', false); + githubSetLabelMappingStatus('success', response.message || 'Label mappings loaded.'); + } else { + $('#save-label-mappings').prop('disabled', true); + githubSetLabelMappingStatus('danger', response.message || 'Failed to load label mappings.'); } }, error: function(xhr) { + githubToggleLabelMappingControls(false); + $('#save-label-mappings').prop('disabled', true); + + var errorMessage = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Failed to load label mappings.'; + githubSetLabelMappingStatus('danger', errorMessage); console.error('Failed to load label mappings:', xhr); } }); } function githubRenderLabelMappings(mappings) { - var container = $('#label-mappings-container'); - container.empty(); + var $container = $('#label-mappings-container'); + $container.empty(); - if (mappings.length === 0) { - container.html('

No label mappings configured

'); + if (!mappings || mappings.length === 0) { + githubAddLabelMappingRow(); return; } - $.each(mappings, function(i, mapping) { + $.each(mappings, function(_, mapping) { githubAddLabelMappingRow(mapping); }); } function githubAddLabelMappingRow(mapping) { mapping = mapping || {}; + + var $container = $('#label-mappings-container'); + $container.find('.label-mapping-empty').remove(); + + var freescoutTag = mapping.freescout_tag || ''; + var githubLabel = mapping.github_label || ''; + var threshold = typeof mapping.confidence_threshold !== 'undefined' && mapping.confidence_threshold !== null + ? parseFloat(mapping.confidence_threshold) + : ''; + + var thresholdValue = threshold === '' || isNaN(threshold) ? '' : threshold; var html = '
' + - '' + + '' + '' + - '' + - '' + - '' + '
'; - $('#label-mappings-container').append(html); + $container.append(html); + + if (GitHub.state.currentRepository) { + $('#save-label-mappings').prop('disabled', false); + } +} + +function githubCollectLabelMappings() { + var rows = []; + var hasError = false; + + $('.label-mapping-row').each(function() { + var $row = $(this); + var freescoutTag = ($row.find('.label-mapping-freescout').val() || '').trim(); + var githubLabel = ($row.find('.label-mapping-github').val() || '').trim(); + var thresholdRaw = $row.find('.label-mapping-threshold').val(); + var threshold = thresholdRaw === '' ? null : parseFloat(thresholdRaw); + + $row.removeClass('has-error'); + + if (!freescoutTag && !githubLabel && threshold === null) { + return; + } + + if (!freescoutTag || !githubLabel) { + hasError = true; + $row.addClass('has-error'); + return; + } + + rows.push({ + freescout_tag: freescoutTag, + github_label: githubLabel, + confidence_threshold: threshold === null || isNaN(threshold) ? 0.80 : threshold + }); + }); + + return { + mappings: rows, + hasError: hasError + }; +} + +function githubSaveLabelMappings() { + var repository = GitHub.state.currentRepository; + if (!repository) { + showFloatingAlert('warning', 'Select a repository before saving label mappings.'); + return; + } + + var result = githubCollectLabelMappings(); + if (result.hasError) { + githubSetLabelMappingStatus('danger', 'Complete all mapping rows before saving.'); + showFloatingAlert('error', 'Please complete all mapping rows before saving.'); + return; + } + + githubSetLabelMappingsSaving(true); + githubSetLabelMappingStatus('muted', 'Saving label mappings…'); + + $.ajax({ + url: laroute.route('github.save_label_mappings'), + type: 'POST', + data: JSON.stringify({ + repository: repository, + mappings: result.mappings + }), + contentType: 'application/json', + headers: { + 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') + }, + success: function(response) { + githubSetLabelMappingsSaving(false); + + if (response.status === 'success') { + githubRenderLabelMappings(response.data || []); + githubSetLabelMappingStatus('success', response.message || 'Label mappings saved successfully.'); + showFloatingAlert('success', response.message || 'Label mappings saved.'); + } else { + githubSetLabelMappingStatus('danger', response.message || 'Failed to save label mappings.'); + showFloatingAlert('error', response.message || 'Failed to save label mappings.'); + } + }, + error: function(xhr) { + githubSetLabelMappingsSaving(false); + + var message = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Failed to save label mappings.'; + githubSetLabelMappingStatus('danger', message); + showFloatingAlert('error', message); + } + }); +} + +function githubSetLabelMappingsSaving(isSaving) { + var $button = $('#save-label-mappings'); + if (!$button.length) { + return; + } + + if (isSaving) { + GitHub.state.labelMappingsSaving = true; + if (!$button.data('original-html')) { + $button.data('original-html', $button.html()); + } + $button.prop('disabled', true).html(' Saving…'); + } else { + GitHub.state.labelMappingsSaving = false; + var originalHtml = $button.data('original-html'); + if (originalHtml) { + $button.html(originalHtml); + } + if (GitHub.state.currentRepository) { + $button.prop('disabled', false); + } + } +} + +function githubSetLabelMappingStatus(tone, message) { + var $status = $('#label-mapping-status'); + if (!$status.length) { + return; + } + + var toneClass = 'text-muted'; + if (tone === 'success') toneClass = 'text-success'; + if (tone === 'danger') toneClass = 'text-danger'; + if (tone === 'warning') toneClass = 'text-warning'; + + $status + .removeClass('text-muted text-success text-danger text-warning') + .addClass(toneClass) + .text(message || ''); +} + +function githubToggleLabelMappingControls(disabled) { + $('#add-label-mapping').prop('disabled', disabled); + if (disabled) { + $('#save-label-mappings').prop('disabled', true); + } else if (GitHub.state.currentRepository && !GitHub.state.labelMappingsSaving) { + $('#save-label-mappings').prop('disabled', false); + } +} + +function githubResetLabelMappingsUI(message) { + var $container = $('#label-mappings-container'); + if (!$container.length) { + return; + } + + var text = message || 'Select a repository to configure label mappings.'; + $container.html('

' + text + '

'); + githubSetLabelMappingStatus('muted', ''); + $('#save-label-mappings').prop('disabled', true); + $('#add-label-mapping').prop('disabled', true); +} + +function githubUpdateLabelMappingSection(repository) { + var $section = $('#label-mapping-section'); + if (!$section.length) { + return; + } + + if (!repository) { + $section.hide(); + githubResetLabelMappingsUI(); + return; + } + + $section.show(); + $('#add-label-mapping').prop('disabled', false); } function githubSearchIssues(repository, query) { @@ -846,7 +1414,6 @@ function githubGenerateIssueContent() { _token: $('meta[name="csrf-token"]').attr('content') }, success: function(response) { - console.log('GitHub: Generate content success response:', response); if (response.status === 'success') { if (response.data.title && !$titleField.val()) { @@ -861,7 +1428,6 @@ function githubGenerateIssueContent() { var labelsSelect = $('#github-issue-labels'); if (labelsSelect.hasClass('select2-hidden-accessible')) { labelsSelect.val(response.data.suggested_labels).trigger('change'); - console.log('Auto-selected labels:', response.data.suggested_labels); } } @@ -1034,9 +1600,6 @@ $(document).ready(function() { // Initialize the GitHub modals functionality githubInitModals(); - // Initialize sidebar action handlers - githubInitSidebarActions(); - // Only load repositories if we don't have cached ones and we're actually going to use them // Skip auto-loading on conversation pages since we already have default repository if (!GitHub.cache.repositories) { @@ -1069,149 +1632,6 @@ $(document).ready(function() { } }); -// Missing functions that were in the sidebar template -function githubCreateIssue() { - $('#github-create-issue-btn').click(function() { - var formData = $('#github-create-issue-form').serialize(); - var $btn = $(this); - - $btn.prop('disabled', true); - $btn.find('.glyphicon').removeClass('glyphicon-plus').addClass('glyphicon-refresh glyphicon-spin'); - - $.ajax({ - url: laroute.route('github.create_issue'), - type: 'POST', - data: formData + '&_token=' + $('meta[name="csrf-token"]').attr('content'), - success: function(response) { - if (response.status === 'success') { - $('#github-create-issue-modal').modal('hide'); - showFloatingAlert('success', response.message); - window.location.reload(); // Refresh to show new issue - } else { - showFloatingAlert('error', response.message); - } - }, - error: function(xhr) { - var response = xhr.responseJSON || {}; - var errorMessage = 'An error occurred'; - - if (response.message) { - errorMessage = response.message; - } else if (response.errors) { - // Handle validation errors - var errors = []; - for (var field in response.errors) { - if (response.errors.hasOwnProperty(field)) { - errors = errors.concat(response.errors[field]); - } - } - errorMessage = errors.length > 0 ? errors.join(', ') : 'Validation failed'; - } else if (xhr.status === 422) { - errorMessage = 'The given data was invalid. Please check your input and try again.'; - } else if (xhr.status === 403) { - errorMessage = 'You do not have permission to perform this action.'; - } else if (xhr.status === 404) { - errorMessage = 'The requested resource was not found.'; - } else if (xhr.status >= 500) { - errorMessage = 'Server error occurred. Please try again later.'; - } - - showFloatingAlert('error', errorMessage); - }, - complete: function() { - $btn.prop('disabled', false); - $btn.find('.glyphicon').removeClass('glyphicon-refresh glyphicon-spin').addClass('glyphicon-plus'); - } - }); - }); -} - -function githubLinkIssue() { - $('#github-link-issue-btn').click(function() { - var formData = $('#github-link-issue-form').serialize(); - var $btn = $(this); - - $btn.prop('disabled', true); - $btn.find('.glyphicon').removeClass('glyphicon-link').addClass('glyphicon-refresh glyphicon-spin'); - - $.ajax({ - url: laroute.route('github.link_issue'), - type: 'POST', - data: formData + '&_token=' + $('meta[name="csrf-token"]').attr('content'), - success: function(response) { - if (response.status === 'success') { - $('#github-link-issue-modal').modal('hide'); - showFloatingAlert('success', response.message); - window.location.reload(); // Refresh to show linked issue - } else { - showFloatingAlert('error', response.message); - } - }, - error: function(xhr) { - var response = xhr.responseJSON || {}; - var errorMessage = 'An error occurred'; - - if (response.message) { - errorMessage = response.message; - } else if (response.errors) { - // Handle validation errors - var errors = []; - for (var field in response.errors) { - if (response.errors.hasOwnProperty(field)) { - errors = errors.concat(response.errors[field]); - } - } - errorMessage = errors.length > 0 ? errors.join(', ') : 'Validation failed'; - } else if (xhr.status === 422) { - errorMessage = 'The given data was invalid. Please check your input and try again.'; - } else if (xhr.status === 403) { - errorMessage = 'You do not have permission to perform this action.'; - } else if (xhr.status === 404) { - errorMessage = 'The requested resource was not found.'; - } else if (xhr.status >= 500) { - errorMessage = 'Server error occurred. Please try again later.'; - } - - showFloatingAlert('error', errorMessage); - }, - complete: function() { - $btn.prop('disabled', false); - $btn.find('.glyphicon').removeClass('glyphicon-refresh glyphicon-spin').addClass('glyphicon-link'); - } - }); - }); -} - -function githubInitSidebarActions() { - $(document).ready(function() { - // Initialize create and link issue handlers - githubCreateIssue(); - githubLinkIssue(); - - // Issue actions - $(document).on('click', '.github-issue-action', function(e) { - e.preventDefault(); - var action = $(this).data('action'); - var issueId = $(this).data('issue-id'); - - if (action === 'unlink') { - if (confirm('Are you sure you want to unlink this issue?')) { - githubUnlinkIssue(issueId); - } - } else if (action === 'refresh') { - githubRefreshIssue(issueId); - } - }); - - // Search result selection - $(document).on('click', '.github-search-result-item', function() { - var issueNumber = $(this).data('issue-number'); - $('#github-issue-number').val(issueNumber); - $('#github-search-results').hide(); - }); - }); -} - function githubUnlinkIssue(issueId) { $.ajax({ url: laroute.route('github.unlink_issue'), @@ -1361,12 +1781,10 @@ function githubAutoRefreshOnLoad() { if (lastAutoRefresh && (now - parseInt(lastAutoRefresh)) < fiveMinutes) { // Skip auto-refresh if we've done it recently - console.log('GitHub: Skipping auto-refresh, done recently'); return; } // Perform silent refresh (no success message) - console.log('GitHub: Auto-refreshing issues for conversation'); githubRefreshConversationIssues(); // Update the last auto-refresh timestamp diff --git a/README.md b/README.md index 6cb6436..cabd2b6 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,27 @@ A comprehensive GitHub integration module for FreeScout that enables support tea composer install --no-dev ``` -3. **Run database migrations**: +3. **Enable the module** (from your FreeScout project root). Skip if already enabled in the UI: ```bash - php artisan migrate + php artisan module:enable Github ``` -4. **Configure the module** via FreeScout Admin → Settings → GitHub +4. **Refresh FreeScout caches** so the module command is registered: + ```bash + php artisan freescout:clear-cache + ``` + +5. **Clear application cache** so Laravel registers the Github commands + ```bash + php artisan cache:clear + ``` + +6. **Run the module installation command** to execute this module's migrations: + ```bash + php artisan freescout-github:install + ``` + +7. **Configure the module** via FreeScout Admin → Settings → GitHub ## ⚙️ Configuration diff --git a/Github/Resources/views/partials/sidebar.blade.php b/Resources/views/partials/sidebar.blade.php similarity index 97% rename from Github/Resources/views/partials/sidebar.blade.php rename to Resources/views/partials/sidebar.blade.php index 4dac73a..c7ec2c6 100644 --- a/Github/Resources/views/partials/sidebar.blade.php +++ b/Resources/views/partials/sidebar.blade.php @@ -118,8 +118,8 @@
- + @php $defaultRepo = \Option::get('github.default_repository'); @endphp @@ -195,8 +195,8 @@
- + @php $defaultRepo = \Option::get('github.default_repository'); @endphp diff --git a/Github/Resources/views/settings.blade.php b/Resources/views/settings.blade.php similarity index 95% rename from Github/Resources/views/settings.blade.php rename to Resources/views/settings.blade.php index 2b35c70..0a23186 100644 --- a/Github/Resources/views/settings.blade.php +++ b/Resources/views/settings.blade.php @@ -292,12 +292,18 @@
-
+

{{ __('Select a repository to configure label mappings') }}

- +
+ + + +
diff --git a/Github/Services/GithubApiClient.php b/Services/GithubApiClient.php similarity index 100% rename from Github/Services/GithubApiClient.php rename to Services/GithubApiClient.php diff --git a/Github/Services/IssueContentGenerator.php b/Services/IssueContentGenerator.php similarity index 90% rename from Github/Services/IssueContentGenerator.php rename to Services/IssueContentGenerator.php index 130fd19..500f158 100644 --- a/Github/Services/IssueContentGenerator.php +++ b/Services/IssueContentGenerator.php @@ -3,6 +3,7 @@ namespace Modules\Github\Services; use App\Conversation; +use Illuminate\Support\Str; class IssueContentGenerator { @@ -983,47 +984,138 @@ private function extractConversationSummary($threads) } /** - * Extract diagnostic information using AI analysis + * Extract diagnostic information without relying on an external AI call. */ private function extractDiagnosticInfo($conversationText) { - - $diagnosticInfo = "Analyze this support conversation (provided as structured JSON) and extract diagnostic information. + $info = [ + 'reproduction_confirmed' => false, + 'root_cause' => null, + 'issue_type' => null, + 'symptoms' => [], + 'conflicting_plugins' => [], + 'technical_details' => [], + 'reproduction_steps' => [], + 'support_analysis' => [], + 'customer_environment' => [], + ]; -Conversation Data: -$conversationText + $structured = $this->decodeConversationJson($conversationText); + $messages = $structured['messages'] ?? []; -Extract the following diagnostic information if present: -1. reproduction_confirmed: true/false - did support team confirm they reproduced the issue? -2. root_cause: string - any identified root cause or technical analysis -3. issue_type: string - type of issue (CSS, JavaScript, plugin conflict, etc.) -4. symptoms: array - specific symptoms or behaviors described -5. conflicting_plugins: array - any plugins mentioned as causing conflicts -6. technical_details: array - versions, error messages, browser info, etc. -7. reproduction_steps: array - any steps mentioned to reproduce the issue -8. support_analysis: array - key findings or analysis from support team -9. customer_environment: object - customer's setup details (WordPress version, plugins, etc.) - -Pay special attention to: -- Internal notes from support team (sender_type: \"Support Team (Internal Note)\") -- Support team messages confirming reproduction -- Technical analysis and root cause identification -- Plugin conflicts and compatibility issues - -Only include fields that have actual information. Return valid JSON only. - -Example response: -{ - \"reproduction_confirmed\": true, - \"root_cause\": \"CSS issue with checkbox styling caused by plugin conflict\", - \"issue_type\": \"CSS conflict\", - \"symptoms\": [\"checkboxes become unclickable when WP Fusion is activated\"], - \"conflicting_plugins\": [\"User Menus plugin\", \"WP Fusion\"], - \"support_analysis\": [\"Issue appears after User Menu update\", \"Working fine few months ago\", \"Inspected elements and confirmed CSS issue\"], - \"customer_environment\": {\"troubleshooting_method\": \"Health Check plugin used to isolate conflict\"} -}"; - - return $diagnosticInfo; + foreach ($messages as $message) { + $body = (string) ($message['message'] ?? ''); + if ($body === '') { + continue; + } + + $lowerBody = strtolower($body); + + if (!$info['reproduction_confirmed'] && preg_match('/reproduc(ed|e|ing)|able to replicate|able to reproduce/i', $body)) { + $info['reproduction_confirmed'] = true; + } + + if ($info['root_cause'] === null && (strpos($lowerBody, 'root cause') !== false || strpos($lowerBody, 'because') !== false)) { + $info['root_cause'] = Str::limit($body, 280); + } + + if ($info['issue_type'] === null) { + $info['issue_type'] = $this->detectIssueType($lowerBody); + } + + if (preg_match_all('/error:? (.+?)(?:\.|\n|$)/i', $body, $errorMatches)) { + foreach ($errorMatches[1] as $error) { + $info['symptoms'][] = trim($error); + } + } + + if (preg_match_all('/([A-Z][A-Za-z0-9\s]+)\s+plugin/i', $body, $pluginMatches)) { + foreach ($pluginMatches[1] as $plugin) { + $info['conflicting_plugins'][] = trim($plugin); + } + } + + if (preg_match_all('/step\s*\d*[:\-]\s*(.+)/i', $body, $stepMatches)) { + foreach ($stepMatches[1] as $step) { + $info['reproduction_steps'][] = trim($step); + } + } + + $sender = strtolower($message['sender_type'] ?? ''); + if (strpos($sender, 'support') !== false) { + $info['support_analysis'][] = Str::limit($body, 200); + } + } + + $this->populateEnvironmentDetails($conversationText, $info); + $this->populateTechnicalDetails($conversationText, $info); + + $info['symptoms'] = array_values(array_unique(array_filter($info['symptoms']))); + $info['conflicting_plugins'] = array_values(array_unique(array_filter($info['conflicting_plugins']))); + $info['reproduction_steps'] = array_values(array_unique(array_filter($info['reproduction_steps']))); + $info['support_analysis'] = array_values(array_unique(array_filter($info['support_analysis']))); + + return $info; + } + + private function decodeConversationJson(string $conversationText): ?array + { + if (preg_match('/```json\s*(.*?)\s*```/is', $conversationText, $matches)) { + $json = trim($matches[1]); + $decoded = json_decode($json, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + } + + return null; + } + + private function detectIssueType(string $lowerBody): ?string + { + $map = [ + 'CSS' => ['css', 'stylesheet', 'style'], + 'JavaScript' => ['javascript', 'js error', 'console', 'script'], + 'Performance' => ['slow', 'performance', 'timeout'], + 'API' => ['api', 'webhook', 'endpoint'], + 'Database' => ['database', 'sql', 'query'], + ]; + + foreach ($map as $type => $keywords) { + foreach ($keywords as $keyword) { + if (strpos($lowerBody, $keyword) !== false) { + return $type; + } + } + } + + return null; + } + + private function populateEnvironmentDetails(string $conversationText, array &$info): void + { + if (preg_match('/WordPress\s+([0-9\.]+)/i', $conversationText, $match)) { + $info['customer_environment']['wordpress_version'] = $match[1]; + } + + if (preg_match('/WooCommerce\s+([0-9\.]+)/i', $conversationText, $match)) { + $info['customer_environment']['woocommerce_version'] = $match[1]; + } + + if (preg_match('/PHP\s+([0-9\.]+)/i', $conversationText, $match)) { + $info['customer_environment']['php_version'] = $match[1]; + } + } + + private function populateTechnicalDetails(string $conversationText, array &$info): void + { + if (preg_match_all('/(Fatal error|Warning|Notice):\s*(.+?)(?:\.|\n)/i', $conversationText, $matches)) { + foreach ($matches[0] as $detail) { + $info['technical_details'][] = trim($detail); + } + } + + $info['technical_details'] = array_values(array_unique(array_filter($info['technical_details']))); } /** diff --git a/Github/Services/LabelAssignmentService.php b/Services/LabelAssignmentService.php similarity index 100% rename from Github/Services/LabelAssignmentService.php rename to Services/LabelAssignmentService.php diff --git a/Support/RepositoryCache.php b/Support/RepositoryCache.php new file mode 100644 index 0000000..7e2d12a --- /dev/null +++ b/Support/RepositoryCache.php @@ -0,0 +1,322 @@ + 'success', + 'repositories' => $cached['repositories'], + 'source' => 'cache', + 'fetched_at' => $cached['fetched_at'] ?? null, + ]; + } + + if (!$hydrate) { + return [ + 'status' => 'empty', + 'message' => 'Repository cache is empty.', + ]; + } + + $fetchResult = self::fetchAndCache($token, $hash); + if ($fetchResult['status'] === 'success') { + return [ + 'status' => 'success', + 'repositories' => $fetchResult['data'], + 'source' => 'api', + 'fetched_at' => $fetchResult['fetched_at'], + ]; + } + + return $fetchResult; + } + + /** + * Search cached repositories (hydrating if required). + * + * @param string $query + * @param int $limit + * @return array + */ + public static function search(string $query = '', int $limit = 20): array + { + $query = trim($query); + $limit = $limit > 0 ? min($limit, 50) : 20; + + $repositoriesResult = self::getRepositories(true); + if ($repositoriesResult['status'] !== 'success') { + return $repositoriesResult; + } + + $repositories = $repositoriesResult['repositories']; + $source = $repositoriesResult['source'] ?? 'cache'; + $fetchedAt = $repositoriesResult['fetched_at'] ?? null; + + $filtered = self::filterRepositoriesByQuery($repositories, $query, $limit); + $throttled = false; + $retryAfter = null; + + if ($query !== '' && empty($filtered) && $source !== 'api') { + $tokenData = self::resolveToken(); + if ($tokenData['status'] !== 'success') { + return $tokenData; + } + + [$token, $hash] = $tokenData['data']; + $refreshResult = self::fetchAndCache($token, $hash); + + if ($refreshResult['status'] !== 'success') { + if ($refreshResult['status'] === 'throttled') { + $throttled = true; + $retryAfter = $refreshResult['retry_after'] ?? self::THROTTLE_SECONDS; + } else { + return $refreshResult; + } + } else { + $source = 'api'; + $fetchedAt = $refreshResult['fetched_at']; + $filtered = self::filterRepositoriesByQuery($refreshResult['data'], $query, $limit); + } + } + + return [ + 'status' => 'success', + 'repositories' => $filtered, + 'source' => $source, + 'fetched_at' => $fetchedAt, + 'throttled' => $throttled, + 'retry_after' => $retryAfter, + ]; + } + + /** + * Clear cached repositories and throttle marker. + * + * @return void + */ + public static function clear(): void + { + $tokenData = self::resolveToken(false); + if ($tokenData['status'] !== 'success') { + return; + } + + [, $hash] = $tokenData['data']; + Cache::forget(self::cacheKey($hash)); + } + + /** + * Resolve stored GitHub token and hash. + * + * @param bool $requireToken + * @return array + */ + private static function resolveToken(bool $requireToken = true): array + { + $token = (string) \Option::get('github.token'); + if ($token === '') { + if ($requireToken) { + return [ + 'status' => 'error', + 'message' => 'GitHub token is not configured.', + ]; + } + + return [ + 'status' => 'empty', + 'message' => 'GitHub token is not configured.', + ]; + } + + $hash = substr(hash('sha256', $token), 0, 32); + + return [ + 'status' => 'success', + 'data' => [$token, $hash], + ]; + } + + /** + * Fetch repositories from GitHub API and cache the results. + * + * @param string $token + * @param string $hash + * @return array + */ + private static function fetchAndCache(string $token, string $hash): array + { + $throttle = self::checkThrottle($hash); + if ($throttle['status'] === 'throttled') { + return $throttle; + } + + self::markApiCall($hash); + + $result = GithubApiClient::getRepositories(); + if (($result['status'] ?? null) !== 'success') { + return [ + 'status' => $result['status'] ?? 'error', + 'message' => $result['message'] ?? 'Failed to fetch repositories from GitHub.', + ]; + } + + $repositories = self::sanitizeRepositories($result['data'] ?? []); + $payload = [ + 'token_hash' => $hash, + 'repositories' => $repositories, + 'fetched_at' => Carbon::now()->timestamp, + ]; + + Cache::forever(self::cacheKey($hash), $payload); + + return [ + 'status' => 'success', + 'data' => $repositories, + 'fetched_at' => $payload['fetched_at'], + ]; + } + + /** + * Ensure repositories contain expected structure and filter unsupported ones. + * + * @param array $repositories + * @return array + */ + private static function sanitizeRepositories(array $repositories): array + { + return array_values(array_filter(array_map(function ($repo) { + if (!is_array($repo) || empty($repo['full_name']) || empty($repo['name'])) { + return null; + } + + if (isset($repo['has_issues']) && $repo['has_issues'] === false) { + return null; + } + + return [ + 'id' => $repo['id'] ?? null, + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'private' => $repo['private'] ?? false, + 'has_issues' => $repo['has_issues'] ?? true, + 'updated_at' => $repo['updated_at'] ?? null, + ]; + }, $repositories))); + } + + /** + * Filter repositories by query and limit results. + * + * @param array $repositories + * @param string $query + * @param int $limit + * @return array + */ + private static function filterRepositoriesByQuery(array $repositories, string $query, int $limit): array + { + $filtered = $repositories; + + if ($query !== '') { + $filtered = array_values(array_filter($repositories, function ($repo) use ($query) { + $fullName = isset($repo['full_name']) ? $repo['full_name'] : ''; + $name = isset($repo['name']) ? $repo['name'] : ''; + + return stripos($fullName, $query) !== false || stripos($name, $query) !== false; + })); + } + + if ($limit > 0) { + $filtered = array_slice($filtered, 0, $limit); + } + + return $filtered; + } + + /** + * Determine whether GitHub API throttling is active. + * + * @param string $hash + * @return array + */ + private static function checkThrottle(string $hash): array + { + $key = self::throttleKey($hash); + $lastFetch = Cache::get($key); + if (!$lastFetch) { + return ['status' => 'ok']; + } + + $secondsSince = Carbon::now()->timestamp - (int) $lastFetch; + if ($secondsSince >= self::THROTTLE_SECONDS) { + return ['status' => 'ok']; + } + + return [ + 'status' => 'throttled', + 'message' => 'GitHub repository list refreshed recently. Please wait before trying again.', + 'retry_after' => self::THROTTLE_SECONDS - $secondsSince, + ]; + } + + /** + * Store the timestamp of the last GitHub API call. + * + * @param string $hash + * @return void + */ + private static function markApiCall(string $hash): void + { + Cache::forever(self::throttleKey($hash), Carbon::now()->timestamp); + } + + /** + * Build cache key. + * + * @param string $hash + * @return string + */ + private static function cacheKey(string $hash): string + { + return self::CACHE_PREFIX . $hash; + } + + /** + * Build throttle key. + * + * @param string $hash + * @return string + */ + private static function throttleKey(string $hash): string + { + return self::THROTTLE_PREFIX . $hash; + } +} + + diff --git a/Github/composer.json b/composer.json similarity index 100% rename from Github/composer.json rename to composer.json diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..52f7f8a --- /dev/null +++ b/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a4d8d2cdb9b16cab2a6a81d63921e117", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/Github/module.json b/module.json similarity index 100% rename from Github/module.json rename to module.json diff --git a/Github/start.php b/start.php similarity index 76% rename from Github/start.php rename to start.php index c8ac61a..115abf2 100644 --- a/Github/start.php +++ b/start.php @@ -7,11 +7,6 @@ * to create, link, and track GitHub issues directly from support conversations. */ -// Register the module with FreeScout -if (!defined('GITHUB_MODULE')) { - define('GITHUB_MODULE', true); -} - // Load module routes when running as a standalone FreeScout module if (class_exists('\Route')) { require __DIR__ . '/Http/routes.php';