From bca40a9241b114c243747ba0d60055203b61edec Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Thu, 13 Nov 2025 15:25:19 +0200 Subject: [PATCH 1/7] Move files to root folder --- {Github/Config => Config}/config.php | 0 .../Migrations/2024_01_01_000001_create_github_issues_table.php | 0 .../2024_01_01_000002_create_github_issue_conversation_table.php | 0 .../2024_01_01_000003_create_github_label_mappings_table.php | 0 {Github/Entities => Entities}/GithubIssue.php | 0 {Github/Entities => Entities}/GithubIssueConversation.php | 0 {Github/Entities => Entities}/GithubLabelMapping.php | 0 {Github/Http => Http}/Controllers/GithubController.php | 0 {Github/Http => Http}/routes.php | 0 {Github/Providers => Providers}/GithubServiceProvider.php | 0 {Github/Public => Public}/css/module.css | 0 {Github/Public => Public}/js/laroute.js | 0 {Github/Public => Public}/js/module.js | 0 {Github/Resources => Resources}/views/partials/sidebar.blade.php | 0 {Github/Resources => Resources}/views/settings.blade.php | 0 {Github/Services => Services}/GithubApiClient.php | 0 {Github/Services => Services}/IssueContentGenerator.php | 0 {Github/Services => Services}/LabelAssignmentService.php | 0 Github/composer.json => composer.json | 0 Github/module.json => module.json | 0 Github/start.php => start.php | 0 21 files changed, 0 insertions(+), 0 deletions(-) rename {Github/Config => Config}/config.php (100%) rename {Github/Database => Database}/Migrations/2024_01_01_000001_create_github_issues_table.php (100%) rename {Github/Database => Database}/Migrations/2024_01_01_000002_create_github_issue_conversation_table.php (100%) rename {Github/Database => Database}/Migrations/2024_01_01_000003_create_github_label_mappings_table.php (100%) rename {Github/Entities => Entities}/GithubIssue.php (100%) rename {Github/Entities => Entities}/GithubIssueConversation.php (100%) rename {Github/Entities => Entities}/GithubLabelMapping.php (100%) rename {Github/Http => Http}/Controllers/GithubController.php (100%) rename {Github/Http => Http}/routes.php (100%) rename {Github/Providers => Providers}/GithubServiceProvider.php (100%) rename {Github/Public => Public}/css/module.css (100%) rename {Github/Public => Public}/js/laroute.js (100%) rename {Github/Public => Public}/js/module.js (100%) rename {Github/Resources => Resources}/views/partials/sidebar.blade.php (100%) rename {Github/Resources => Resources}/views/settings.blade.php (100%) rename {Github/Services => Services}/GithubApiClient.php (100%) rename {Github/Services => Services}/IssueContentGenerator.php (100%) rename {Github/Services => Services}/LabelAssignmentService.php (100%) rename Github/composer.json => composer.json (100%) rename Github/module.json => module.json (100%) rename Github/start.php => start.php (100%) 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/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 100% rename from Github/Http/Controllers/GithubController.php rename to Http/Controllers/GithubController.php diff --git a/Github/Http/routes.php b/Http/routes.php similarity index 100% rename from Github/Http/routes.php rename to Http/routes.php diff --git a/Github/Providers/GithubServiceProvider.php b/Providers/GithubServiceProvider.php similarity index 100% rename from Github/Providers/GithubServiceProvider.php rename to Providers/GithubServiceProvider.php diff --git a/Github/Public/css/module.css b/Public/css/module.css similarity index 100% rename from Github/Public/css/module.css rename to Public/css/module.css diff --git a/Github/Public/js/laroute.js b/Public/js/laroute.js similarity index 100% rename from Github/Public/js/laroute.js rename to Public/js/laroute.js diff --git a/Github/Public/js/module.js b/Public/js/module.js similarity index 100% rename from Github/Public/js/module.js rename to Public/js/module.js diff --git a/Github/Resources/views/partials/sidebar.blade.php b/Resources/views/partials/sidebar.blade.php similarity index 100% rename from Github/Resources/views/partials/sidebar.blade.php rename to Resources/views/partials/sidebar.blade.php diff --git a/Github/Resources/views/settings.blade.php b/Resources/views/settings.blade.php similarity index 100% rename from Github/Resources/views/settings.blade.php rename to Resources/views/settings.blade.php 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 100% rename from Github/Services/IssueContentGenerator.php rename to Services/IssueContentGenerator.php 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/Github/composer.json b/composer.json similarity index 100% rename from Github/composer.json rename to composer.json 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 100% rename from Github/start.php rename to start.php From 3d31f01d65d76e23e11a7ff276e0cc7ce9c4a9a3 Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Thu, 13 Nov 2025 15:46:11 +0200 Subject: [PATCH 2/7] Ad install command and improve docs --- .gitignore | 1 + Console/InstallCommand.php | 70 +++++++++++++++++++++++++++++ Providers/GithubServiceProvider.php | 18 +++++++- README.md | 16 +++++-- composer.lock | 18 ++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 Console/InstallCommand.php create mode 100644 composer.lock 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/Console/InstallCommand.php b/Console/InstallCommand.php new file mode 100644 index 0000000..db384e3 --- /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') || $this->getLaravel()->environment('production'); + + 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/Providers/GithubServiceProvider.php b/Providers/GithubServiceProvider.php index d8adf85..67687ae 100644 --- a/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(); + } } /** @@ -187,4 +191,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/README.md b/README.md index 6cb6436..fcba258 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,22 @@ 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. **Run the module installation command** to execute this module's migrations: + ```bash + php artisan freescout-github:install + ``` + +6. **Configure the module** via FreeScout Admin → Settings → GitHub ## ⚙️ Configuration 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" +} From 62d60071ca50318f200af77f9c3940055fbf4950 Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Thu, 13 Nov 2025 15:50:02 +0200 Subject: [PATCH 3/7] Fix force issue --- Console/InstallCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Console/InstallCommand.php b/Console/InstallCommand.php index db384e3..a3598aa 100644 --- a/Console/InstallCommand.php +++ b/Console/InstallCommand.php @@ -43,7 +43,7 @@ public function handle() $parameters = ['module' => $moduleName]; - $force = $this->option('force') || $this->getLaravel()->environment('production'); + $force = $this->option('force'); if ($force) { $parameters['--force'] = true; From f8b9008893e14c4da3d56062763d6b20d596e37e Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Fri, 14 Nov 2025 15:39:59 +0200 Subject: [PATCH 4/7] fix CSS not loading --- Providers/GithubServiceProvider.php | 22 +++++++++++++++++++--- start.php | 5 ----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Providers/GithubServiceProvider.php b/Providers/GithubServiceProvider.php index 67687ae..a384341 100644 --- a/Providers/GithubServiceProvider.php +++ b/Providers/GithubServiceProvider.php @@ -92,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; }); diff --git a/start.php b/start.php index c8ac61a..115abf2 100644 --- a/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'; From a765f38e86b2acbf651e3ce40e80f247942ae23a Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Fri, 14 Nov 2025 15:41:46 +0200 Subject: [PATCH 5/7] Add cache clear to readme --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fcba258..cabd2b6 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,17 @@ A comprehensive GitHub integration module for FreeScout that enables support tea php artisan freescout:clear-cache ``` -5. **Run the module installation command** to execute this module's migrations: +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 ``` -6. **Configure the module** via FreeScout Admin → Settings → GitHub +7. **Configure the module** via FreeScout Admin → Settings → GitHub ## ⚙️ Configuration From df8dcbf6d3a67b29a4d3b09a03ab8ff572e3158c Mon Sep 17 00:00:00 2001 From: Sorin Marta Date: Fri, 14 Nov 2025 23:26:13 +0200 Subject: [PATCH 6/7] Introduce multi select and better cache management --- Http/Controllers/GithubController.php | 150 +++++- Http/routes.php | 2 + Public/js/laroute.js | 8 + Public/js/module.js | 536 +++++++++++++++++---- Resources/views/partials/sidebar.blade.php | 8 +- Support/RepositoryCache.php | 322 +++++++++++++ 6 files changed, 915 insertions(+), 111 deletions(-) create mode 100644 Support/RepositoryCache.php diff --git a/Http/Controllers/GithubController.php b/Http/Controllers/GithubController.php index c66edaa..9858db1 100644 --- a/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 */ diff --git a/Http/routes.php b/Http/routes.php index 2e79f32..fb46251 100644 --- a/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/Public/js/laroute.js b/Public/js/laroute.js index e12e995..f836ff6 100644 --- a/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/Public/js/module.js b/Public/js/module.js index f30a82e..d4aae32 100644 --- a/Public/js/module.js +++ b/Public/js/module.js @@ -10,11 +10,125 @@ var GitHub = { maxSearchResults: 10 }, cache: { - repositories: null + repositories: null, + loading: false, + loadingCallbacks: [], + repoSearchTimers: {}, + activeRepoRequests: {}, + lastThrottleNotice: 0 }, warningsShown: [] // Track warnings to prevent duplicates }; +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 @@ -66,6 +180,7 @@ function githubInitSettings() { if (isAjaxSuccess(response)) { githubShowConnectionResult(response); if (response.repositories) { + githubPersistRepositoryCache(response.repositories); githubPopulateRepositories(response.repositories); } } else { @@ -80,7 +195,7 @@ function githubInitSettings() { // Refresh repositories button $("#refresh-repositories").click(function(e) { e.preventDefault(); - githubLoadRepositories(); + githubRefreshRepositoryCache(); }); // Refresh allowed labels button @@ -161,26 +276,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 +296,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 +320,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 +449,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 +747,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 +780,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 +791,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; diff --git a/Resources/views/partials/sidebar.blade.php b/Resources/views/partials/sidebar.blade.php index 4dac73a..c7ec2c6 100644 --- a/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/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; + } +} + + From 4ab6f13f32aca14d95dd8f6f08da1e0634592b6e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Mon, 24 Nov 2025 16:31:30 +0100 Subject: [PATCH 7/7] Enhance label mapping UI and diagnostic extraction Improves the label mapping management in settings with better UI feedback, validation, and save functionality. Refactors backend validation and mapping persistence logic. Diagnostic extraction in IssueContentGenerator now parses conversation data without external AI, extracting structured info directly. Removes debug logs and streamlines JS event handling for issue actions. --- Http/Controllers/GithubController.php | 34 +- Public/css/module.css | 16 + Public/js/module.js | 458 +++++++++++++++----------- Resources/views/settings.blade.php | 14 +- Services/IssueContentGenerator.php | 164 +++++++-- 5 files changed, 447 insertions(+), 239 deletions(-) diff --git a/Http/Controllers/GithubController.php b/Http/Controllers/GithubController.php index 9858db1..43ca663 100644 --- a/Http/Controllers/GithubController.php +++ b/Http/Controllers/GithubController.php @@ -674,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'], @@ -695,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/Public/css/module.css b/Public/css/module.css index ff64aa4..c56efa8 100644 --- a/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/Public/js/module.js b/Public/js/module.js index d4aae32..2d0cc95 100644 --- a/Public/js/module.js +++ b/Public/js/module.js @@ -17,9 +17,39 @@ var GitHub = { 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'); @@ -134,14 +164,11 @@ function githubInitSettings() { // 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 } } @@ -207,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(); } }); @@ -230,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 @@ -240,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(); }); }); } @@ -850,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; } @@ -876,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 @@ -977,7 +995,6 @@ function githubPopulateAllowedLabels(labels, currentAllowedLabels) { } }); - console.log('Populated allowed labels with Select2:', labels.length, 'labels, selected:', $select.val()); } function githubLoadRepositoryLabels(repository) { @@ -1001,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')) { @@ -1023,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({ @@ -1073,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) { @@ -1194,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()) { @@ -1209,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); } } @@ -1382,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) { @@ -1417,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'), @@ -1709,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/Resources/views/settings.blade.php b/Resources/views/settings.blade.php index 2b35c70..0a23186 100644 --- a/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/Services/IssueContentGenerator.php b/Services/IssueContentGenerator.php index 130fd19..500f158 100644 --- a/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']))); } /**