From 40d893af4419fd82d13d7e54ddbd79aded981912 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 23:56:46 +0000 Subject: [PATCH] monorepo sepration --- .env.example | 76 --- .github/package-workflows/README.md | 62 -- .github/package-workflows/ci.yml | 55 -- .github/package-workflows/release.yml | 40 -- .github/workflows/ci.yml | 41 +- .github/workflows/release.yml | 8 +- Boot.php | 173 ++++++ Console/AnalyzeCommand.php | 141 +++++ Console/CheckCommand.php | 109 ++++ Console/CheckUpdatesCommand.php | 202 ++++++ Console/IssuesCommand.php | 139 +++++ Console/SendDigestsCommand.php | 146 +++++ Controllers/Api/WebhookController.php | 268 ++++++++ Jobs/CheckVendorUpdatesJob.php | 143 +++++ Jobs/ProcessUptelligenceWebhook.php | 198 ++++++ Models/AnalysisLog.php | 187 ++++++ Models/Asset.php | 214 +++++++ Models/AssetVersion.php | 49 ++ Models/DiffCache.php | 251 ++++++++ Models/Pattern.php | 172 ++++++ Models/PatternCollection.php | 70 +++ Models/PatternVariant.php | 31 + Models/UpstreamTodo.php | 241 ++++++++ Models/UptelligenceDigest.php | 285 +++++++++ Models/UptelligenceWebhook.php | 475 ++++++++++++++ Models/UptelligenceWebhookDelivery.php | 356 +++++++++++ Models/Vendor.php | 230 +++++++ Models/VersionRelease.php | 219 +++++++ Notifications/NewReleaseDetected.php | 155 +++++ Notifications/SendUptelligenceDigest.php | 257 ++++++++ Services/AIAnalyzerService.php | 479 +++++++++++++++ Services/AssetTrackerService.php | 439 +++++++++++++ Services/DiffAnalyzerService.php | 334 ++++++++++ Services/IssueGeneratorService.php | 474 ++++++++++++++ Services/UpstreamPlanGeneratorService.php | 433 +++++++++++++ Services/UptelligenceDigestService.php | 271 ++++++++ Services/VendorStorageService.php | 579 ++++++++++++++++++ Services/VendorUpdateCheckerService.php | 467 ++++++++++++++ Services/WebhookReceiverService.php | 435 +++++++++++++ View/Blade/admin/asset-manager.blade.php | 194 ++++++ View/Blade/admin/dashboard.blade.php | 298 +++++++++ View/Blade/admin/diff-viewer.blade.php | 240 ++++++++ View/Blade/admin/digest-preferences.blade.php | 250 ++++++++ View/Blade/admin/todo-list.blade.php | 224 +++++++ View/Blade/admin/vendor-manager.blade.php | 232 +++++++ View/Blade/admin/webhook-manager.blade.php | 391 ++++++++++++ View/Modal/Admin/AssetManager.php | 129 ++++ View/Modal/Admin/Dashboard.php | 134 ++++ View/Modal/Admin/DiffViewer.php | 174 ++++++ View/Modal/Admin/DigestPreferences.php | 237 +++++++ View/Modal/Admin/TodoList.php | 213 +++++++ View/Modal/Admin/VendorManager.php | 140 +++++ View/Modal/Admin/WebhookManager.php | 253 ++++++++ app/Http/Controllers/.gitkeep | 0 app/Mod/.gitkeep | 0 app/Models/.gitkeep | 0 app/Providers/AppServiceProvider.php | 24 - artisan | 15 - bootstrap/app.php | 26 - bootstrap/cache/.gitignore | 2 - bootstrap/providers.php | 5 - composer.json | 68 +- config.php | 300 +++++++++ config/core.php | 24 - database/factories/.gitkeep | 0 ...1_01_000001_create_uptelligence_tables.php | 118 ++++ ...0002_create_uptelligence_digests_table.php | 38 ++ ...003_create_uptelligence_webhooks_table.php | 75 +++ database/seeders/DatabaseSeeder.php | 16 - package.json | 16 - phpunit.xml | 33 - postcss.config.js | 6 - public/.htaccess | 21 - public/index.php | 17 - public/robots.txt | 2 - resources/css/app.css | 3 - resources/js/app.js | 1 - resources/js/bootstrap.js | 3 - resources/views/welcome.blade.php | 65 -- routes/admin.php | 30 + routes/api.php | 33 +- routes/console.php | 3 - routes/web.php | 7 - storage/app/.gitignore | 3 - storage/app/public/.gitignore | 2 - storage/framework/.gitignore | 9 - storage/framework/cache/.gitignore | 3 - storage/framework/cache/data/.gitignore | 2 - storage/framework/sessions/.gitignore | 2 - storage/framework/testing/.gitignore | 2 - storage/framework/views/.gitignore | 2 - storage/logs/.gitignore | 2 - tailwind.config.js | 11 - vite.config.js | 11 - 94 files changed, 12359 insertions(+), 654 deletions(-) delete mode 100644 .env.example delete mode 100644 .github/package-workflows/README.md delete mode 100644 .github/package-workflows/ci.yml delete mode 100644 .github/package-workflows/release.yml create mode 100644 Boot.php create mode 100644 Console/AnalyzeCommand.php create mode 100644 Console/CheckCommand.php create mode 100644 Console/CheckUpdatesCommand.php create mode 100644 Console/IssuesCommand.php create mode 100644 Console/SendDigestsCommand.php create mode 100644 Controllers/Api/WebhookController.php create mode 100644 Jobs/CheckVendorUpdatesJob.php create mode 100644 Jobs/ProcessUptelligenceWebhook.php create mode 100644 Models/AnalysisLog.php create mode 100644 Models/Asset.php create mode 100644 Models/AssetVersion.php create mode 100644 Models/DiffCache.php create mode 100644 Models/Pattern.php create mode 100644 Models/PatternCollection.php create mode 100644 Models/PatternVariant.php create mode 100644 Models/UpstreamTodo.php create mode 100644 Models/UptelligenceDigest.php create mode 100644 Models/UptelligenceWebhook.php create mode 100644 Models/UptelligenceWebhookDelivery.php create mode 100644 Models/Vendor.php create mode 100644 Models/VersionRelease.php create mode 100644 Notifications/NewReleaseDetected.php create mode 100644 Notifications/SendUptelligenceDigest.php create mode 100644 Services/AIAnalyzerService.php create mode 100644 Services/AssetTrackerService.php create mode 100644 Services/DiffAnalyzerService.php create mode 100644 Services/IssueGeneratorService.php create mode 100644 Services/UpstreamPlanGeneratorService.php create mode 100644 Services/UptelligenceDigestService.php create mode 100644 Services/VendorStorageService.php create mode 100644 Services/VendorUpdateCheckerService.php create mode 100644 Services/WebhookReceiverService.php create mode 100644 View/Blade/admin/asset-manager.blade.php create mode 100644 View/Blade/admin/dashboard.blade.php create mode 100644 View/Blade/admin/diff-viewer.blade.php create mode 100644 View/Blade/admin/digest-preferences.blade.php create mode 100644 View/Blade/admin/todo-list.blade.php create mode 100644 View/Blade/admin/vendor-manager.blade.php create mode 100644 View/Blade/admin/webhook-manager.blade.php create mode 100644 View/Modal/Admin/AssetManager.php create mode 100644 View/Modal/Admin/Dashboard.php create mode 100644 View/Modal/Admin/DiffViewer.php create mode 100644 View/Modal/Admin/DigestPreferences.php create mode 100644 View/Modal/Admin/TodoList.php create mode 100644 View/Modal/Admin/VendorManager.php create mode 100644 View/Modal/Admin/WebhookManager.php delete mode 100644 app/Http/Controllers/.gitkeep delete mode 100644 app/Mod/.gitkeep delete mode 100644 app/Models/.gitkeep delete mode 100644 app/Providers/AppServiceProvider.php delete mode 100755 artisan delete mode 100644 bootstrap/app.php delete mode 100644 bootstrap/cache/.gitignore delete mode 100644 bootstrap/providers.php create mode 100644 config.php delete mode 100644 config/core.php delete mode 100644 database/factories/.gitkeep create mode 100644 database/migrations/0001_01_01_000001_create_uptelligence_tables.php create mode 100644 database/migrations/0001_01_01_000002_create_uptelligence_digests_table.php create mode 100644 database/migrations/0001_01_01_000003_create_uptelligence_webhooks_table.php delete mode 100644 database/seeders/DatabaseSeeder.php delete mode 100644 package.json delete mode 100644 phpunit.xml delete mode 100644 postcss.config.js delete mode 100644 public/.htaccess delete mode 100644 public/index.php delete mode 100644 public/robots.txt delete mode 100644 resources/css/app.css delete mode 100644 resources/js/app.js delete mode 100644 resources/js/bootstrap.js delete mode 100644 resources/views/welcome.blade.php create mode 100644 routes/admin.php delete mode 100644 routes/console.php delete mode 100644 routes/web.php delete mode 100644 storage/app/.gitignore delete mode 100644 storage/app/public/.gitignore delete mode 100644 storage/framework/.gitignore delete mode 100644 storage/framework/cache/.gitignore delete mode 100644 storage/framework/cache/data/.gitignore delete mode 100644 storage/framework/sessions/.gitignore delete mode 100644 storage/framework/testing/.gitignore delete mode 100644 storage/framework/views/.gitignore delete mode 100644 storage/logs/.gitignore delete mode 100644 tailwind.config.js delete mode 100644 vite.config.js diff --git a/.env.example b/.env.example deleted file mode 100644 index 01b4da4..0000000 --- a/.env.example +++ /dev/null @@ -1,76 +0,0 @@ -APP_NAME="Core PHP App" -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost - -APP_LOCALE=en_GB -APP_FALLBACK_LOCALE=en_GB -APP_FAKER_LOCALE=en_GB - -APP_MAINTENANCE_DRIVER=file - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=core -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" - -# Core PHP Framework -CORE_CACHE_DISCOVERY=true - -# CDN Configuration (optional) -CDN_ENABLED=false -CDN_DRIVER=bunny -BUNNYCDN_API_KEY= -BUNNYCDN_STORAGE_ZONE= -BUNNYCDN_PULL_ZONE= - -# Flux Pro (optional) -FLUX_LICENSE_KEY= diff --git a/.github/package-workflows/README.md b/.github/package-workflows/README.md deleted file mode 100644 index 999966f..0000000 --- a/.github/package-workflows/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Package Workflows - -These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects. - -## README Badges - -Add these badges to your package README (replace `{package}` with your package name): - -```markdown -[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package}) -[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package}) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) -``` - -## Usage - -Copy the relevant workflows to your library's `.github/workflows/` directory: - -```bash -# In your library repo -mkdir -p .github/workflows -cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/ -cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/ -``` - -## Workflows - -### ci.yml -- Runs on push/PR to main -- Tests against PHP 8.2, 8.3, 8.4 -- Tests against Laravel 11 and 12 -- Runs Pint linting -- Runs Pest tests - -### release.yml -- Triggers on version tags (v*) -- Generates changelog using git-cliff -- Creates GitHub release - -## Requirements - -For these workflows to work, your package needs: - -1. **cliff.toml** - Copy from core-template root -2. **Pest configured** - `composer require pestphp/pest --dev` -3. **Pint configured** - `composer require laravel/pint --dev` -4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads -5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button - -## Recommended composer.json scripts - -```json -{ - "scripts": { - "lint": "pint", - "test": "pest", - "test:coverage": "pest --coverage" - } -} -``` diff --git a/.github/package-workflows/ci.yml b/.github/package-workflows/ci.yml deleted file mode 100644 index 7c5f722..0000000 --- a/.github/package-workflows/ci.yml +++ /dev/null @@ -1,55 +0,0 @@ -# CI workflow for library packages (host-uk/core-*, etc.) -# Copy this to .github/workflows/ci.yml in library repos - -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - tests: - runs-on: ubuntu-latest - - strategy: - fail-fast: true - matrix: - php: [8.2, 8.3, 8.4] - laravel: [11.*, 12.*] - exclude: - - php: 8.2 - laravel: 12.* - - name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite - coverage: pcov - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update - composer update --prefer-dist --no-interaction --no-progress - - - name: Run Pint - run: vendor/bin/pint --test - - - name: Run tests - run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml - - - name: Upload coverage to Codecov - if: matrix.php == '8.3' && matrix.laravel == '12.*' - uses: codecov/codecov-action@v4 - with: - files: coverage.xml - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/package-workflows/release.yml b/.github/package-workflows/release.yml deleted file mode 100644 index 035294e..0000000 --- a/.github/package-workflows/release.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Release workflow for library packages -# Copy this to .github/workflows/release.yml in library repos - -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - name: Create Release - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate changelog - id: changelog - uses: orhun/git-cliff-action@v3 - with: - config: cliff.toml - args: --latest --strip header - env: - OUTPUT: CHANGELOG.md - - - name: Create release - uses: softprops/action-gh-release@v2 - with: - body_path: CHANGELOG.md - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b37b20f..7c5f722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,6 @@ +# CI workflow for library packages (host-uk/core-*, etc.) +# Copy this to .github/workflows/ci.yml in library repos + name: CI on: @@ -8,19 +11,22 @@ on: jobs: tests: - if: github.event.repository.visibility == 'public' runs-on: ubuntu-latest strategy: fail-fast: true matrix: php: [8.2, 8.3, 8.4] + laravel: [11.*, 12.*] + exclude: + - php: 8.2 + laravel: 12.* - name: PHP ${{ matrix.php }} + name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -30,7 +36,9 @@ jobs: coverage: pcov - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress - name: Run Pint run: vendor/bin/pint --test @@ -39,30 +47,9 @@ jobs: run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml - name: Upload coverage to Codecov - if: matrix.php == '8.3' - uses: codecov/codecov-action@v5 + if: matrix.php == '8.3' && matrix.laravel == '12.*' + uses: codecov/codecov-action@v4 with: files: coverage.xml fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} - - assets: - if: github.event.repository.visibility == 'public' - runs-on: ubuntu-latest - name: Assets - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build assets - run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da1ba48..035294e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,6 @@ +# Release workflow for library packages +# Copy this to .github/workflows/release.yml in library repos + name: Release on: @@ -10,19 +13,18 @@ permissions: jobs: release: - if: github.event.repository.visibility == 'public' runs-on: ubuntu-latest name: Create Release steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Generate changelog id: changelog - uses: orhun/git-cliff-action@v4 + uses: orhun/git-cliff-action@v3 with: config: cliff.toml args: --latest --strip header diff --git a/Boot.php b/Boot.php new file mode 100644 index 0000000..7ba3490 --- /dev/null +++ b/Boot.php @@ -0,0 +1,173 @@ + + */ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ApiRoutesRegistering::class => 'onApiRoutes', + ConsoleBooting::class => 'onConsole', + ]; + + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/database/migrations'); + $this->configureRateLimiting(); + $this->validateConfig(); + } + + public function register(): void + { + $this->mergeConfigFrom( + __DIR__.'/config.php', + 'upstream' + ); + + $this->app->singleton(\Core\Uptelligence\Services\IssueGeneratorService::class); + $this->app->singleton(\Core\Uptelligence\Services\UpstreamPlanGeneratorService::class); + $this->app->singleton(\Core\Uptelligence\Services\VendorStorageService::class); + $this->app->singleton(\Core\Uptelligence\Services\DiffAnalyzerService::class); + $this->app->singleton(\Core\Uptelligence\Services\AssetTrackerService::class); + $this->app->singleton(\Core\Uptelligence\Services\AIAnalyzerService::class); + $this->app->singleton(\Core\Uptelligence\Services\VendorUpdateCheckerService::class); + $this->app->singleton(\Core\Uptelligence\Services\UptelligenceDigestService::class); + $this->app->singleton(\Core\Uptelligence\Services\WebhookReceiverService::class); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/routes/admin.php'); + } + + // Admin components + $event->livewire('uptelligence.admin.dashboard', View\Modal\Admin\Dashboard::class); + $event->livewire('uptelligence.admin.vendor-manager', View\Modal\Admin\VendorManager::class); + $event->livewire('uptelligence.admin.todo-list', View\Modal\Admin\TodoList::class); + $event->livewire('uptelligence.admin.diff-viewer', View\Modal\Admin\DiffViewer::class); + $event->livewire('uptelligence.admin.asset-manager', View\Modal\Admin\AssetManager::class); + $event->livewire('uptelligence.admin.digest-preferences', View\Modal\Admin\DigestPreferences::class); + $event->livewire('uptelligence.admin.webhook-manager', View\Modal\Admin\WebhookManager::class); + } + + /** + * Handle API routes registration event. + */ + public function onApiRoutes(ApiRoutesRegistering $event): void + { + if (file_exists(__DIR__.'/routes/api.php')) { + $event->routes(fn () => require __DIR__.'/routes/api.php'); + } + } + + public function onConsole(ConsoleBooting $event): void + { + $event->command(Console\CheckCommand::class); + $event->command(Console\AnalyzeCommand::class); + $event->command(Console\IssuesCommand::class); + $event->command(Console\CheckUpdatesCommand::class); + $event->command(Console\SendDigestsCommand::class); + } + + /** + * Configure rate limiting for AI API calls. + */ + protected function configureRateLimiting(): void + { + // Rate limit for AI API calls: 10 per minute + // Prevents excessive API costs and respects provider rate limits + RateLimiter::for('upstream-ai-api', function () { + return Limit::perMinute(config('upstream.ai.rate_limit', 10)); + }); + + // Rate limit for external registry checks (Packagist, NPM): 30 per minute + // Prevents hammering public registries + RateLimiter::for('upstream-registry', function () { + return Limit::perMinute(30); + }); + + // Rate limit for GitHub/Gitea issue creation: 10 per minute + // Respects GitHub API rate limits + RateLimiter::for('upstream-issues', function () { + return Limit::perMinute(10); + }); + + // Rate limit for incoming webhooks: 60 per minute per endpoint + // Webhooks from external vendor systems need reasonable limits + RateLimiter::for('uptelligence-webhooks', function (Request $request) { + // Use webhook UUID or IP for rate limiting + $webhook = $request->route('webhook'); + + return $webhook + ? Limit::perMinute(60)->by('uptelligence-webhook:'.$webhook) + : Limit::perMinute(30)->by('uptelligence-webhook-ip:'.$request->ip()); + }); + } + + /** + * Validate configuration and warn about missing API keys. + */ + protected function validateConfig(): void + { + // Only validate in non-testing environments + if ($this->app->environment('testing')) { + return; + } + + $warnings = []; + + // Check AI provider configuration + $aiProvider = config('upstream.ai.provider', 'anthropic'); + if ($aiProvider === 'anthropic' && empty(config('services.anthropic.api_key'))) { + $warnings[] = 'Anthropic API key not configured - AI analysis will be disabled'; + } elseif ($aiProvider === 'openai' && empty(config('services.openai.api_key'))) { + $warnings[] = 'OpenAI API key not configured - AI analysis will be disabled'; + } + + // Check GitHub configuration + if (config('upstream.github.enabled', true) && empty(config('upstream.github.token'))) { + $warnings[] = 'GitHub token not configured - issue creation will be disabled'; + } + + // Check Gitea configuration + if (config('upstream.gitea.enabled', true) && empty(config('upstream.gitea.token'))) { + $warnings[] = 'Gitea token not configured - Gitea issue creation will be disabled'; + } + + // Log warnings + foreach ($warnings as $warning) { + Log::warning("Uptelligence: {$warning}"); + } + } +} diff --git a/Console/AnalyzeCommand.php b/Console/AnalyzeCommand.php new file mode 100644 index 0000000..cdbb130 --- /dev/null +++ b/Console/AnalyzeCommand.php @@ -0,0 +1,141 @@ +argument('vendor'); + $vendor = Vendor::where('slug', $vendorSlug)->first(); + + if (! $vendor) { + $this->error("Vendor not found: {$vendorSlug}"); + + return self::FAILURE; + } + + $fromVersion = $this->option('from') ?? $vendor->previous_version; + $toVersion = $this->option('to') ?? $vendor->current_version; + + if (! $fromVersion || ! $toVersion) { + $this->error('Both from and to versions are required.'); + $this->line('Use --from and --to options, or ensure vendor has previous_version and current_version set.'); + + return self::FAILURE; + } + + // Verify both versions exist locally + if (! $storageService->existsLocally($vendor, $fromVersion)) { + $this->error("Version not found locally: {$fromVersion}"); + $this->line("Expected path: {$vendor->getStoragePath($fromVersion)}"); + + return self::FAILURE; + } + + if (! $storageService->existsLocally($vendor, $toVersion)) { + $this->error("Version not found locally: {$toVersion}"); + $this->line("Expected path: {$vendor->getStoragePath($toVersion)}"); + + return self::FAILURE; + } + + $this->info("Analyzing {$vendor->name}: {$fromVersion} → {$toVersion}"); + $this->newLine(); + + // Check if we have an existing release or need to create one + $release = VersionRelease::where('vendor_id', $vendor->id) + ->where('version', $toVersion) + ->where('previous_version', $fromVersion) + ->first(); + + if ($release && $release->analyzed_at) { + $this->line('Using cached analysis from '.$release->analyzed_at->diffForHumans()); + } else { + $this->line('Running diff analysis...'); + + $analyzer = new DiffAnalyzerService($vendor); + $release = $analyzer->analyze($fromVersion, $toVersion); + + $this->info('Analysis complete.'); + } + + // Display summary + $this->newLine(); + $this->table( + ['Metric', 'Count'], + [ + ['Files Added', $release->files_added ?? 0], + ['Files Modified', $release->files_modified ?? 0], + ['Files Removed', $release->files_removed ?? 0], + ['Total Changes', ($release->files_added ?? 0) + ($release->files_modified ?? 0) + ($release->files_removed ?? 0)], + ] + ); + + // Show file details unless --summary + if (! $this->option('summary') && $release->diffs) { + $diffs = $release->diffs()->get(); + + if ($diffs->isNotEmpty()) { + $this->newLine(); + $this->line('Changes by category:'); + + $byCategory = $diffs->groupBy('category'); + foreach ($byCategory as $category => $categoryDiffs) { + $this->line(" {$category}: {$categoryDiffs->count()}"); + } + + // Show modified files + $modified = $diffs->where('change_type', 'modified')->take(20); + if ($modified->isNotEmpty()) { + $this->newLine(); + $this->line('Modified files (up to 20):'); + foreach ($modified as $diff) { + $priority = $vendor->isPriorityPath($diff->file_path) ? ' [PRIORITY]' : ''; + $this->line(" M {$diff->file_path}{$priority}"); + } + } + + // Show added files + $added = $diffs->where('change_type', 'added')->take(10); + if ($added->isNotEmpty()) { + $this->newLine(); + $this->line('Added files (up to 10):'); + foreach ($added as $diff) { + $this->line(" A {$diff->file_path}"); + } + } + + // Show removed files + $removed = $diffs->where('change_type', 'removed')->take(10); + if ($removed->isNotEmpty()) { + $this->newLine(); + $this->line('Removed files (up to 10):'); + foreach ($removed as $diff) { + $this->line(" D {$diff->file_path}"); + } + } + } + } + + $vendor->update(['last_analyzed_at' => now()]); + + return self::SUCCESS; + } +} diff --git a/Console/CheckCommand.php b/Console/CheckCommand.php new file mode 100644 index 0000000..292dcc2 --- /dev/null +++ b/Console/CheckCommand.php @@ -0,0 +1,109 @@ +argument('vendor'); + + if ($vendorSlug) { + $vendors = Vendor::where('slug', $vendorSlug)->get(); + if ($vendors->isEmpty()) { + $this->error("Vendor not found: {$vendorSlug}"); + + return self::FAILURE; + } + } else { + $vendors = Vendor::active()->get(); + } + + if ($vendors->isEmpty()) { + $this->warn('No active vendors found.'); + + return self::SUCCESS; + } + + $this->info('Checking vendors for updates...'); + $this->newLine(); + + $table = []; + foreach ($vendors as $vendor) { + $localExists = $storageService->existsLocally($vendor, $vendor->current_version ?? 'current'); + $hasCurrentVersion = ! empty($vendor->current_version); + $hasPreviousVersion = ! empty($vendor->previous_version); + + $status = match (true) { + ! $hasCurrentVersion => 'No version tracked', + $localExists && $hasPreviousVersion => 'Ready to analyze', + $localExists => 'Current only', + default => 'Files missing', + }; + + $table[] = [ + $vendor->slug, + $vendor->name, + $vendor->getSourceTypeLabel(), + $vendor->current_version ?? '-', + $vendor->previous_version ?? '-', + $vendor->getPendingTodosCount(), + $status, + ]; + + $vendor->update(['last_checked_at' => now()]); + } + + $this->table( + ['Slug', 'Name', 'Type', 'Current', 'Previous', 'Pending', 'Status'], + $table + ); + + if ($this->option('assets')) { + $this->newLine(); + $this->info('Checking package assets...'); + + $results = $assetService->checkAllForUpdates(); + $assetTable = []; + + foreach ($results as $slug => $result) { + $statusIcon = match ($result['status']) { + 'success' => $result['has_update'] ?? false + ? 'Update available' + : 'Up to date', + 'rate_limited' => 'Rate limited', + 'skipped' => 'Skipped', + default => 'Error', + }; + + $assetTable[] = [ + $slug, + $result['latest'] ?? $result['installed'] ?? '-', + $statusIcon, + ]; + } + + $this->table(['Asset', 'Version', 'Status'], $assetTable); + } + + $this->newLine(); + $this->info('Check complete.'); + + return self::SUCCESS; + } +} diff --git a/Console/CheckUpdatesCommand.php b/Console/CheckUpdatesCommand.php new file mode 100644 index 0000000..a72b5be --- /dev/null +++ b/Console/CheckUpdatesCommand.php @@ -0,0 +1,202 @@ +option('vendor'); + $checkAssets = $this->option('assets'); + $jsonOutput = $this->option('json'); + + if (! $jsonOutput) { + $this->info('Checking for upstream updates...'); + $this->newLine(); + } + + // Check vendors + $vendorResults = $this->checkVendors($vendorChecker, $vendorSlug); + + // Check assets if requested + $assetResults = []; + if ($checkAssets) { + $assetResults = $this->checkAssets($assetChecker); + } + + // Output results + if ($jsonOutput) { + $this->outputJson($vendorResults, $assetResults); + } else { + $this->outputTable($vendorResults, $assetResults); + } + + // Return appropriate exit code + $hasUpdates = collect($vendorResults)->contains(fn ($r) => $r['has_update'] ?? false) + || collect($assetResults)->contains(fn ($r) => $r['has_update'] ?? false); + + return $hasUpdates ? self::SUCCESS : self::SUCCESS; + } + + /** + * Check vendors for updates. + */ + protected function checkVendors(VendorUpdateCheckerService $checker, ?string $vendorSlug): array + { + if ($vendorSlug) { + $vendor = Vendor::where('slug', $vendorSlug)->first(); + if (! $vendor) { + $this->error("Vendor not found: {$vendorSlug}"); + + return []; + } + + $this->line("Checking vendor: {$vendor->name}"); + + return [$vendor->slug => $checker->checkVendor($vendor)]; + } + + $vendors = Vendor::active()->get(); + if ($vendors->isEmpty()) { + $this->warn('No active vendors found.'); + + return []; + } + + $this->line("Checking {$vendors->count()} vendor(s)..."); + + return $checker->checkAllVendors(); + } + + /** + * Check assets for updates. + */ + protected function checkAssets(AssetTrackerService $checker): array + { + $this->newLine(); + $this->line('Checking package assets...'); + + return $checker->checkAllForUpdates(); + } + + /** + * Output results as a table. + */ + protected function outputTable(array $vendorResults, array $assetResults): void + { + if (! empty($vendorResults)) { + $this->newLine(); + $this->line('Vendor Update Check Results:'); + + $table = []; + foreach ($vendorResults as $slug => $result) { + $status = match ($result['status'] ?? 'unknown') { + 'success' => $result['has_update'] + ? 'Update available' + : 'Up to date', + 'skipped' => 'Skipped', + 'rate_limited' => 'Rate limited', + 'error' => 'Error', + default => 'Unknown', + }; + + $table[] = [ + $slug, + $result['current'] ?? '-', + $result['latest'] ?? '-', + $status, + $result['message'] ?? '', + ]; + } + + $this->table( + ['Vendor', 'Current', 'Latest', 'Status', 'Message'], + $table + ); + } + + if (! empty($assetResults)) { + $this->newLine(); + $this->line('Asset Update Check Results:'); + + $table = []; + foreach ($assetResults as $slug => $result) { + $status = match ($result['status'] ?? 'unknown') { + 'success' => $result['has_update'] ?? false + ? 'Update available' + : 'Up to date', + 'skipped' => 'Skipped', + 'rate_limited' => 'Rate limited', + 'info' => 'Info', + 'error' => 'Error', + default => 'Unknown', + }; + + $table[] = [ + $slug, + $result['installed'] ?? $result['latest'] ?? '-', + $status, + $result['message'] ?? '', + ]; + } + + $this->table( + ['Asset', 'Version', 'Status', 'Message'], + $table + ); + } + + // Summary + $this->newLine(); + $vendorUpdates = collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(); + $assetUpdates = collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(); + $totalUpdates = $vendorUpdates + $assetUpdates; + + if ($totalUpdates > 0) { + $this->warn("Found {$totalUpdates} update(s) available."); + } else { + $this->info('All vendors and assets are up to date.'); + } + } + + /** + * Output results as JSON. + */ + protected function outputJson(array $vendorResults, array $assetResults): void + { + $output = [ + 'vendors' => $vendorResults, + 'assets' => $assetResults, + 'summary' => [ + 'vendors_checked' => count($vendorResults), + 'vendors_with_updates' => collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(), + 'assets_checked' => count($assetResults), + 'assets_with_updates' => collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(), + ], + ]; + + $this->line(json_encode($output, JSON_PRETTY_PRINT)); + } +} diff --git a/Console/IssuesCommand.php b/Console/IssuesCommand.php new file mode 100644 index 0000000..158c86e --- /dev/null +++ b/Console/IssuesCommand.php @@ -0,0 +1,139 @@ +argument('vendor'); + $status = $this->option('status'); + $type = $this->option('type'); + $quickWins = $this->option('quick-wins'); + $limit = (int) $this->option('limit'); + + $query = UpstreamTodo::with('vendor'); + + // Filter by vendor + if ($vendorSlug) { + $vendor = Vendor::where('slug', $vendorSlug)->first(); + if (! $vendor) { + $this->error("Vendor not found: {$vendorSlug}"); + + return self::FAILURE; + } + $query->where('vendor_id', $vendor->id); + } + + // Filter by status + if ($status !== 'all') { + $query->where('status', $status); + } + + // Filter by type + if ($type) { + $query->where('type', $type); + } + + // Quick wins filter + if ($quickWins) { + $query->quickWins(); + } + + // Order by priority + $query->orderByDesc('priority')->orderByDesc('created_at'); + + $todos = $query->limit($limit)->get(); + + if ($todos->isEmpty()) { + $this->info('No issues found matching criteria.'); + + return self::SUCCESS; + } + + // Show summary stats + $this->line('Issue Summary:'); + $this->newLine(); + + $totalPending = UpstreamTodo::pending()->count(); + $totalInProgress = UpstreamTodo::inProgress()->count(); + $totalQuickWins = UpstreamTodo::quickWins()->count(); + $totalSecurity = UpstreamTodo::securityRelated()->pending()->count(); + + $this->line(" Pending: {$totalPending}"); + $this->line(" In Progress: {$totalInProgress}"); + $this->line(" Quick Wins: {$totalQuickWins}"); + $this->line(" Security: {$totalSecurity}"); + + $this->newLine(); + $this->line("Showing {$todos->count()} issues:"); + $this->newLine(); + + $table = []; + foreach ($todos as $todo) { + $icon = $todo->getTypeIcon(); + $priorityColor = match (true) { + $todo->priority >= 8 => 'red', + $todo->priority >= 5 => 'yellow', + default => 'gray', + }; + + $statusBadge = match ($todo->status) { + 'pending' => 'pending', + 'in_progress' => 'in progress', + 'ported' => 'ported', + 'skipped' => 'skipped', + 'wont_port' => 'wont port', + default => $todo->status, + }; + + $quickWinBadge = $todo->isQuickWin() ? ' [QW]' : ''; + + $table[] = [ + $todo->id, + $todo->vendor->slug, + "{$icon} {$todo->type}", + "{$todo->priority}", + $todo->effort, + mb_substr($todo->title, 0, 40).(mb_strlen($todo->title) > 40 ? '...' : '').$quickWinBadge, + $statusBadge, + $todo->github_issue_number ? "#{$todo->github_issue_number}" : '-', + ]; + } + + $this->table( + ['ID', 'Vendor', 'Type', 'Pri', 'Effort', 'Title', 'Status', 'Issue'], + $table + ); + + // Show vendor breakdown if not filtered + if (! $vendorSlug) { + $this->newLine(); + $this->line('By Vendor:'); + + $byVendor = $todos->groupBy(fn ($t) => $t->vendor->slug); + foreach ($byVendor as $slug => $vendorTodos) { + $quickWinCount = $vendorTodos->filter->isQuickWin()->count(); + $qwInfo = $quickWinCount > 0 ? " ({$quickWinCount} quick wins)" : ''; + $this->line(" {$slug}: {$vendorTodos->count()}{$qwInfo}"); + } + } + + return self::SUCCESS; + } +} diff --git a/Console/SendDigestsCommand.php b/Console/SendDigestsCommand.php new file mode 100644 index 0000000..6fed385 --- /dev/null +++ b/Console/SendDigestsCommand.php @@ -0,0 +1,146 @@ +option('frequency'); + $dryRun = $this->option('dry-run'); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No emails will be sent'); + $this->newLine(); + } + + $frequencies = $frequency + ? [$frequency] + : [ + UptelligenceDigest::FREQUENCY_DAILY, + UptelligenceDigest::FREQUENCY_WEEKLY, + UptelligenceDigest::FREQUENCY_MONTHLY, + ]; + + $totalStats = ['sent' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($frequencies as $freq) { + $this->processFrequency($freq, $dryRun, $totalStats); + } + + $this->newLine(); + $this->info('Digest processing complete.'); + $this->table( + ['Metric', 'Count'], + [ + ['Sent', $totalStats['sent']], + ['Skipped (no content)', $totalStats['skipped']], + ['Failed', $totalStats['failed']], + ] + ); + + return $totalStats['failed'] > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * Process digests for a specific frequency. + */ + protected function processFrequency(string $frequency, bool $dryRun, array &$totalStats): void + { + $this->info("Processing {$frequency} digests..."); + + $digests = UptelligenceDigest::dueForDigest($frequency) + ->with(['user', 'workspace']) + ->get(); + + if ($digests->isEmpty()) { + $this->line(" No {$frequency} digests due."); + + return; + } + + $this->line(" Found {$digests->count()} digest(s) to process."); + + foreach ($digests as $digest) { + $this->processDigest($digest, $dryRun, $totalStats); + } + } + + /** + * Process a single digest. + */ + protected function processDigest(UptelligenceDigest $digest, bool $dryRun, array &$totalStats): void + { + $email = $digest->user?->email ?? 'unknown'; + $workspaceName = $digest->workspace?->name ?? 'unknown'; + + // Skip if user or workspace deleted + if (! $digest->user || ! $digest->workspace) { + $this->warn(" Skipping digest {$digest->id} - user or workspace deleted"); + $digest->delete(); + $totalStats['skipped']++; + + return; + } + + // Generate content preview + $content = $this->digestService->generateDigestContent($digest); + + if (! $content['has_content']) { + $this->line(" Skipping {$email} ({$workspaceName}) - no content to report"); + + if (! $dryRun) { + $digest->markAsSent(); + } + + $totalStats['skipped']++; + + return; + } + + $releasesCount = $content['releases']->count(); + $todosCount = $content['todos']['total'] ?? 0; + $securityCount = $content['security_count']; + + $this->line(" Sending to {$email} ({$workspaceName}): {$releasesCount} releases, {$todosCount} todos, {$securityCount} security"); + + if ($dryRun) { + $totalStats['sent']++; + + return; + } + + try { + $this->digestService->sendDigest($digest); + $this->info(' Sent successfully'); + $totalStats['sent']++; + } catch (\Exception $e) { + $this->error(" Failed: {$e->getMessage()}"); + $totalStats['failed']++; + } + } +} diff --git a/Controllers/Api/WebhookController.php b/Controllers/Api/WebhookController.php new file mode 100644 index 0000000..a1cf114 --- /dev/null +++ b/Controllers/Api/WebhookController.php @@ -0,0 +1,268 @@ +isActive()) { + Log::warning('Uptelligence webhook received for disabled endpoint', [ + 'webhook_id' => $webhook->id, + 'vendor_id' => $webhook->vendor_id, + ]); + + return response('Webhook disabled', 403); + } + + // Check circuit breaker + if ($webhook->isCircuitBroken()) { + Log::warning('Uptelligence webhook endpoint circuit breaker open', [ + 'webhook_id' => $webhook->id, + 'failure_count' => $webhook->failure_count, + ]); + + return response('Service unavailable', 503); + } + + // Get raw payload + $payload = $request->getContent(); + + // Verify signature + $signature = $this->extractSignature($request, $webhook->provider); + $signatureStatus = $this->service->verifySignature($webhook, $payload, $signature); + + if ($signatureStatus === UptelligenceWebhookDelivery::SIGNATURE_INVALID) { + Log::warning('Uptelligence webhook signature verification failed', [ + 'webhook_id' => $webhook->id, + 'vendor_id' => $webhook->vendor_id, + 'source_ip' => $request->ip(), + ]); + + return response('Invalid signature', 401); + } + + // Parse JSON payload + $data = json_decode($payload, true); + if (json_last_error() !== JSON_ERROR_NONE) { + Log::warning('Uptelligence webhook invalid JSON payload', [ + 'webhook_id' => $webhook->id, + 'error' => json_last_error_msg(), + ]); + + return response('Invalid JSON payload', 400); + } + + // Determine event type + $eventType = $this->determineEventType($request, $data, $webhook->provider); + + // Create delivery log + $delivery = UptelligenceWebhookDelivery::create([ + 'webhook_id' => $webhook->id, + 'vendor_id' => $webhook->vendor_id, + 'event_type' => $eventType, + 'provider' => $webhook->provider, + 'payload' => $data, + 'status' => UptelligenceWebhookDelivery::STATUS_PENDING, + 'source_ip' => $request->ip(), + 'signature_status' => $signatureStatus, + ]); + + Log::info('Uptelligence webhook received', [ + 'delivery_id' => $delivery->id, + 'webhook_id' => $webhook->id, + 'vendor_id' => $webhook->vendor_id, + 'event_type' => $eventType, + ]); + + // Update webhook last received timestamp + $webhook->markReceived(); + + // Dispatch job for async processing + ProcessUptelligenceWebhook::dispatch($delivery); + + return response('Accepted', 202); + } + + /** + * Extract signature from request headers based on provider. + */ + protected function extractSignature(Request $request, string $provider): ?string + { + return match ($provider) { + UptelligenceWebhook::PROVIDER_GITHUB => $this->extractGitHubSignature($request), + UptelligenceWebhook::PROVIDER_GITLAB => $request->header('X-Gitlab-Token'), + UptelligenceWebhook::PROVIDER_NPM => $request->header('X-Npm-Signature'), + UptelligenceWebhook::PROVIDER_PACKAGIST => $request->header('X-Hub-Signature'), + default => $this->extractGenericSignature($request), + }; + } + + /** + * Extract GitHub signature (prefers SHA-256). + */ + protected function extractGitHubSignature(Request $request): ?string + { + // Prefer SHA-256 + $signature = $request->header('X-Hub-Signature-256'); + if ($signature) { + return $signature; + } + + // Fall back to SHA-1 (legacy) + return $request->header('X-Hub-Signature'); + } + + /** + * Extract signature from generic headers. + */ + protected function extractGenericSignature(Request $request): ?string + { + $signatureHeaders = [ + 'X-Signature', + 'X-Hub-Signature-256', + 'X-Hub-Signature', + 'X-Webhook-Signature', + 'Signature', + ]; + + foreach ($signatureHeaders as $header) { + $value = $request->header($header); + if ($value) { + return $value; + } + } + + return null; + } + + /** + * Determine the event type from request and payload. + */ + protected function determineEventType(Request $request, array $data, string $provider): string + { + return match ($provider) { + UptelligenceWebhook::PROVIDER_GITHUB => $this->determineGitHubEventType($request, $data), + UptelligenceWebhook::PROVIDER_GITLAB => $this->determineGitLabEventType($request, $data), + UptelligenceWebhook::PROVIDER_NPM => $this->determineNpmEventType($data), + UptelligenceWebhook::PROVIDER_PACKAGIST => $this->determinePackagistEventType($data), + default => $this->determineGenericEventType($request, $data), + }; + } + + /** + * Determine GitHub event type. + */ + protected function determineGitHubEventType(Request $request, array $data): string + { + $event = $request->header('X-GitHub-Event', 'unknown'); + $action = $data['action'] ?? 'unknown'; + + return "github.{$event}.{$action}"; + } + + /** + * Determine GitLab event type. + */ + protected function determineGitLabEventType(Request $request, array $data): string + { + $objectKind = $data['object_kind'] ?? 'unknown'; + $action = $data['action'] ?? 'unknown'; + + return "gitlab.{$objectKind}.{$action}"; + } + + /** + * Determine npm event type. + */ + protected function determineNpmEventType(array $data): string + { + $event = $data['event'] ?? 'package:unknown'; + $normalised = str_replace(':', '.', $event); + + return "npm.{$normalised}"; + } + + /** + * Determine Packagist event type. + */ + protected function determinePackagistEventType(array $data): string + { + // Packagist webhooks typically indicate an update + return 'packagist.package.update'; + } + + /** + * Determine generic event type. + */ + protected function determineGenericEventType(Request $request, array $data): string + { + // Check headers + $eventType = $request->header('X-Event-Type') + ?? $request->header('X-Webhook-Event'); + + if ($eventType) { + return "custom.{$eventType}"; + } + + // Check payload + $event = $data['event'] + ?? $data['event_type'] + ?? $data['action'] + ?? 'unknown'; + + return "custom.{$event}"; + } + + /** + * Test endpoint to verify webhook configuration. + * + * POST /api/uptelligence/webhook/{webhook}/test + */ + public function test(Request $request, UptelligenceWebhook $webhook): Response + { + // This endpoint is for testing - requires the webhook to exist + // and optionally verifies signature + + $payload = $request->getContent(); + $signature = $this->extractSignature($request, $webhook->provider); + $signatureStatus = $this->service->verifySignature($webhook, $payload, $signature); + + return response()->json([ + 'status' => 'ok', + 'webhook_id' => $webhook->uuid, + 'vendor_id' => $webhook->vendor_id, + 'provider' => $webhook->provider, + 'is_active' => $webhook->is_active, + 'signature_status' => $signatureStatus, + 'has_secret' => ! empty($webhook->secret), + ]); + } +} diff --git a/Jobs/CheckVendorUpdatesJob.php b/Jobs/CheckVendorUpdatesJob.php new file mode 100644 index 0000000..8287f6a --- /dev/null +++ b/Jobs/CheckVendorUpdatesJob.php @@ -0,0 +1,143 @@ +checkAssets = $checkAssets; + $this->vendorSlug = $vendorSlug; + $this->onQueue('default'); + } + + /** + * Execute the job. + */ + public function handle( + VendorUpdateCheckerService $vendorChecker, + AssetTrackerService $assetChecker + ): void { + $vendorResults = $this->checkVendors($vendorChecker); + $assetResults = $this->checkAssets ? $this->checkAssets($assetChecker) : []; + + $this->logSummary($vendorResults, $assetResults); + } + + /** + * Check vendors for updates. + */ + protected function checkVendors(VendorUpdateCheckerService $checker): array + { + if ($this->vendorSlug) { + $vendor = Vendor::where('slug', $this->vendorSlug)->first(); + if (! $vendor) { + Log::warning('Uptelligence: Vendor not found for update check', [ + 'slug' => $this->vendorSlug, + ]); + + return []; + } + + return [$vendor->slug => $checker->checkVendor($vendor)]; + } + + return $checker->checkAllVendors(); + } + + /** + * Check assets for updates. + */ + protected function checkAssets(AssetTrackerService $checker): array + { + return $checker->checkAllForUpdates(); + } + + /** + * Log a summary of the check results. + */ + protected function logSummary(array $vendorResults, array $assetResults): void + { + $vendorUpdates = collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(); + $vendorErrors = collect($vendorResults)->filter(fn ($r) => ($r['status'] ?? '') === 'error')->count(); + $vendorSkipped = collect($vendorResults)->filter(fn ($r) => ($r['status'] ?? '') === 'skipped')->count(); + + $assetUpdates = collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(); + $assetErrors = collect($assetResults)->filter(fn ($r) => ($r['status'] ?? '') === 'error')->count(); + + Log::info('Uptelligence: Update check complete', [ + 'vendors_checked' => count($vendorResults), + 'vendors_with_updates' => $vendorUpdates, + 'vendors_skipped' => $vendorSkipped, + 'vendor_errors' => $vendorErrors, + 'assets_checked' => count($assetResults), + 'assets_with_updates' => $assetUpdates, + 'asset_errors' => $assetErrors, + ]); + + // Log individual updates found + foreach ($vendorResults as $slug => $result) { + if ($result['has_update'] ?? false) { + Log::info("Uptelligence: Vendor update available - {$slug}", [ + 'current' => $result['current'] ?? 'unknown', + 'latest' => $result['latest'] ?? 'unknown', + ]); + } + } + + foreach ($assetResults as $slug => $result) { + if ($result['has_update'] ?? false) { + Log::info("Uptelligence: Asset update available - {$slug}", [ + 'latest' => $result['latest'] ?? 'unknown', + ]); + } + } + } + + /** + * Get the tags that should be assigned to the job. + */ + public function tags(): array + { + $tags = ['uptelligence', 'update-check']; + + if ($this->vendorSlug) { + $tags[] = "vendor:{$this->vendorSlug}"; + } + + return $tags; + } +} diff --git a/Jobs/ProcessUptelligenceWebhook.php b/Jobs/ProcessUptelligenceWebhook.php new file mode 100644 index 0000000..c028c16 --- /dev/null +++ b/Jobs/ProcessUptelligenceWebhook.php @@ -0,0 +1,198 @@ +onQueue('uptelligence-webhooks'); + } + + /** + * Execute the job. + */ + public function handle(WebhookReceiverService $service): void + { + $this->delivery->markProcessing(); + + Log::info('Processing Uptelligence webhook', [ + 'delivery_id' => $this->delivery->id, + 'webhook_id' => $this->delivery->webhook_id, + 'vendor_id' => $this->delivery->vendor_id, + 'event_type' => $this->delivery->event_type, + ]); + + try { + // Get webhook and vendor + $webhook = $this->delivery->webhook; + $vendor = $this->delivery->vendor; + + if (! $webhook || ! $vendor) { + throw new \RuntimeException('Webhook or vendor not found'); + } + + // Parse the payload + $parsedData = $service->parsePayload( + $this->delivery->provider, + $this->delivery->payload + ); + + if (! $parsedData) { + $this->delivery->markSkipped('Not a release event or unable to parse'); + Log::info('Uptelligence webhook skipped (not a release event)', [ + 'delivery_id' => $this->delivery->id, + ]); + + return; + } + + // Process the release + $result = $service->processRelease( + $this->delivery, + $vendor, + $parsedData + ); + + // Update delivery record + $this->delivery->update([ + 'version' => $parsedData['version'] ?? null, + 'tag_name' => $parsedData['tag_name'] ?? null, + 'parsed_data' => $parsedData, + ]); + + // Mark as completed + $this->delivery->markCompleted($parsedData); + + // Reset failure count on webhook + $webhook->resetFailureCount(); + + // Send notification if new release was created + if ($result['action'] === 'created') { + $this->sendReleaseNotification($vendor, $parsedData, $result); + } + + Log::info('Uptelligence webhook processed successfully', [ + 'delivery_id' => $this->delivery->id, + 'action' => $result['action'], + 'version' => $result['version'] ?? null, + 'release_id' => $result['release_id'] ?? null, + ]); + } catch (\Exception $e) { + $this->handleFailure($e); + throw $e; + } + } + + /** + * Send notification when a new release is detected. + */ + protected function sendReleaseNotification( + \Core\Uptelligence\Models\Vendor $vendor, + array $parsedData, + array $result + ): void { + try { + // Get users subscribed to digest notifications for this vendor + $digests = \Core\Uptelligence\Models\UptelligenceDigest::where('is_enabled', true) + ->with('user') + ->get(); + + foreach ($digests as $digest) { + // Check if this digest includes releases and this vendor + if ($digest->user && $digest->includesReleases() && $digest->includesVendor($vendor->id)) { + $digest->user->notify(new NewReleaseDetected( + vendor: $vendor, + version: $parsedData['version'], + releaseData: $parsedData, + )); + } + } + } catch (\Exception $e) { + // Don't fail the webhook processing if notification fails + Log::warning('Failed to send release notification', [ + 'delivery_id' => $this->delivery->id, + 'vendor_id' => $vendor->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Handle job failure. + */ + protected function handleFailure(\Exception $e): void + { + $this->delivery->markFailed($e->getMessage()); + + // Increment failure count on webhook + if ($webhook = $this->delivery->webhook) { + $webhook->incrementFailureCount(); + } + + Log::error('Uptelligence webhook processing failed', [ + 'delivery_id' => $this->delivery->id, + 'webhook_id' => $this->delivery->webhook_id, + 'error' => $e->getMessage(), + 'attempts' => $this->attempts(), + ]); + } + + /** + * Handle a job failure (called by Laravel). + */ + public function failed(\Throwable $exception): void + { + Log::error('Uptelligence webhook job failed permanently', [ + 'delivery_id' => $this->delivery->id, + 'webhook_id' => $this->delivery->webhook_id, + 'error' => $exception->getMessage(), + 'attempts' => $this->attempts(), + ]); + + $this->delivery->markFailed( + "Processing failed after {$this->attempts()} attempts: {$exception->getMessage()}" + ); + } +} diff --git a/Models/AnalysisLog.php b/Models/AnalysisLog.php new file mode 100644 index 0000000..de677eb --- /dev/null +++ b/Models/AnalysisLog.php @@ -0,0 +1,187 @@ + 'array', + ]; + + // Relationships + public function vendor(): BelongsTo + { + return $this->belongsTo(Vendor::class); + } + + public function versionRelease(): BelongsTo + { + return $this->belongsTo(VersionRelease::class); + } + + // Scopes + public function scopeErrors($query) + { + return $query->whereNotNull('error_message'); + } + + public function scopeRecent($query, int $limit = 50) + { + return $query->latest()->limit($limit); + } + + public function scopeByAction($query, string $action) + { + return $query->where('action', $action); + } + + // Factory methods + public static function logVersionDetected(Vendor $vendor, string $version, ?string $previousVersion = null): self + { + return self::create([ + 'vendor_id' => $vendor->id, + 'action' => self::ACTION_VERSION_DETECTED, + 'context' => [ + 'version' => $version, + 'previous_version' => $previousVersion, + ], + ]); + } + + public static function logAnalysisStarted(VersionRelease $release): self + { + return self::create([ + 'vendor_id' => $release->vendor_id, + 'version_release_id' => $release->id, + 'action' => self::ACTION_ANALYSIS_STARTED, + 'context' => [ + 'version' => $release->version, + ], + ]); + } + + public static function logAnalysisCompleted(VersionRelease $release, array $stats): self + { + return self::create([ + 'vendor_id' => $release->vendor_id, + 'version_release_id' => $release->id, + 'action' => self::ACTION_ANALYSIS_COMPLETED, + 'context' => $stats, + ]); + } + + public static function logAnalysisFailed(VersionRelease $release, string $error): self + { + return self::create([ + 'vendor_id' => $release->vendor_id, + 'version_release_id' => $release->id, + 'action' => self::ACTION_ANALYSIS_FAILED, + 'error_message' => $error, + ]); + } + + public static function logTodoCreated(UpstreamTodo $todo): self + { + return self::create([ + 'vendor_id' => $todo->vendor_id, + 'action' => self::ACTION_TODO_CREATED, + 'context' => [ + 'todo_id' => $todo->id, + 'title' => $todo->title, + 'type' => $todo->type, + 'priority' => $todo->priority, + ], + ]); + } + + public static function logIssueCreated(UpstreamTodo $todo, string $issueUrl): self + { + return self::create([ + 'vendor_id' => $todo->vendor_id, + 'action' => self::ACTION_ISSUE_CREATED, + 'context' => [ + 'todo_id' => $todo->id, + 'issue_url' => $issueUrl, + 'issue_number' => $todo->github_issue_number, + ], + ]); + } + + // Helpers + public function isError(): bool + { + return $this->error_message !== null; + } + + public function getActionIcon(): string + { + return match ($this->action) { + self::ACTION_VERSION_DETECTED => '📦', + self::ACTION_ANALYSIS_STARTED => '🔍', + self::ACTION_ANALYSIS_COMPLETED => '✅', + self::ACTION_ANALYSIS_FAILED => '❌', + self::ACTION_TODO_CREATED => '📝', + self::ACTION_TODO_UPDATED => '✏️', + self::ACTION_ISSUE_CREATED => '🎫', + self::ACTION_PORT_STARTED => '🚀', + self::ACTION_PORT_COMPLETED => '🎉', + default => '📌', + }; + } + + public function getActionLabel(): string + { + return match ($this->action) { + self::ACTION_VERSION_DETECTED => 'New Version Detected', + self::ACTION_ANALYSIS_STARTED => 'Analysis Started', + self::ACTION_ANALYSIS_COMPLETED => 'Analysis Completed', + self::ACTION_ANALYSIS_FAILED => 'Analysis Failed', + self::ACTION_TODO_CREATED => 'Todo Created', + self::ACTION_TODO_UPDATED => 'Todo Updated', + self::ACTION_ISSUE_CREATED => 'Issue Created', + self::ACTION_PORT_STARTED => 'Port Started', + self::ACTION_PORT_COMPLETED => 'Port Completed', + default => ucfirst(str_replace('_', ' ', $this->action)), + }; + } +} diff --git a/Models/Asset.php b/Models/Asset.php new file mode 100644 index 0000000..f2bb037 --- /dev/null +++ b/Models/Asset.php @@ -0,0 +1,214 @@ + 'array', + 'build_config' => 'array', + 'used_in_projects' => 'array', + 'licence_expires_at' => 'date', + 'last_checked_at' => 'datetime', + 'auto_update' => 'boolean', + 'is_active' => 'boolean', + ]; + + // Relationships + public function versions(): HasMany + { + return $this->hasMany(AssetVersion::class); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeComposer($query) + { + return $query->where('type', self::TYPE_COMPOSER); + } + + public function scopeNpm($query) + { + return $query->where('type', self::TYPE_NPM); + } + + public function scopeNeedsUpdate($query) + { + return $query->whereColumn('installed_version', '!=', 'latest_version') + ->whereNotNull('latest_version'); + } + + public function scopeAutoUpdate($query) + { + return $query->where('auto_update', true); + } + + // Helpers + public function hasUpdate(): bool + { + return $this->latest_version + && $this->installed_version + && version_compare($this->latest_version, $this->installed_version, '>'); + } + + public function isLicenceExpired(): bool + { + return $this->licence_expires_at && $this->licence_expires_at->isPast(); + } + + public function isLicenceExpiringSoon(int $days = 30): bool + { + return $this->licence_expires_at + && $this->licence_expires_at->isFuture() + && $this->licence_expires_at->diffInDays(now()) <= $days; + } + + public function getTypeIcon(): string + { + return match ($this->type) { + self::TYPE_COMPOSER => '📦', + self::TYPE_NPM => '📦', + self::TYPE_FONT => '🔤', + self::TYPE_THEME => '🎨', + self::TYPE_CDN => '🌐', + self::TYPE_MANUAL => '📁', + default => '📄', + }; + } + + public function getTypeLabel(): string + { + return match ($this->type) { + self::TYPE_COMPOSER => 'Composer', + self::TYPE_NPM => 'NPM', + self::TYPE_FONT => 'Font', + self::TYPE_THEME => 'Theme', + self::TYPE_CDN => 'CDN', + self::TYPE_MANUAL => 'Manual', + default => ucfirst($this->type), + }; + } + + public function getLicenceIcon(): string + { + if ($this->isLicenceExpired()) { + return '🔴'; + } + if ($this->isLicenceExpiringSoon()) { + return '🟡'; + } + + return match ($this->licence_type) { + self::LICENCE_LIFETIME => '♾️', + self::LICENCE_SUBSCRIPTION => '🔄', + self::LICENCE_OSS => '🌐', + self::LICENCE_TRIAL => '⏳', + default => '📄', + }; + } + + public function getInstallCommand(): ?string + { + if (! $this->package_name) { + return null; + } + + return match ($this->type) { + self::TYPE_COMPOSER => "composer require {$this->package_name}", + self::TYPE_NPM => "npm install {$this->package_name}", + default => null, + }; + } + + public function getUpdateCommand(): ?string + { + if (! $this->package_name) { + return null; + } + + return match ($this->type) { + self::TYPE_COMPOSER => "composer update {$this->package_name}", + self::TYPE_NPM => "npm update {$this->package_name}", + default => null, + }; + } + + // For MCP context + public function toMcpContext(): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'type' => $this->type, + 'package' => $this->package_name, + 'version' => $this->installed_version, + 'latest' => $this->latest_version, + 'has_update' => $this->hasUpdate(), + 'licence' => $this->licence_type, + 'install_path' => $this->install_path, + 'install_command' => $this->getInstallCommand(), + 'setup_notes' => $this->setup_notes, + 'build_config' => $this->build_config, + ]; + } +} diff --git a/Models/AssetVersion.php b/Models/AssetVersion.php new file mode 100644 index 0000000..104c12a --- /dev/null +++ b/Models/AssetVersion.php @@ -0,0 +1,49 @@ + 'array', + 'released_at' => 'datetime', + ]; + + public function asset(): BelongsTo + { + return $this->belongsTo(Asset::class); + } + + public function hasBreakingChanges(): bool + { + return ! empty($this->breaking_changes); + } + + public function isStored(): bool + { + return $this->local_path && file_exists($this->local_path); + } +} diff --git a/Models/DiffCache.php b/Models/DiffCache.php new file mode 100644 index 0000000..877b2ad --- /dev/null +++ b/Models/DiffCache.php @@ -0,0 +1,251 @@ +belongsTo(VersionRelease::class); + } + + // Scopes + public function scopeAdded($query) + { + return $query->where('change_type', self::CHANGE_ADDED); + } + + public function scopeModified($query) + { + return $query->where('change_type', self::CHANGE_MODIFIED); + } + + public function scopeRemoved($query) + { + return $query->where('change_type', self::CHANGE_REMOVED); + } + + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeSecurityRelated($query) + { + return $query->where('category', self::CATEGORY_SECURITY); + } + + // Helpers + public static function detectCategory(string $filePath): string + { + $path = strtolower($filePath); + + // Security-related files + if (str_contains($path, 'security') || + str_contains($path, 'auth') || + str_contains($path, 'password') || + str_contains($path, 'permission') || + str_contains($path, 'middleware')) { + return self::CATEGORY_SECURITY; + } + + // Controllers + if (str_contains($path, '/controllers/') || str_ends_with($path, 'controller.php')) { + return self::CATEGORY_CONTROLLER; + } + + // Models + if (str_contains($path, '/models/') || str_ends_with($path, 'model.php')) { + return self::CATEGORY_MODEL; + } + + // Views/Templates + if (str_contains($path, '/views/') || + str_contains($path, '/themes/') || + str_ends_with($path, '.blade.php')) { + return self::CATEGORY_VIEW; + } + + // Migrations + if (str_contains($path, '/migrations/') || str_contains($path, '/database/')) { + return self::CATEGORY_MIGRATION; + } + + // Config + if (str_contains($path, '/config/') || str_ends_with($path, 'config.php')) { + return self::CATEGORY_CONFIG; + } + + // Routes + if (str_contains($path, '/routes/') || str_ends_with($path, 'routes.php')) { + return self::CATEGORY_ROUTE; + } + + // Languages + if (str_contains($path, '/languages/') || str_contains($path, '/lang/')) { + return self::CATEGORY_LANGUAGE; + } + + // Assets + if (preg_match('/\.(css|js|scss|less|png|jpg|gif|svg|woff|ttf)$/', $path)) { + return self::CATEGORY_ASSET; + } + + // Plugins + if (str_contains($path, '/plugins/')) { + return self::CATEGORY_PLUGIN; + } + + // Blocks (BioLinks specific) + if (str_contains($path, '/blocks/') || str_contains($path, 'biolink')) { + return self::CATEGORY_BLOCK; + } + + // API + if (str_contains($path, '/api/') || str_contains($path, 'api.php')) { + return self::CATEGORY_API; + } + + return self::CATEGORY_OTHER; + } + + public function getFileName(): string + { + return basename($this->file_path); + } + + public function getDirectory(): string + { + return dirname($this->file_path); + } + + public function getExtension(): string + { + return pathinfo($this->file_path, PATHINFO_EXTENSION); + } + + public function getDiffLineCount(): int + { + if (! $this->diff_content) { + return 0; + } + + return substr_count($this->diff_content, "\n") + 1; + } + + public function getAddedLines(): int + { + if (! $this->diff_content) { + return 0; + } + + return preg_match_all('/^\+[^+]/m', $this->diff_content); + } + + public function getRemovedLines(): int + { + if (! $this->diff_content) { + return 0; + } + + return preg_match_all('/^-[^-]/m', $this->diff_content); + } + + public function getChangeTypeIcon(): string + { + return match ($this->change_type) { + self::CHANGE_ADDED => '➕', + self::CHANGE_MODIFIED => '✏️', + self::CHANGE_REMOVED => '➖', + default => '📄', + }; + } + + public function getChangeTypeBadgeClass(): string + { + return match ($this->change_type) { + self::CHANGE_ADDED => 'bg-green-100 text-green-800', + self::CHANGE_MODIFIED => 'bg-blue-100 text-blue-800', + self::CHANGE_REMOVED => 'bg-red-100 text-red-800', + default => 'bg-gray-100 text-gray-800', + }; + } + + public function getCategoryIcon(): string + { + return match ($this->category) { + self::CATEGORY_CONTROLLER => '🎮', + self::CATEGORY_MODEL => '📊', + self::CATEGORY_VIEW => '👁️', + self::CATEGORY_MIGRATION => '🗄️', + self::CATEGORY_CONFIG => '⚙️', + self::CATEGORY_ROUTE => '🛤️', + self::CATEGORY_LANGUAGE => '🌐', + self::CATEGORY_ASSET => '🎨', + self::CATEGORY_PLUGIN => '🔌', + self::CATEGORY_BLOCK => '🧱', + self::CATEGORY_SECURITY => '🔒', + self::CATEGORY_API => '🔌', + default => '📄', + }; + } +} diff --git a/Models/Pattern.php b/Models/Pattern.php new file mode 100644 index 0000000..70af7c2 --- /dev/null +++ b/Models/Pattern.php @@ -0,0 +1,172 @@ + 'array', + 'required_assets' => 'array', + 'quality_score' => 'decimal:2', + 'is_vetted' => 'boolean', + 'is_active' => 'boolean', + ]; + + // Relationships + public function variants(): HasMany + { + return $this->hasMany(PatternVariant::class); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeVetted($query) + { + return $query->where('is_vetted', true); + } + + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeLanguage($query, string $language) + { + return $query->where('language', $language); + } + + public function scopeWithTag($query, string $tag) + { + return $query->whereJsonContains('tags', $tag); + } + + public function scopeSearch($query, string $search) + { + return $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhereJsonContains('tags', $search); + }); + } + + // Helpers + public function getCategoryIcon(): string + { + return match ($this->category) { + self::CATEGORY_COMPONENT => '🧩', + self::CATEGORY_LAYOUT => '📐', + self::CATEGORY_THEME => '🎨', + self::CATEGORY_SNIPPET => '📝', + self::CATEGORY_WORKFLOW => '⚙️', + self::CATEGORY_TEMPLATE => '📄', + default => '📦', + }; + } + + public function getLanguageIcon(): string + { + return match ($this->language) { + 'blade' => '🔹', + 'vue' => '💚', + 'react' => '⚛️', + 'css' => '🎨', + 'php' => '🐘', + 'javascript', 'js' => '💛', + 'typescript', 'ts' => '💙', + default => '📄', + }; + } + + public function incrementUsage(): void + { + $this->increment('usage_count'); + } + + public function getRequiredAssetsObjects(): array + { + if (empty($this->required_assets)) { + return []; + } + + return Asset::whereIn('slug', $this->required_assets)->get()->all(); + } + + // For MCP context + public function toMcpContext(): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'category' => $this->category, + 'language' => $this->language, + 'description' => $this->description, + 'tags' => $this->tags, + 'code' => $this->code, + 'usage_example' => $this->usage_example, + 'required_assets' => $this->required_assets, + 'source' => $this->source_type, + 'is_vetted' => $this->is_vetted, + 'variants' => $this->variants->map(fn ($v) => [ + 'name' => $v->name, + 'code' => $v->code, + 'notes' => $v->notes, + ])->all(), + ]; + } +} diff --git a/Models/PatternCollection.php b/Models/PatternCollection.php new file mode 100644 index 0000000..259aa07 --- /dev/null +++ b/Models/PatternCollection.php @@ -0,0 +1,70 @@ + 'array', + 'required_assets' => 'array', + 'is_active' => 'boolean', + ]; + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + // Helpers + public function getPatterns() + { + if (empty($this->pattern_ids)) { + return collect(); + } + + return Pattern::whereIn('id', $this->pattern_ids)->get(); + } + + public function getRequiredAssetsObjects(): array + { + if (empty($this->required_assets)) { + return []; + } + + return Asset::whereIn('slug', $this->required_assets)->get()->all(); + } + + // For MCP context + public function toMcpContext(): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'patterns' => $this->getPatterns()->map(fn ($p) => $p->toMcpContext())->all(), + 'required_assets' => $this->required_assets, + ]; + } +} diff --git a/Models/PatternVariant.php b/Models/PatternVariant.php new file mode 100644 index 0000000..667c86f --- /dev/null +++ b/Models/PatternVariant.php @@ -0,0 +1,31 @@ +belongsTo(Pattern::class); + } +} diff --git a/Models/UpstreamTodo.php b/Models/UpstreamTodo.php new file mode 100644 index 0000000..e73ac7d --- /dev/null +++ b/Models/UpstreamTodo.php @@ -0,0 +1,241 @@ + 'array', + 'dependencies' => 'array', + 'tags' => 'array', + 'ai_analysis' => 'array', + 'ai_confidence' => 'decimal:2', + 'has_conflicts' => 'boolean', + 'priority' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + // Relationships + public function vendor(): BelongsTo + { + return $this->belongsTo(Vendor::class); + } + + // Scopes + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeInProgress($query) + { + return $query->where('status', self::STATUS_IN_PROGRESS); + } + + public function scopeCompleted($query) + { + return $query->whereIn('status', [self::STATUS_PORTED, self::STATUS_SKIPPED, self::STATUS_WONT_PORT]); + } + + public function scopeQuickWins($query) + { + return $query->where('status', self::STATUS_PENDING) + ->where('effort', self::EFFORT_LOW) + ->where('priority', '>=', 5) + ->orderByDesc('priority'); + } + + public function scopeHighPriority($query) + { + return $query->where('priority', '>=', 7); + } + + public function scopeByType($query, string $type) + { + return $query->where('type', $type); + } + + public function scopeSecurityRelated($query) + { + return $query->where('type', self::TYPE_SECURITY); + } + + // Helpers + public function isQuickWin(): bool + { + return $this->effort === self::EFFORT_LOW && $this->priority >= 5; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isCompleted(): bool + { + return in_array($this->status, [self::STATUS_PORTED, self::STATUS_SKIPPED, self::STATUS_WONT_PORT]); + } + + public function markInProgress(): void + { + $this->update([ + 'status' => self::STATUS_IN_PROGRESS, + 'started_at' => now(), + ]); + } + + public function markPorted(?string $notes = null): void + { + $this->update([ + 'status' => self::STATUS_PORTED, + 'completed_at' => now(), + 'port_notes' => $notes ?? $this->port_notes, + ]); + } + + public function markSkipped(?string $reason = null): void + { + $this->update([ + 'status' => self::STATUS_SKIPPED, + 'completed_at' => now(), + 'port_notes' => $reason ?? $this->port_notes, + ]); + } + + public function markWontPort(?string $reason = null): void + { + $this->update([ + 'status' => self::STATUS_WONT_PORT, + 'completed_at' => now(), + 'port_notes' => $reason ?? $this->port_notes, + ]); + } + + public function getFilesCount(): int + { + return count($this->files ?? []); + } + + public function getPriorityLabel(): string + { + return match (true) { + $this->priority >= 8 => 'Critical', + $this->priority >= 6 => 'High', + $this->priority >= 4 => 'Medium', + default => 'Low', + }; + } + + public function getEffortLabel(): string + { + return match ($this->effort) { + self::EFFORT_LOW => '< 1 hour', + self::EFFORT_MEDIUM => '1-4 hours', + self::EFFORT_HIGH => '4+ hours', + default => 'Unknown', + }; + } + + public function getTypeIcon(): string + { + return match ($this->type) { + self::TYPE_FEATURE => '✨', + self::TYPE_BUGFIX => '🐛', + self::TYPE_SECURITY => '🔒', + self::TYPE_UI => '🎨', + self::TYPE_BLOCK => '🧱', + self::TYPE_API => '🔌', + self::TYPE_REFACTOR => '♻️', + self::TYPE_DEPENDENCY => '📦', + default => '📝', + }; + } + + public function getStatusBadgeClass(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800', + self::STATUS_IN_PROGRESS => 'bg-blue-100 text-blue-800', + self::STATUS_PORTED => 'bg-green-100 text-green-800', + self::STATUS_SKIPPED => 'bg-gray-100 text-gray-800', + self::STATUS_WONT_PORT => 'bg-red-100 text-red-800', + default => 'bg-gray-100 text-gray-800', + }; + } +} diff --git a/Models/UptelligenceDigest.php b/Models/UptelligenceDigest.php new file mode 100644 index 0000000..72cb959 --- /dev/null +++ b/Models/UptelligenceDigest.php @@ -0,0 +1,285 @@ + 'array', + 'is_enabled' => 'boolean', + 'last_sent_at' => 'datetime', + ]; + + protected $attributes = [ + 'frequency' => self::FREQUENCY_WEEKLY, + 'is_enabled' => true, + ]; + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Scope to enabled digests only. + */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('is_enabled', true); + } + + /** + * Scope to digests with specific frequency. + */ + public function scopeWithFrequency(Builder $query, string $frequency): Builder + { + return $query->where('frequency', $frequency); + } + + /** + * Scope to digests that are due to be sent. + * + * Daily: last_sent_at is null or older than 24 hours + * Weekly: last_sent_at is null or older than 7 days + * Monthly: last_sent_at is null or older than 30 days + */ + public function scopeDueForDigest(Builder $query, string $frequency): Builder + { + $cutoff = match ($frequency) { + self::FREQUENCY_DAILY => now()->subDay(), + self::FREQUENCY_WEEKLY => now()->subWeek(), + self::FREQUENCY_MONTHLY => now()->subMonth(), + default => now()->subWeek(), + }; + + return $query->enabled() + ->withFrequency($frequency) + ->where(function (Builder $q) use ($cutoff) { + $q->whereNull('last_sent_at') + ->orWhere('last_sent_at', '<=', $cutoff); + }); + } + + // ------------------------------------------------------------------------- + // Preferences Helpers + // ------------------------------------------------------------------------- + + /** + * Get the list of vendor IDs to include in the digest. + * Returns null if all vendors should be included. + */ + public function getVendorIds(): ?array + { + return $this->preferences['vendor_ids'] ?? null; + } + + /** + * Set the vendor IDs to include in the digest. + */ + public function setVendorIds(?array $vendorIds): void + { + $this->preferences = array_merge($this->preferences ?? [], [ + 'vendor_ids' => $vendorIds, + ]); + } + + /** + * Check if a specific vendor should be included in the digest. + */ + public function includesVendor(int $vendorId): bool + { + $vendorIds = $this->getVendorIds(); + + // If no filter set, include all vendors + if ($vendorIds === null) { + return true; + } + + return in_array($vendorId, $vendorIds); + } + + /** + * Get the update types to include (releases, todos, security). + * Returns all types if not specified. + */ + public function getIncludedTypes(): array + { + return $this->preferences['include_types'] ?? [ + 'releases', + 'todos', + 'security', + ]; + } + + /** + * Set which update types to include. + */ + public function setIncludedTypes(array $types): void + { + $this->preferences = array_merge($this->preferences ?? [], [ + 'include_types' => $types, + ]); + } + + /** + * Check if releases should be included. + */ + public function includesReleases(): bool + { + return in_array('releases', $this->getIncludedTypes()); + } + + /** + * Check if todos should be included. + */ + public function includesTodos(): bool + { + return in_array('todos', $this->getIncludedTypes()); + } + + /** + * Check if security updates should be highlighted. + */ + public function includesSecurity(): bool + { + return in_array('security', $this->getIncludedTypes()); + } + + /** + * Get minimum priority threshold for todos. + * Returns null if no threshold (include all priorities). + */ + public function getMinPriority(): ?int + { + return $this->preferences['min_priority'] ?? null; + } + + /** + * Set minimum priority threshold. + */ + public function setMinPriority(?int $priority): void + { + $this->preferences = array_merge($this->preferences ?? [], [ + 'min_priority' => $priority, + ]); + } + + // ------------------------------------------------------------------------- + // Status Helpers + // ------------------------------------------------------------------------- + + /** + * Check if this digest is due to be sent. + */ + public function isDue(): bool + { + if (! $this->is_enabled) { + return false; + } + + if ($this->last_sent_at === null) { + return true; + } + + return match ($this->frequency) { + self::FREQUENCY_DAILY => $this->last_sent_at->lte(now()->subDay()), + self::FREQUENCY_WEEKLY => $this->last_sent_at->lte(now()->subWeek()), + self::FREQUENCY_MONTHLY => $this->last_sent_at->lte(now()->subMonth()), + default => false, + }; + } + + /** + * Mark the digest as sent. + */ + public function markAsSent(): void + { + $this->update(['last_sent_at' => now()]); + } + + /** + * Get a human-readable frequency label. + */ + public function getFrequencyLabel(): string + { + return match ($this->frequency) { + self::FREQUENCY_DAILY => 'Daily', + self::FREQUENCY_WEEKLY => 'Weekly', + self::FREQUENCY_MONTHLY => 'Monthly', + default => ucfirst($this->frequency), + }; + } + + /** + * Get the next scheduled send date. + */ + public function getNextSendDate(): ?\Carbon\Carbon + { + if (! $this->is_enabled) { + return null; + } + + $lastSent = $this->last_sent_at ?? now(); + + return match ($this->frequency) { + self::FREQUENCY_DAILY => $lastSent->copy()->addDay(), + self::FREQUENCY_WEEKLY => $lastSent->copy()->addWeek(), + self::FREQUENCY_MONTHLY => $lastSent->copy()->addMonth(), + default => null, + }; + } + + /** + * Get available frequency options for forms. + */ + public static function getFrequencyOptions(): array + { + return [ + self::FREQUENCY_DAILY => 'Daily', + self::FREQUENCY_WEEKLY => 'Weekly', + self::FREQUENCY_MONTHLY => 'Monthly', + ]; + } +} diff --git a/Models/UptelligenceWebhook.php b/Models/UptelligenceWebhook.php new file mode 100644 index 0000000..77e75fd --- /dev/null +++ b/Models/UptelligenceWebhook.php @@ -0,0 +1,475 @@ + 'boolean', + 'failure_count' => 'integer', + 'grace_period_seconds' => 'integer', + 'last_received_at' => 'datetime', + 'secret_rotated_at' => 'datetime', + 'secret' => 'encrypted', + 'previous_secret' => 'encrypted', + 'settings' => 'array', + ]; + + protected $hidden = [ + 'secret', + 'previous_secret', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (UptelligenceWebhook $webhook) { + if (empty($webhook->uuid)) { + $webhook->uuid = (string) Str::uuid(); + } + + // Generate a secret if not provided + if (empty($webhook->secret)) { + $webhook->secret = Str::random(64); + } + + // Default grace period: 24 hours + if (empty($webhook->grace_period_seconds)) { + $webhook->grace_period_seconds = 86400; + } + }); + } + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function vendor(): BelongsTo + { + return $this->belongsTo(Vendor::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(UptelligenceWebhookDelivery::class, 'webhook_id'); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForVendor($query, int $vendorId) + { + return $query->where('vendor_id', $vendorId); + } + + public function scopeByProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + // ------------------------------------------------------------------------- + // State Checks + // ------------------------------------------------------------------------- + + public function isActive(): bool + { + return $this->is_active === true; + } + + public function isCircuitBroken(): bool + { + return $this->failure_count >= self::MAX_FAILURES; + } + + public function isInGracePeriod(): bool + { + if (empty($this->secret_rotated_at)) { + return false; + } + + $rotatedAt = Carbon::parse($this->secret_rotated_at); + $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; + $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); + + return now()->isBefore($graceEndsAt); + } + + // ------------------------------------------------------------------------- + // Signature Verification + // ------------------------------------------------------------------------- + + /** + * Verify webhook signature based on provider. + * + * Supports: + * - GitHub: X-Hub-Signature-256 (sha256=...) + * - GitLab: X-Gitlab-Token (token comparison) + * - npm: npm registry webhooks + * - Packagist: Packagist webhooks + * - Custom: HMAC-SHA256 + */ + public function verifySignature(string $payload, ?string $signature): bool + { + // If no secret configured, skip verification + if (empty($this->secret)) { + return true; + } + + // Signature required when secret is set + if (empty($signature)) { + return false; + } + + // Check against current secret + if ($this->verifyAgainstSecret($payload, $signature, $this->secret)) { + return true; + } + + // Check against previous secret if in grace period + if ($this->isInGracePeriod() && ! empty($this->previous_secret)) { + if ($this->verifyAgainstSecret($payload, $signature, $this->previous_secret)) { + return true; + } + } + + return false; + } + + /** + * Verify signature against a specific secret. + */ + protected function verifyAgainstSecret(string $payload, string $signature, string $secret): bool + { + return match ($this->provider) { + self::PROVIDER_GITHUB => $this->verifyGitHubSignature($payload, $signature, $secret), + self::PROVIDER_GITLAB => $this->verifyGitLabSignature($signature, $secret), + self::PROVIDER_NPM => $this->verifyNpmSignature($payload, $signature, $secret), + self::PROVIDER_PACKAGIST => $this->verifyPackagistSignature($payload, $signature, $secret), + default => $this->verifyHmacSignature($payload, $signature, $secret), + }; + } + + /** + * Verify GitHub-style signature (sha256=...). + */ + protected function verifyGitHubSignature(string $payload, string $signature, string $secret): bool + { + // Handle sha256= prefix + if (str_starts_with($signature, 'sha256=')) { + $signature = substr($signature, 7); + } + + $expectedSignature = hash_hmac('sha256', $payload, $secret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Verify GitLab-style signature (X-Gitlab-Token header). + */ + protected function verifyGitLabSignature(string $signature, string $secret): bool + { + return hash_equals($secret, $signature); + } + + /** + * Verify npm webhook signature. + */ + protected function verifyNpmSignature(string $payload, string $signature, string $secret): bool + { + // npm uses sha256 HMAC + $expectedSignature = hash_hmac('sha256', $payload, $secret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Verify Packagist webhook signature. + */ + protected function verifyPackagistSignature(string $payload, string $signature, string $secret): bool + { + // Packagist uses sha1 HMAC + $expectedSignature = hash_hmac('sha1', $payload, $secret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Verify generic HMAC-SHA256 signature. + */ + protected function verifyHmacSignature(string $payload, string $signature, string $secret): bool + { + // Handle sha256= prefix + if (str_starts_with($signature, 'sha256=')) { + $signature = substr($signature, 7); + } + + $expectedSignature = hash_hmac('sha256', $payload, $secret); + + return hash_equals($expectedSignature, $signature); + } + + // ------------------------------------------------------------------------- + // Status Management + // ------------------------------------------------------------------------- + + public function incrementFailureCount(): void + { + $this->increment('failure_count'); + + // Auto-disable after too many failures (circuit breaker) + if ($this->failure_count >= self::MAX_FAILURES) { + $this->update(['is_active' => false]); + } + } + + public function resetFailureCount(): void + { + $this->update([ + 'failure_count' => 0, + ]); + } + + public function markReceived(): void + { + $this->update(['last_received_at' => now()]); + } + + // ------------------------------------------------------------------------- + // Secret Management + // ------------------------------------------------------------------------- + + /** + * Rotate the secret and keep the previous one for grace period. + */ + public function rotateSecret(): string + { + $newSecret = Str::random(64); + + $this->update([ + 'previous_secret' => $this->secret, + 'secret' => $newSecret, + 'secret_rotated_at' => now(), + ]); + + return $newSecret; + } + + /** + * Regenerate the secret without keeping the previous one. + */ + public function regenerateSecret(): string + { + $newSecret = Str::random(64); + + $this->update([ + 'secret' => $newSecret, + 'previous_secret' => null, + 'secret_rotated_at' => null, + ]); + + return $newSecret; + } + + // ------------------------------------------------------------------------- + // URL Generation + // ------------------------------------------------------------------------- + + /** + * Get the webhook endpoint URL. + */ + public function getEndpointUrl(): string + { + return route('api.uptelligence.webhooks.receive', ['webhook' => $this->uuid]); + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + /** + * Get provider label. + */ + public function getProviderLabel(): string + { + return match ($this->provider) { + self::PROVIDER_GITHUB => 'GitHub', + self::PROVIDER_GITLAB => 'GitLab', + self::PROVIDER_NPM => 'npm', + self::PROVIDER_PACKAGIST => 'Packagist', + self::PROVIDER_CUSTOM => 'Custom', + default => ucfirst($this->provider), + }; + } + + /** + * Get provider icon name. + */ + public function getProviderIcon(): string + { + return match ($this->provider) { + self::PROVIDER_GITHUB => 'code-bracket', + self::PROVIDER_GITLAB => 'code-bracket-square', + self::PROVIDER_NPM => 'cube', + self::PROVIDER_PACKAGIST => 'archive-box', + self::PROVIDER_CUSTOM => 'cog-6-tooth', + default => 'globe-alt', + }; + } + + /** + * Get Flux badge colour for status. + */ + public function getStatusColorAttribute(): string + { + if (! $this->is_active) { + return 'zinc'; + } + + if ($this->isCircuitBroken()) { + return 'red'; + } + + if ($this->failure_count > 0) { + return 'yellow'; + } + + return 'green'; + } + + /** + * Get status label. + */ + public function getStatusLabelAttribute(): string + { + if (! $this->is_active) { + return 'Disabled'; + } + + if ($this->isCircuitBroken()) { + return 'Circuit Open'; + } + + if ($this->failure_count > 0) { + return "Active ({$this->failure_count} failures)"; + } + + return 'Active'; + } + + /** + * Get time remaining in grace period. + */ + public function getGraceTimeRemainingAttribute(): ?int + { + if (! $this->isInGracePeriod()) { + return null; + } + + $rotatedAt = Carbon::parse($this->secret_rotated_at); + $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; + $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); + + return (int) now()->diffInSeconds($graceEndsAt, false); + } + + /** + * Get when the grace period ends. + */ + public function getGraceEndsAtAttribute(): ?Carbon + { + if (empty($this->secret_rotated_at)) { + return null; + } + + $rotatedAt = Carbon::parse($this->secret_rotated_at); + $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; + + return $rotatedAt->copy()->addSeconds($gracePeriodSeconds); + } +} diff --git a/Models/UptelligenceWebhookDelivery.php b/Models/UptelligenceWebhookDelivery.php new file mode 100644 index 0000000..8785957 --- /dev/null +++ b/Models/UptelligenceWebhookDelivery.php @@ -0,0 +1,356 @@ + 'array', + 'parsed_data' => 'array', + 'processed_at' => 'datetime', + 'next_retry_at' => 'datetime', + 'retry_count' => 'integer', + 'max_retries' => 'integer', + ]; + + protected $attributes = [ + 'status' => self::STATUS_PENDING, + 'retry_count' => 0, + 'max_retries' => self::DEFAULT_MAX_RETRIES, + ]; + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function webhook(): BelongsTo + { + return $this->belongsTo(UptelligenceWebhook::class, 'webhook_id'); + } + + public function vendor(): BelongsTo + { + return $this->belongsTo(Vendor::class); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeProcessing($query) + { + return $query->where('status', self::STATUS_PROCESSING); + } + + public function scopeCompleted($query) + { + return $query->where('status', self::STATUS_COMPLETED); + } + + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + public function scopeForWebhook($query, int $webhookId) + { + return $query->where('webhook_id', $webhookId); + } + + public function scopeForVendor($query, int $vendorId) + { + return $query->where('vendor_id', $vendorId); + } + + public function scopeRecent($query, int $hours = 24) + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } + + /** + * Scope to webhooks that are ready for retry. + */ + public function scopeRetryable($query) + { + return $query->where(function ($q) { + $q->where('status', self::STATUS_PENDING) + ->orWhere('status', self::STATUS_FAILED); + }) + ->where(function ($q) { + $q->whereNull('next_retry_at') + ->orWhere('next_retry_at', '<=', now()); + }) + ->whereColumn('retry_count', '<', 'max_retries'); + } + + // ------------------------------------------------------------------------- + // Status Management + // ------------------------------------------------------------------------- + + public function markProcessing(): void + { + $this->update(['status' => self::STATUS_PROCESSING]); + } + + public function markCompleted(?array $parsedData = null): void + { + $update = [ + 'status' => self::STATUS_COMPLETED, + 'processed_at' => now(), + 'error_message' => null, + ]; + + if ($parsedData !== null) { + $update['parsed_data'] = $parsedData; + } + + $this->update($update); + } + + public function markFailed(string $error): void + { + $this->update([ + 'status' => self::STATUS_FAILED, + 'processed_at' => now(), + 'error_message' => $error, + ]); + } + + public function markSkipped(string $reason): void + { + $this->update([ + 'status' => self::STATUS_SKIPPED, + 'processed_at' => now(), + 'error_message' => $reason, + ]); + } + + /** + * Schedule a retry with exponential backoff. + */ + public function scheduleRetry(): void + { + $retryCount = $this->retry_count + 1; + $delaySeconds = (int) pow(2, $retryCount) * 30; // 30s, 60s, 120s, 240s... + + $this->update([ + 'status' => self::STATUS_PENDING, + 'retry_count' => $retryCount, + 'next_retry_at' => now()->addSeconds($delaySeconds), + ]); + } + + // ------------------------------------------------------------------------- + // State Checks + // ------------------------------------------------------------------------- + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isProcessing(): bool + { + return $this->status === self::STATUS_PROCESSING; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + public function hasExceededMaxRetries(): bool + { + return $this->retry_count >= $this->max_retries; + } + + public function canRetry(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_FAILED]) + && ! $this->hasExceededMaxRetries(); + } + + // ------------------------------------------------------------------------- + // Display Helpers + // ------------------------------------------------------------------------- + + /** + * Get Flux badge colour for status. + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'yellow', + self::STATUS_PROCESSING => 'blue', + self::STATUS_COMPLETED => 'green', + self::STATUS_FAILED => 'red', + self::STATUS_SKIPPED => 'zinc', + default => 'zinc', + }; + } + + /** + * Get icon for status. + */ + public function getStatusIconAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'clock', + self::STATUS_PROCESSING => 'arrow-path', + self::STATUS_COMPLETED => 'check', + self::STATUS_FAILED => 'x-mark', + self::STATUS_SKIPPED => 'minus', + default => 'question-mark-circle', + }; + } + + /** + * Get Flux badge colour for event type. + */ + public function getEventColorAttribute(): string + { + return match (true) { + str_contains($this->event_type, 'release') => 'green', + str_contains($this->event_type, 'publish') => 'blue', + str_contains($this->event_type, 'tag') => 'purple', + str_contains($this->event_type, 'update') => 'blue', + default => 'zinc', + }; + } + + /** + * Get Flux badge colour for signature status. + */ + public function getSignatureColorAttribute(): string + { + return match ($this->signature_status) { + self::SIGNATURE_VALID => 'green', + self::SIGNATURE_INVALID => 'red', + self::SIGNATURE_MISSING => 'yellow', + default => 'zinc', + }; + } + + /** + * Get retry progress as a percentage. + */ + public function getRetryProgressAttribute(): int + { + if ($this->max_retries === 0) { + return 100; + } + + return (int) round(($this->retry_count / $this->max_retries) * 100); + } + + /** + * Get human-readable retry status. + */ + public function getRetryStatusAttribute(): string + { + if ($this->status === self::STATUS_COMPLETED) { + return 'Completed'; + } + + if ($this->hasExceededMaxRetries()) { + return 'Exhausted'; + } + + if ($this->next_retry_at && $this->next_retry_at->isFuture()) { + return "Retry #{$this->retry_count} at ".$this->next_retry_at->format('H:i:s'); + } + + if ($this->retry_count > 0) { + return "Failed after {$this->retry_count} retries"; + } + + return 'Pending'; + } +} diff --git a/Models/Vendor.php b/Models/Vendor.php new file mode 100644 index 0000000..4205085 --- /dev/null +++ b/Models/Vendor.php @@ -0,0 +1,230 @@ + 'array', + 'ignored_paths' => 'array', + 'priority_paths' => 'array', + 'is_active' => 'boolean', + 'last_checked_at' => 'datetime', + 'last_analyzed_at' => 'datetime', + ]; + + // Relationships + public function todos(): HasMany + { + return $this->hasMany(UpstreamTodo::class); + } + + public function releases(): HasMany + { + return $this->hasMany(VersionRelease::class); + } + + public function logs(): HasMany + { + return $this->hasMany(AnalysisLog::class); + } + + public function webhooks(): HasMany + { + return $this->hasMany(UptelligenceWebhook::class); + } + + public function webhookDeliveries(): HasMany + { + return $this->hasMany(UptelligenceWebhookDelivery::class); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeLicensed($query) + { + return $query->where('source_type', self::SOURCE_LICENSED); + } + + public function scopeOss($query) + { + return $query->where('source_type', self::SOURCE_OSS); + } + + public function scopePlugins($query) + { + return $query->where('source_type', self::SOURCE_PLUGIN); + } + + public function scopeByPlatform($query, string $platform) + { + return $query->where('plugin_platform', $platform); + } + + // Helpers + public function getStoragePath(string $version = 'current'): string + { + return storage_path("app/vendors/{$this->slug}/{$version}"); + } + + public function shouldIgnorePath(string $path): bool + { + foreach ($this->ignored_paths ?? [] as $pattern) { + if (fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } + + public function isPriorityPath(string $path): bool + { + foreach ($this->priority_paths ?? [] as $pattern) { + if (fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } + + public function mapToHostHub(string $upstreamPath): ?string + { + foreach ($this->path_mapping ?? [] as $from => $to) { + if (str_starts_with($upstreamPath, $from)) { + return str_replace($from, $to, $upstreamPath); + } + } + + return null; + } + + public function getPendingTodosCount(): int + { + return $this->todos()->where('status', 'pending')->count(); + } + + public function getQuickWinsCount(): int + { + return $this->todos() + ->where('status', 'pending') + ->where('effort', 'low') + ->where('priority', '>=', 5) + ->count(); + } + + // Source type helpers + public function isLicensed(): bool + { + return $this->source_type === self::SOURCE_LICENSED; + } + + public function isOss(): bool + { + return $this->source_type === self::SOURCE_OSS; + } + + public function isPlugin(): bool + { + return $this->source_type === self::SOURCE_PLUGIN; + } + + public function canGitSync(): bool + { + return $this->isOss() && ! empty($this->git_repo_url); + } + + public function requiresManualUpload(): bool + { + return $this->isLicensed() || $this->isPlugin(); + } + + public function getSourceTypeLabel(): string + { + return match ($this->source_type) { + self::SOURCE_LICENSED => 'Licensed Software', + self::SOURCE_OSS => 'Open Source', + self::SOURCE_PLUGIN => 'Plugin', + default => 'Unknown', + }; + } + + public function getSourceTypeIcon(): string + { + return match ($this->source_type) { + self::SOURCE_LICENSED => '🔐', + self::SOURCE_OSS => '🌐', + self::SOURCE_PLUGIN => '🔌', + default => '📦', + }; + } + + public function getPlatformLabel(): ?string + { + if (! $this->plugin_platform) { + return null; + } + + return match ($this->plugin_platform) { + self::PLATFORM_ALTUM => 'Altum/phpBioLinks', + self::PLATFORM_WORDPRESS => 'WordPress', + self::PLATFORM_LARAVEL => 'Laravel Package', + default => 'Other', + }; + } +} diff --git a/Models/VersionRelease.php b/Models/VersionRelease.php new file mode 100644 index 0000000..2ae284c --- /dev/null +++ b/Models/VersionRelease.php @@ -0,0 +1,219 @@ + 'array', + 'metadata_json' => 'array', + 'files_added' => 'integer', + 'files_modified' => 'integer', + 'files_removed' => 'integer', + 'todos_created' => 'integer', + 'file_size' => 'integer', + 'analyzed_at' => 'datetime', + 'archived_at' => 'datetime', + 'last_downloaded_at' => 'datetime', + ]; + + // Relationships + public function vendor(): BelongsTo + { + return $this->belongsTo(Vendor::class); + } + + public function diffs(): HasMany + { + return $this->hasMany(DiffCache::class); + } + + public function logs(): HasMany + { + return $this->hasMany(AnalysisLog::class); + } + + // Scopes + public function scopeAnalyzed($query) + { + return $query->whereNotNull('analyzed_at'); + } + + public function scopePendingAnalysis($query) + { + return $query->whereNull('analyzed_at'); + } + + public function scopeRecent($query, int $days = 30) + { + return $query->where('created_at', '>=', now()->subDays($days)); + } + + public function scopeArchived($query) + { + return $query->where('storage_disk', self::DISK_S3)->whereNotNull('archived_at'); + } + + public function scopeLocal($query) + { + return $query->where(function ($q) { + $q->where('storage_disk', self::DISK_LOCAL) + ->orWhereNull('storage_disk'); + }); + } + + public function scopeNotArchived($query) + { + return $query->whereNull('archived_at'); + } + + // Helpers + public function getTotalChanges(): int + { + return $this->files_added + $this->files_modified + $this->files_removed; + } + + public function isAnalyzed(): bool + { + return $this->analyzed_at !== null; + } + + public function getVersionCompare(): string + { + if ($this->previous_version) { + return "{$this->previous_version} → {$this->version}"; + } + + return $this->version; + } + + public function getStoragePath(): string + { + return $this->storage_path ?? storage_path("app/vendors/{$this->vendor->slug}/{$this->version}"); + } + + public function getSummaryHighlights(): array + { + $summary = $this->summary ?? []; + + return [ + 'features' => $summary['features'] ?? [], + 'fixes' => $summary['fixes'] ?? [], + 'security' => $summary['security'] ?? [], + 'breaking' => $summary['breaking_changes'] ?? [], + ]; + } + + public function getImpactLevel(): string + { + $total = $this->getTotalChanges(); + $security = $this->diffs()->where('category', 'security')->count(); + + if ($security > 0) { + return 'critical'; + } + + return match (true) { + $total >= 100 => 'major', + $total >= 20 => 'moderate', + default => 'minor', + }; + } + + public function getImpactBadgeClass(): string + { + return match ($this->getImpactLevel()) { + 'critical' => 'bg-red-100 text-red-800', + 'major' => 'bg-orange-100 text-orange-800', + 'moderate' => 'bg-yellow-100 text-yellow-800', + default => 'bg-green-100 text-green-800', + }; + } + + // Storage helpers + public function isArchivedToS3(): bool + { + return $this->storage_disk === self::DISK_S3 && ! empty($this->s3_key); + } + + public function isLocal(): bool + { + return $this->storage_disk === self::DISK_LOCAL || empty($this->storage_disk); + } + + public function hasMetadata(): bool + { + return ! empty($this->metadata_json); + } + + public function getFileSizeForHumans(): string + { + if (! $this->file_size) { + return 'Unknown'; + } + + $bytes = $this->file_size; + $units = ['B', 'KB', 'MB', 'GB']; + $power = $bytes > 0 ? floor(log($bytes, 1024)) : 0; + + return number_format($bytes / pow(1024, $power), 2).' '.$units[$power]; + } + + public function getStorageStatusBadge(): array + { + if ($this->isArchivedToS3()) { + return [ + 'label' => 'S3 Archived', + 'class' => 'bg-blue-100 text-blue-800', + 'icon' => 'cloud', + ]; + } + + return [ + 'label' => 'Local', + 'class' => 'bg-gray-100 text-gray-800', + 'icon' => 'folder', + ]; + } +} diff --git a/Notifications/NewReleaseDetected.php b/Notifications/NewReleaseDetected.php new file mode 100644 index 0000000..a49ce22 --- /dev/null +++ b/Notifications/NewReleaseDetected.php @@ -0,0 +1,155 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject($this->getSubject()) + ->greeting('New Release Detected'); + + // Main announcement + $message->line("**{$this->vendor->name}** has released version **{$this->version}**."); + + // Previous version context + if ($this->vendor->previous_version) { + $message->line("Previous version: {$this->vendor->previous_version}"); + } + + // Release details + if (! empty($this->releaseData['release_name'])) { + $message->line('---'); + $message->line("**Release:** {$this->releaseData['release_name']}"); + } + + // Release notes excerpt + if (! empty($this->releaseData['body'])) { + $excerpt = $this->getBodyExcerpt($this->releaseData['body'], 200); + $message->line('**Notes:**'); + $message->line($excerpt); + } + + // Prerelease warning + if ($this->releaseData['prerelease'] ?? false) { + $message->line('---'); + $message->line('This is a **pre-release** version.'); + } + + // Call to action + $message->action('View in Dashboard', route('hub.admin.uptelligence.vendors')); + + // Release URL + if (! empty($this->releaseData['url'])) { + $message->line('---'); + $message->line("[View release on {$this->getProviderName()}]({$this->releaseData['url']})"); + } + + $message->salutation('Host UK - Uptelligence'); + + return $message; + } + + /** + * Get the subject line. + */ + protected function getSubject(): string + { + $prerelease = ($this->releaseData['prerelease'] ?? false) ? ' (pre-release)' : ''; + + return "New release: {$this->vendor->name} {$this->version}{$prerelease}"; + } + + /** + * Get the provider name for display. + */ + protected function getProviderName(): string + { + $eventType = $this->releaseData['event_type'] ?? ''; + + return match (true) { + str_starts_with($eventType, 'github.') => 'GitHub', + str_starts_with($eventType, 'gitlab.') => 'GitLab', + str_starts_with($eventType, 'npm.') => 'npm', + str_starts_with($eventType, 'packagist.') => 'Packagist', + default => 'source', + }; + } + + /** + * Get a truncated excerpt of the body text. + */ + protected function getBodyExcerpt(string $body, int $maxLength): string + { + // Remove markdown links for cleaner display + $text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $body); + + // Remove excessive newlines + $text = preg_replace('/\n{3,}/', "\n\n", $text); + + // Truncate + if (strlen($text) > $maxLength) { + $text = substr($text, 0, $maxLength); + // Try to end at a sentence or word boundary + if (preg_match('/^(.+[.!?])\s/', $text, $matches)) { + $text = $matches[1]; + } else { + $text = preg_replace('/\s+\S*$/', '', $text).'...'; + } + } + + return $text; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'vendor_id' => $this->vendor->id, + 'vendor_name' => $this->vendor->name, + 'version' => $this->version, + 'previous_version' => $this->vendor->previous_version, + 'prerelease' => $this->releaseData['prerelease'] ?? false, + 'release_url' => $this->releaseData['url'] ?? null, + ]; + } +} diff --git a/Notifications/SendUptelligenceDigest.php b/Notifications/SendUptelligenceDigest.php new file mode 100644 index 0000000..d6d2bba --- /dev/null +++ b/Notifications/SendUptelligenceDigest.php @@ -0,0 +1,257 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage) + ->subject($this->getSubject()) + ->greeting($this->getGreeting()); + + // Add security alert if there are security updates + if ($this->securityCount > 0) { + $message->line($this->formatSecurityAlert()); + } + + // Summary overview + $message->line($this->formatSummary()); + + // New releases section + if ($this->releases->isNotEmpty()) { + $message->line('---'); + $message->line('**New Releases**'); + + foreach ($this->releases->take(5) as $release) { + $message->line($this->formatRelease($release)); + } + + if ($this->releases->count() > 5) { + $remaining = $this->releases->count() - 5; + $message->line("*...and {$remaining} more release(s)*"); + } + } + + // Todos summary section + if (($this->todosByPriority['total'] ?? 0) > 0) { + $message->line('---'); + $message->line('**Pending Work**'); + $message->line($this->formatTodosBreakdown()); + } + + // Call to action + $message->action('View Dashboard', route('hub.admin.uptelligence.dashboard')); + + // Footer + $message->line('---'); + $message->line($this->formatFrequencyNote()); + $message->salutation('Host UK'); + + return $message; + } + + /** + * Get the subject line based on content. + */ + protected function getSubject(): string + { + $parts = []; + + if ($this->securityCount > 0) { + $parts[] = "{$this->securityCount} security"; + } + + if ($this->releases->isNotEmpty()) { + $count = $this->releases->count(); + $parts[] = "{$count} release".($count !== 1 ? 's' : ''); + } + + $totalTodos = $this->todosByPriority['total'] ?? 0; + if ($totalTodos > 0) { + $parts[] = "{$totalTodos} pending"; + } + + if (empty($parts)) { + return 'Uptelligence digest - no new updates'; + } + + $summary = implode(', ', $parts); + + return "Uptelligence digest - {$summary}"; + } + + /** + * Get the greeting based on time of day. + */ + protected function getGreeting(): string + { + $hour = now()->hour; + + return match (true) { + $hour < 12 => 'Good morning', + $hour < 17 => 'Good afternoon', + default => 'Good evening', + }; + } + + /** + * Format the security alert message. + */ + protected function formatSecurityAlert(): string + { + $plural = $this->securityCount !== 1 ? 's' : ''; + + return "**Security Alert:** {$this->securityCount} security-related update{$plural} " + .'require attention. Review these items as a priority.'; + } + + /** + * Format the summary overview. + */ + protected function formatSummary(): string + { + $frequency = $this->digest->getFrequencyLabel(); + $period = match ($this->digest->frequency) { + UptelligenceDigest::FREQUENCY_DAILY => 'the past day', + UptelligenceDigest::FREQUENCY_WEEKLY => 'the past week', + UptelligenceDigest::FREQUENCY_MONTHLY => 'the past month', + default => 'recently', + }; + + $parts = []; + + if ($this->releases->isNotEmpty()) { + $count = $this->releases->count(); + $parts[] = "{$count} new release".($count !== 1 ? 's' : ''); + } + + $totalTodos = $this->todosByPriority['total'] ?? 0; + if ($totalTodos > 0) { + $parts[] = "{$totalTodos} pending task".($totalTodos !== 1 ? 's' : ''); + } + + if (empty($parts)) { + return "Your {$frequency} summary for {$period}: No significant updates."; + } + + $summary = implode(' and ', $parts); + + return "Your {$frequency} summary for {$period}: {$summary}."; + } + + /** + * Format a single release for the email. + */ + protected function formatRelease(array $release): string + { + $version = $release['version']; + $vendor = $release['vendor_name']; + $impact = ucfirst($release['impact_level']); + $changes = $release['files_changed']; + + $previousVersion = $release['previous_version']; + $versionText = $previousVersion + ? "{$previousVersion} to {$version}" + : $version; + + return "- **{$vendor}** updated to {$versionText} ({$changes} files, {$impact} impact)"; + } + + /** + * Format the todos breakdown. + */ + protected function formatTodosBreakdown(): string + { + $parts = []; + + $critical = $this->todosByPriority['critical'] ?? 0; + $high = $this->todosByPriority['high'] ?? 0; + $medium = $this->todosByPriority['medium'] ?? 0; + $low = $this->todosByPriority['low'] ?? 0; + + if ($critical > 0) { + $parts[] = "{$critical} critical"; + } + if ($high > 0) { + $parts[] = "{$high} high priority"; + } + if ($medium > 0) { + $parts[] = "{$medium} medium"; + } + if ($low > 0) { + $parts[] = "{$low} low"; + } + + if (empty($parts)) { + return 'No pending tasks at this time.'; + } + + return implode(', ', $parts).' items awaiting review.'; + } + + /** + * Format the frequency note. + */ + protected function formatFrequencyNote(): string + { + $frequency = strtolower($this->digest->getFrequencyLabel()); + + return "You receive this digest {$frequency}. " + .'Update your preferences in the Uptelligence settings.'; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'digest_id' => $this->digest->id, + 'workspace_id' => $this->digest->workspace_id, + 'releases_count' => $this->releases->count(), + 'todos_total' => $this->todosByPriority['total'] ?? 0, + 'security_count' => $this->securityCount, + 'frequency' => $this->digest->frequency, + ]; + } +} diff --git a/Services/AIAnalyzerService.php b/Services/AIAnalyzerService.php new file mode 100644 index 0000000..b6b3f6e --- /dev/null +++ b/Services/AIAnalyzerService.php @@ -0,0 +1,479 @@ +provider = $config['provider'] ?? 'anthropic'; + $this->model = $config['model'] ?? 'claude-sonnet-4-20250514'; + $this->maxTokens = $config['max_tokens'] ?? 4096; + $this->temperature = $config['temperature'] ?? 0.3; + $this->apiKey = $this->provider === 'anthropic' + ? config('services.anthropic.api_key') + : config('services.openai.api_key'); + } + + /** + * Analyse a version release and create todos. + */ + public function analyzeRelease(VersionRelease $release): Collection + { + $diffs = $release->diffs; + $todos = collect(); + + // Group related diffs for batch analysis + $groups = $this->groupRelatedDiffs($diffs); + + foreach ($groups as $group) { + $analysis = $this->analyzeGroup($release, $group); + + if ($analysis && $this->shouldCreateTodo($analysis)) { + $todo = $this->createTodo($release, $group, $analysis); + $todos->push($todo); + } + } + + // Update release with AI-generated summary + $summary = $this->generateReleaseSummary($release, $todos); + $release->update(['summary' => $summary]); + + return $todos; + } + + /** + * Group related diffs together (e.g., controller + view + route). + */ + protected function groupRelatedDiffs(Collection $diffs): array + { + $groups = []; + $processed = []; + + foreach ($diffs as $diff) { + if (in_array($diff->id, $processed)) { + continue; + } + + $group = [$diff]; + $processed[] = $diff->id; + + // Find related files by common patterns + $baseName = $this->extractBaseName($diff->file_path); + + foreach ($diffs as $related) { + if (in_array($related->id, $processed)) { + continue; + } + + if ($this->areRelated($diff, $related, $baseName)) { + $group[] = $related; + $processed[] = $related->id; + } + } + + $groups[] = $group; + } + + return $groups; + } + + /** + * Extract base name from file path for grouping. + */ + protected function extractBaseName(string $path): string + { + $filename = pathinfo($path, PATHINFO_FILENAME); + + // Remove common suffixes + $filename = preg_replace('/(Controller|Model|Service|View|Block)$/i', '', $filename); + + return strtolower($filename); + } + + /** + * Check if two diffs are related. + */ + protected function areRelated(DiffCache $diff1, DiffCache $diff2, string $baseName): bool + { + // Same directory + if (dirname($diff1->file_path) === dirname($diff2->file_path)) { + return true; + } + + // Same base name in different directories + $name2 = $this->extractBaseName($diff2->file_path); + if ($baseName && $baseName === $name2) { + return true; + } + + return false; + } + + /** + * Analyse a group of related diffs using AI. + */ + protected function analyzeGroup(VersionRelease $release, array $diffs): ?array + { + // Build context for AI + $context = $this->buildContext($release, $diffs); + + // Call AI API + $prompt = $this->buildAnalysisPrompt($context); + $response = $this->callAI($prompt); + + if (! $response) { + return null; + } + + return $this->parseAnalysisResponse($response); + } + + /** + * Build context string for AI. + */ + protected function buildContext(VersionRelease $release, array $diffs): string + { + $context = "Vendor: {$release->vendor->name}\n"; + $context .= "Version: {$release->previous_version} → {$release->version}\n\n"; + $context .= "Changed files:\n"; + + foreach ($diffs as $diff) { + $context .= "- [{$diff->change_type}] {$diff->file_path} ({$diff->category})\n"; + + // Include diff content for modified files (truncated) + if ($diff->diff_content && strlen($diff->diff_content) < 5000) { + $context .= "```diff\n".$diff->diff_content."\n```\n\n"; + } + } + + return $context; + } + + /** + * Build the analysis prompt. + */ + protected function buildAnalysisPrompt(string $context): string + { + return << features > bugfixes > refactors), + "effort": "low|medium|high" (low = < 1 hour, medium = 1-4 hours, high = 4+ hours), + "has_conflicts": true|false (likely to conflict with our customisations?), + "conflict_reason": "If has_conflicts is true, explain why", + "port_notes": "Any specific notes for the developer who will port this", + "tags": ["relevant", "tags"], + "dependencies": ["list of other features this depends on"], + "skip_reason": null or "reason to skip this change" +} + +Only return the JSON, no additional text. +PROMPT; + } + + /** + * Call the AI API with rate limiting. + */ + protected function callAI(string $prompt): ?string + { + if (! $this->apiKey) { + Log::debug('Uptelligence: AI API key not configured, skipping analysis'); + + return null; + } + + // Check rate limit before making API call + if (RateLimiter::tooManyAttempts('upstream-ai-api', 10)) { + $seconds = RateLimiter::availableIn('upstream-ai-api'); + Log::warning('Uptelligence: AI API rate limit exceeded', [ + 'provider' => $this->provider, + 'retry_after_seconds' => $seconds, + ]); + + return null; + } + + RateLimiter::hit('upstream-ai-api'); + + try { + if ($this->provider === 'anthropic') { + return $this->callAnthropic($prompt); + } else { + return $this->callOpenAI($prompt); + } + } catch (\Exception $e) { + Log::error('Uptelligence: AI API call failed', [ + 'provider' => $this->provider, + 'model' => $this->model, + 'error' => $e->getMessage(), + 'exception_class' => get_class($e), + ]); + report($e); + + return null; + } + } + + /** + * Call Anthropic API with retry logic. + */ + protected function callAnthropic(string $prompt): ?string + { + $response = Http::withHeaders([ + 'x-api-key' => $this->apiKey, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ]) + ->timeout(60) + ->retry(3, function (int $attempt, \Exception $exception) { + // Exponential backoff: 1s, 2s, 4s + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: Anthropic API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + // Only retry on connection/timeout errors or 5xx responses + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->post('https://api.anthropic.com/v1/messages', [ + 'model' => $this->model, + 'max_tokens' => $this->maxTokens, + 'temperature' => $this->temperature, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + + if ($response->successful()) { + return $response->json('content.0.text'); + } + + Log::error('Uptelligence: Anthropic API request failed', [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + + return null; + } + + /** + * Call OpenAI API with retry logic. + */ + protected function callOpenAI(string $prompt): ?string + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ]) + ->timeout(60) + ->retry(3, function (int $attempt, \Exception $exception) { + // Exponential backoff: 1s, 2s, 4s + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: OpenAI API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + // Only retry on connection/timeout errors or 5xx responses + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $this->model, + 'max_tokens' => $this->maxTokens, + 'temperature' => $this->temperature, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + + if ($response->successful()) { + return $response->json('choices.0.message.content'); + } + + Log::error('Uptelligence: OpenAI API request failed', [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + + return null; + } + + /** + * Parse AI response into structured data. + */ + protected function parseAnalysisResponse(string $response): ?array + { + // Extract JSON from response + $json = $response; + if (preg_match('/```json\s*(.*?)\s*```/s', $response, $matches)) { + $json = $matches[1]; + } + + try { + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return null; + } + } + + /** + * Determine if we should create a todo from analysis. + */ + protected function shouldCreateTodo(array $analysis): bool + { + // Skip if explicitly marked to skip + if (! empty($analysis['skip_reason'])) { + return false; + } + + // Skip very low priority refactors + if ($analysis['type'] === 'refactor' && ($analysis['priority'] ?? 5) < 3) { + return false; + } + + return true; + } + + /** + * Create a todo from analysis. + */ + protected function createTodo(VersionRelease $release, array $diffs, array $analysis): UpstreamTodo + { + $files = array_map(fn ($d) => $d->file_path, $diffs); + + $todo = UpstreamTodo::create([ + 'vendor_id' => $release->vendor_id, + 'from_version' => $release->previous_version, + 'to_version' => $release->version, + 'type' => $analysis['type'] ?? 'feature', + 'status' => UpstreamTodo::STATUS_PENDING, + 'title' => $analysis['title'] ?? 'Untitled change', + 'description' => $analysis['description'] ?? null, + 'port_notes' => $analysis['port_notes'] ?? null, + 'priority' => $analysis['priority'] ?? 5, + 'effort' => $analysis['effort'] ?? UpstreamTodo::EFFORT_MEDIUM, + 'has_conflicts' => $analysis['has_conflicts'] ?? false, + 'conflict_reason' => $analysis['conflict_reason'] ?? null, + 'files' => $files, + 'dependencies' => $analysis['dependencies'] ?? [], + 'tags' => $analysis['tags'] ?? [], + 'ai_analysis' => $analysis, + 'ai_confidence' => 0.85, // Default confidence + ]); + + AnalysisLog::logTodoCreated($todo); + + // Update release todos count + $release->increment('todos_created'); + + return $todo; + } + + /** + * Generate AI summary of the release. + */ + protected function generateReleaseSummary(VersionRelease $release, Collection $todos): array + { + return [ + 'overview' => $this->generateOverviewText($release, $todos), + 'features' => $todos->where('type', 'feature')->pluck('title')->toArray(), + 'fixes' => $todos->where('type', 'bugfix')->pluck('title')->toArray(), + 'security' => $todos->where('type', 'security')->pluck('title')->toArray(), + 'breaking_changes' => $todos->where('has_conflicts', true)->pluck('title')->toArray(), + 'quick_wins' => $todos->filter->isQuickWin()->pluck('title')->toArray(), + 'stats' => [ + 'total_todos' => $todos->count(), + 'by_type' => $todos->groupBy('type')->map->count()->toArray(), + 'by_effort' => $todos->groupBy('effort')->map->count()->toArray(), + ], + ]; + } + + /** + * Generate overview text. + */ + protected function generateOverviewText(VersionRelease $release, Collection $todos): string + { + $features = $todos->where('type', 'feature')->count(); + $security = $todos->where('type', 'security')->count(); + $quickWins = $todos->filter->isQuickWin()->count(); + + $text = "Version {$release->version} contains {$todos->count()} notable changes"; + + if ($features > 0) { + $text .= ", including {$features} new feature(s)"; + } + + if ($security > 0) { + $text .= ". {$security} security-related update(s) require attention"; + } + + if ($quickWins > 0) { + $text .= ". {$quickWins} quick win(s) can be ported easily"; + } + + return $text.'.'; + } +} diff --git a/Services/AssetTrackerService.php b/Services/AssetTrackerService.php new file mode 100644 index 0000000..1d21ea2 --- /dev/null +++ b/Services/AssetTrackerService.php @@ -0,0 +1,439 @@ +get() as $asset) { + $results[$asset->slug] = $this->checkForUpdate($asset); + } + + return $results; + } + + /** + * Check a single asset for updates. + */ + public function checkForUpdate(Asset $asset): array + { + $result = match ($asset->type) { + Asset::TYPE_COMPOSER => $this->checkComposerPackage($asset), + Asset::TYPE_NPM => $this->checkNpmPackage($asset), + Asset::TYPE_FONT => $this->checkFontAsset($asset), + default => ['status' => 'skipped', 'message' => 'No auto-check for this type'], + }; + + $asset->update(['last_checked_at' => now()]); + + return $result; + } + + /** + * Check Composer package for updates with rate limiting and retry logic. + */ + protected function checkComposerPackage(Asset $asset): array + { + if (! $asset->package_name) { + return ['status' => 'error', 'message' => 'No package name configured']; + } + + // Check rate limit before making API call + if (RateLimiter::tooManyAttempts('upstream-registry', 30)) { + $seconds = RateLimiter::availableIn('upstream-registry'); + + return [ + 'status' => 'rate_limited', + 'message' => "Rate limit exceeded. Retry after {$seconds} seconds", + ]; + } + + RateLimiter::hit('upstream-registry'); + + // Try Packagist first with retry logic + $response = Http::timeout(30) + ->retry(3, function (int $attempt, \Exception $exception) { + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: Packagist API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->get("https://repo.packagist.org/p2/{$asset->package_name}.json"); + + if ($response->successful()) { + $data = $response->json(); + $packages = $data['packages'][$asset->package_name] ?? []; + + if (! empty($packages)) { + // Get latest stable version + $latest = collect($packages) + ->filter(fn ($p) => ! str_contains($p['version'] ?? '', 'dev')) + ->sortByDesc('version') + ->first(); + + if ($latest) { + $latestVersion = ltrim($latest['version'], 'v'); + $hasUpdate = $asset->installed_version && + version_compare($latestVersion, $asset->installed_version, '>'); + + $asset->update(['latest_version' => $latestVersion]); + + // Record version if new + $this->recordVersion($asset, $latestVersion, $latest); + + return [ + 'status' => 'success', + 'latest' => $latestVersion, + 'has_update' => $hasUpdate, + ]; + } + } + } else { + Log::warning('Uptelligence: Packagist API request failed', [ + 'package' => $asset->package_name, + 'status' => $response->status(), + ]); + } + + // Try custom registry (e.g., Flux Pro) + if ($asset->registry_url) { + return $this->checkCustomComposerRegistry($asset); + } + + return ['status' => 'error', 'message' => 'Could not fetch package info']; + } + + /** + * Check custom Composer registry (like Flux Pro). + */ + protected function checkCustomComposerRegistry(Asset $asset): array + { + // For licensed packages, we need to check the installed version via composer show + $result = Process::run("composer show {$asset->package_name} --format=json 2>/dev/null"); + + if ($result->successful()) { + $data = json_decode($result->output(), true); + $installedVersion = $data['versions'][0] ?? null; + + if ($installedVersion) { + $asset->update(['installed_version' => $installedVersion]); + + return [ + 'status' => 'success', + 'installed' => $installedVersion, + 'message' => 'Check registry manually for latest version', + ]; + } + } + + return ['status' => 'info', 'message' => 'Licensed package - check registry manually']; + } + + /** + * Check NPM package for updates with rate limiting and retry logic. + */ + protected function checkNpmPackage(Asset $asset): array + { + if (! $asset->package_name) { + return ['status' => 'error', 'message' => 'No package name configured']; + } + + // Check rate limit before making API call + if (RateLimiter::tooManyAttempts('upstream-registry', 30)) { + $seconds = RateLimiter::availableIn('upstream-registry'); + + return [ + 'status' => 'rate_limited', + 'message' => "Rate limit exceeded. Retry after {$seconds} seconds", + ]; + } + + RateLimiter::hit('upstream-registry'); + + // Check npm registry with retry logic + $response = Http::timeout(30) + ->retry(3, function (int $attempt, \Exception $exception) { + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: NPM registry API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->get("https://registry.npmjs.org/{$asset->package_name}"); + + if ($response->successful()) { + $data = $response->json(); + $latestVersion = $data['dist-tags']['latest'] ?? null; + + if ($latestVersion) { + $hasUpdate = $asset->installed_version && + version_compare($latestVersion, $asset->installed_version, '>'); + + $asset->update(['latest_version' => $latestVersion]); + + // Record version if new + $versionData = $data['versions'][$latestVersion] ?? []; + $this->recordVersion($asset, $latestVersion, $versionData); + + return [ + 'status' => 'success', + 'latest' => $latestVersion, + 'has_update' => $hasUpdate, + ]; + } + } else { + Log::warning('Uptelligence: NPM registry API request failed', [ + 'package' => $asset->package_name, + 'status' => $response->status(), + ]); + } + + // Check for scoped/private packages via npm view + $result = Process::run("npm view {$asset->package_name} version 2>/dev/null"); + if ($result->successful()) { + $latestVersion = trim($result->output()); + if ($latestVersion) { + $asset->update(['latest_version' => $latestVersion]); + + return [ + 'status' => 'success', + 'latest' => $latestVersion, + 'has_update' => $asset->installed_version && + version_compare($latestVersion, $asset->installed_version, '>'), + ]; + } + } + + return ['status' => 'error', 'message' => 'Could not fetch package info']; + } + + /** + * Check Font Awesome kit for updates. + */ + protected function checkFontAsset(Asset $asset): array + { + // Font Awesome kits auto-update, just verify the kit is valid + $kitId = $asset->licence_meta['kit_id'] ?? null; + + if (! $kitId) { + return ['status' => 'info', 'message' => 'No kit ID configured']; + } + + // Can't easily check FA API without auth, mark as checked + return [ + 'status' => 'success', + 'message' => 'Font kit configured - auto-updates via CDN', + ]; + } + + /** + * Parse a release timestamp safely. + * + * Handles various timestamp formats from Packagist and NPM. + */ + protected function parseReleaseTimestamp(?string $time): ?Carbon + { + if (empty($time)) { + return null; + } + + try { + return Carbon::parse($time); + } catch (\Exception $e) { + Log::warning('Uptelligence: Failed to parse release timestamp', [ + 'time' => $time, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Record a new version in history. + */ + protected function recordVersion(Asset $asset, string $version, array $data = []): void + { + $releasedAt = $this->parseReleaseTimestamp($data['time'] ?? null); + + AssetVersion::updateOrCreate( + [ + 'asset_id' => $asset->id, + 'version' => $version, + ], + [ + 'changelog' => $data['description'] ?? null, + 'download_url' => $data['dist']['url'] ?? null, + 'released_at' => $releasedAt, + ] + ); + } + + /** + * Update an asset to its latest version. + */ + public function updateAsset(Asset $asset): array + { + return match ($asset->type) { + Asset::TYPE_COMPOSER => $this->updateComposerPackage($asset), + Asset::TYPE_NPM => $this->updateNpmPackage($asset), + default => ['status' => 'skipped', 'message' => 'Manual update required'], + }; + } + + /** + * Update a Composer package. + */ + protected function updateComposerPackage(Asset $asset): array + { + if (! $asset->package_name) { + return ['status' => 'error', 'message' => 'No package name']; + } + + $result = Process::timeout(300)->run( + "composer update {$asset->package_name} --no-interaction" + ); + + if ($result->successful()) { + // Get new installed version + $showResult = Process::run("composer show {$asset->package_name} --format=json"); + if ($showResult->successful()) { + $data = json_decode($showResult->output(), true); + $newVersion = $data['versions'][0] ?? $asset->latest_version; + $asset->update(['installed_version' => $newVersion]); + } + + return ['status' => 'success', 'message' => 'Package updated']; + } + + return ['status' => 'error', 'message' => $result->errorOutput()]; + } + + /** + * Update an NPM package. + */ + protected function updateNpmPackage(Asset $asset): array + { + if (! $asset->package_name) { + return ['status' => 'error', 'message' => 'No package name']; + } + + $result = Process::timeout(300)->run("npm update {$asset->package_name}"); + + if ($result->successful()) { + $asset->update(['installed_version' => $asset->latest_version]); + + return ['status' => 'success', 'message' => 'Package updated']; + } + + return ['status' => 'error', 'message' => $result->errorOutput()]; + } + + /** + * Sync installed versions from composer.lock and package-lock.json. + */ + public function syncInstalledVersions(string $projectPath): array + { + $synced = []; + + // Sync from composer.lock + $composerLock = $projectPath.'/composer.lock'; + if (file_exists($composerLock)) { + $lock = json_decode(file_get_contents($composerLock), true); + $packages = array_merge( + $lock['packages'] ?? [], + $lock['packages-dev'] ?? [] + ); + + foreach ($packages as $package) { + $asset = Asset::where('package_name', $package['name']) + ->where('type', Asset::TYPE_COMPOSER) + ->first(); + + if ($asset) { + $version = ltrim($package['version'], 'v'); + $asset->update(['installed_version' => $version]); + $synced[] = $asset->slug; + } + } + } + + // Sync from package-lock.json + $packageLock = $projectPath.'/package-lock.json'; + if (file_exists($packageLock)) { + $lock = json_decode(file_get_contents($packageLock), true); + $packages = $lock['packages'] ?? []; + + foreach ($packages as $name => $data) { + // Skip root package and nested deps + if (! $name || str_starts_with($name, 'node_modules/node_modules')) { + continue; + } + + $packageName = str_replace('node_modules/', '', $name); + $asset = Asset::where('package_name', $packageName) + ->where('type', Asset::TYPE_NPM) + ->first(); + + if ($asset) { + $asset->update(['installed_version' => $data['version']]); + $synced[] = $asset->slug; + } + } + } + + return $synced; + } +} diff --git a/Services/DiffAnalyzerService.php b/Services/DiffAnalyzerService.php new file mode 100644 index 0000000..87ffa04 --- /dev/null +++ b/Services/DiffAnalyzerService.php @@ -0,0 +1,334 @@ +vendor = $vendor; + } + + /** + * Analyse differences between two versions. + */ + public function analyze(string $previousVersion, string $currentVersion): VersionRelease + { + $this->previousPath = $this->vendor->getStoragePath($previousVersion); + $this->currentPath = $this->vendor->getStoragePath($currentVersion); + + // Create version release record + $release = VersionRelease::create([ + 'vendor_id' => $this->vendor->id, + 'version' => $currentVersion, + 'previous_version' => $previousVersion, + 'storage_path' => $this->currentPath, + ]); + + AnalysisLog::logAnalysisStarted($release); + + try { + // Get all file changes + $changes = $this->getFileChanges(); + + // Cache the diffs + $stats = $this->cacheDiffs($release, $changes); + + // Update release with stats + $release->update([ + 'files_added' => $stats['added'], + 'files_modified' => $stats['modified'], + 'files_removed' => $stats['removed'], + 'analyzed_at' => now(), + ]); + + AnalysisLog::logAnalysisCompleted($release, $stats); + + return $release; + } catch (\Exception $e) { + AnalysisLog::logAnalysisFailed($release, $e->getMessage()); + throw $e; + } + } + + /** + * Get all file changes between versions using diff. + */ + protected function getFileChanges(): array + { + $changes = [ + 'added' => [], + 'modified' => [], + 'removed' => [], + ]; + + // Get list of all files in both versions + $previousFiles = $this->getFileList($this->previousPath); + $currentFiles = $this->getFileList($this->currentPath); + + // Find added files + $addedFiles = array_diff($currentFiles, $previousFiles); + foreach ($addedFiles as $file) { + if (! $this->shouldIgnore($file)) { + $changes['added'][] = $file; + } + } + + // Find removed files + $removedFiles = array_diff($previousFiles, $currentFiles); + foreach ($removedFiles as $file) { + if (! $this->shouldIgnore($file)) { + $changes['removed'][] = $file; + } + } + + // Find modified files + $commonFiles = array_intersect($previousFiles, $currentFiles); + foreach ($commonFiles as $file) { + if ($this->shouldIgnore($file)) { + continue; + } + + $prevPath = $this->previousPath.'/'.$file; + $currPath = $this->currentPath.'/'.$file; + + if ($this->filesAreDifferent($prevPath, $currPath)) { + $changes['modified'][] = $file; + } + } + + return $changes; + } + + /** + * Get list of all files in a directory recursively. + */ + protected function getFileList(string $basePath): array + { + if (! File::isDirectory($basePath)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $relativePath = str_replace($basePath.'/', '', $file->getPathname()); + $files[] = $relativePath; + } + } + + return $files; + } + + /** + * Check if a file should be ignored. + */ + protected function shouldIgnore(string $path): bool + { + return $this->vendor->shouldIgnorePath($path); + } + + /** + * Check if two files are different. + */ + protected function filesAreDifferent(string $path1, string $path2): bool + { + if (! File::exists($path1) || ! File::exists($path2)) { + return true; + } + + // Quick hash comparison + return md5_file($path1) !== md5_file($path2); + } + + /** + * Cache all diffs in the database. + * + * Uses a database transaction to ensure atomic operations - + * if any diff fails to save, all changes are rolled back. + */ + protected function cacheDiffs(VersionRelease $release, array $changes): array + { + $stats = ['added' => 0, 'modified' => 0, 'removed' => 0]; + + DB::transaction(function () use ($release, $changes, &$stats) { + // Cache added files + foreach ($changes['added'] as $file) { + $filePath = $this->currentPath.'/'.$file; + $content = File::exists($filePath) ? File::get($filePath) : null; + + DiffCache::create([ + 'version_release_id' => $release->id, + 'file_path' => $file, + 'change_type' => DiffCache::CHANGE_ADDED, + 'new_content' => $content, + 'category' => DiffCache::detectCategory($file), + ]); + $stats['added']++; + } + + // Cache modified files with diff + foreach ($changes['modified'] as $file) { + $diff = $this->generateDiff($file); + + DiffCache::create([ + 'version_release_id' => $release->id, + 'file_path' => $file, + 'change_type' => DiffCache::CHANGE_MODIFIED, + 'diff_content' => $diff, + 'category' => DiffCache::detectCategory($file), + ]); + $stats['modified']++; + } + + // Cache removed files + foreach ($changes['removed'] as $file) { + DiffCache::create([ + 'version_release_id' => $release->id, + 'file_path' => $file, + 'change_type' => DiffCache::CHANGE_REMOVED, + 'category' => DiffCache::detectCategory($file), + ]); + $stats['removed']++; + } + }); + + return $stats; + } + + /** + * Validate that a path is safe and doesn't contain path traversal attempts. + * + * @throws InvalidArgumentException if path is invalid + */ + protected function validatePath(string $path, string $basePath): string + { + // Check for path traversal attempts + if (str_contains($path, '..') || str_contains($path, "\0")) { + Log::warning('Uptelligence: Path traversal attempt detected', [ + 'path' => $path, + 'basePath' => $basePath, + ]); + throw new InvalidArgumentException('Invalid path: path traversal not allowed'); + } + + $fullPath = $basePath.'/'.$path; + $realPath = realpath($fullPath); + $realBasePath = realpath($basePath); + + // If path doesn't exist yet, validate the directory portion + if ($realPath === false) { + $dirPath = dirname($fullPath); + $realDirPath = realpath($dirPath); + + if ($realDirPath === false || ! str_starts_with($realDirPath, $realBasePath)) { + Log::warning('Uptelligence: Path escapes base directory', [ + 'path' => $path, + 'basePath' => $basePath, + ]); + throw new InvalidArgumentException('Invalid path: must be within base directory'); + } + + return $fullPath; + } + + // Ensure the real path is within the base path + if (! str_starts_with($realPath, $realBasePath)) { + Log::warning('Uptelligence: Path escapes base directory', [ + 'path' => $path, + 'realPath' => $realPath, + 'basePath' => $basePath, + ]); + throw new InvalidArgumentException('Invalid path: must be within base directory'); + } + + return $realPath; + } + + /** + * Generate diff for a file. + * + * Uses array-based Process invocation to prevent shell injection. + * Validates paths to prevent path traversal attacks. + */ + protected function generateDiff(string $file): string + { + // Validate paths before using them + $prevPath = $this->validatePath($file, $this->previousPath); + $currPath = $this->validatePath($file, $this->currentPath); + + // Use array syntax to prevent shell injection - paths are passed as separate arguments + // rather than being interpolated into a shell command string + $result = Process::run(['diff', '-u', $prevPath, $currPath]); + + return $result->output(); + } + + /** + * Get priority files that changed. + */ + public function getPriorityChanges(VersionRelease $release): Collection + { + return $release->diffs() + ->get() + ->filter(fn ($diff) => $this->vendor->isPriorityPath($diff->file_path)); + } + + /** + * Get security-related changes. + */ + public function getSecurityChanges(VersionRelease $release): Collection + { + return $release->diffs() + ->where('category', DiffCache::CATEGORY_SECURITY) + ->get(); + } + + /** + * Generate summary statistics. + */ + public function getSummary(VersionRelease $release): array + { + $diffs = $release->diffs; + + return [ + 'total_changes' => $diffs->count(), + 'by_type' => [ + 'added' => $diffs->where('change_type', DiffCache::CHANGE_ADDED)->count(), + 'modified' => $diffs->where('change_type', DiffCache::CHANGE_MODIFIED)->count(), + 'removed' => $diffs->where('change_type', DiffCache::CHANGE_REMOVED)->count(), + ], + 'by_category' => $diffs->groupBy('category')->map->count()->toArray(), + 'priority_files' => $this->getPriorityChanges($release)->count(), + 'security_files' => $this->getSecurityChanges($release)->count(), + ]; + } +} diff --git a/Services/IssueGeneratorService.php b/Services/IssueGeneratorService.php new file mode 100644 index 0000000..7cc47e1 --- /dev/null +++ b/Services/IssueGeneratorService.php @@ -0,0 +1,474 @@ +githubToken = config('upstream.github.token', ''); + $this->giteaUrl = config('upstream.gitea.url', ''); + $this->giteaToken = config('upstream.gitea.token', ''); + $this->defaultLabels = config('upstream.github.default_labels', ['upstream']); + $this->assignees = array_filter(config('upstream.github.assignees', [])); + } + + /** + * Validate target_repo format (should be 'owner/repo'). + * + * @throws InvalidArgumentException if format is invalid + */ + protected function validateTargetRepo(?string $targetRepo): bool + { + if (empty($targetRepo)) { + return false; + } + + // Must be in format 'owner/repo' with no extra slashes + if (! preg_match('#^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$#', $targetRepo)) { + Log::warning('Uptelligence: Invalid target_repo format', [ + 'target_repo' => $targetRepo, + 'expected_format' => 'owner/repo', + ]); + + return false; + } + + return true; + } + + /** + * Create GitHub issues for all pending todos. + */ + public function createIssuesForVendor(Vendor $vendor, bool $useGitea = false): Collection + { + // Validate target_repo format before processing + if (! $this->validateTargetRepo($vendor->target_repo)) { + Log::error('Uptelligence: Cannot create issues - invalid target_repo', [ + 'vendor' => $vendor->slug, + 'target_repo' => $vendor->target_repo, + ]); + + return collect(); + } + + $todos = $vendor->todos() + ->where('status', UpstreamTodo::STATUS_PENDING) + ->whereNull('github_issue_number') + ->orderByDesc('priority') + ->get(); + + $issues = collect(); + + foreach ($todos as $todo) { + // Check rate limit before creating issue + if (RateLimiter::tooManyAttempts('upstream-issues', 10)) { + $seconds = RateLimiter::availableIn('upstream-issues'); + Log::warning('Uptelligence: Issue creation rate limit exceeded', [ + 'vendor' => $vendor->slug, + 'retry_after_seconds' => $seconds, + ]); + break; + } + + try { + if ($useGitea) { + $issue = $this->createGiteaIssue($todo); + } else { + $issue = $this->createGitHubIssue($todo); + } + + if ($issue) { + $issues->push($issue); + RateLimiter::hit('upstream-issues'); + } + } catch (\Exception $e) { + Log::error('Uptelligence: Failed to create issue', [ + 'vendor' => $vendor->slug, + 'todo_id' => $todo->id, + 'todo_title' => $todo->title, + 'error' => $e->getMessage(), + 'exception_class' => get_class($e), + ]); + report($e); + } + } + + return $issues; + } + + /** + * Create a GitHub issue for a todo with retry logic. + */ + public function createGitHubIssue(UpstreamTodo $todo): ?array + { + if (! $this->githubToken || ! $this->validateTargetRepo($todo->vendor->target_repo)) { + return null; + } + + $body = $this->buildIssueBody($todo); + $labels = $this->buildLabels($todo); + + [$owner, $repo] = explode('/', $todo->vendor->target_repo); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->githubToken, + 'Accept' => 'application/vnd.github.v3+json', + ]) + ->timeout(30) + ->retry(3, function (int $attempt, \Exception $exception) { + // Exponential backoff: 1s, 2s, 4s + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: GitHub API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + // Only retry on connection/timeout errors or 5xx/429 responses + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [ + 'title' => $this->buildIssueTitle($todo), + 'body' => $body, + 'labels' => $labels, + 'assignees' => $this->assignees, + ]); + + if ($response->successful()) { + $issue = $response->json(); + + $todo->update([ + 'github_issue_number' => $issue['number'], + ]); + + AnalysisLog::logIssueCreated($todo, $issue['html_url']); + + return $issue; + } + + Log::error('Uptelligence: GitHub issue creation failed', [ + 'todo_id' => $todo->id, + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + + return null; + } + + /** + * Create a Gitea issue for a todo with retry logic. + */ + public function createGiteaIssue(UpstreamTodo $todo): ?array + { + if (! $this->giteaToken || ! $this->giteaUrl || ! $this->validateTargetRepo($todo->vendor->target_repo)) { + return null; + } + + $body = $this->buildIssueBody($todo); + + [$owner, $repo] = explode('/', $todo->vendor->target_repo); + + $response = Http::withHeaders([ + 'Authorization' => 'token '.$this->giteaToken, + 'Accept' => 'application/json', + ]) + ->timeout(30) + ->retry(3, function (int $attempt, \Exception $exception) { + // Exponential backoff: 1s, 2s, 4s + $delay = (int) pow(2, $attempt - 1) * 1000; + + Log::warning('Uptelligence: Gitea API retry', [ + 'attempt' => $attempt, + 'delay_ms' => $delay, + 'error' => $exception->getMessage(), + ]); + + return $delay; + }, function (\Exception $exception) { + // Only retry on connection/timeout errors or 5xx/429 responses + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }) + ->post("{$this->giteaUrl}/api/v1/repos/{$owner}/{$repo}/issues", [ + 'title' => $this->buildIssueTitle($todo), + 'body' => $body, + 'labels' => [], // Gitea handles labels differently + ]); + + if ($response->successful()) { + $issue = $response->json(); + + $todo->update([ + 'github_issue_number' => (string) $issue['number'], + ]); + + $issueUrl = "{$this->giteaUrl}/{$owner}/{$repo}/issues/{$issue['number']}"; + AnalysisLog::logIssueCreated($todo, $issueUrl); + + return $issue; + } + + Log::error('Uptelligence: Gitea issue creation failed', [ + 'todo_id' => $todo->id, + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + + return null; + } + + /** + * Build issue title. + */ + protected function buildIssueTitle(UpstreamTodo $todo): string + { + $icon = $todo->getTypeIcon(); + $prefix = '[Upstream] '; + + return $prefix.$icon.' '.$todo->title; + } + + /** + * Build issue body with all relevant info. + */ + protected function buildIssueBody(UpstreamTodo $todo): string + { + $body = "## Upstream Change\n\n"; + $body .= "**Vendor:** {$todo->vendor->name} ({$todo->vendor->vendor_name})\n"; + $body .= "**Version:** {$todo->from_version} → {$todo->to_version}\n"; + $body .= "**Type:** {$todo->type}\n"; + $body .= "**Priority:** {$todo->priority}/10 ({$todo->getPriorityLabel()})\n"; + $body .= "**Effort:** {$todo->getEffortLabel()}\n\n"; + + if ($todo->description) { + $body .= "## Description\n\n{$todo->description}\n\n"; + } + + if ($todo->port_notes) { + $body .= "## Porting Notes\n\n{$todo->port_notes}\n\n"; + } + + if ($todo->has_conflicts) { + $body .= "## ⚠️ Potential Conflicts\n\n{$todo->conflict_reason}\n\n"; + } + + if (! empty($todo->files)) { + $body .= "## Files Changed\n\n"; + foreach ($todo->files as $file) { + $mapped = $todo->vendor->mapToHostHub($file); + if ($mapped) { + $body .= "- `{$file}` → `{$mapped}`\n"; + } else { + $body .= "- `{$file}`\n"; + } + } + $body .= "\n"; + } + + if (! empty($todo->dependencies)) { + $body .= "## Dependencies\n\n"; + foreach ($todo->dependencies as $dep) { + $body .= "- {$dep}\n"; + } + $body .= "\n"; + } + + if (! empty($todo->tags)) { + $body .= "## Tags\n\n"; + $body .= implode(', ', array_map(fn ($t) => "`{$t}`", $todo->tags))."\n\n"; + } + + $body .= "---\n"; + $body .= "_Auto-generated by Upstream Intelligence Pipeline_\n"; + $body .= '_AI Confidence: '.round(($todo->ai_confidence ?? 0.85) * 100)."%_\n"; + + return $body; + } + + /** + * Build labels for the issue. + */ + protected function buildLabels(UpstreamTodo $todo): array + { + $labels = $this->defaultLabels; + + // Add type label + $labels[] = 'type:'.$todo->type; + + // Add priority label + if ($todo->priority >= 8) { + $labels[] = 'priority:high'; + } elseif ($todo->priority >= 5) { + $labels[] = 'priority:medium'; + } else { + $labels[] = 'priority:low'; + } + + // Add effort label + $labels[] = 'effort:'.$todo->effort; + + // Add quick-win label + if ($todo->isQuickWin()) { + $labels[] = 'quick-win'; + } + + // Add vendor label + $labels[] = 'vendor:'.$todo->vendor->slug; + + return $labels; + } + + /** + * Create a weekly digest issue. + */ + public function createWeeklyDigest(Vendor $vendor): ?array + { + $todos = $vendor->todos() + ->where('status', UpstreamTodo::STATUS_PENDING) + ->whereNull('github_issue_number') + ->where('created_at', '>=', now()->subWeek()) + ->orderByDesc('priority') + ->get(); + + if ($todos->isEmpty()) { + return null; + } + + $title = "[Weekly Digest] {$vendor->name} - ".now()->format('M d, Y'); + $body = $this->buildDigestBody($vendor, $todos); + + if (! $this->githubToken || ! $vendor->target_repo) { + return null; + } + + [$owner, $repo] = explode('/', $vendor->target_repo); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->githubToken, + 'Accept' => 'application/vnd.github.v3+json', + ])->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => ['upstream', 'digest'], + ]); + + if ($response->successful()) { + return $response->json(); + } + + return null; + } + + /** + * Build weekly digest body. + */ + protected function buildDigestBody(Vendor $vendor, Collection $todos): string + { + $body = "# Weekly Upstream Digest\n\n"; + $body .= "**Vendor:** {$vendor->name}\n"; + $body .= '**Week of:** '.now()->subWeek()->format('M d').' - '.now()->format('M d, Y')."\n"; + $body .= "**Total Changes:** {$todos->count()}\n\n"; + + // Quick wins + $quickWins = $todos->filter->isQuickWin(); + if ($quickWins->isNotEmpty()) { + $body .= "## 🚀 Quick Wins ({$quickWins->count()})\n\n"; + foreach ($quickWins as $todo) { + $body .= "- {$todo->getTypeIcon()} {$todo->title}\n"; + } + $body .= "\n"; + } + + // Security + $security = $todos->where('type', 'security'); + if ($security->isNotEmpty()) { + $body .= "## 🔒 Security Updates ({$security->count()})\n\n"; + foreach ($security as $todo) { + $body .= "- {$todo->title}\n"; + } + $body .= "\n"; + } + + // Features + $features = $todos->where('type', 'feature'); + if ($features->isNotEmpty()) { + $body .= "## ✨ New Features ({$features->count()})\n\n"; + foreach ($features as $todo) { + $body .= "- {$todo->title} (Priority: {$todo->priority}/10)\n"; + } + $body .= "\n"; + } + + // Bug fixes + $bugfixes = $todos->where('type', 'bugfix'); + if ($bugfixes->isNotEmpty()) { + $body .= "## 🐛 Bug Fixes ({$bugfixes->count()})\n\n"; + foreach ($bugfixes as $todo) { + $body .= "- {$todo->title}\n"; + } + $body .= "\n"; + } + + // Other + $other = $todos->whereNotIn('type', ['feature', 'bugfix', 'security'])->where(fn ($t) => ! $t->isQuickWin()); + if ($other->isNotEmpty()) { + $body .= "## 📝 Other Changes ({$other->count()})\n\n"; + foreach ($other as $todo) { + $body .= "- {$todo->getTypeIcon()} {$todo->title}\n"; + } + $body .= "\n"; + } + + $body .= "---\n"; + $body .= "_Auto-generated by Upstream Intelligence Pipeline_\n"; + + return $body; + } +} diff --git a/Services/UpstreamPlanGeneratorService.php b/Services/UpstreamPlanGeneratorService.php new file mode 100644 index 0000000..dbdff19 --- /dev/null +++ b/Services/UpstreamPlanGeneratorService.php @@ -0,0 +1,433 @@ +agenticModuleAvailable()) { + report(new \RuntimeException('Agentic module not available - cannot generate plan from release')); + + return null; + } + + $vendor = $release->vendor; + $todos = UpstreamTodo::where('vendor_id', $vendor->id) + ->where('from_version', $release->previous_version) + ->where('to_version', $release->version) + ->where('status', 'pending') + ->orderByDesc('priority') + ->get(); + + if ($todos->isEmpty()) { + return null; + } + + return $this->createPlanFromTodos($vendor, $release, $todos, $options); + } + + /** + * Generate an AgentPlan from vendor's pending todos. + * + * @return \Mod\Agentic\Models\AgentPlan|null Returns null if Agentic module unavailable or no todos + */ + public function generateFromVendor(Vendor $vendor, array $options = []): mixed + { + if (! $this->agenticModuleAvailable()) { + report(new \RuntimeException('Agentic module not available - cannot generate plan from vendor')); + + return null; + } + + $todos = UpstreamTodo::where('vendor_id', $vendor->id) + ->where('status', 'pending') + ->orderByDesc('priority') + ->get(); + + if ($todos->isEmpty()) { + return null; + } + + $release = $vendor->releases()->latest()->first(); + + return $this->createPlanFromTodos($vendor, $release, $todos, $options); + } + + /** + * Create AgentPlan from a collection of todos. + * + * @return \Mod\Agentic\Models\AgentPlan + */ + protected function createPlanFromTodos( + Vendor $vendor, + ?VersionRelease $release, + Collection $todos, + array $options = [] + ): mixed { + $version = $release?->version ?? $vendor->current_version ?? 'latest'; + $activateImmediately = $options['activate'] ?? false; + $includeContext = $options['include_context'] ?? true; + + // Create plan title + $title = $options['title'] ?? "Port {$vendor->name} {$version}"; + $slug = \Mod\Agentic\Models\AgentPlan::generateSlug($title); + + // Build context + $context = $includeContext ? $this->buildContext($vendor, $release, $todos) : null; + + // Group todos by type for phases + $groupedTodos = $this->groupTodosForPhases($todos); + + // Create the plan + $plan = \Mod\Agentic\Models\AgentPlan::create([ + 'slug' => $slug, + 'title' => $title, + 'description' => $this->buildDescription($vendor, $release, $todos), + 'context' => $context, + 'status' => $activateImmediately ? \Mod\Agentic\Models\AgentPlan::STATUS_ACTIVE : \Mod\Agentic\Models\AgentPlan::STATUS_DRAFT, + 'metadata' => [ + 'source' => 'upstream_analysis', + 'vendor_id' => $vendor->id, + 'vendor_slug' => $vendor->slug, + 'version_release_id' => $release?->id, + 'version' => $version, + 'todo_count' => $todos->count(), + 'generated_at' => now()->toIso8601String(), + ], + ]); + + // Create phases + $this->createPhasesFromGroupedTodos($plan, $groupedTodos); + + return $plan->fresh(['agentPhases']); + } + + /** + * Group todos into logical phases. + */ + protected function groupTodosForPhases(Collection $todos): array + { + // Define phase order and groupings + $phaseConfig = [ + 'security' => [ + 'name' => 'Security Updates', + 'description' => 'Critical security patches that should be applied first', + 'types' => ['security'], + 'priority' => 1, + ], + 'database' => [ + 'name' => 'Database & Schema Changes', + 'description' => 'Database migrations and schema updates', + 'types' => ['migration', 'database'], + 'priority' => 2, + ], + 'core' => [ + 'name' => 'Core Feature Updates', + 'description' => 'Main feature implementations and bug fixes', + 'types' => ['feature', 'bugfix', 'block'], + 'priority' => 3, + ], + 'api' => [ + 'name' => 'API Changes', + 'description' => 'API endpoint and integration updates', + 'types' => ['api'], + 'priority' => 4, + ], + 'ui' => [ + 'name' => 'UI & Frontend Changes', + 'description' => 'User interface and visual updates', + 'types' => ['ui', 'view'], + 'priority' => 5, + ], + 'refactor' => [ + 'name' => 'Refactoring & Dependencies', + 'description' => 'Code refactoring and dependency updates', + 'types' => ['refactor', 'dependency'], + 'priority' => 6, + ], + ]; + + $phases = []; + $assignedTodoIds = []; + + // Assign todos to phases based on type + foreach ($phaseConfig as $phaseKey => $config) { + $phaseTodos = $todos->filter(function ($todo) use ($config, $assignedTodoIds) { + return in_array($todo->type, $config['types']) && + ! in_array($todo->id, $assignedTodoIds); + }); + + if ($phaseTodos->isNotEmpty()) { + $phases[$phaseKey] = [ + 'config' => $config, + 'todos' => $phaseTodos, + ]; + $assignedTodoIds = array_merge($assignedTodoIds, $phaseTodos->pluck('id')->toArray()); + } + } + + // Handle any remaining unassigned todos + $remainingTodos = $todos->filter(fn ($todo) => ! in_array($todo->id, $assignedTodoIds)); + if ($remainingTodos->isNotEmpty()) { + $phases['other'] = [ + 'config' => [ + 'name' => 'Other Changes', + 'description' => 'Additional updates and changes', + 'priority' => 99, + ], + 'todos' => $remainingTodos, + ]; + } + + // Sort by priority + uasort($phases, fn ($a, $b) => ($a['config']['priority'] ?? 99) <=> ($b['config']['priority'] ?? 99)); + + return $phases; + } + + /** + * Create AgentPhases from grouped todos. + * + * @param \Mod\Agentic\Models\AgentPlan $plan + */ + protected function createPhasesFromGroupedTodos(mixed $plan, array $groupedPhases): void + { + $order = 1; + + foreach ($groupedPhases as $phaseKey => $phaseData) { + $config = $phaseData['config']; + $todos = $phaseData['todos']; + + // Build tasks from todos + $tasks = $todos->map(function ($todo) { + return [ + 'name' => $todo->title, + 'status' => 'pending', + 'notes' => $todo->description, + 'todo_id' => $todo->id, + 'priority' => $todo->priority, + 'effort' => $todo->effort, + 'files' => $todo->files, + ]; + })->sortByDesc('priority')->values()->toArray(); + + // Create the phase + \Mod\Agentic\Models\AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'order' => $order, + 'name' => $config['name'], + 'description' => $config['description'] ?? null, + 'tasks' => $tasks, + 'status' => \Mod\Agentic\Models\AgentPhase::STATUS_PENDING, + 'metadata' => [ + 'phase_key' => $phaseKey, + 'todo_count' => $todos->count(), + 'todo_ids' => $todos->pluck('id')->toArray(), + ], + ]); + + $order++; + } + + // Add review phase + \Mod\Agentic\Models\AgentPhase::create([ + 'agent_plan_id' => $plan->id, + 'order' => $order, + 'name' => 'Review & Testing', + 'description' => 'Final review, testing, and documentation updates', + 'tasks' => [ + ['name' => 'Run test suite', 'status' => 'pending'], + ['name' => 'Review all changes', 'status' => 'pending'], + ['name' => 'Update documentation', 'status' => 'pending'], + ['name' => 'Create PR/merge request', 'status' => 'pending'], + ], + 'status' => \Mod\Agentic\Models\AgentPhase::STATUS_PENDING, + 'metadata' => [ + 'phase_key' => 'review', + 'is_final' => true, + ], + ]); + } + + /** + * Build context string for the plan. + */ + protected function buildContext(Vendor $vendor, ?VersionRelease $release, Collection $todos): string + { + $context = "## Upstream Porting Context\n\n"; + $context .= "**Vendor:** {$vendor->name} ({$vendor->vendor_name})\n"; + $context .= "**Source Type:** {$vendor->getSourceTypeLabel()}\n"; + + if ($release) { + $context .= "**Version:** {$release->getVersionCompare()}\n"; + $context .= "**Files Changed:** {$release->getTotalChanges()}\n"; + } + + $context .= "**Total Todos:** {$todos->count()}\n\n"; + + // Quick stats + $byType = $todos->groupBy('type'); + $context .= "### Changes by Type\n\n"; + foreach ($byType as $type => $items) { + $context .= "- **{$type}:** {$items->count()}\n"; + } + + // Path mapping info + if ($vendor->path_mapping) { + $context .= "\n### Path Mapping\n\n"; + foreach ($vendor->path_mapping as $from => $to) { + $context .= "- `{$from}` → `{$to}`\n"; + } + } + + // Target repo + if ($vendor->target_repo) { + $context .= "\n**Target Repository:** {$vendor->target_repo}\n"; + } + + // Quick wins + $quickWins = $todos->filter(fn ($t) => $t->effort === 'low' && $t->priority >= 5); + if ($quickWins->isNotEmpty()) { + $context .= "\n### Quick Wins ({$quickWins->count()})\n\n"; + foreach ($quickWins->take(5) as $todo) { + $context .= "- {$todo->title}\n"; + } + if ($quickWins->count() > 5) { + $context .= '- ... and '.($quickWins->count() - 5)." more\n"; + } + } + + // Security items + $security = $todos->where('type', 'security'); + if ($security->isNotEmpty()) { + $context .= "\n### Security Updates ({$security->count()})\n\n"; + foreach ($security as $todo) { + $context .= "- {$todo->title}\n"; + } + } + + return $context; + } + + /** + * Build description for the plan. + */ + protected function buildDescription(Vendor $vendor, ?VersionRelease $release, Collection $todos): string + { + $desc = "Auto-generated plan for porting {$vendor->name} updates"; + + if ($release) { + $desc .= " from version {$release->previous_version} to {$release->version}"; + } + + $desc .= ". Contains {$todos->count()} items"; + + $security = $todos->where('type', 'security')->count(); + if ($security > 0) { + $desc .= " including {$security} security update(s)"; + } + + $desc .= '.'; + + return $desc; + } + + /** + * Sync AgentPlan tasks with UpstreamTodo status. + * + * @param \Mod\Agentic\Models\AgentPlan $plan + */ + public function syncPlanWithTodos(mixed $plan): int + { + if (! $this->agenticModuleAvailable()) { + report(new \RuntimeException('Agentic module not available - cannot sync plan with todos')); + + return 0; + } + + $synced = 0; + + foreach ($plan->agentPhases as $phase) { + $tasks = $phase->tasks ?? []; + $updated = false; + + foreach ($tasks as $i => $task) { + if (! isset($task['todo_id'])) { + continue; + } + + $todo = UpstreamTodo::find($task['todo_id']); + if (! $todo) { + continue; + } + + // Sync status + $newStatus = match ($todo->status) { + 'ported', 'wont_port', 'skipped' => 'completed', + 'in_progress' => 'in_progress', + default => 'pending', + }; + + if (($task['status'] ?? 'pending') !== $newStatus) { + $tasks[$i]['status'] = $newStatus; + $updated = true; + $synced++; + } + } + + if ($updated) { + $phase->update(['tasks' => $tasks]); + } + } + + return $synced; + } + + /** + * Mark upstream todo as ported when task is completed. + */ + public function markTodoAsPorted(int $todoId): bool + { + $todo = UpstreamTodo::find($todoId); + if (! $todo) { + return false; + } + + $todo->update([ + 'status' => 'ported', + 'completed_at' => now(), + ]); + + return true; + } +} diff --git a/Services/UptelligenceDigestService.php b/Services/UptelligenceDigestService.php new file mode 100644 index 0000000..64c0459 --- /dev/null +++ b/Services/UptelligenceDigestService.php @@ -0,0 +1,271 @@ +last_sent_at ?? now()->subMonth(); + $vendorIds = $digest->getVendorIds(); + $minPriority = $digest->getMinPriority(); + + // Build base vendor query + $vendorQuery = Vendor::active(); + if ($vendorIds !== null) { + $vendorQuery->whereIn('id', $vendorIds); + } + $trackedVendorIds = $vendorQuery->pluck('id'); + + // Gather new releases + $releases = collect(); + if ($digest->includesReleases()) { + $releases = $this->getNewReleases($trackedVendorIds, $sinceDate); + } + + // Gather pending todos grouped by priority + $todosByPriority = []; + if ($digest->includesTodos()) { + $todosByPriority = $this->getTodosByPriority($trackedVendorIds, $minPriority); + } + + // Count security-related updates + $securityCount = 0; + if ($digest->includesSecurity()) { + $securityCount = $this->getSecurityUpdatesCount($trackedVendorIds, $sinceDate); + } + + $hasContent = $releases->isNotEmpty() + || ! empty(array_filter($todosByPriority)) + || $securityCount > 0; + + return [ + 'releases' => $releases, + 'todos' => $todosByPriority, + 'security_count' => $securityCount, + 'has_content' => $hasContent, + ]; + } + + /** + * Get new releases since the given date. + */ + protected function getNewReleases(Collection $vendorIds, \DateTimeInterface $since): Collection + { + return VersionRelease::whereIn('vendor_id', $vendorIds) + ->where('created_at', '>=', $since) + ->analyzed() + ->with('vendor:id,name,slug') + ->orderByDesc('created_at') + ->take(20) + ->get() + ->map(fn (VersionRelease $release) => [ + 'vendor_name' => $release->vendor->name, + 'vendor_slug' => $release->vendor->slug, + 'version' => $release->version, + 'previous_version' => $release->previous_version, + 'files_changed' => $release->getTotalChanges(), + 'impact_level' => $release->getImpactLevel(), + 'todos_created' => $release->todos_created ?? 0, + 'analyzed_at' => $release->analyzed_at, + ]); + } + + /** + * Get pending todos grouped by priority level. + * + * @return array{critical: int, high: int, medium: int, low: int, total: int} + */ + protected function getTodosByPriority(Collection $vendorIds, ?int $minPriority): array + { + $query = UpstreamTodo::whereIn('vendor_id', $vendorIds) + ->pending(); + + if ($minPriority !== null) { + $query->where('priority', '>=', $minPriority); + } + + $todos = $query->get(['priority']); + + return [ + 'critical' => $todos->where('priority', '>=', 8)->count(), + 'high' => $todos->whereBetween('priority', [6, 7])->count(), + 'medium' => $todos->whereBetween('priority', [4, 5])->count(), + 'low' => $todos->where('priority', '<', 4)->count(), + 'total' => $todos->count(), + ]; + } + + /** + * Get count of security-related updates since the given date. + */ + protected function getSecurityUpdatesCount(Collection $vendorIds, \DateTimeInterface $since): int + { + return UpstreamTodo::whereIn('vendor_id', $vendorIds) + ->securityRelated() + ->pending() + ->where('created_at', '>=', $since) + ->count(); + } + + /** + * Send a digest notification to a user. + */ + public function sendDigest(UptelligenceDigest $digest): bool + { + $content = $this->generateDigestContent($digest); + + // Skip if there's nothing to report + if (! $content['has_content']) { + Log::debug('Uptelligence: Skipping empty digest', [ + 'user_id' => $digest->user_id, + 'workspace_id' => $digest->workspace_id, + ]); + + // Still mark as sent to prevent re-checking + $digest->markAsSent(); + + return false; + } + + try { + $digest->user->notify(new SendUptelligenceDigest( + digest: $digest, + releases: $content['releases'], + todosByPriority: $content['todos'], + securityCount: $content['security_count'], + )); + + $digest->markAsSent(); + + Log::info('Uptelligence: Digest sent successfully', [ + 'user_id' => $digest->user_id, + 'workspace_id' => $digest->workspace_id, + 'releases_count' => $content['releases']->count(), + 'todos_count' => $content['todos']['total'] ?? 0, + ]); + + return true; + } catch (\Exception $e) { + Log::error('Uptelligence: Failed to send digest', [ + 'user_id' => $digest->user_id, + 'workspace_id' => $digest->workspace_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Process all digests due for a specific frequency. + * + * @return array{sent: int, skipped: int, failed: int} + */ + public function processDigests(string $frequency): array + { + $stats = ['sent' => 0, 'skipped' => 0, 'failed' => 0]; + + $digests = UptelligenceDigest::dueForDigest($frequency) + ->with(['user', 'workspace']) + ->get(); + + foreach ($digests as $digest) { + // Skip if user or workspace no longer exists + if (! $digest->user || ! $digest->workspace) { + $digest->delete(); + $stats['skipped']++; + + continue; + } + + $result = $this->sendDigest($digest); + + if ($result) { + $stats['sent']++; + } else { + // Check if it was skipped (empty) or failed + if (! $this->generateDigestContent($digest)['has_content']) { + $stats['skipped']++; + } else { + $stats['failed']++; + } + } + } + + return $stats; + } + + /** + * Get a preview of what would be included in a digest. + * + * Useful for showing users what they'll receive before enabling. + */ + public function getDigestPreview(UptelligenceDigest $digest): array + { + $content = $this->generateDigestContent($digest); + + // Get top vendors with pending work + $vendorIds = $digest->getVendorIds(); + $vendorQuery = Vendor::active() + ->withCount(['todos as pending_count' => fn ($q) => $q->pending()]); + + if ($vendorIds !== null) { + $vendorQuery->whereIn('id', $vendorIds); + } + + $topVendors = $vendorQuery + ->having('pending_count', '>', 0) + ->orderByDesc('pending_count') + ->take(5) + ->get(['id', 'name', 'slug', 'current_version']); + + return [ + 'releases' => $content['releases']->take(5), + 'todos' => $content['todos'], + 'security_count' => $content['security_count'], + 'top_vendors' => $topVendors, + 'has_content' => $content['has_content'], + 'frequency_label' => $digest->getFrequencyLabel(), + 'next_send' => $digest->getNextSendDate()?->format('j F Y'), + ]; + } + + /** + * Get or create a digest preference for a user in a workspace. + */ + public function getOrCreateDigest(int $userId, int $workspaceId): UptelligenceDigest + { + return UptelligenceDigest::firstOrCreate( + [ + 'user_id' => $userId, + 'workspace_id' => $workspaceId, + ], + [ + 'frequency' => UptelligenceDigest::FREQUENCY_WEEKLY, + 'is_enabled' => false, // Start disabled, user must opt-in + ] + ); + } +} diff --git a/Services/VendorStorageService.php b/Services/VendorStorageService.php new file mode 100644 index 0000000..2911d8a --- /dev/null +++ b/Services/VendorStorageService.php @@ -0,0 +1,579 @@ +storageMode = config('upstream.storage.disk', 'local'); + $this->bucket = config('upstream.storage.s3.bucket', 'hostuk'); + $this->prefix = config('upstream.storage.s3.prefix', 'upstream/vendors/'); + $this->tempPath = config('upstream.storage.temp_path', storage_path('app/temp/upstream')); + $this->s3Disk = config('upstream.storage.s3.disk', 's3'); + } + + /** + * Check if S3 storage is enabled. + */ + public function isS3Enabled(): bool + { + return $this->storageMode === 's3'; + } + + /** + * Get the S3 storage disk instance. + */ + protected function s3(): Filesystem + { + return Storage::disk($this->s3Disk); + } + + /** + * Get the local storage disk instance. + */ + protected function local(): Filesystem + { + return Storage::disk('local'); + } + + /** + * Get local path for a vendor version. + */ + public function getLocalPath(Vendor $vendor, string $version): string + { + return storage_path("app/vendors/{$vendor->slug}/{$version}"); + } + + /** + * Get S3 key for a vendor version archive. + */ + public function getS3Key(Vendor $vendor, string $version): string + { + return "{$this->prefix}{$vendor->slug}/{$version}.tar.gz"; + } + + /** + * Get temp directory for processing. + */ + public function getTempPath(?string $suffix = null): string + { + $path = $this->tempPath.'/'.Str::uuid(); + if ($suffix) { + $path .= '/'.$suffix; + } + + return $path; + } + + /** + * Ensure version is available locally for processing. + * Downloads from S3 if needed. + */ + public function ensureLocal(VersionRelease $release): string + { + $localPath = $this->getLocalPath($release->vendor, $release->version); + $relativePath = $this->getRelativeLocalPath($release->vendor, $release->version); + + // Already available locally + if ($this->local()->exists($relativePath) && $this->local()->exists("{$relativePath}/.version_marker")) { + return $localPath; + } + + // Need to download from S3 + if ($release->storage_disk === 's3' && $release->s3_key) { + $this->downloadFromS3($release, $localPath); + $release->update(['last_downloaded_at' => now()]); + + return $localPath; + } + + // Check if we have local files but no marker + if ($this->local()->exists($relativePath)) { + $this->local()->put("{$relativePath}/.version_marker", $release->version); + + return $localPath; + } + + throw new RuntimeException( + "Version {$release->version} not available locally or in S3" + ); + } + + /** + * Get relative path for local storage (relative to storage/app). + */ + protected function getRelativeLocalPath(Vendor $vendor, string $version): string + { + return "vendors/{$vendor->slug}/{$version}"; + } + + /** + * Archive a version to S3 cold storage. + */ + public function archiveToS3(VersionRelease $release): bool + { + if (! $this->isS3Enabled()) { + return false; + } + + $localPath = $this->getLocalPath($release->vendor, $release->version); + $relativePath = $this->getRelativeLocalPath($release->vendor, $release->version); + + if (! $this->local()->exists($relativePath)) { + throw new RuntimeException("Local path not found: {$localPath}"); + } + + // Create tar.gz archive + $archivePath = $this->createArchive($localPath, $release->vendor->slug, $release->version); + + // Calculate hash before upload + $hash = hash_file('sha256', $archivePath); + $size = filesize($archivePath); + + // Upload to S3 + $s3Key = $this->getS3Key($release->vendor, $release->version); + $this->uploadToS3($archivePath, $s3Key); + + // Update release record + $release->update([ + 'storage_disk' => 's3', + 's3_key' => $s3Key, + 'file_hash' => $hash, + 'file_size' => $size, + 'archived_at' => now(), + ]); + + // Cleanup archive file using Storage facade + $this->local()->delete($this->getRelativeTempPath($archivePath)); + + // Optionally delete local files + if (config('upstream.storage.archive.delete_local_after_archive', true)) { + $this->deleteLocalIfAllowed($release); + } + + return true; + } + + /** + * Get relative path for a temp file. + */ + protected function getRelativeTempPath(string $absolutePath): string + { + $storagePath = storage_path('app/'); + + return str_starts_with($absolutePath, $storagePath) + ? substr($absolutePath, strlen($storagePath)) + : $absolutePath; + } + + /** + * Download a version from S3. + */ + public function downloadFromS3(VersionRelease $release, ?string $targetPath = null): string + { + if (! $release->s3_key) { + throw new RuntimeException("No S3 key for version {$release->version}"); + } + + $targetPath = $targetPath ?? $this->getLocalPath($release->vendor, $release->version); + $relativeTempPath = 'temp/upstream/'.Str::uuid().'.tar.gz'; + + // Ensure temp directory exists via Storage facade + $this->local()->makeDirectory(dirname($relativeTempPath)); + + $contents = $this->s3()->get($release->s3_key); + if ($contents === null) { + Log::error('Uptelligence: Failed to download from S3', [ + 's3_key' => $release->s3_key, + 'version' => $release->version, + ]); + throw new RuntimeException("Failed to download from S3: {$release->s3_key}"); + } + + $this->local()->put($relativeTempPath, $contents); + $tempArchive = storage_path("app/{$relativeTempPath}"); + + // Verify hash if available + if ($release->file_hash) { + $downloadedHash = hash_file('sha256', $tempArchive); + if ($downloadedHash !== $release->file_hash) { + $this->local()->delete($relativeTempPath); + Log::error('Uptelligence: S3 download hash mismatch', [ + 'version' => $release->version, + 'expected' => $release->file_hash, + 'actual' => $downloadedHash, + ]); + throw new RuntimeException( + "Hash mismatch for {$release->version}: expected {$release->file_hash}, got {$downloadedHash}" + ); + } + } + + // Ensure target directory exists + $relativeTargetPath = $this->getRelativeLocalPath($release->vendor, $release->version); + $this->local()->makeDirectory($relativeTargetPath); + + // Extract archive + $this->extractArchive($tempArchive, $targetPath); + + // Cleanup temp archive + $this->local()->delete($relativeTempPath); + + // Add version marker + $this->local()->put("{$relativeTargetPath}/.version_marker", $release->version); + + return $targetPath; + } + + /** + * Create a tar.gz archive of a directory. + */ + public function createArchive(string $sourcePath, string $vendorSlug, string $version): string + { + $relativePath = 'temp/upstream/'.Str::uuid(); + $archiveRelativePath = "{$relativePath}/{$vendorSlug}-{$version}.tar.gz"; + + // Ensure directory exists via Storage facade + $this->local()->makeDirectory($relativePath); + + $archivePath = storage_path("app/{$archiveRelativePath}"); + + // Use Symfony Process for safe command execution + $process = new Process(['tar', '-czf', $archivePath, '-C', $sourcePath, '.']); + $process->setTimeout(300); + $process->run(); + + if (! $process->isSuccessful()) { + Log::error('Uptelligence: Failed to create archive', [ + 'source' => $sourcePath, + 'error' => $process->getErrorOutput(), + ]); + throw new RuntimeException('Failed to create archive: '.$process->getErrorOutput()); + } + + return $archivePath; + } + + /** + * Extract a tar.gz archive. + */ + public function extractArchive(string $archivePath, string $targetPath): void + { + // Ensure target directory exists via Storage facade + $relativeTargetPath = str_replace(storage_path('app/'), '', $targetPath); + if (str_starts_with($relativeTargetPath, '/')) { + // Absolute path outside storage - use direct mkdir + if (! is_dir($targetPath)) { + mkdir($targetPath, 0755, true); + } + } else { + $this->local()->makeDirectory($relativeTargetPath); + } + + // Use Symfony Process for safe command execution + $process = new Process(['tar', '-xzf', $archivePath, '-C', $targetPath]); + $process->setTimeout(300); + $process->run(); + + if (! $process->isSuccessful()) { + Log::error('Uptelligence: Failed to extract archive', [ + 'archive' => $archivePath, + 'target' => $targetPath, + 'error' => $process->getErrorOutput(), + ]); + throw new RuntimeException('Failed to extract archive: '.$process->getErrorOutput()); + } + } + + /** + * Upload a file to S3. + */ + protected function uploadToS3(string $localPath, string $s3Key): void + { + // Read file using Storage facade if path is within storage/app + $relativePath = $this->getRelativeTempPath($localPath); + + if ($this->local()->exists($relativePath)) { + $contents = $this->local()->get($relativePath); + } else { + // Fallback for absolute paths outside storage + $contents = file_get_contents($localPath); + } + + $uploaded = $this->s3()->put($s3Key, $contents, [ + 'ContentType' => 'application/gzip', + ]); + + if (! $uploaded) { + Log::error('Uptelligence: Failed to upload to S3', ['s3_key' => $s3Key]); + throw new RuntimeException("Failed to upload to S3: {$s3Key}"); + } + } + + /** + * Delete local version files if allowed by retention policy. + */ + public function deleteLocalIfAllowed(VersionRelease $release): bool + { + $keepVersions = config('upstream.storage.archive.keep_local_versions', 2); + + // Get vendor's recent versions + $recentVersions = VersionRelease::where('vendor_id', $release->vendor_id) + ->orderByDesc('created_at') + ->take($keepVersions) + ->pluck('version') + ->toArray(); + + // Don't delete if in recent list + if (in_array($release->version, $recentVersions)) { + return false; + } + + // Don't delete current or previous version + $vendor = $release->vendor; + if ($release->version === $vendor->current_version || + $release->version === $vendor->previous_version) { + return false; + } + + $relativePath = $this->getRelativeLocalPath($vendor, $release->version); + + if ($this->local()->exists($relativePath)) { + $this->local()->deleteDirectory($relativePath); + + return true; + } + + return false; + } + + /** + * Extract metadata from a version directory. + * This metadata can be used for analysis without downloading. + */ + public function extractMetadata(string $path): array + { + $metadata = [ + 'file_count' => 0, + 'total_size' => 0, + 'directories' => [], + 'file_types' => [], + 'key_files' => [], + ]; + + if (! File::isDirectory($path)) { + return $metadata; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $metadata['file_count']++; + $metadata['total_size'] += $file->getSize(); + + $ext = strtolower($file->getExtension()); + $metadata['file_types'][$ext] = ($metadata['file_types'][$ext] ?? 0) + 1; + + // Track key files + $relativePath = str_replace($path.'/', '', $file->getPathname()); + if ($this->isKeyFile($relativePath)) { + $metadata['key_files'][] = $relativePath; + } + } + } + + // Get top-level directories + $dirs = File::directories($path); + $metadata['directories'] = array_map(fn ($d) => basename($d), $dirs); + + return $metadata; + } + + /** + * Check if a file is considered a "key file" worth tracking in metadata. + */ + protected function isKeyFile(string $path): bool + { + $keyPatterns = [ + 'composer.json', + 'package.json', + 'readme.md', + 'readme.txt', + 'changelog.md', + 'changelog.txt', + 'version.php', + 'config/*.php', + 'database/migrations/*', + ]; + + $lowercasePath = strtolower($path); + foreach ($keyPatterns as $pattern) { + if (fnmatch($pattern, $lowercasePath)) { + return true; + } + } + + return false; + } + + /** + * Check if a version exists in S3. + */ + public function existsInS3(Vendor $vendor, string $version): bool + { + $s3Key = $this->getS3Key($vendor, $version); + + return $this->s3()->exists($s3Key); + } + + /** + * Check if a version exists locally. + */ + public function existsLocally(Vendor $vendor, string $version): bool + { + $relativePath = $this->getRelativeLocalPath($vendor, $version); + + return $this->local()->exists($relativePath); + } + + /** + * Get storage status for a version. + */ + public function getStorageStatus(VersionRelease $release): array + { + $relativePath = $this->getRelativeLocalPath($release->vendor, $release->version); + + return [ + 'version' => $release->version, + 'storage_disk' => $release->storage_disk, + 'local_exists' => $this->local()->exists($relativePath), + 's3_exists' => $release->s3_key ? $this->s3()->exists($release->s3_key) : false, + 's3_key' => $release->s3_key, + 'file_size' => $release->file_size, + 'file_hash' => $release->file_hash, + 'archived_at' => $release->archived_at?->toIso8601String(), + 'last_downloaded_at' => $release->last_downloaded_at?->toIso8601String(), + ]; + } + + /** + * Cleanup old temp files. + */ + public function cleanupTemp(): int + { + $maxAge = config('upstream.storage.archive.cleanup_after_hours', 24); + $cutoff = now()->subHours($maxAge); + $cleaned = 0; + + $tempRelativePath = 'temp/upstream'; + + if (! $this->local()->exists($tempRelativePath)) { + return 0; + } + + $directories = $this->local()->directories($tempRelativePath); + foreach ($directories as $dir) { + $mtime = $this->local()->lastModified($dir); + if ($mtime < $cutoff->timestamp) { + $this->local()->deleteDirectory($dir); + $cleaned++; + } + } + + return $cleaned; + } + + /** + * Get storage statistics for dashboard. + */ + public function getStorageStats(): array + { + $releases = VersionRelease::with('vendor')->get(); + + $stats = [ + 'total_versions' => $releases->count(), + 'local_only' => 0, + 's3_only' => 0, + 'both' => 0, + 'local_size' => 0, + 's3_size' => 0, + ]; + + foreach ($releases as $release) { + $localExists = $this->existsLocally($release->vendor, $release->version); + $s3Exists = $release->storage_disk === 's3'; + + if ($localExists && $s3Exists) { + $stats['both']++; + } elseif ($localExists) { + $stats['local_only']++; + } elseif ($s3Exists) { + $stats['s3_only']++; + } + + if ($release->file_size) { + $stats['s3_size'] += $release->file_size; + } + + if ($localExists) { + $localPath = $this->getLocalPath($release->vendor, $release->version); + $stats['local_size'] += $this->getDirectorySize($localPath); + } + } + + return $stats; + } + + /** + * Get size of a directory in bytes. + */ + protected function getDirectorySize(string $path): int + { + if (! File::isDirectory($path)) { + return 0; + } + + $size = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $size += $file->getSize(); + } + } + + return $size; + } +} diff --git a/Services/VendorUpdateCheckerService.php b/Services/VendorUpdateCheckerService.php new file mode 100644 index 0000000..3c45814 --- /dev/null +++ b/Services/VendorUpdateCheckerService.php @@ -0,0 +1,467 @@ + + */ + public function checkAllVendors(): array + { + $results = []; + + foreach (Vendor::active()->get() as $vendor) { + $results[$vendor->slug] = $this->checkVendor($vendor); + } + + return $results; + } + + /** + * Check a single vendor for updates. + * + * @return array{status: string, current: ?string, latest: ?string, has_update: bool, message?: string} + */ + public function checkVendor(Vendor $vendor): array + { + // Determine check method based on source type and git URL + $result = match (true) { + $vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor), + $vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor), + default => $this->skipCheck($vendor), + }; + + // Update last_checked_at + $vendor->update(['last_checked_at' => now()]); + + // If update found and it's significant, create a todo + if ($result['has_update'] && $result['latest']) { + $this->createUpdateTodo($vendor, $result['latest']); + } + + return $result; + } + + /** + * Check GitHub repository for new releases. + */ + protected function checkGitHub(Vendor $vendor): array + { + if (! $vendor->git_repo_url) { + return $this->errorResult('No Git repository URL configured'); + } + + // Rate limit check + if (RateLimiter::tooManyAttempts('upstream-registry', 30)) { + $seconds = RateLimiter::availableIn('upstream-registry'); + + return $this->rateLimitedResult($seconds); + } + + RateLimiter::hit('upstream-registry'); + + // Parse owner/repo from URL + $parsed = $this->parseGitHubUrl($vendor->git_repo_url); + if (! $parsed) { + return $this->errorResult('Invalid GitHub URL format'); + } + + [$owner, $repo] = $parsed; + + // Build request with optional token + $request = Http::timeout(30) + ->retry(3, function (int $attempt) { + return (int) pow(2, $attempt - 1) * 1000; + }, function (\Exception $exception) { + if ($exception instanceof \Illuminate\Http\Client\ConnectionException) { + return true; + } + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + $status = $exception->response?->status(); + + return $status >= 500 || $status === 429; + } + + return false; + }); + + // Add auth token if configured + $token = config('upstream.github.token'); + if ($token) { + $request->withToken($token); + } + + // Fetch latest release + $response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest"); + + if ($response->status() === 404) { + // No releases - try tags instead + return $this->checkGitHubTags($vendor, $owner, $repo, $token); + } + + if (! $response->successful()) { + Log::warning('Uptelligence: GitHub API request failed', [ + 'vendor' => $vendor->slug, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return $this->errorResult("GitHub API error: {$response->status()}"); + } + + $data = $response->json(); + $latestVersion = $this->normaliseVersion($data['tag_name'] ?? ''); + + if (! $latestVersion) { + return $this->errorResult('Could not determine latest version'); + } + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: [ + 'name' => $data['name'] ?? null, + 'body' => $data['body'] ?? null, + 'published_at' => $data['published_at'] ?? null, + 'html_url' => $data['html_url'] ?? null, + ] + ); + } + + /** + * Check GitHub tags when no releases exist. + */ + protected function checkGitHubTags(Vendor $vendor, string $owner, string $repo, ?string $token): array + { + $request = Http::timeout(30); + if ($token) { + $request->withToken($token); + } + + $response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/tags", [ + 'per_page' => 1, + ]); + + if (! $response->successful()) { + return $this->errorResult("GitHub tags API error: {$response->status()}"); + } + + $tags = $response->json(); + if (empty($tags)) { + return $this->errorResult('No releases or tags found'); + } + + $latestVersion = $this->normaliseVersion($tags[0]['name'] ?? ''); + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: ['tag' => $tags[0]['name'] ?? null] + ); + } + + /** + * Check Gitea repository for new releases. + */ + protected function checkGitea(Vendor $vendor): array + { + if (! $vendor->git_repo_url) { + return $this->errorResult('No Git repository URL configured'); + } + + // Rate limit check + if (RateLimiter::tooManyAttempts('upstream-registry', 30)) { + $seconds = RateLimiter::availableIn('upstream-registry'); + + return $this->rateLimitedResult($seconds); + } + + RateLimiter::hit('upstream-registry'); + + // Parse owner/repo from URL + $parsed = $this->parseGiteaUrl($vendor->git_repo_url); + if (! $parsed) { + return $this->errorResult('Invalid Gitea URL format'); + } + + [$baseUrl, $owner, $repo] = $parsed; + + $request = Http::timeout(30); + + // Add auth token if configured + $token = config('upstream.gitea.token'); + if ($token) { + $request->withHeaders(['Authorization' => "token {$token}"]); + } + + // Fetch latest release + $response = $request->get("{$baseUrl}/api/v1/repos/{$owner}/{$repo}/releases/latest"); + + if ($response->status() === 404) { + // No releases - try tags + return $this->checkGiteaTags($vendor, $baseUrl, $owner, $repo, $token); + } + + if (! $response->successful()) { + Log::warning('Uptelligence: Gitea API request failed', [ + 'vendor' => $vendor->slug, + 'status' => $response->status(), + ]); + + return $this->errorResult("Gitea API error: {$response->status()}"); + } + + $data = $response->json(); + $latestVersion = $this->normaliseVersion($data['tag_name'] ?? ''); + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: [ + 'name' => $data['name'] ?? null, + 'body' => $data['body'] ?? null, + 'published_at' => $data['published_at'] ?? null, + ] + ); + } + + /** + * Check Gitea tags when no releases exist. + */ + protected function checkGiteaTags(Vendor $vendor, string $baseUrl, string $owner, string $repo, ?string $token): array + { + $request = Http::timeout(30); + if ($token) { + $request->withHeaders(['Authorization' => "token {$token}"]); + } + + $response = $request->get("{$baseUrl}/api/v1/repos/{$owner}/{$repo}/tags", [ + 'limit' => 1, + ]); + + if (! $response->successful()) { + return $this->errorResult("Gitea tags API error: {$response->status()}"); + } + + $tags = $response->json(); + if (empty($tags)) { + return $this->errorResult('No releases or tags found'); + } + + $latestVersion = $this->normaliseVersion($tags[0]['name'] ?? ''); + + return $this->buildResult( + vendor: $vendor, + latestVersion: $latestVersion, + releaseInfo: ['tag' => $tags[0]['name'] ?? null] + ); + } + + /** + * Skip check for vendors that don't support auto-checking. + */ + protected function skipCheck(Vendor $vendor): array + { + $message = match (true) { + $vendor->isLicensed() => 'Licensed software - manual check required', + $vendor->isPlugin() => 'Plugin - check vendor marketplace manually', + ! $vendor->git_repo_url => 'No Git repository URL configured', + default => 'Unsupported source type for auto-checking', + }; + + return [ + 'status' => 'skipped', + 'current' => $vendor->current_version, + 'latest' => null, + 'has_update' => false, + 'message' => $message, + ]; + } + + /** + * Build the result array. + */ + protected function buildResult(Vendor $vendor, ?string $latestVersion, array $releaseInfo = []): array + { + if (! $latestVersion) { + return $this->errorResult('Could not determine latest version'); + } + + $currentVersion = $this->normaliseVersion($vendor->current_version ?? ''); + $hasUpdate = $currentVersion && version_compare($latestVersion, $currentVersion, '>'); + + // Store latest version info on vendor if new + if ($hasUpdate) { + Log::info('Uptelligence: New version detected', [ + 'vendor' => $vendor->slug, + 'current' => $currentVersion, + 'latest' => $latestVersion, + ]); + } + + return [ + 'status' => 'success', + 'current' => $currentVersion, + 'latest' => $latestVersion, + 'has_update' => $hasUpdate, + 'release_info' => $releaseInfo, + ]; + } + + /** + * Create an update todo when new version is detected. + */ + protected function createUpdateTodo(Vendor $vendor, string $newVersion): void + { + // Check if we already have a pending todo for this version + $existing = UpstreamTodo::where('vendor_id', $vendor->id) + ->where('to_version', $newVersion) + ->whereIn('status', [UpstreamTodo::STATUS_PENDING, UpstreamTodo::STATUS_IN_PROGRESS]) + ->exists(); + + if ($existing) { + return; + } + + // Create new todo + UpstreamTodo::create([ + 'vendor_id' => $vendor->id, + 'from_version' => $vendor->current_version, + 'to_version' => $newVersion, + 'type' => UpstreamTodo::TYPE_DEPENDENCY, + 'status' => UpstreamTodo::STATUS_PENDING, + 'title' => "Update {$vendor->name} to {$newVersion}", + 'description' => "A new version of {$vendor->name} is available.\n\n" + ."Current: {$vendor->current_version}\n" + ."Latest: {$newVersion}\n\n" + .'Review the changelog and update as appropriate.', + 'priority' => 5, + 'effort' => UpstreamTodo::EFFORT_MEDIUM, + 'tags' => ['auto-detected', 'update-available'], + ]); + + Log::info('Uptelligence: Created update todo', [ + 'vendor' => $vendor->slug, + 'from' => $vendor->current_version, + 'to' => $newVersion, + ]); + } + + /** + * Build an error result. + */ + protected function errorResult(string $message): array + { + return [ + 'status' => 'error', + 'current' => null, + 'latest' => null, + 'has_update' => false, + 'message' => $message, + ]; + } + + /** + * Build a rate-limited result. + */ + protected function rateLimitedResult(int $seconds): array + { + return [ + 'status' => 'rate_limited', + 'current' => null, + 'latest' => null, + 'has_update' => false, + 'message' => "Rate limit exceeded. Retry after {$seconds} seconds", + ]; + } + + /** + * Check if URL is a GitHub URL. + */ + protected function isGitHubUrl(?string $url): bool + { + if (! $url) { + return false; + } + + return str_contains($url, 'github.com'); + } + + /** + * Check if URL is a Gitea URL. + */ + protected function isGiteaUrl(?string $url): bool + { + if (! $url) { + return false; + } + + $giteaUrl = config('upstream.gitea.url', 'https://git.host.uk'); + + return str_contains($url, parse_url($giteaUrl, PHP_URL_HOST) ?? ''); + } + + /** + * Parse GitHub URL to extract owner/repo. + * + * @return array{0: string, 1: string}|null + */ + protected function parseGitHubUrl(string $url): ?array + { + // Match github.com/owner/repo patterns + if (preg_match('#github\.com[/:]([^/]+)/([^/.]+)#i', $url, $matches)) { + return [$matches[1], rtrim($matches[2], '.git')]; + } + + return null; + } + + /** + * Parse Gitea URL to extract base URL, owner, and repo. + * + * @return array{0: string, 1: string, 2: string}|null + */ + protected function parseGiteaUrl(string $url): ?array + { + // Match gitea URLs like https://git.host.uk/owner/repo + if (preg_match('#(https?://[^/]+)/([^/]+)/([^/.]+)#i', $url, $matches)) { + return [$matches[1], $matches[2], rtrim($matches[3], '.git')]; + } + + return null; + } + + /** + * Normalise version string (remove 'v' prefix, etc.). + */ + protected function normaliseVersion(?string $version): ?string + { + if (! $version) { + return null; + } + + // Remove leading 'v' or 'V' + $version = ltrim($version, 'vV'); + + // Remove any leading/trailing whitespace + $version = trim($version); + + return $version ?: null; + } +} diff --git a/Services/WebhookReceiverService.php b/Services/WebhookReceiverService.php new file mode 100644 index 0000000..b41158a --- /dev/null +++ b/Services/WebhookReceiverService.php @@ -0,0 +1,435 @@ +secret)) { + return UptelligenceWebhookDelivery::SIGNATURE_MISSING; + } + + if (empty($signature)) { + return UptelligenceWebhookDelivery::SIGNATURE_MISSING; + } + + $isValid = $webhook->verifySignature($payload, $signature); + + return $isValid + ? UptelligenceWebhookDelivery::SIGNATURE_VALID + : UptelligenceWebhookDelivery::SIGNATURE_INVALID; + } + + // ------------------------------------------------------------------------- + // Payload Parsing + // ------------------------------------------------------------------------- + + /** + * Parse payload based on provider. + * + * Returns normalised release data or null if not a release event. + * + * @return array{ + * event_type: string, + * version: string|null, + * tag_name: string|null, + * release_name: string|null, + * body: string|null, + * url: string|null, + * prerelease: bool, + * draft: bool, + * published_at: string|null, + * author: string|null, + * raw: array + * }|null + */ + public function parsePayload(string $provider, array $payload): ?array + { + return match ($provider) { + UptelligenceWebhook::PROVIDER_GITHUB => $this->parseGitHubPayload($payload), + UptelligenceWebhook::PROVIDER_GITLAB => $this->parseGitLabPayload($payload), + UptelligenceWebhook::PROVIDER_NPM => $this->parseNpmPayload($payload), + UptelligenceWebhook::PROVIDER_PACKAGIST => $this->parsePackagistPayload($payload), + default => $this->parseCustomPayload($payload), + }; + } + + /** + * Parse GitHub release webhook payload. + * + * GitHub sends: + * - action: published, created, edited, deleted, prereleased, released + * - release: { tag_name, name, body, draft, prerelease, created_at, published_at, author } + */ + protected function parseGitHubPayload(array $payload): ?array + { + // Only process release events + $action = $payload['action'] ?? null; + if (! in_array($action, ['published', 'released', 'created'])) { + return null; + } + + $release = $payload['release'] ?? []; + if (empty($release)) { + return null; + } + + $tagName = $release['tag_name'] ?? null; + $version = $this->normaliseVersion($tagName); + + return [ + 'event_type' => "github.release.{$action}", + 'version' => $version, + 'tag_name' => $tagName, + 'release_name' => $release['name'] ?? $tagName, + 'body' => $release['body'] ?? null, + 'url' => $release['html_url'] ?? null, + 'prerelease' => (bool) ($release['prerelease'] ?? false), + 'draft' => (bool) ($release['draft'] ?? false), + 'published_at' => $release['published_at'] ?? $release['created_at'] ?? null, + 'author' => $release['author']['login'] ?? null, + 'raw' => $release, + ]; + } + + /** + * Parse GitLab release webhook payload. + * + * GitLab sends: + * - object_kind: release + * - action: create, update, delete + * - tag: tag name + * - name, description, released_at + */ + protected function parseGitLabPayload(array $payload): ?array + { + $objectKind = $payload['object_kind'] ?? null; + $action = $payload['action'] ?? null; + + // Handle release events + if ($objectKind === 'release' && in_array($action, ['create', 'update'])) { + $tagName = $payload['tag'] ?? null; + $version = $this->normaliseVersion($tagName); + + return [ + 'event_type' => "gitlab.release.{$action}", + 'version' => $version, + 'tag_name' => $tagName, + 'release_name' => $payload['name'] ?? $tagName, + 'body' => $payload['description'] ?? null, + 'url' => $payload['url'] ?? null, + 'prerelease' => false, + 'draft' => false, + 'published_at' => $payload['released_at'] ?? $payload['created_at'] ?? null, + 'author' => null, + 'raw' => $payload, + ]; + } + + // Handle tag push events (may indicate release) + if ($objectKind === 'tag_push') { + $ref = $payload['ref'] ?? ''; + $tagName = str_replace('refs/tags/', '', $ref); + $version = $this->normaliseVersion($tagName); + + // Only process if it looks like a version tag + if ($version && $this->isVersionTag($tagName)) { + return [ + 'event_type' => 'gitlab.tag.push', + 'version' => $version, + 'tag_name' => $tagName, + 'release_name' => $tagName, + 'body' => null, + 'url' => null, + 'prerelease' => false, + 'draft' => false, + 'published_at' => null, + 'author' => $payload['user_name'] ?? null, + 'raw' => $payload, + ]; + } + } + + return null; + } + + /** + * Parse npm publish webhook payload. + * + * npm sends: + * - event: package:publish + * - name: package name + * - version: version number + * - dist-tags: { latest, next, etc. } + */ + protected function parseNpmPayload(array $payload): ?array + { + $event = $payload['event'] ?? null; + + // Handle package publish events + if ($event !== 'package:publish') { + return null; + } + + $version = $payload['version'] ?? null; + if (empty($version)) { + return null; + } + + $distTags = $payload['dist-tags'] ?? []; + $isLatest = ($distTags['latest'] ?? null) === $version; + + return [ + 'event_type' => 'npm.package.publish', + 'version' => $version, + 'tag_name' => $version, + 'release_name' => ($payload['name'] ?? 'Package')." v{$version}", + 'body' => null, + 'url' => isset($payload['name']) ? "https://www.npmjs.com/package/{$payload['name']}/v/{$version}" : null, + 'prerelease' => ! $isLatest, + 'draft' => false, + 'published_at' => $payload['time'] ?? null, + 'author' => $payload['maintainers'][0]['name'] ?? null, + 'raw' => $payload, + ]; + } + + /** + * Parse Packagist webhook payload. + * + * Packagist sends: + * - package: { name, url } + * - versions: array of version objects + */ + protected function parsePackagistPayload(array $payload): ?array + { + $package = $payload['package'] ?? $payload['repository'] ?? []; + $versions = $payload['versions'] ?? []; + + // Find the latest version + if (empty($versions)) { + return null; + } + + // Get the most recent version (first in array or highest semver) + $latestVersion = null; + $latestVersionData = null; + + foreach ($versions as $versionKey => $versionData) { + // Skip dev versions + if (str_contains($versionKey, 'dev-')) { + continue; + } + + $normalised = $this->normaliseVersion($versionKey); + if ($normalised && (! $latestVersion || version_compare($normalised, $latestVersion, '>'))) { + $latestVersion = $normalised; + $latestVersionData = $versionData; + } + } + + if (! $latestVersion) { + return null; + } + + return [ + 'event_type' => 'packagist.package.update', + 'version' => $latestVersion, + 'tag_name' => $latestVersionData['version'] ?? $latestVersion, + 'release_name' => ($package['name'] ?? 'Package')." {$latestVersion}", + 'body' => $latestVersionData['description'] ?? null, + 'url' => $package['url'] ?? null, + 'prerelease' => false, + 'draft' => false, + 'published_at' => $latestVersionData['time'] ?? null, + 'author' => $latestVersionData['authors'][0]['name'] ?? null, + 'raw' => $payload, + ]; + } + + /** + * Parse custom webhook payload. + * + * Accepts a flexible format for custom integrations. + */ + protected function parseCustomPayload(array $payload): ?array + { + // Try common field names for version + $version = $payload['version'] + ?? $payload['tag'] + ?? $payload['tag_name'] + ?? $payload['release']['version'] + ?? $payload['release']['tag_name'] + ?? null; + + if (empty($version)) { + return null; + } + + $normalised = $this->normaliseVersion($version); + + return [ + 'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'custom.release', + 'version' => $normalised ?? $version, + 'tag_name' => $version, + 'release_name' => $payload['name'] ?? $payload['release_name'] ?? $version, + 'body' => $payload['body'] ?? $payload['description'] ?? $payload['changelog'] ?? null, + 'url' => $payload['url'] ?? $payload['release_url'] ?? null, + 'prerelease' => (bool) ($payload['prerelease'] ?? false), + 'draft' => (bool) ($payload['draft'] ?? false), + 'published_at' => $payload['published_at'] ?? $payload['released_at'] ?? $payload['timestamp'] ?? null, + 'author' => $payload['author'] ?? null, + 'raw' => $payload, + ]; + } + + // ------------------------------------------------------------------------- + // Release Processing + // ------------------------------------------------------------------------- + + /** + * Process a parsed release and create/update vendor version record. + * + * @return array{action: string, release_id: int|null, version: string|null} + */ + public function processRelease( + UptelligenceWebhookDelivery $delivery, + Vendor $vendor, + array $parsedData + ): array { + $version = $parsedData['version'] ?? null; + + if (empty($version)) { + return [ + 'action' => 'skipped', + 'release_id' => null, + 'version' => null, + 'reason' => 'No version found in payload', + ]; + } + + // Skip draft releases + if ($parsedData['draft'] ?? false) { + return [ + 'action' => 'skipped', + 'release_id' => null, + 'version' => $version, + 'reason' => 'Draft release', + ]; + } + + // Check if this version already exists + $existingRelease = VersionRelease::where('vendor_id', $vendor->id) + ->where('version', $version) + ->first(); + + if ($existingRelease) { + Log::info('Uptelligence webhook: Version already exists', [ + 'vendor_id' => $vendor->id, + 'version' => $version, + 'release_id' => $existingRelease->id, + ]); + + return [ + 'action' => 'exists', + 'release_id' => $existingRelease->id, + 'version' => $version, + ]; + } + + // Create new version release record + $release = VersionRelease::create([ + 'vendor_id' => $vendor->id, + 'version' => $version, + 'previous_version' => $vendor->current_version, + 'metadata_json' => [ + 'release_name' => $parsedData['release_name'] ?? null, + 'body' => $parsedData['body'] ?? null, + 'url' => $parsedData['url'] ?? null, + 'prerelease' => $parsedData['prerelease'] ?? false, + 'published_at' => $parsedData['published_at'] ?? null, + 'author' => $parsedData['author'] ?? null, + 'webhook_delivery_id' => $delivery->id, + 'event_type' => $parsedData['event_type'] ?? null, + ], + ]); + + // Update vendor's current version + $vendor->update([ + 'previous_version' => $vendor->current_version, + 'current_version' => $version, + 'last_checked_at' => now(), + ]); + + Log::info('Uptelligence webhook: New release recorded', [ + 'vendor_id' => $vendor->id, + 'vendor_name' => $vendor->name, + 'version' => $version, + 'release_id' => $release->id, + ]); + + return [ + 'action' => 'created', + 'release_id' => $release->id, + 'version' => $version, + ]; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Normalise a version string by removing common prefixes. + */ + protected function normaliseVersion(?string $version): ?string + { + if (empty($version)) { + return null; + } + + // Remove common prefixes + $normalised = preg_replace('/^v(?:ersion)?[.\-]?/i', '', $version); + + // Validate it looks like a version number + if (preg_match('/^\d+\.\d+/', $normalised)) { + return $normalised; + } + + // If it doesn't look like a version, return as-is + return $version; + } + + /** + * Check if a tag name looks like a version tag. + */ + protected function isVersionTag(string $tagName): bool + { + // Common version patterns + return (bool) preg_match('/^v?\d+\.\d+(\.\d+)?/', $tagName); + } +} diff --git a/View/Blade/admin/asset-manager.blade.php b/View/Blade/admin/asset-manager.blade.php new file mode 100644 index 0000000..eb4e3e3 --- /dev/null +++ b/View/Blade/admin/asset-manager.blade.php @@ -0,0 +1,194 @@ + + +
+ + Reset Filters + + + Back + +
+
+ + {{-- Stats Summary --}} +
+ +
Total Assets
+
{{ $this->assetStats['total'] }}
+
+ +
Need Update
+
{{ $this->assetStats['needs_update'] }}
+
+ +
Composer
+
{{ $this->assetStats['composer'] }}
+
+ +
NPM
+
{{ $this->assetStats['npm'] }}
+
+ +
Expiring Soon
+
{{ $this->assetStats['expiring_soon'] }}
+
+ +
Expired
+
{{ $this->assetStats['expired'] }}
+
+
+ + {{-- Filters --}} +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + Asset + + + Type + + Installed + Latest + + Licence + + + Last Checked + + Status + Actions + + + + @forelse ($this->assets as $asset) + + +
+ {{ $asset->getTypeIcon() }} +
+
{{ $asset->name }}
+ @if($asset->package_name) +
{{ $asset->package_name }}
+ @endif +
+
+
+ + + {{ $asset->getTypeLabel() }} + + + + {{ $asset->installed_version ?? 'N/A' }} + + + @if($asset->hasUpdate()) + {{ $asset->latest_version }} + @else + {{ $asset->latest_version ?? 'N/A' }} + @endif + + +
+ {{ $asset->getLicenceIcon() }} + {{ ucfirst($asset->licence_type ?? 'N/A') }} +
+ @if($asset->licence_expires_at) +
+ {{ $asset->licence_expires_at->format('d M Y') }} +
+ @endif +
+ + {{ $asset->last_checked_at?->diffForHumans() ?? 'Never' }} + + + @if($asset->isLicenceExpired()) + Expired + @elseif($asset->hasUpdate()) + Update Available + @elseif($asset->isLicenceExpiringSoon()) + Expiring Soon + @elseif($asset->is_active) + Active + @else + Inactive + @endif + + + + + + @if($asset->getUpdateCommand()) + + Copy Update Command + + @endif + @if($asset->registry_url) + + View in Registry + + @endif + + + {{ $asset->is_active ? 'Deactivate' : 'Activate' }} + + + + +
+ @empty + + +
+ + No assets found + Try adjusting your filters +
+
+
+ @endforelse +
+
+ + @if($this->assets->hasPages()) +
+ {{ $this->assets->links() }} +
+ @endif +
+
diff --git a/View/Blade/admin/dashboard.blade.php b/View/Blade/admin/dashboard.blade.php new file mode 100644 index 0000000..6500c21 --- /dev/null +++ b/View/Blade/admin/dashboard.blade.php @@ -0,0 +1,298 @@ + + +
+ + Manage Vendors + + + Refresh + + + + + + View Todos + + + View Diffs + + + Manage Assets + + + + Webhook Manager + + + Digest Preferences + + + +
+
+ + {{-- Summary Stats --}} + + + {{-- Secondary Stats Row --}} +
+ +
+
+ +
+
+ In Progress + {{ $this->stats['in_progress'] }} +
+
+
+ + +
+
+ +
+
+ Assets Tracked + {{ $this->stats['assets_tracked'] }} +
+
+
+ + +
+
+ +
+
+ Assets Need Update + {{ $this->stats['assets_need_update'] }} +
+
+
+
+ +
+ {{-- Vendors Summary --}} + +
+
+ Top Vendors + By pending todos +
+ + View All + +
+ + + + Vendor + Version + Pending + Last Checked + + + + @forelse ($this->vendorSummary as $vendor) + + +
+ @if($vendor['source_type'] === 'licensed') + + @elseif($vendor['source_type'] === 'oss') + + @else + + @endif + {{ $vendor['name'] }} +
+
+ + {{ $vendor['current_version'] ?? 'N/A' }} + + + @if($vendor['pending_todos'] > 0) + + {{ $vendor['pending_todos'] }} + + @else + 0 + @endif + + + {{ $vendor['last_checked'] }} + +
+ @empty + + +
+ + No vendors tracked yet +
+
+
+ @endforelse +
+
+
+ + {{-- Todos by Type --}} + +
+
+ Todos by Type + Pending items breakdown +
+ + View All + +
+ +
+ @php + $typeConfig = [ + 'feature' => ['icon' => 'sparkles', 'color' => 'blue', 'label' => 'Features'], + 'bugfix' => ['icon' => 'bug-ant', 'color' => 'yellow', 'label' => 'Bug Fixes'], + 'security' => ['icon' => 'shield-check', 'color' => 'red', 'label' => 'Security'], + 'ui' => ['icon' => 'paint-brush', 'color' => 'purple', 'label' => 'UI Changes'], + 'api' => ['icon' => 'code-bracket', 'color' => 'cyan', 'label' => 'API Changes'], + 'refactor' => ['icon' => 'arrow-path-rounded-square', 'color' => 'green', 'label' => 'Refactors'], + 'dependency' => ['icon' => 'cube', 'color' => 'orange', 'label' => 'Dependencies'], + 'block' => ['icon' => 'square-3-stack-3d', 'color' => 'pink', 'label' => 'Blocks'], + ]; + @endphp + + @forelse ($this->todosByType as $type => $count) + @php $config = $typeConfig[$type] ?? ['icon' => 'document', 'color' => 'zinc', 'label' => ucfirst($type)]; @endphp +
+
+ + {{ $config['label'] }} +
+ {{ $count }} +
+ @empty +
+ + No pending todos +
+ @endforelse +
+
+
+ + {{-- Recent Todos --}} + +
+
+ Recent High-Priority Todos + Pending items ordered by priority +
+ + View All Pending + +
+ + + + Todo + Vendor + Type + Priority + Effort + + + + @forelse ($this->recentTodos as $todo) + + + {{ $todo->title }} + @if($todo->isQuickWin()) + Quick Win + @endif + + + {{ $todo->vendor->name }} + + + {{ $todo->getTypeIcon() }} {{ ucfirst($todo->type) }} + + + + {{ $todo->getPriorityLabel() }} + + + + {{ $todo->getEffortLabel() }} + + + @empty + + +
+ + No pending todos +
+
+
+ @endforelse +
+
+
+ + {{-- Recent Releases --}} + @if($this->recentReleases->isNotEmpty()) + +
+
+ Recent Version Releases + Latest analysed vendor updates +
+ + View Diffs + +
+ + + + Vendor + Version + Changes + Impact + Analysed + + + + @foreach ($this->recentReleases as $release) + + + {{ $release->vendor->name }} + + + {{ $release->getVersionCompare() }} + + +
+ +{{ $release->files_added }} + ~{{ $release->files_modified }} + -{{ $release->files_removed }} +
+
+ + + {{ ucfirst($release->getImpactLevel()) }} + + + + {{ $release->analyzed_at?->diffForHumans() ?? 'Pending' }} + +
+ @endforeach +
+
+
+ @endif +
diff --git a/View/Blade/admin/diff-viewer.blade.php b/View/Blade/admin/diff-viewer.blade.php new file mode 100644 index 0000000..587bee8 --- /dev/null +++ b/View/Blade/admin/diff-viewer.blade.php @@ -0,0 +1,240 @@ + + +
+ + Back + +
+
+ + {{-- Vendor and Release Selection --}} +
+ + + @foreach($this->vendors as $vendor) + + @endforeach + + + @if($this->releases->isNotEmpty()) + + + @foreach($this->releases as $release) + + @endforeach + + @else +
+
+ Select a vendor to view available releases +
+
+ @endif +
+ + @if($this->selectedRelease) + {{-- Release Summary --}} +
+ +
Total Changes
+
{{ $this->diffStats['total'] }}
+
+ +
+
Added
+ {{ $this->diffStats['added'] }} +
+
+{{ $this->diffStats['added'] }}
+
+ +
+
Modified
+ {{ $this->diffStats['modified'] }} +
+
~{{ $this->diffStats['modified'] }}
+
+ +
+
Removed
+ {{ $this->diffStats['removed'] }} +
+
-{{ $this->diffStats['removed'] }}
+
+
+ + {{-- Category Breakdown --}} + @if(count($this->diffStats['by_category']) > 0) +
+ Changes by Category +
+ @foreach($this->diffStats['by_category'] as $cat => $count) + + {{ ucfirst($cat) }} + {{ $count }} + + @endforeach +
+
+ @endif + + {{-- Filter by Change Type --}} +
+ Filter: + + All + + + Added + + + Modified + + + Removed + +
+ + {{-- Diffs Table --}} + + + + Type + File Path + Category + Lines + Actions + + + + @forelse ($this->diffs as $diff) + + + @if($diff->change_type === 'added') + + + @elseif($diff->change_type === 'modified') + ~ + @else + - + @endif + + +
+ {{ $diff->getDirectory() }}/{{ $diff->getFileName() }} +
+
+ +
+ {{ $diff->getCategoryIcon() }} + {{ ucfirst($diff->category) }} +
+
+ + @if($diff->diff_content) +
+ +{{ $diff->getAddedLines() }} + / + -{{ $diff->getRemovedLines() }} +
+ @else + - + @endif +
+ + + View + + +
+ @empty + + +
+ + No diffs found + Select a release to view file changes +
+
+
+ @endforelse +
+
+ + @if($this->diffs->hasPages()) +
+ {{ $this->diffs->links() }} +
+ @endif +
+ @else + +
+
+ +
+ Select a Vendor and Release + Choose a vendor and release version to view file diffs. +
+
+ @endif + + {{-- Diff Detail Modal --}} + + @if($this->selectedDiff) +
+
+
+
+ {{ $this->selectedDiff->getChangeTypeIcon() }} + {{ $this->selectedDiff->getFileName() }} +
+ {{ $this->selectedDiff->file_path }} +
+
+ + {{ ucfirst($this->selectedDiff->change_type) }} + + {{ ucfirst($this->selectedDiff->category) }} +
+
+ + @if($this->selectedDiff->diff_content) +
+
+ Unified Diff +
+ +{{ $this->selectedDiff->getAddedLines() }} added + -{{ $this->selectedDiff->getRemovedLines() }} removed +
+
+
{{ $this->selectedDiff->diff_content }}
+
+ @elseif($this->selectedDiff->new_content) +
+
+ New File Content +
+
{{ $this->selectedDiff->new_content }}
+
+ @else +
+ +

No content available for this file change.

+ @if($this->selectedDiff->change_type === 'removed') +

This file was removed in the new version.

+ @endif +
+ @endif + +
+ Close +
+
+ @endif +
+
diff --git a/View/Blade/admin/digest-preferences.blade.php b/View/Blade/admin/digest-preferences.blade.php new file mode 100644 index 0000000..071990c --- /dev/null +++ b/View/Blade/admin/digest-preferences.blade.php @@ -0,0 +1,250 @@ + + +
+ + Back to Dashboard + +
+
+ +
+ {{-- Settings Panel --}} +
+ {{-- Enable/Disable Card --}} + +
+
+ Email Digests + Receive periodic summaries of vendor updates and pending tasks +
+ +
+
+ + {{-- Frequency Selection --}} + + Frequency + + + @foreach(\Core\Uptelligence\Models\UptelligenceDigest::getFrequencyOptions() as $value => $label) + + @endforeach + + + + {{-- Content Types --}} + + Include in Digest + +
+ + + + + +
+
+ + {{-- Vendor Filter --}} + +
+
+ Vendor Filter + Select which vendors to include (leave empty for all) +
+ @if(!empty($selectedVendorIds)) + + Clear Filter + + @endif +
+ +
+ @foreach($this->vendors as $vendor) + + + @if($vendor->source_type === 'licensed') + + @elseif($vendor->source_type === 'oss') + + @else + + @endif + {{ $vendor->slug }} + + + @endforeach +
+ + @if($this->vendors->isEmpty()) +
+ + No vendors tracked yet +
+ @endif +
+ + {{-- Priority Threshold --}} + + Priority Threshold + Only include tasks at or above this priority level + + + All priorities + Medium and above (4+) + High and above (6+) + Critical only (8+) + + + + {{-- Actions --}} +
+ + Send Test Digest + + +
+ + Preview + + + Save Preferences + +
+
+
+ + {{-- Preview Panel --}} +
+ + Preview + What your next digest would include + + @php $preview = $this->preview; @endphp + + @if(!$preview['has_content']) +
+ + No content to preview +

There are no updates matching your filters

+
+ @else +
+ {{-- Security Alert --}} + @if($preview['security_count'] > 0) +
+
+ + {{ $preview['security_count'] }} security update{{ $preview['security_count'] !== 1 ? 's' : '' }} +
+
+ @endif + + {{-- Releases --}} + @if($preview['releases']->isNotEmpty()) +
+

Recent Releases

+
    + @foreach($preview['releases'] as $release) +
  • + {{ $release['vendor_name'] }} + {{ $release['version'] }} +
  • + @endforeach +
+
+ @endif + + {{-- Todos Summary --}} + @if(($preview['todos']['total'] ?? 0) > 0) +
+

Pending Tasks

+
+ @if($preview['todos']['critical'] > 0) +
+ Critical + {{ $preview['todos']['critical'] }} +
+ @endif + @if($preview['todos']['high'] > 0) +
+ High + {{ $preview['todos']['high'] }} +
+ @endif + @if($preview['todos']['medium'] > 0) +
+ Medium + {{ $preview['todos']['medium'] }} +
+ @endif + @if($preview['todos']['low'] > 0) +
+ Low + {{ $preview['todos']['low'] }} +
+ @endif +
+
+ @endif + + {{-- Top Vendors --}} + @if($preview['top_vendors']->isNotEmpty()) +
+

Top Vendors

+
    + @foreach($preview['top_vendors'] as $vendor) +
  • + {{ $vendor->name }} + {{ $vendor->pending_count }} pending +
  • + @endforeach +
+
+ @endif + + {{-- Next Send --}} + @if($preview['next_send']) +
+

Frequency: {{ $preview['frequency_label'] }}

+

Next send: {{ $preview['next_send'] }}

+
+ @endif +
+ @endif +
+
+
+
diff --git a/View/Blade/admin/todo-list.blade.php b/View/Blade/admin/todo-list.blade.php new file mode 100644 index 0000000..8605e77 --- /dev/null +++ b/View/Blade/admin/todo-list.blade.php @@ -0,0 +1,224 @@ + + +
+ @if(count($selectedTodos) > 0) + + + Bulk Actions ({{ count($selectedTodos) }}) + + + + Mark In Progress + + + Mark Ported + + + Mark Skipped + + + + Mark Won't Port + + + + @endif + + Reset Filters + + + Back + +
+
+ + {{-- Status Tabs --}} +
+ + Pending + {{ $this->todoStats['pending'] }} + + + Quick Wins + {{ $this->todoStats['quick_wins'] }} + + + In Progress + {{ $this->todoStats['in_progress'] }} + + + Completed + {{ $this->todoStats['completed'] }} + +
+ + {{-- Filters --}} +
+ + + + + @foreach($this->vendors as $vendor) + + @endforeach + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + Todo + + Vendor + + Type + + + Priority + + + Effort + + Status + Actions + + + + @forelse ($this->todos as $todo) + + + + + +
+
+ {{ $todo->title }} + @if($todo->isQuickWin()) + Quick Win + @endif + @if($todo->has_conflicts) + + @endif +
+ @if($todo->description) +
{{ Str::limit($todo->description, 80) }}
+ @endif + @if($todo->files && count($todo->files) > 0) +
+ {{ count($todo->files) }} file(s) +
+ @endif +
+
+ + {{ $todo->vendor->name }} + + + + {{ $todo->getTypeIcon() }} + {{ ucfirst($todo->type) }} + + + + + {{ $todo->getPriorityLabel() }} ({{ $todo->priority }}) + + + + + {{ $todo->getEffortLabel() }} + + + + + {{ ucfirst(str_replace('_', ' ', $todo->status)) }} + + + + + + + @if($todo->isPending()) + + Start Progress + + + Mark Ported + + + Skip + + + + Won't Port + + @elseif($todo->status === 'in_progress') + + Mark Ported + + + Skip + + @endif + @if($todo->github_issue_number) + + + View GitHub Issue + + @endif + + + +
+ @empty + + +
+ + No todos found + Try adjusting your filters +
+
+
+ @endforelse +
+
+ + @if($this->todos->hasPages()) +
+ {{ $this->todos->links() }} +
+ @endif +
+
diff --git a/View/Blade/admin/vendor-manager.blade.php b/View/Blade/admin/vendor-manager.blade.php new file mode 100644 index 0000000..49b09a4 --- /dev/null +++ b/View/Blade/admin/vendor-manager.blade.php @@ -0,0 +1,232 @@ + + +
+ + + + + + + + + Back + +
+
+ + + + + + Vendor + + + Type + + + Current Version + + Pending Todos + Quick Wins + + Last Checked + + + Status + + Actions + + + + @forelse ($this->vendors as $vendor) + + +
+ @if($vendor->source_type === 'licensed') + + @elseif($vendor->source_type === 'oss') + + @else + + @endif +
+
{{ $vendor->name }}
+
{{ $vendor->slug }}
+
+
+
+ + + {{ $vendor->getSourceTypeLabel() }} + + + + {{ $vendor->current_version ?? 'N/A' }} + + + @if($vendor->pending_todos_count > 0) + + {{ $vendor->pending_todos_count }} + + @else + 0 + @endif + + + @if($vendor->quick_wins_count > 0) + {{ $vendor->quick_wins_count }} + @else + - + @endif + + + {{ $vendor->last_checked_at?->diffForHumans() ?? 'Never' }} + + + @if($vendor->is_active) + Active + @else + Inactive + @endif + + +
+ + + + + + View Details + + + View Todos + + + View Diffs + + + + {{ $vendor->is_active ? 'Deactivate' : 'Activate' }} + + + +
+
+
+ @empty + + +
+ + No vendors found + Try adjusting your search or filters +
+
+
+ @endforelse +
+
+ + @if($this->vendors->hasPages()) +
+ {{ $this->vendors->links() }} +
+ @endif +
+ + {{-- Vendor Detail Modal --}} + + @if($this->selectedVendor) +
+
+
+ {{ $this->selectedVendor->name }} + {{ $this->selectedVendor->vendor_name ?? $this->selectedVendor->slug }} +
+ + {{ $this->selectedVendor->getSourceTypeLabel() }} + +
+ +
+
+
Current Version
+
{{ $this->selectedVendor->current_version ?? 'N/A' }}
+
+
+
Previous Version
+
{{ $this->selectedVendor->previous_version ?? 'N/A' }}
+
+
+
Pending Todos
+
{{ $this->selectedVendor->pending_todos_count }}
+
+
+
Last Checked
+
{{ $this->selectedVendor->last_checked_at?->format('d M Y H:i') ?? 'Never' }}
+
+
+ + @if($this->selectedVendor->git_repo_url) + + @endif + + @if($this->selectedVendor->todos->isNotEmpty()) +
+ Recent Todos +
+ @foreach($this->selectedVendor->todos as $todo) +
+
+ {{ $todo->getTypeIcon() }} + {{ $todo->title }} +
+
+ + P{{ $todo->priority }} + + + {{ ucfirst($todo->effort) }} + +
+
+ @endforeach +
+
+ @endif + + @if($this->selectedVendorReleases->isNotEmpty()) +
+ Recent Releases +
+ @foreach($this->selectedVendorReleases->take(5) as $release) +
+
{{ $release->getVersionCompare() }}
+
+ +{{ $release->files_added }} + ~{{ $release->files_modified }} + -{{ $release->files_removed }} + {{ $release->analyzed_at?->diffForHumans() }} +
+
+ @endforeach +
+
+ @endif + +
+ Close + + View All Todos + +
+
+ @endif +
+
diff --git a/View/Blade/admin/webhook-manager.blade.php b/View/Blade/admin/webhook-manager.blade.php new file mode 100644 index 0000000..4df2a96 --- /dev/null +++ b/View/Blade/admin/webhook-manager.blade.php @@ -0,0 +1,391 @@ + + +
+ + + @foreach ($this->vendors as $vendor) + + @endforeach + + + + + + + + + + + + + + + + New Webhook + + + Back + +
+
+ + + + + Vendor + Provider + Endpoint URL + Deliveries (24h) + Last Received + Status + Actions + + + + @forelse ($this->webhooks as $webhook) + + +
+ +
+
{{ $webhook->vendor->name }}
+
{{ $webhook->vendor->slug }}
+
+
+
+ + + {{ $webhook->getProviderLabel() }} + + + + {{ $webhook->getEndpointUrl() }} + + + @if($webhook->recent_deliveries_count > 0) + {{ $webhook->recent_deliveries_count }} + @else + - + @endif + + + {{ $webhook->last_received_at?->diffForHumans() ?? 'Never' }} + + + + {{ $webhook->status_label }} + + + +
+ + + + + + View Details + + + View Deliveries + + + + Rotate Secret + + + {{ $webhook->is_active ? 'Disable' : 'Enable' }} + + + + Delete + + + +
+
+
+ @empty + + +
+ + No webhooks configured + Create a webhook to receive vendor release notifications + + Create Webhook + +
+
+
+ @endforelse +
+
+ + @if($this->webhooks->hasPages()) +
+ {{ $this->webhooks->links() }} +
+ @endif +
+ + {{-- Webhook Detail Modal --}} + + @if($this->selectedWebhook) +
+
+
+ {{ $this->selectedWebhook->vendor->name }} + {{ $this->selectedWebhook->getProviderLabel() }} Webhook +
+ + {{ $this->selectedWebhook->status_label }} + +
+ +
+
Webhook Endpoint URL
+
+ + {{ $this->selectedWebhook->getEndpointUrl() }} + + +
+
+ +
+
+
Total Deliveries
+
{{ $this->selectedWebhook->deliveries_count }}
+
+
+
Last 24 Hours
+
{{ $this->selectedWebhook->recent_deliveries_count }}
+
+
+
Last Received
+
{{ $this->selectedWebhook->last_received_at?->format('d M Y H:i') ?? 'Never' }}
+
+
+
Failure Count
+
+ {{ $this->selectedWebhook->failure_count }} +
+
+
+ + @if($this->selectedWebhook->isInGracePeriod()) +
+
+ + Secret rotation in progress +
+

+ Both old and new secrets are accepted until {{ $this->selectedWebhook->grace_ends_at->format('d M Y H:i') }}. +

+
+ @endif + +
+
Setup Instructions
+
+ @if($this->selectedWebhook->provider === 'github') +

1. Go to your GitHub repository Settings > Webhooks

+

2. Click "Add webhook"

+

3. Paste the endpoint URL above

+

4. Set Content type to application/json

+

5. Enter your webhook secret

+

6. Select "Let me select individual events" and choose "Releases"

+ @elseif($this->selectedWebhook->provider === 'gitlab') +

1. Go to your GitLab project Settings > Webhooks

+

2. Enter the endpoint URL

+

3. Add your secret token

+

4. Select "Releases events" trigger

+ @elseif($this->selectedWebhook->provider === 'npm') +

1. Configure your npm package hooks using npm hook add

+

2. Use the endpoint URL and your webhook secret

+ @elseif($this->selectedWebhook->provider === 'packagist') +

1. Go to your Packagist package page

+

2. Edit the package settings

+

3. Add a webhook URL pointing to this endpoint

+ @else +

Configure your system to POST JSON payloads to the endpoint URL.

+

Include the version in your payload as version, tag, or tag_name.

+

Sign payloads with HMAC-SHA256 using your secret and include in X-Signature header.

+ @endif +
+
+ +
+ + Delete + +
+ + Rotate Secret + + + View Deliveries + + Close +
+
+
+ @endif +
+ + {{-- Create Webhook Modal --}} + +
+
+ Create Webhook + Configure a new vendor release webhook +
+ +
+ + + @foreach ($this->vendors as $vendor) + + @endforeach + + + + + + + + + +
+ +
+ Cancel + Create Webhook +
+
+
+ + {{-- Secret Display Modal --}} + +
+
+ Webhook Secret + Copy this secret now - it will not be shown again +
+ +
+
+ + Important +
+

+ This is the only time you will see this secret. Copy it now and store it securely. +

+
+ + @if($displaySecret) +
+
Webhook Secret
+
+ + {{ $displaySecret }} + + +
+
+ @endif + +
+ I have copied the secret +
+
+
+ + {{-- Deliveries Modal --}} + +
+
+ Webhook Deliveries + Recent webhook delivery history +
+ +
+ + + + + + + + + + + + + @forelse ($this->selectedWebhookDeliveries as $delivery) + + + + + + + + + @if($delivery->error_message) + + + + @endif + @empty + + + + @endforelse + +
TimeEventVersionStatusSignatureActions
+ {{ $delivery->created_at->format('d M H:i:s') }} + + + {{ $delivery->event_type }} + + + {{ $delivery->version ?? '-' }} + + + {{ ucfirst($delivery->status) }} + + + + {{ ucfirst($delivery->signature_status ?? 'unknown') }} + + + @if($delivery->canRetry()) + + Retry + + @endif +
+
+ Error: {{ $delivery->error_message }} +
+
+ No deliveries recorded yet +
+
+ +
+ Close +
+
+
+
diff --git a/View/Modal/Admin/AssetManager.php b/View/Modal/Admin/AssetManager.php new file mode 100644 index 0000000..28849ff --- /dev/null +++ b/View/Modal/Admin/AssetManager.php @@ -0,0 +1,129 @@ +checkHadesAccess(); + } + + #[Computed] + public function assets(): \Illuminate\Pagination\LengthAwarePaginator + { + return Asset::query() + ->when($this->search, fn ($q) => $q->where(function ($sq) { + $sq->where('name', 'like', "%{$this->search}%") + ->orWhere('slug', 'like', "%{$this->search}%") + ->orWhere('package_name', 'like', "%{$this->search}%"); + })) + ->when($this->type, fn ($q) => $q->where('type', $this->type)) + ->when($this->licenceType, fn ($q) => $q->where('licence_type', $this->licenceType)) + ->when($this->needsUpdate, fn ($q) => $q->needsUpdate()) + ->orderBy($this->sortBy, $this->sortDir) + ->paginate(20); + } + + #[Computed] + public function assetStats(): array + { + return [ + 'total' => Asset::active()->count(), + 'needs_update' => Asset::needsUpdate()->count(), + 'composer' => Asset::composer()->count(), + 'npm' => Asset::npm()->count(), + 'expiring_soon' => Asset::active()->get()->filter->isLicenceExpiringSoon()->count(), + 'expired' => Asset::active()->get()->filter->isLicenceExpired()->count(), + ]; + } + + public function sortBy(string $column): void + { + if ($this->sortBy === $column) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDir = 'asc'; + } + + $this->resetPage(); + } + + public function toggleActive(int $assetId): void + { + $asset = Asset::findOrFail($assetId); + $asset->update(['is_active' => ! $asset->is_active]); + unset($this->assets, $this->assetStats); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedType(): void + { + $this->resetPage(); + } + + public function updatedLicenceType(): void + { + $this->resetPage(); + } + + public function updatedNeedsUpdate(): void + { + $this->resetPage(); + } + + public function resetFilters(): void + { + $this->reset(['search', 'type', 'licenceType', 'needsUpdate']); + $this->resetPage(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.asset-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Asset Manager']); + } +} diff --git a/View/Modal/Admin/Dashboard.php b/View/Modal/Admin/Dashboard.php new file mode 100644 index 0000000..a984bc4 --- /dev/null +++ b/View/Modal/Admin/Dashboard.php @@ -0,0 +1,134 @@ +checkHadesAccess(); + } + + #[Computed] + public function stats(): array + { + return [ + 'vendors_tracked' => Vendor::active()->count(), + 'pending_todos' => UpstreamTodo::pending()->count(), + 'quick_wins' => UpstreamTodo::quickWins()->count(), + 'security_updates' => UpstreamTodo::securityRelated()->pending()->count(), + 'in_progress' => UpstreamTodo::inProgress()->count(), + 'assets_tracked' => Asset::active()->count(), + 'assets_need_update' => Asset::needsUpdate()->count(), + ]; + } + + #[Computed] + public function statCards(): array + { + return [ + ['value' => $this->stats['vendors_tracked'], 'label' => 'Vendors Tracked', 'icon' => 'building-office', 'color' => 'blue'], + ['value' => $this->stats['pending_todos'], 'label' => 'Pending Todos', 'icon' => 'clipboard-document-list', 'color' => 'yellow'], + ['value' => $this->stats['quick_wins'], 'label' => 'Quick Wins', 'icon' => 'bolt', 'color' => 'green'], + ['value' => $this->stats['security_updates'], 'label' => 'Security Updates', 'icon' => 'shield-exclamation', 'color' => 'red'], + ]; + } + + #[Computed] + public function recentReleases(): \Illuminate\Database\Eloquent\Collection + { + return VersionRelease::with('vendor') + ->analyzed() + ->latest() + ->take(5) + ->get(); + } + + #[Computed] + public function recentTodos(): \Illuminate\Database\Eloquent\Collection + { + return UpstreamTodo::with('vendor') + ->pending() + ->orderByDesc('priority') + ->take(10) + ->get(); + } + + #[Computed] + public function vendorSummary(): array + { + return Vendor::active() + ->withCount(['todos as pending_todos_count' => fn ($q) => $q->pending()]) + ->orderByDesc('pending_todos_count') + ->take(5) + ->get() + ->map(fn ($v) => [ + 'id' => $v->id, + 'name' => $v->name, + 'slug' => $v->slug, + 'source_type' => $v->source_type, + 'current_version' => $v->current_version, + 'pending_todos' => $v->pending_todos_count, + 'last_checked' => $v->last_checked_at?->diffForHumans() ?? 'Never', + ]) + ->toArray(); + } + + #[Computed] + public function todosByType(): array + { + return UpstreamTodo::pending() + ->selectRaw('type, COUNT(*) as count') + ->groupBy('type') + ->pluck('count', 'type') + ->toArray(); + } + + #[Computed] + public function todosByEffort(): array + { + return UpstreamTodo::pending() + ->selectRaw('effort, COUNT(*) as count') + ->groupBy('effort') + ->pluck('count', 'effort') + ->toArray(); + } + + public function refresh(): void + { + unset( + $this->stats, + $this->statCards, + $this->recentReleases, + $this->recentTodos, + $this->vendorSummary, + $this->todosByType, + $this->todosByEffort + ); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.dashboard') + ->layout('hub::admin.layouts.app', ['title' => 'Uptelligence Dashboard']); + } +} diff --git a/View/Modal/Admin/DiffViewer.php b/View/Modal/Admin/DiffViewer.php new file mode 100644 index 0000000..9b4a411 --- /dev/null +++ b/View/Modal/Admin/DiffViewer.php @@ -0,0 +1,174 @@ +checkHadesAccess(); + } + + #[Computed] + public function vendors(): \Illuminate\Database\Eloquent\Collection + { + return Vendor::active() + ->whereHas('releases') + ->orderBy('name') + ->get(); + } + + #[Computed] + public function releases(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->vendorId) { + return collect(); + } + + return VersionRelease::where('vendor_id', $this->vendorId) + ->analyzed() + ->latest() + ->get(); + } + + #[Computed] + public function selectedRelease(): ?VersionRelease + { + if (! $this->releaseId) { + return null; + } + + return VersionRelease::with('vendor')->find($this->releaseId); + } + + #[Computed] + public function diffs(): \Illuminate\Pagination\LengthAwarePaginator + { + if (! $this->releaseId) { + return DiffCache::whereNull('id')->paginate(20); + } + + return DiffCache::where('version_release_id', $this->releaseId) + ->when($this->category, fn ($q) => $q->where('category', $this->category)) + ->when($this->changeType, fn ($q) => $q->where('change_type', $this->changeType)) + ->orderByRaw("FIELD(change_type, 'added', 'modified', 'removed')") + ->orderBy('file_path') + ->paginate(30); + } + + #[Computed] + public function diffStats(): array + { + if (! $this->releaseId) { + return [ + 'total' => 0, + 'added' => 0, + 'modified' => 0, + 'removed' => 0, + 'by_category' => [], + ]; + } + + $diffs = DiffCache::where('version_release_id', $this->releaseId)->get(); + + return [ + 'total' => $diffs->count(), + 'added' => $diffs->where('change_type', DiffCache::CHANGE_ADDED)->count(), + 'modified' => $diffs->where('change_type', DiffCache::CHANGE_MODIFIED)->count(), + 'removed' => $diffs->where('change_type', DiffCache::CHANGE_REMOVED)->count(), + 'by_category' => $diffs->groupBy('category')->map->count()->toArray(), + ]; + } + + #[Computed] + public function selectedDiff(): ?DiffCache + { + if (! $this->selectedDiffId) { + return null; + } + + return DiffCache::find($this->selectedDiffId); + } + + public function selectVendor(int $vendorId): void + { + $this->vendorId = $vendorId; + $this->releaseId = null; + $this->resetPage(); + unset($this->releases, $this->selectedRelease, $this->diffs, $this->diffStats); + } + + public function selectRelease(int $releaseId): void + { + $this->releaseId = $releaseId; + $this->resetPage(); + unset($this->selectedRelease, $this->diffs, $this->diffStats); + } + + public function viewDiff(int $diffId): void + { + $this->selectedDiffId = $diffId; + $this->showDiffModal = true; + unset($this->selectedDiff); + } + + public function closeDiffModal(): void + { + $this->showDiffModal = false; + $this->selectedDiffId = null; + } + + public function updatedCategory(): void + { + $this->resetPage(); + } + + public function updatedChangeType(): void + { + $this->resetPage(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.diff-viewer') + ->layout('hub::admin.layouts.app', ['title' => 'Diff Viewer']); + } +} diff --git a/View/Modal/Admin/DigestPreferences.php b/View/Modal/Admin/DigestPreferences.php new file mode 100644 index 0000000..ac53282 --- /dev/null +++ b/View/Modal/Admin/DigestPreferences.php @@ -0,0 +1,237 @@ +digestService = $digestService; + } + + public function mount(): void + { + $this->checkHadesAccess(); + $this->loadPreferences(); + } + + /** + * Load existing preferences from database. + */ + protected function loadPreferences(): void + { + $digest = $this->getDigest(); + + $this->isEnabled = $digest->is_enabled; + $this->frequency = $digest->frequency; + $this->selectedVendorIds = $digest->getVendorIds() ?? []; + $this->selectedTypes = $digest->getIncludedTypes(); + $this->minPriority = $digest->getMinPriority(); + } + + /** + * Get or create the digest record for the current user. + */ + protected function getDigest(): UptelligenceDigest + { + $user = auth()->user(); + $workspaceId = $user->defaultHostWorkspace()?->id; + + if (! $workspaceId) { + abort(403, 'No workspace context'); + } + + return $this->digestService->getOrCreateDigest($user->id, $workspaceId); + } + + #[Computed] + public function vendors(): \Illuminate\Database\Eloquent\Collection + { + return Vendor::active() + ->orderBy('name') + ->get(['id', 'name', 'slug', 'source_type']); + } + + #[Computed] + public function digest(): UptelligenceDigest + { + return $this->getDigest(); + } + + #[Computed] + public function preview(): array + { + $digest = $this->getDigest(); + + // Apply current form values to preview + $digest->is_enabled = true; // Preview as if enabled + $digest->frequency = $this->frequency; + $digest->preferences = [ + 'vendor_ids' => empty($this->selectedVendorIds) ? null : $this->selectedVendorIds, + 'include_types' => $this->selectedTypes, + 'min_priority' => $this->minPriority, + ]; + + return $this->digestService->getDigestPreview($digest); + } + + /** + * Save preferences to database. + */ + public function save(): void + { + $digest = $this->getDigest(); + + $digest->update([ + 'is_enabled' => $this->isEnabled, + 'frequency' => $this->frequency, + 'preferences' => [ + 'vendor_ids' => empty($this->selectedVendorIds) ? null : $this->selectedVendorIds, + 'include_types' => $this->selectedTypes, + 'min_priority' => $this->minPriority, + ], + ]); + + $this->dispatch('toast', message: 'Digest preferences saved successfully.', type: 'success'); + + // Refresh computed + unset($this->digest); + } + + /** + * Toggle digest enabled state. + */ + public function toggleEnabled(): void + { + $this->isEnabled = ! $this->isEnabled; + $this->save(); + } + + /** + * Toggle vendor selection. + */ + public function toggleVendor(int $vendorId): void + { + if (in_array($vendorId, $this->selectedVendorIds)) { + $this->selectedVendorIds = array_values( + array_diff($this->selectedVendorIds, [$vendorId]) + ); + } else { + $this->selectedVendorIds[] = $vendorId; + } + + // Clear preview cache + unset($this->preview); + } + + /** + * Select all vendors. + */ + public function selectAllVendors(): void + { + $this->selectedVendorIds = []; + unset($this->preview); + } + + /** + * Toggle type selection. + */ + public function toggleType(string $type): void + { + if (in_array($type, $this->selectedTypes)) { + $this->selectedTypes = array_values( + array_diff($this->selectedTypes, [$type]) + ); + } else { + $this->selectedTypes[] = $type; + } + + unset($this->preview); + } + + /** + * Show the preview panel. + */ + public function showPreview(): void + { + $this->showPreview = true; + unset($this->preview); + } + + /** + * Hide the preview panel. + */ + public function hidePreview(): void + { + $this->showPreview = false; + } + + /** + * Send a test digest immediately. + */ + public function sendTestDigest(): void + { + $digest = $this->getDigest(); + + // Temporarily enable for test + $wasEnabled = $digest->is_enabled; + $digest->is_enabled = true; + + try { + $sent = $this->digestService->sendDigest($digest); + + if ($sent) { + $this->dispatch('toast', message: 'Test digest sent to your email.', type: 'success'); + } else { + $this->dispatch('toast', message: 'No content to include in digest.', type: 'info'); + } + } catch (\Exception $e) { + $this->dispatch('toast', message: 'Failed to send test digest: '.$e->getMessage(), type: 'error'); + } + + // Restore original state + if (! $wasEnabled) { + $digest->update(['is_enabled' => false]); + } + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.digest-preferences') + ->layout('hub::admin.layouts.app', ['title' => 'Digest Preferences']); + } +} diff --git a/View/Modal/Admin/TodoList.php b/View/Modal/Admin/TodoList.php new file mode 100644 index 0000000..2c7cc34 --- /dev/null +++ b/View/Modal/Admin/TodoList.php @@ -0,0 +1,213 @@ +checkHadesAccess(); + } + + #[Computed] + public function vendors(): \Illuminate\Database\Eloquent\Collection + { + return Vendor::active()->orderBy('name')->get(); + } + + #[Computed] + public function todos(): \Illuminate\Pagination\LengthAwarePaginator + { + return UpstreamTodo::with('vendor') + ->when($this->search, fn ($q) => $q->where(function ($sq) { + $sq->where('title', 'like', "%{$this->search}%") + ->orWhere('description', 'like', "%{$this->search}%"); + })) + ->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId)) + ->when($this->status, fn ($q) => match ($this->status) { + 'pending' => $q->pending(), + 'in_progress' => $q->inProgress(), + 'completed' => $q->completed(), + 'quick_wins' => $q->quickWins(), + default => $q, + }) + ->when($this->type, fn ($q) => $q->where('type', $this->type)) + ->when($this->effort, fn ($q) => $q->where('effort', $this->effort)) + ->when($this->priority, fn ($q) => match ($this->priority) { + 'critical' => $q->where('priority', '>=', 8), + 'high' => $q->whereBetween('priority', [6, 7]), + 'medium' => $q->whereBetween('priority', [4, 5]), + 'low' => $q->where('priority', '<', 4), + default => $q, + }) + ->orderBy($this->sortBy, $this->sortDir) + ->paginate(20); + } + + #[Computed] + public function todoStats(): array + { + $baseQuery = UpstreamTodo::query() + ->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId)); + + return [ + 'pending' => (clone $baseQuery)->pending()->count(), + 'in_progress' => (clone $baseQuery)->inProgress()->count(), + 'quick_wins' => (clone $baseQuery)->quickWins()->count(), + 'completed' => (clone $baseQuery)->completed()->count(), + ]; + } + + public function sortBy(string $column): void + { + if ($this->sortBy === $column) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDir = $column === 'priority' ? 'desc' : 'asc'; + } + + $this->resetPage(); + } + + public function markStatus(int $todoId, string $status): void + { + $todo = UpstreamTodo::findOrFail($todoId); + + match ($status) { + 'in_progress' => $todo->markInProgress(), + 'ported' => $todo->markPorted(), + 'skipped' => $todo->markSkipped(), + 'wont_port' => $todo->markWontPort(), + default => null, + }; + + unset($this->todos, $this->todoStats); + } + + public function bulkMarkStatus(string $status): void + { + if (empty($this->selectedTodos)) { + return; + } + + $todos = UpstreamTodo::whereIn('id', $this->selectedTodos)->get(); + + foreach ($todos as $todo) { + match ($status) { + 'in_progress' => $todo->markInProgress(), + 'ported' => $todo->markPorted(), + 'skipped' => $todo->markSkipped(), + 'wont_port' => $todo->markWontPort(), + default => null, + }; + } + + $this->selectedTodos = []; + $this->selectAll = false; + unset($this->todos, $this->todoStats); + } + + public function toggleSelectAll(): void + { + if ($this->selectAll) { + $this->selectedTodos = $this->todos->pluck('id')->toArray(); + } else { + $this->selectedTodos = []; + } + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedVendorId(): void + { + $this->resetPage(); + unset($this->todoStats); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + public function updatedType(): void + { + $this->resetPage(); + } + + public function updatedEffort(): void + { + $this->resetPage(); + } + + public function updatedPriority(): void + { + $this->resetPage(); + } + + public function resetFilters(): void + { + $this->reset(['search', 'vendorId', 'status', 'type', 'effort', 'priority']); + $this->status = 'pending'; + $this->resetPage(); + unset($this->todoStats); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.todo-list') + ->layout('hub::admin.layouts.app', ['title' => 'Upstream Todos']); + } +} diff --git a/View/Modal/Admin/VendorManager.php b/View/Modal/Admin/VendorManager.php new file mode 100644 index 0000000..6323b6b --- /dev/null +++ b/View/Modal/Admin/VendorManager.php @@ -0,0 +1,140 @@ +checkHadesAccess(); + } + + #[Computed] + public function vendors(): \Illuminate\Pagination\LengthAwarePaginator + { + return Vendor::query() + ->withCount([ + 'todos as pending_todos_count' => fn ($q) => $q->pending(), + 'todos as quick_wins_count' => fn ($q) => $q->quickWins(), + ]) + ->when($this->search, fn ($q) => $q->where(function ($sq) { + $sq->where('name', 'like', "%{$this->search}%") + ->orWhere('slug', 'like', "%{$this->search}%") + ->orWhere('vendor_name', 'like', "%{$this->search}%"); + })) + ->when($this->sourceType, fn ($q) => $q->where('source_type', $this->sourceType)) + ->orderBy($this->sortBy, $this->sortDir) + ->paginate(15); + } + + #[Computed] + public function selectedVendor(): ?Vendor + { + if (! $this->selectedVendorId) { + return null; + } + + return Vendor::with(['todos' => fn ($q) => $q->pending()->orderByDesc('priority')->take(5)]) + ->withCount(['todos as pending_todos_count' => fn ($q) => $q->pending()]) + ->find($this->selectedVendorId); + } + + #[Computed] + public function selectedVendorReleases(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->selectedVendorId) { + return collect(); + } + + return VersionRelease::where('vendor_id', $this->selectedVendorId) + ->analyzed() + ->latest() + ->take(10) + ->get(); + } + + public function selectVendor(int $vendorId): void + { + $this->selectedVendorId = $vendorId; + $this->showVendorModal = true; + unset($this->selectedVendor, $this->selectedVendorReleases); + } + + public function closeVendorModal(): void + { + $this->showVendorModal = false; + $this->selectedVendorId = null; + } + + public function sortBy(string $column): void + { + if ($this->sortBy === $column) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $column; + $this->sortDir = 'asc'; + } + + $this->resetPage(); + } + + public function toggleActive(int $vendorId): void + { + $vendor = Vendor::findOrFail($vendorId); + $vendor->update(['is_active' => ! $vendor->is_active]); + unset($this->vendors); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedSourceType(): void + { + $this->resetPage(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.vendor-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Vendor Manager']); + } +} diff --git a/View/Modal/Admin/WebhookManager.php b/View/Modal/Admin/WebhookManager.php new file mode 100644 index 0000000..2ca3f33 --- /dev/null +++ b/View/Modal/Admin/WebhookManager.php @@ -0,0 +1,253 @@ +checkHadesAccess(); + } + + #[Computed] + public function webhooks(): \Illuminate\Pagination\LengthAwarePaginator + { + return UptelligenceWebhook::query() + ->with('vendor') + ->withCount(['deliveries', 'deliveries as recent_deliveries_count' => fn ($q) => $q->recent(24)]) + ->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId)) + ->when($this->provider, fn ($q) => $q->where('provider', $this->provider)) + ->when($this->status === 'active', fn ($q) => $q->where('is_active', true)) + ->when($this->status === 'disabled', fn ($q) => $q->where('is_active', false)) + ->latest() + ->paginate(15); + } + + #[Computed] + public function vendors(): \Illuminate\Database\Eloquent\Collection + { + return Vendor::orderBy('name')->get(['id', 'name', 'slug']); + } + + #[Computed] + public function selectedWebhook(): ?UptelligenceWebhook + { + if (! $this->selectedWebhookId) { + return null; + } + + return UptelligenceWebhook::with('vendor') + ->withCount(['deliveries', 'deliveries as recent_deliveries_count' => fn ($q) => $q->recent(24)]) + ->find($this->selectedWebhookId); + } + + #[Computed] + public function selectedWebhookDeliveries(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->selectedWebhookId) { + return collect(); + } + + return UptelligenceWebhookDelivery::where('webhook_id', $this->selectedWebhookId) + ->latest() + ->take(20) + ->get(); + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + public function selectWebhook(int $webhookId): void + { + $this->selectedWebhookId = $webhookId; + $this->showWebhookModal = true; + $this->displaySecret = null; + unset($this->selectedWebhook, $this->selectedWebhookDeliveries); + } + + public function closeWebhookModal(): void + { + $this->showWebhookModal = false; + $this->selectedWebhookId = null; + $this->displaySecret = null; + } + + public function openCreateModal(): void + { + $this->createVendorId = $this->vendorId; + $this->createProvider = UptelligenceWebhook::PROVIDER_GITHUB; + $this->showCreateModal = true; + } + + public function closeCreateModal(): void + { + $this->showCreateModal = false; + $this->createVendorId = null; + $this->displaySecret = null; + } + + public function createWebhook(): void + { + $this->validate([ + 'createVendorId' => 'required|exists:vendors,id', + 'createProvider' => 'required|in:'.implode(',', UptelligenceWebhook::PROVIDERS), + ]); + + // Check if webhook already exists for this vendor/provider + $existing = UptelligenceWebhook::where('vendor_id', $this->createVendorId) + ->where('provider', $this->createProvider) + ->first(); + + if ($existing) { + $this->addError('createVendorId', 'A webhook for this vendor and provider already exists.'); + + return; + } + + $webhook = UptelligenceWebhook::create([ + 'vendor_id' => $this->createVendorId, + 'provider' => $this->createProvider, + ]); + + // Show the secret to the user + $this->displaySecret = $webhook->secret; + $this->showSecretModal = true; + $this->showCreateModal = false; + + // Select the new webhook + $this->selectedWebhookId = $webhook->id; + + unset($this->webhooks); + } + + public function toggleActive(int $webhookId): void + { + $webhook = UptelligenceWebhook::findOrFail($webhookId); + $webhook->update(['is_active' => ! $webhook->is_active]); + + // Reset failure count when re-enabling + if ($webhook->is_active) { + $webhook->resetFailureCount(); + } + + unset($this->webhooks, $this->selectedWebhook); + } + + public function regenerateSecret(int $webhookId): void + { + $webhook = UptelligenceWebhook::findOrFail($webhookId); + $this->displaySecret = $webhook->rotateSecret(); + $this->showSecretModal = true; + unset($this->selectedWebhook); + } + + public function closeSecretModal(): void + { + $this->showSecretModal = false; + $this->displaySecret = null; + } + + public function viewDeliveries(int $webhookId): void + { + $this->selectedWebhookId = $webhookId; + $this->showDeliveriesModal = true; + unset($this->selectedWebhookDeliveries); + } + + public function closeDeliveriesModal(): void + { + $this->showDeliveriesModal = false; + } + + public function retryDelivery(int $deliveryId): void + { + $delivery = UptelligenceWebhookDelivery::findOrFail($deliveryId); + + if ($delivery->canRetry()) { + $delivery->scheduleRetry(); + \Core\Uptelligence\Jobs\ProcessUptelligenceWebhook::dispatch($delivery); + } + + unset($this->selectedWebhookDeliveries); + } + + public function deleteWebhook(int $webhookId): void + { + $webhook = UptelligenceWebhook::findOrFail($webhookId); + $webhook->delete(); + + $this->closeWebhookModal(); + unset($this->webhooks); + } + + public function updatedVendorId(): void + { + $this->resetPage(); + } + + public function updatedProvider(): void + { + $this->resetPage(); + } + + public function updatedStatus(): void + { + $this->resetPage(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('uptelligence::admin.webhook-manager') + ->layout('hub::admin.layouts.app', ['title' => 'Webhook Manager']); + } +} diff --git a/app/Http/Controllers/.gitkeep b/app/Http/Controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Mod/.gitkeep b/app/Mod/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php deleted file mode 100644 index 452e6b6..0000000 --- a/app/Providers/AppServiceProvider.php +++ /dev/null @@ -1,24 +0,0 @@ -handleCommand(new ArgvInput); - -exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php deleted file mode 100644 index 4687853..0000000 --- a/bootstrap/app.php +++ /dev/null @@ -1,26 +0,0 @@ -withProviders([ - // Core PHP Framework - \Core\LifecycleEventProvider::class, - \Core\Website\Boot::class, - \Core\Front\Boot::class, - \Core\Mod\Boot::class, - ]) - ->withRouting( - web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', - commands: __DIR__.'/../routes/console.php', - health: '/up', - ) - ->withMiddleware(function (Middleware $middleware) { - \Core\Front\Boot::middleware($middleware); - }) - ->withExceptions(function (Exceptions $exceptions) { - // - })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php deleted file mode 100644 index 38b258d..0000000 --- a/bootstrap/providers.php +++ /dev/null @@ -1,5 +0,0 @@ - [ + // Primary storage disk: 'local' or 's3' + 'disk' => env('UPSTREAM_STORAGE_DISK', 'local'), + + // Local paths (always used for active/temp files) + 'base_path' => storage_path('app/vendors'), + 'licensed' => storage_path('app/vendors/licensed'), + 'oss' => storage_path('app/vendors/oss'), + 'plugins' => storage_path('app/vendors/plugins'), + 'temp_path' => storage_path('app/temp/upstream'), + + // S3 cold storage settings (Hetzner Object Store compatible) + 's3' => [ + // Private bucket for vendor archives (not publicly accessible) + 'bucket' => env('UPSTREAM_S3_BUCKET', 'hostuk'), + 'prefix' => env('UPSTREAM_S3_PREFIX', 'upstream/vendors/'), + 'region' => env('UPSTREAM_S3_REGION', env('AWS_DEFAULT_REGION', 'eu-west-2')), + + // Dual endpoint support for Hetzner Object Store + // Private: Internal access only (hostuk) + // Public: CDN/public access (host-uk) - NOT used for vendor archives + 'private_endpoint' => env('S3_PRIVATE_ENDPOINT', env('AWS_ENDPOINT')), + 'public_endpoint' => env('S3_PUBLIC_ENDPOINT'), + + // Disk name in config/filesystems.php + // Defaults to private storage for vendor archives + 'disk' => env('UPSTREAM_S3_DISK', 's3-private'), + ], + + // Archive behavior + 'archive' => [ + // Auto-archive to S3 after import (if disk is 's3') + 'auto_archive' => env('UPSTREAM_AUTO_ARCHIVE', true), + // Delete local files after successful S3 upload + 'delete_local_after_archive' => env('UPSTREAM_DELETE_LOCAL', true), + // Keep local copies for N most recent versions per vendor + 'keep_local_versions' => env('UPSTREAM_KEEP_LOCAL', 2), + // Cleanup temp files older than N hours + 'cleanup_after_hours' => env('UPSTREAM_CLEANUP_HOURS', 24), + ], + + // Download behavior + 'download' => [ + // Max concurrent downloads + 'max_concurrent' => 3, + // Download timeout in seconds + 'timeout' => 300, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Vendor Source Types + |-------------------------------------------------------------------------- + | - licensed: Paid software (manual upload/extract) + | - oss: Open source (git submodule capable) + | - plugin: Plugin packages (Altum, WordPress, etc.) + */ + 'source_types' => [ + 'licensed' => [ + 'label' => 'Licensed Software', + 'description' => 'Paid/proprietary software requiring manual version uploads', + 'can_git_sync' => false, + 'requires_upload' => true, + ], + 'oss' => [ + 'label' => 'Open Source', + 'description' => 'Open source projects that can be git submoduled', + 'can_git_sync' => true, + 'requires_upload' => false, + ], + 'plugin' => [ + 'label' => 'Plugin/Extension', + 'description' => 'Plugins for various platforms (Altum, WordPress, etc.)', + 'can_git_sync' => false, + 'requires_upload' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Plugin Platforms + |-------------------------------------------------------------------------- + */ + 'plugin_platforms' => [ + 'altum' => 'Altum/phpBioLinks', + 'wordpress' => 'WordPress', + 'laravel' => 'Laravel Package', + 'other' => 'Other', + ], + + /* + |-------------------------------------------------------------------------- + | Auto-Detection Patterns + |-------------------------------------------------------------------------- + | File patterns to auto-detect change categories + */ + 'detection_patterns' => [ + 'security' => [ + '*/security/*', + '*/auth/*', + '*password*', + '*permission*', + '*/middleware/*', + '*csrf*', + '*xss*', + ], + 'controller' => [ + '*/controllers/*', + '*Controller.php', + ], + 'model' => [ + '*/models/*', + '*Model.php', + '*/Entities/*', + ], + 'view' => [ + '*/views/*', + '*/themes/*', + '*.blade.php', + '*/templates/*', + ], + 'migration' => [ + '*/migrations/*', + '*/database/*', + '*schema*', + ], + 'api' => [ + '*/api/*', + '*api.php', + '*/Api/*', + ], + 'block' => [ + '*/blocks/*', + '*biolink*', + '*Block.php', + ], + 'plugin' => [ + '*/plugins/*', + '*Plugin.php', + ], + ], + + /* + |-------------------------------------------------------------------------- + | AI Analysis Settings + |-------------------------------------------------------------------------- + */ + 'ai' => [ + 'provider' => env('UPSTREAM_AI_PROVIDER', 'anthropic'), + 'model' => env('UPSTREAM_AI_MODEL', 'claude-sonnet-4-20250514'), + 'max_tokens' => 4096, + 'temperature' => 0.3, + + // Rate limiting: max AI API calls per minute + 'rate_limit' => env('UPSTREAM_AI_RATE_LIMIT', 10), + + // Prompt templates + 'prompts' => [ + 'categorize' => 'Analyse this code diff and categorise the change type (feature, bugfix, security, ui, refactor, etc). Also estimate the effort level (low, medium, high) and priority (1-10) for porting.', + 'summarize' => 'Summarise the key changes in this version update in bullet points. Focus on user-facing features, security updates, and breaking changes.', + 'dependencies' => 'Identify any dependencies this change has on other files or features that would need to be ported first.', + ], + ], + + /* + |-------------------------------------------------------------------------- + | GitHub Integration + |-------------------------------------------------------------------------- + */ + 'github' => [ + 'enabled' => env('UPSTREAM_GITHUB_ENABLED', true), + 'token' => env('GITHUB_TOKEN'), + 'default_labels' => ['upstream', 'auto-generated'], + 'assignees' => explode(',', env('UPSTREAM_GITHUB_ASSIGNEES', '')), + ], + + /* + |-------------------------------------------------------------------------- + | Gitea Integration (Internal) + |-------------------------------------------------------------------------- + */ + 'gitea' => [ + 'enabled' => env('UPSTREAM_GITEA_ENABLED', true), + 'url' => env('GITEA_URL', 'https://git.host.uk'), + 'token' => env('GITEA_TOKEN'), + 'org' => env('GITEA_ORG', 'host-uk'), + ], + + /* + |-------------------------------------------------------------------------- + | Update Checker Settings + |-------------------------------------------------------------------------- + */ + 'update_checker' => [ + // Auto-create todos when updates are detected + 'create_todos' => env('UPSTREAM_CREATE_TODOS', true), + + // Default priority for auto-created update todos (1-10) + 'default_priority' => 5, + + // Skip checking vendors that haven't been updated in N days + // Set to 0 to always check all vendors + 'skip_recently_checked_days' => 0, + ], + + /* + |-------------------------------------------------------------------------- + | Notifications + |-------------------------------------------------------------------------- + */ + 'notifications' => [ + 'slack_webhook' => env('UPSTREAM_SLACK_WEBHOOK'), + 'discord_webhook' => env('UPSTREAM_DISCORD_WEBHOOK'), + 'email_recipients' => explode(',', env('UPSTREAM_EMAIL_RECIPIENTS', '')), + ], + + /* + |-------------------------------------------------------------------------- + | Default Vendor Configurations + |-------------------------------------------------------------------------- + | Pre-configured vendors to seed the database with + */ + 'default_vendors' => [ + [ + 'slug' => '66biolinks', + 'name' => '66biolinks', + 'vendor_name' => 'AltumCode', + 'source_type' => 'licensed', + 'path_mapping' => [ + 'app/' => 'product/app/', + 'themes/' => 'product/themes/', + 'plugins/' => 'product/plugins/', + ], + 'ignored_paths' => [ + 'vendor/*', + 'node_modules/*', + 'storage/*', + '.git/*', + '*.log', + ], + 'priority_paths' => [ + 'app/controllers/*', + 'app/models/*', + 'plugins/*/init.php', + 'themes/altum/views/l/*', + ], + 'target_repo' => 'host-uk/bio.host.uk.com', + ], + [ + 'slug' => 'mixpost-pro', + 'name' => 'Mixpost Pro', + 'vendor_name' => 'Inovector', + 'source_type' => 'licensed', + 'path_mapping' => [ + 'src/' => 'packages/mixpost-pro/src/', + ], + 'ignored_paths' => [ + 'vendor/*', + 'node_modules/*', + 'tests/*', + ], + 'priority_paths' => [ + 'src/Http/Controllers/*', + 'src/Models/*', + 'src/Services/*', + ], + 'target_repo' => 'host-uk/host.uk.com', + ], + [ + 'slug' => 'mixpost-enterprise', + 'name' => 'Mixpost Enterprise', + 'vendor_name' => 'Inovector', + 'source_type' => 'licensed', + 'path_mapping' => [ + 'src/' => 'packages/mixpost-enterprise/src/', + ], + 'ignored_paths' => [ + 'vendor/*', + 'node_modules/*', + 'tests/*', + ], + 'priority_paths' => [ + 'src/Billing/*', + 'src/Features/*', + ], + 'target_repo' => 'host-uk/host.uk.com', + ], + ], +]; diff --git a/config/core.php b/config/core.php deleted file mode 100644 index 06502fa..0000000 --- a/config/core.php +++ /dev/null @@ -1,24 +0,0 @@ - [ - app_path('Core'), - app_path('Mod'), - app_path('Website'), - ], - - 'services' => [ - 'cache_discovery' => env('CORE_CACHE_DISCOVERY', true), - ], - - 'cdn' => [ - 'enabled' => env('CDN_ENABLED', false), - 'driver' => env('CDN_DRIVER', 'bunny'), - ], -]; diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/migrations/0001_01_01_000001_create_uptelligence_tables.php b/database/migrations/0001_01_01_000001_create_uptelligence_tables.php new file mode 100644 index 0000000..1f1adc4 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_uptelligence_tables.php @@ -0,0 +1,118 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->string('type', 32)->default('http'); + $table->string('url', 2048); + $table->string('method', 10)->default('GET'); + $table->json('headers')->nullable(); + $table->text('body')->nullable(); + $table->json('expected_response')->nullable(); + $table->unsignedSmallInteger('interval_seconds')->default(300); + $table->unsignedSmallInteger('timeout_seconds')->default(30); + $table->unsignedTinyInteger('retries')->default(3); + $table->string('status', 32)->default('active'); + $table->string('current_status', 32)->default('unknown'); + $table->decimal('uptime_percentage', 5, 2)->default(100); + $table->unsignedInteger('avg_response_ms')->nullable(); + $table->timestamp('last_checked_at')->nullable(); + $table->timestamp('last_up_at')->nullable(); + $table->timestamp('last_down_at')->nullable(); + $table->json('notification_channels')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'status']); + $table->index(['status', 'current_status']); + $table->index('last_checked_at'); + }); + + // 2. Monitor Checks + Schema::create('uptelligence_checks', function (Blueprint $table) { + $table->id(); + $table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete(); + $table->string('status', 32); + $table->unsignedInteger('response_time_ms')->nullable(); + $table->unsignedSmallInteger('status_code')->nullable(); + $table->text('error_message')->nullable(); + $table->json('response_headers')->nullable(); + $table->text('response_body')->nullable(); + $table->string('checked_from', 64)->nullable(); + $table->timestamp('created_at'); + + $table->index(['monitor_id', 'created_at']); + $table->index(['monitor_id', 'status']); + }); + + // 3. Monitor Incidents + Schema::create('uptelligence_incidents', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete(); + $table->string('status', 32)->default('ongoing'); + $table->text('cause')->nullable(); + $table->unsignedInteger('duration_seconds')->nullable(); + $table->unsignedInteger('checks_failed')->default(1); + $table->timestamp('started_at'); + $table->timestamp('resolved_at')->nullable(); + $table->timestamp('acknowledged_at')->nullable(); + $table->foreignId('acknowledged_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['monitor_id', 'status']); + $table->index(['status', 'started_at']); + }); + + // 4. Monitor Daily Stats + Schema::create('uptelligence_daily_stats', function (Blueprint $table) { + $table->id(); + $table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete(); + $table->date('date'); + $table->unsignedInteger('checks_total')->default(0); + $table->unsignedInteger('checks_up')->default(0); + $table->unsignedInteger('checks_down')->default(0); + $table->decimal('uptime_percentage', 5, 2)->default(100); + $table->unsignedInteger('avg_response_ms')->nullable(); + $table->unsignedInteger('min_response_ms')->nullable(); + $table->unsignedInteger('max_response_ms')->nullable(); + $table->unsignedInteger('incidents_count')->default(0); + $table->unsignedInteger('total_downtime_seconds')->default(0); + $table->timestamps(); + + $table->unique(['monitor_id', 'date']); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('uptelligence_daily_stats'); + Schema::dropIfExists('uptelligence_incidents'); + Schema::dropIfExists('uptelligence_checks'); + Schema::dropIfExists('uptelligence_monitors'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_uptelligence_digests_table.php b/database/migrations/0001_01_01_000002_create_uptelligence_digests_table.php new file mode 100644 index 0000000..c0eb9f3 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_uptelligence_digests_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('frequency', 16)->default('weekly'); // daily, weekly, monthly + $table->timestamp('last_sent_at')->nullable(); + $table->json('preferences')->nullable(); // vendor filters, update types, etc. + $table->boolean('is_enabled')->default(true); + $table->timestamps(); + + // Each user can only have one digest preference per workspace + $table->unique(['user_id', 'workspace_id']); + + // Index for finding users due for digest + $table->index(['is_enabled', 'frequency', 'last_sent_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('uptelligence_digests'); + } +}; diff --git a/database/migrations/0001_01_01_000003_create_uptelligence_webhooks_table.php b/database/migrations/0001_01_01_000003_create_uptelligence_webhooks_table.php new file mode 100644 index 0000000..47582af --- /dev/null +++ b/database/migrations/0001_01_01_000003_create_uptelligence_webhooks_table.php @@ -0,0 +1,75 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('vendor_id')->constrained('vendors')->cascadeOnDelete(); + $table->string('provider', 32); // github, gitlab, npm, packagist, custom + $table->text('secret')->nullable(); // encrypted, for signature verification + $table->text('previous_secret')->nullable(); // encrypted, for grace period + $table->timestamp('secret_rotated_at')->nullable(); + $table->unsignedInteger('grace_period_seconds')->default(86400); // 24 hours + $table->boolean('is_active')->default(true); + $table->unsignedInteger('failure_count')->default(0); + $table->timestamp('last_received_at')->nullable(); + $table->json('settings')->nullable(); // provider-specific settings + $table->timestamps(); + $table->softDeletes(); + + $table->index(['vendor_id', 'is_active']); + $table->index('provider'); + }); + + // 2. Webhook delivery logs + Schema::create('uptelligence_webhook_deliveries', function (Blueprint $table) { + $table->id(); + $table->foreignId('webhook_id')->constrained('uptelligence_webhooks')->cascadeOnDelete(); + $table->foreignId('vendor_id')->constrained('vendors')->cascadeOnDelete(); + $table->string('event_type', 64); // release.published, package.updated, etc. + $table->string('provider', 32); + $table->string('version')->nullable(); // extracted version + $table->string('tag_name')->nullable(); // original tag name + $table->json('payload'); // raw payload + $table->json('parsed_data')->nullable(); // normalised release data + $table->string('status', 32)->default('pending'); // pending, processing, completed, failed + $table->text('error_message')->nullable(); + $table->string('source_ip', 45)->nullable(); + $table->string('signature_status', 16)->nullable(); // valid, invalid, missing + $table->timestamp('processed_at')->nullable(); + $table->unsignedTinyInteger('retry_count')->default(0); + $table->unsignedTinyInteger('max_retries')->default(3); + $table->timestamp('next_retry_at')->nullable(); + $table->timestamps(); + + $table->index(['webhook_id', 'status']); + $table->index(['vendor_id', 'created_at']); + $table->index(['status', 'next_retry_at']); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('uptelligence_webhook_deliveries'); + Schema::dropIfExists('uptelligence_webhooks'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php deleted file mode 100644 index df6818f..0000000 --- a/database/seeders/DatabaseSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - - - - - tests/Unit - - - tests/Feature - - - - - app - - - - - - - - - - - - - - - - diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 49c0612..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index 3aec5e2..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,21 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Handle Authorization Header - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Send Requests To Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 947d989..0000000 --- a/public/index.php +++ /dev/null @@ -1,17 +0,0 @@ -handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb05362..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index b5c61c9..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 953d266..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,3 +0,0 @@ -import axios from 'axios'; -window.axios = axios; -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 88808ac..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - Core PHP Framework - - - -
-

Core PHP Framework

-

Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}

- -
- - diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..d3c82e5 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,30 @@ +middleware(['web', 'auth'])->group(function () { + Route::get('/', Dashboard::class)->name('hub.admin.uptelligence'); + Route::get('/vendors', VendorManager::class)->name('hub.admin.uptelligence.vendors'); + Route::get('/todos', TodoList::class)->name('hub.admin.uptelligence.todos'); + Route::get('/diffs', DiffViewer::class)->name('hub.admin.uptelligence.diffs'); + Route::get('/assets', AssetManager::class)->name('hub.admin.uptelligence.assets'); + Route::get('/digests', DigestPreferences::class)->name('hub.admin.uptelligence.digests'); + Route::get('/webhooks', WebhookManager::class)->name('hub.admin.uptelligence.webhooks'); +}); diff --git a/routes/api.php b/routes/api.php index 15fbf70..dcd3449 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,34 @@ name('api.uptelligence.webhooks.')->group(function () { + Route::post('/{webhook}', [WebhookController::class, 'receive']) + ->name('receive') + ->middleware('throttle:uptelligence-webhooks'); + + Route::post('/{webhook}/test', [WebhookController::class, 'test']) + ->name('test') + ->middleware('throttle:uptelligence-webhooks'); +}); diff --git a/routes/console.php b/routes/console.php deleted file mode 100644 index d6f3c8b..0000000 --- a/routes/console.php +++ /dev/null @@ -1,3 +0,0 @@ -