monorepo sepration
This commit is contained in:
parent
737e705755
commit
40d893af44
94 changed files with 12359 additions and 654 deletions
76
.env.example
76
.env.example
|
|
@ -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=
|
||||
62
.github/package-workflows/README.md
vendored
62
.github/package-workflows/README.md
vendored
|
|
@ -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
|
||||
[](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](https://packagist.org/packages/host-uk/{package})
|
||||
[](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"
|
||||
}
|
||||
}
|
||||
```
|
||||
55
.github/package-workflows/ci.yml
vendored
55
.github/package-workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
40
.github/package-workflows/release.yml
vendored
40
.github/package-workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
173
Boot.php
Normal file
173
Boot.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence;
|
||||
|
||||
use Core\Events\AdminPanelBooting;
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
use Core\Events\ConsoleBooting;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Uptelligence Module Boot
|
||||
*
|
||||
* Upstream vendor tracking and dependency intelligence.
|
||||
* Manages vendor versions, diffs, todos, and asset tracking.
|
||||
*/
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
protected string $moduleName = 'uptelligence';
|
||||
|
||||
/**
|
||||
* Events this module listens to for lazy loading.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
141
Console/AnalyzeCommand.php
Normal file
141
Console/AnalyzeCommand.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
use Core\Uptelligence\Services\DiffAnalyzerService;
|
||||
use Core\Uptelligence\Services\VendorStorageService;
|
||||
|
||||
class AnalyzeCommand extends Command
|
||||
{
|
||||
protected $signature = 'upstream:analyze
|
||||
{vendor : Vendor slug to analyze}
|
||||
{--from= : Previous version (defaults to vendor.previous_version)}
|
||||
{--to= : Current version (defaults to vendor.current_version)}
|
||||
{--summary : Show summary only, no file details}';
|
||||
|
||||
protected $description = 'Analyze differences between vendor versions';
|
||||
|
||||
public function handle(VendorStorageService $storageService): int
|
||||
{
|
||||
$vendorSlug = $this->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('<fg=cyan>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('<fg=yellow>Modified files (up to 20):</>');
|
||||
foreach ($modified as $diff) {
|
||||
$priority = $vendor->isPriorityPath($diff->file_path) ? ' <fg=magenta>[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('<fg=green>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('<fg=red>Removed files (up to 10):</>');
|
||||
foreach ($removed as $diff) {
|
||||
$this->line(" D {$diff->file_path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$vendor->update(['last_analyzed_at' => now()]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
109
Console/CheckCommand.php
Normal file
109
Console/CheckCommand.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Services\AssetTrackerService;
|
||||
use Core\Uptelligence\Services\VendorStorageService;
|
||||
|
||||
class CheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'upstream:check
|
||||
{vendor? : Vendor slug to check (optional, checks all if omitted)}
|
||||
{--assets : Also check package assets for updates}';
|
||||
|
||||
protected $description = 'Check vendors for upstream updates';
|
||||
|
||||
public function handle(
|
||||
VendorStorageService $storageService,
|
||||
AssetTrackerService $assetService
|
||||
): int {
|
||||
$vendorSlug = $this->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 => '<fg=yellow>No version tracked</>',
|
||||
$localExists && $hasPreviousVersion => '<fg=green>Ready to analyze</>',
|
||||
$localExists => '<fg=blue>Current only</>',
|
||||
default => '<fg=red>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
|
||||
? '<fg=yellow>Update available</>'
|
||||
: '<fg=green>Up to date</>',
|
||||
'rate_limited' => '<fg=red>Rate limited</>',
|
||||
'skipped' => '<fg=gray>Skipped</>',
|
||||
default => '<fg=red>Error</>',
|
||||
};
|
||||
|
||||
$assetTable[] = [
|
||||
$slug,
|
||||
$result['latest'] ?? $result['installed'] ?? '-',
|
||||
$statusIcon,
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['Asset', 'Version', 'Status'], $assetTable);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Check complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
202
Console/CheckUpdatesCommand.php
Normal file
202
Console/CheckUpdatesCommand.php
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Services\AssetTrackerService;
|
||||
use Core\Uptelligence\Services\VendorUpdateCheckerService;
|
||||
|
||||
/**
|
||||
* Artisan command to check vendors and assets for upstream updates.
|
||||
*
|
||||
* Can be run manually or scheduled via the scheduler.
|
||||
*/
|
||||
class CheckUpdatesCommand extends Command
|
||||
{
|
||||
protected $signature = 'uptelligence:check-updates
|
||||
{--vendor= : Specific vendor slug to check}
|
||||
{--assets : Also check package assets for updates}
|
||||
{--no-todos : Do not create todos for updates found}
|
||||
{--json : Output results as JSON}';
|
||||
|
||||
protected $description = 'Check vendors and assets for upstream updates';
|
||||
|
||||
public function handle(
|
||||
VendorUpdateCheckerService $vendorChecker,
|
||||
AssetTrackerService $assetChecker
|
||||
): int {
|
||||
$vendorSlug = $this->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('<fg=cyan>Vendor Update Check Results:</>');
|
||||
|
||||
$table = [];
|
||||
foreach ($vendorResults as $slug => $result) {
|
||||
$status = match ($result['status'] ?? 'unknown') {
|
||||
'success' => $result['has_update']
|
||||
? '<fg=yellow>Update available</>'
|
||||
: '<fg=green>Up to date</>',
|
||||
'skipped' => '<fg=gray>Skipped</>',
|
||||
'rate_limited' => '<fg=red>Rate limited</>',
|
||||
'error' => '<fg=red>Error</>',
|
||||
default => '<fg=gray>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('<fg=cyan>Asset Update Check Results:</>');
|
||||
|
||||
$table = [];
|
||||
foreach ($assetResults as $slug => $result) {
|
||||
$status = match ($result['status'] ?? 'unknown') {
|
||||
'success' => $result['has_update'] ?? false
|
||||
? '<fg=yellow>Update available</>'
|
||||
: '<fg=green>Up to date</>',
|
||||
'skipped' => '<fg=gray>Skipped</>',
|
||||
'rate_limited' => '<fg=red>Rate limited</>',
|
||||
'info' => '<fg=blue>Info</>',
|
||||
'error' => '<fg=red>Error</>',
|
||||
default => '<fg=gray>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));
|
||||
}
|
||||
}
|
||||
139
Console/IssuesCommand.php
Normal file
139
Console/IssuesCommand.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
class IssuesCommand extends Command
|
||||
{
|
||||
protected $signature = 'upstream:issues
|
||||
{vendor? : Filter by vendor slug}
|
||||
{--status=pending : Filter by status (pending, in_progress, ported, skipped, wont_port, all)}
|
||||
{--type= : Filter by type (feature, bugfix, security, ui, api, refactor, dependency)}
|
||||
{--quick-wins : Show only quick wins (low effort, high priority)}
|
||||
{--limit=50 : Maximum number of issues to display}';
|
||||
|
||||
protected $description = 'List upstream todos/issues for tracking';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$vendorSlug = $this->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('<fg=cyan>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' => '<fg=yellow>pending</>',
|
||||
'in_progress' => '<fg=blue>in progress</>',
|
||||
'ported' => '<fg=green>ported</>',
|
||||
'skipped' => '<fg=gray>skipped</>',
|
||||
'wont_port' => '<fg=red>wont port</>',
|
||||
default => $todo->status,
|
||||
};
|
||||
|
||||
$quickWinBadge = $todo->isQuickWin() ? ' <fg=green>[QW]</>' : '';
|
||||
|
||||
$table[] = [
|
||||
$todo->id,
|
||||
$todo->vendor->slug,
|
||||
"{$icon} {$todo->type}",
|
||||
"<fg={$priorityColor}>{$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('<fg=cyan>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;
|
||||
}
|
||||
}
|
||||
146
Console/SendDigestsCommand.php
Normal file
146
Console/SendDigestsCommand.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||
use Core\Uptelligence\Services\UptelligenceDigestService;
|
||||
|
||||
/**
|
||||
* Send Uptelligence digest emails to subscribed users.
|
||||
*
|
||||
* Processes all digest subscriptions based on their configured frequency
|
||||
* and sends email summaries of vendor updates, new releases, and pending todos.
|
||||
*/
|
||||
class SendDigestsCommand extends Command
|
||||
{
|
||||
protected $signature = 'uptelligence:send-digests
|
||||
{--frequency= : Process only a specific frequency (daily, weekly, monthly)}
|
||||
{--dry-run : Show what would happen without sending}';
|
||||
|
||||
protected $description = 'Send Uptelligence digest emails to subscribed users';
|
||||
|
||||
public function __construct(
|
||||
protected UptelligenceDigestService $digestService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$frequency = $this->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']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
268
Controllers/Api/WebhookController.php
Normal file
268
Controllers/Api/WebhookController.php
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Uptelligence\Jobs\ProcessUptelligenceWebhook;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||
use Core\Uptelligence\Services\WebhookReceiverService;
|
||||
|
||||
/**
|
||||
* WebhookController - receives incoming vendor release webhooks.
|
||||
*
|
||||
* Handles webhooks from GitHub, GitLab, npm, Packagist, and custom sources.
|
||||
* Webhooks are validated, logged, and dispatched to a job for async processing.
|
||||
*/
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WebhookReceiverService $service,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Receive a webhook for a vendor.
|
||||
*
|
||||
* POST /api/uptelligence/webhook/{webhook}
|
||||
*/
|
||||
public function receive(Request $request, UptelligenceWebhook $webhook): Response
|
||||
{
|
||||
// Check if webhook is enabled
|
||||
if (! $webhook->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
143
Jobs/CheckVendorUpdatesJob.php
Normal file
143
Jobs/CheckVendorUpdatesJob.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Services\AssetTrackerService;
|
||||
use Core\Uptelligence\Services\VendorUpdateCheckerService;
|
||||
|
||||
/**
|
||||
* Job to check vendors and assets for upstream updates.
|
||||
*
|
||||
* Can be scheduled to run daily/weekly via the scheduler.
|
||||
* Checks OSS vendors via GitHub/Gitea APIs and assets via registries.
|
||||
*/
|
||||
class CheckVendorUpdatesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Whether to also check package assets.
|
||||
*/
|
||||
protected bool $checkAssets;
|
||||
|
||||
/**
|
||||
* Specific vendor slug to check (null = all vendors).
|
||||
*/
|
||||
protected ?string $vendorSlug;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(bool $checkAssets = true, ?string $vendorSlug = null)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
198
Jobs/ProcessUptelligenceWebhook.php
Normal file
198
Jobs/ProcessUptelligenceWebhook.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||
use Core\Uptelligence\Notifications\NewReleaseDetected;
|
||||
use Core\Uptelligence\Services\WebhookReceiverService;
|
||||
|
||||
/**
|
||||
* ProcessUptelligenceWebhook - async processing of incoming vendor webhooks.
|
||||
*
|
||||
* Handles payload parsing, release creation, and notification dispatch.
|
||||
*/
|
||||
class ProcessUptelligenceWebhook implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Number of times the job may be attempted.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of seconds the job can run.
|
||||
*/
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [10, 30, 60];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public UptelligenceWebhookDelivery $delivery,
|
||||
) {
|
||||
$this->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()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
187
Models/AnalysisLog.php
Normal file
187
Models/AnalysisLog.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Analysis Log - audit trail for upstream analysis operations.
|
||||
*
|
||||
* Tracks version detection, analysis runs, todos created, and porting progress.
|
||||
*/
|
||||
class AnalysisLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Actions
|
||||
public const ACTION_VERSION_DETECTED = 'version_detected';
|
||||
|
||||
public const ACTION_ANALYSIS_STARTED = 'analysis_started';
|
||||
|
||||
public const ACTION_ANALYSIS_COMPLETED = 'analysis_completed';
|
||||
|
||||
public const ACTION_ANALYSIS_FAILED = 'analysis_failed';
|
||||
|
||||
public const ACTION_TODO_CREATED = 'todo_created';
|
||||
|
||||
public const ACTION_TODO_UPDATED = 'todo_updated';
|
||||
|
||||
public const ACTION_ISSUE_CREATED = 'issue_created';
|
||||
|
||||
public const ACTION_PORT_STARTED = 'port_started';
|
||||
|
||||
public const ACTION_PORT_COMPLETED = 'port_completed';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'version_release_id',
|
||||
'action',
|
||||
'context',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => '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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
214
Models/Asset.php
Normal file
214
Models/Asset.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Asset - tracks installed packages, fonts, themes, and CDN resources.
|
||||
*
|
||||
* Monitors versions, licences, and update availability.
|
||||
*/
|
||||
class Asset extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Asset types
|
||||
public const TYPE_COMPOSER = 'composer';
|
||||
|
||||
public const TYPE_NPM = 'npm';
|
||||
|
||||
public const TYPE_FONT = 'font';
|
||||
|
||||
public const TYPE_THEME = 'theme';
|
||||
|
||||
public const TYPE_CDN = 'cdn';
|
||||
|
||||
public const TYPE_MANUAL = 'manual';
|
||||
|
||||
// Licence types
|
||||
public const LICENCE_LIFETIME = 'lifetime';
|
||||
|
||||
public const LICENCE_SUBSCRIPTION = 'subscription';
|
||||
|
||||
public const LICENCE_OSS = 'oss';
|
||||
|
||||
public const LICENCE_TRIAL = 'trial';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'package_name',
|
||||
'registry_url',
|
||||
'licence_type',
|
||||
'licence_expires_at',
|
||||
'licence_meta',
|
||||
'installed_version',
|
||||
'latest_version',
|
||||
'last_checked_at',
|
||||
'auto_update',
|
||||
'install_path',
|
||||
'build_config',
|
||||
'used_in_projects',
|
||||
'setup_notes',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'licence_meta' => '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,
|
||||
];
|
||||
}
|
||||
}
|
||||
49
Models/AssetVersion.php
Normal file
49
Models/AssetVersion.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Asset Version - tracks version history for assets.
|
||||
*
|
||||
* Stores changelog, breaking changes, and download information.
|
||||
*/
|
||||
class AssetVersion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'asset_id',
|
||||
'version',
|
||||
'changelog',
|
||||
'breaking_changes',
|
||||
'download_url',
|
||||
'local_path',
|
||||
'released_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'breaking_changes' => '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);
|
||||
}
|
||||
}
|
||||
251
Models/DiffCache.php
Normal file
251
Models/DiffCache.php
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Diff Cache - stores file changes for version releases.
|
||||
*
|
||||
* Auto-categorises files for filtering and prioritisation.
|
||||
*/
|
||||
class DiffCache extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'diff_cache';
|
||||
|
||||
// Change types
|
||||
public const CHANGE_ADDED = 'added';
|
||||
|
||||
public const CHANGE_MODIFIED = 'modified';
|
||||
|
||||
public const CHANGE_REMOVED = 'removed';
|
||||
|
||||
// Categories (auto-detected)
|
||||
public const CATEGORY_CONTROLLER = 'controller';
|
||||
|
||||
public const CATEGORY_MODEL = 'model';
|
||||
|
||||
public const CATEGORY_VIEW = 'view';
|
||||
|
||||
public const CATEGORY_MIGRATION = 'migration';
|
||||
|
||||
public const CATEGORY_CONFIG = 'config';
|
||||
|
||||
public const CATEGORY_ROUTE = 'route';
|
||||
|
||||
public const CATEGORY_LANGUAGE = 'language';
|
||||
|
||||
public const CATEGORY_ASSET = 'asset';
|
||||
|
||||
public const CATEGORY_PLUGIN = 'plugin';
|
||||
|
||||
public const CATEGORY_BLOCK = 'block';
|
||||
|
||||
public const CATEGORY_SECURITY = 'security';
|
||||
|
||||
public const CATEGORY_API = 'api';
|
||||
|
||||
public const CATEGORY_OTHER = 'other';
|
||||
|
||||
protected $fillable = [
|
||||
'version_release_id',
|
||||
'file_path',
|
||||
'change_type',
|
||||
'diff_content',
|
||||
'new_content',
|
||||
'category',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function versionRelease(): BelongsTo
|
||||
{
|
||||
return $this->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 => '📄',
|
||||
};
|
||||
}
|
||||
}
|
||||
172
Models/Pattern.php
Normal file
172
Models/Pattern.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Pattern - reusable code patterns for development.
|
||||
*
|
||||
* Stores components, layouts, snippets with variants and required assets.
|
||||
*/
|
||||
class Pattern extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Categories
|
||||
public const CATEGORY_COMPONENT = 'component';
|
||||
|
||||
public const CATEGORY_LAYOUT = 'layout';
|
||||
|
||||
public const CATEGORY_THEME = 'theme';
|
||||
|
||||
public const CATEGORY_SNIPPET = 'snippet';
|
||||
|
||||
public const CATEGORY_WORKFLOW = 'workflow';
|
||||
|
||||
public const CATEGORY_TEMPLATE = 'template';
|
||||
|
||||
// Source types
|
||||
public const SOURCE_PURCHASED = 'purchased';
|
||||
|
||||
public const SOURCE_OSS = 'oss';
|
||||
|
||||
public const SOURCE_INTERNAL = 'internal';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'tags',
|
||||
'language',
|
||||
'code',
|
||||
'usage_example',
|
||||
'required_assets',
|
||||
'source_url',
|
||||
'source_type',
|
||||
'author',
|
||||
'usage_count',
|
||||
'quality_score',
|
||||
'is_vetted',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
70
Models/PatternCollection.php
Normal file
70
Models/PatternCollection.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Pattern Collection - groups related patterns together.
|
||||
*
|
||||
* Useful for bundling patterns that work together.
|
||||
*/
|
||||
class PatternCollection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'pattern_ids',
|
||||
'required_assets',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pattern_ids' => '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,
|
||||
];
|
||||
}
|
||||
}
|
||||
31
Models/PatternVariant.php
Normal file
31
Models/PatternVariant.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Pattern Variant - alternative implementations of a pattern.
|
||||
*
|
||||
* Allows storing different versions (e.g. dark mode, compact, etc.)
|
||||
*/
|
||||
class PatternVariant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'pattern_id',
|
||||
'name',
|
||||
'code',
|
||||
'notes',
|
||||
];
|
||||
|
||||
public function pattern(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Pattern::class);
|
||||
}
|
||||
}
|
||||
241
Models/UpstreamTodo.php
Normal file
241
Models/UpstreamTodo.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Upstream Todo - tracks porting tasks from upstream vendors.
|
||||
*
|
||||
* Includes AI analysis, priority, effort, and GitHub issue tracking.
|
||||
*/
|
||||
class UpstreamTodo extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
// Types
|
||||
public const TYPE_FEATURE = 'feature';
|
||||
|
||||
public const TYPE_BUGFIX = 'bugfix';
|
||||
|
||||
public const TYPE_SECURITY = 'security';
|
||||
|
||||
public const TYPE_UI = 'ui';
|
||||
|
||||
public const TYPE_BLOCK = 'block';
|
||||
|
||||
public const TYPE_API = 'api';
|
||||
|
||||
public const TYPE_REFACTOR = 'refactor';
|
||||
|
||||
public const TYPE_DEPENDENCY = 'dependency';
|
||||
|
||||
// Statuses
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_PORTED = 'ported';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const STATUS_WONT_PORT = 'wont_port';
|
||||
|
||||
// Effort levels
|
||||
public const EFFORT_LOW = 'low';
|
||||
|
||||
public const EFFORT_MEDIUM = 'medium';
|
||||
|
||||
public const EFFORT_HIGH = 'high';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'from_version',
|
||||
'to_version',
|
||||
'type',
|
||||
'status',
|
||||
'title',
|
||||
'description',
|
||||
'port_notes',
|
||||
'priority',
|
||||
'effort',
|
||||
'has_conflicts',
|
||||
'conflict_reason',
|
||||
'files',
|
||||
'dependencies',
|
||||
'tags',
|
||||
'github_issue_number',
|
||||
'branch_name',
|
||||
'assigned_to',
|
||||
'ai_analysis',
|
||||
'ai_confidence',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'files' => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
285
Models/UptelligenceDigest.php
Normal file
285
Models/UptelligenceDigest.php
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* UptelligenceDigest - stores user preferences for digest email notifications.
|
||||
*
|
||||
* Tracks which users want to receive periodic summaries of vendor updates,
|
||||
* new releases, and pending todos from the Uptelligence module.
|
||||
*/
|
||||
class UptelligenceDigest extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
// Frequency options
|
||||
public const FREQUENCY_DAILY = 'daily';
|
||||
|
||||
public const FREQUENCY_WEEKLY = 'weekly';
|
||||
|
||||
public const FREQUENCY_MONTHLY = 'monthly';
|
||||
|
||||
protected $table = 'uptelligence_digests';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'workspace_id',
|
||||
'frequency',
|
||||
'last_sent_at',
|
||||
'preferences',
|
||||
'is_enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'preferences' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
475
Models/UptelligenceWebhook.php
Normal file
475
Models/UptelligenceWebhook.php
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* UptelligenceWebhook - webhook endpoint for receiving vendor release notifications.
|
||||
*
|
||||
* Each vendor can have a webhook endpoint configured to receive release
|
||||
* notifications from GitHub, GitLab, npm, Packagist, or custom sources.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property int $vendor_id
|
||||
* @property string $provider
|
||||
* @property string|null $secret
|
||||
* @property string|null $previous_secret
|
||||
* @property Carbon|null $secret_rotated_at
|
||||
* @property int $grace_period_seconds
|
||||
* @property bool $is_active
|
||||
* @property int $failure_count
|
||||
* @property Carbon|null $last_received_at
|
||||
* @property array|null $settings
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
*/
|
||||
class UptelligenceWebhook extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'uptelligence_webhooks';
|
||||
|
||||
// Supported providers
|
||||
public const PROVIDER_GITHUB = 'github';
|
||||
|
||||
public const PROVIDER_GITLAB = 'gitlab';
|
||||
|
||||
public const PROVIDER_NPM = 'npm';
|
||||
|
||||
public const PROVIDER_PACKAGIST = 'packagist';
|
||||
|
||||
public const PROVIDER_CUSTOM = 'custom';
|
||||
|
||||
public const PROVIDERS = [
|
||||
self::PROVIDER_GITHUB,
|
||||
self::PROVIDER_GITLAB,
|
||||
self::PROVIDER_NPM,
|
||||
self::PROVIDER_PACKAGIST,
|
||||
self::PROVIDER_CUSTOM,
|
||||
];
|
||||
|
||||
// Maximum consecutive failures before auto-disable
|
||||
public const MAX_FAILURES = 10;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'vendor_id',
|
||||
'provider',
|
||||
'secret',
|
||||
'previous_secret',
|
||||
'secret_rotated_at',
|
||||
'grace_period_seconds',
|
||||
'is_active',
|
||||
'failure_count',
|
||||
'last_received_at',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => '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);
|
||||
}
|
||||
}
|
||||
356
Models/UptelligenceWebhookDelivery.php
Normal file
356
Models/UptelligenceWebhookDelivery.php
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* UptelligenceWebhookDelivery - log of incoming webhook deliveries.
|
||||
*
|
||||
* Records each webhook delivery, its payload, parsing results,
|
||||
* and processing status for debugging and audit purposes.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $webhook_id
|
||||
* @property int $vendor_id
|
||||
* @property string $event_type
|
||||
* @property string $provider
|
||||
* @property string|null $version
|
||||
* @property string|null $tag_name
|
||||
* @property array $payload
|
||||
* @property array|null $parsed_data
|
||||
* @property string $status
|
||||
* @property string|null $error_message
|
||||
* @property string|null $source_ip
|
||||
* @property string|null $signature_status
|
||||
* @property Carbon|null $processed_at
|
||||
* @property int $retry_count
|
||||
* @property int $max_retries
|
||||
* @property Carbon|null $next_retry_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class UptelligenceWebhookDelivery extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'uptelligence_webhook_deliveries';
|
||||
|
||||
// Status values
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
// Signature status values
|
||||
public const SIGNATURE_VALID = 'valid';
|
||||
|
||||
public const SIGNATURE_INVALID = 'invalid';
|
||||
|
||||
public const SIGNATURE_MISSING = 'missing';
|
||||
|
||||
// Default max retries
|
||||
public const DEFAULT_MAX_RETRIES = 3;
|
||||
|
||||
protected $fillable = [
|
||||
'webhook_id',
|
||||
'vendor_id',
|
||||
'event_type',
|
||||
'provider',
|
||||
'version',
|
||||
'tag_name',
|
||||
'payload',
|
||||
'parsed_data',
|
||||
'status',
|
||||
'error_message',
|
||||
'source_ip',
|
||||
'signature_status',
|
||||
'processed_at',
|
||||
'retry_count',
|
||||
'max_retries',
|
||||
'next_retry_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => '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';
|
||||
}
|
||||
}
|
||||
230
Models/Vendor.php
Normal file
230
Models/Vendor.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Vendor - tracks upstream software sources.
|
||||
*
|
||||
* Supports licensed software, OSS repos, and plugin platforms.
|
||||
*/
|
||||
class Vendor extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
// Source types
|
||||
public const SOURCE_LICENSED = 'licensed';
|
||||
|
||||
public const SOURCE_OSS = 'oss';
|
||||
|
||||
public const SOURCE_PLUGIN = 'plugin';
|
||||
|
||||
// Plugin platforms
|
||||
public const PLATFORM_ALTUM = 'altum';
|
||||
|
||||
public const PLATFORM_WORDPRESS = 'wordpress';
|
||||
|
||||
public const PLATFORM_LARAVEL = 'laravel';
|
||||
|
||||
public const PLATFORM_OTHER = 'other';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'vendor_name',
|
||||
'source_type',
|
||||
'plugin_platform',
|
||||
'git_repo_url',
|
||||
'current_version',
|
||||
'previous_version',
|
||||
'path_mapping',
|
||||
'ignored_paths',
|
||||
'priority_paths',
|
||||
'target_repo',
|
||||
'target_branch',
|
||||
'is_active',
|
||||
'last_checked_at',
|
||||
'last_analyzed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'path_mapping' => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
219
Models/VersionRelease.php
Normal file
219
Models/VersionRelease.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Version Release - tracks a specific version of upstream software.
|
||||
*
|
||||
* Stores file changes, analysis results, and S3 archive status.
|
||||
*/
|
||||
class VersionRelease extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
// Storage disk options
|
||||
public const DISK_LOCAL = 'local';
|
||||
|
||||
public const DISK_S3 = 's3';
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'version',
|
||||
'previous_version',
|
||||
'files_added',
|
||||
'files_modified',
|
||||
'files_removed',
|
||||
'todos_created',
|
||||
'summary',
|
||||
'storage_path',
|
||||
'storage_disk',
|
||||
's3_key',
|
||||
'file_hash',
|
||||
'file_size',
|
||||
'metadata_json',
|
||||
'analyzed_at',
|
||||
'archived_at',
|
||||
'last_downloaded_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'summary' => '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',
|
||||
];
|
||||
}
|
||||
}
|
||||
155
Notifications/NewReleaseDetected.php
Normal file
155
Notifications/NewReleaseDetected.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
/**
|
||||
* NewReleaseDetected - notification when a vendor releases a new version.
|
||||
*
|
||||
* Sent via webhook detection for immediate awareness of new releases.
|
||||
*/
|
||||
class NewReleaseDetected extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Vendor $vendor,
|
||||
public string $version,
|
||||
public array $releaseData = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
257
Notifications/SendUptelligenceDigest.php
Normal file
257
Notifications/SendUptelligenceDigest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Collection;
|
||||
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||
|
||||
/**
|
||||
* SendUptelligenceDigest - email notification for vendor update summaries.
|
||||
*
|
||||
* Sends a periodic digest of new releases, pending todos, and security
|
||||
* updates from tracked upstream vendors.
|
||||
*/
|
||||
class SendUptelligenceDigest extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public UptelligenceDigest $digest,
|
||||
public Collection $releases,
|
||||
public array $todosByPriority,
|
||||
public int $securityCount,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
479
Services/AIAnalyzerService.php
Normal file
479
Services/AIAnalyzerService.php
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Core\Uptelligence\Models\AnalysisLog;
|
||||
use Core\Uptelligence\Models\DiffCache;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
/**
|
||||
* AI Analyzer Service - uses AI to analyse version releases and create todos.
|
||||
*
|
||||
* Supports both Anthropic Claude and OpenAI APIs.
|
||||
*/
|
||||
class AIAnalyzerService
|
||||
{
|
||||
protected string $provider;
|
||||
|
||||
protected string $model;
|
||||
|
||||
protected string $apiKey;
|
||||
|
||||
protected int $maxTokens;
|
||||
|
||||
protected float $temperature;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('upstream.ai');
|
||||
$this->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 <<<PROMPT
|
||||
Analyse the following code changes from an upstream vendor and categorise them for potential porting to our codebase.
|
||||
|
||||
{$context}
|
||||
|
||||
Please provide your analysis in the following JSON format:
|
||||
{
|
||||
"type": "feature|bugfix|security|ui|block|api|refactor|dependency",
|
||||
"title": "Brief title describing the change",
|
||||
"description": "Detailed description of what changed and why it might be valuable",
|
||||
"priority": 1-10 (10 = most important, consider security > 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.'.';
|
||||
}
|
||||
}
|
||||
439
Services/AssetTrackerService.php
Normal file
439
Services/AssetTrackerService.php
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Core\Uptelligence\Models\Asset;
|
||||
use Core\Uptelligence\Models\AssetVersion;
|
||||
|
||||
/**
|
||||
* Asset Tracker Service - monitors and updates package dependencies.
|
||||
*
|
||||
* Checks Packagist, NPM, and custom registries for updates.
|
||||
*/
|
||||
class AssetTrackerService
|
||||
{
|
||||
/**
|
||||
* Check all active assets for updates.
|
||||
*/
|
||||
public function checkAllForUpdates(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach (Asset::active()->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;
|
||||
}
|
||||
}
|
||||
334
Services/DiffAnalyzerService.php
Normal file
334
Services/DiffAnalyzerService.php
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use InvalidArgumentException;
|
||||
use Core\Uptelligence\Models\AnalysisLog;
|
||||
use Core\Uptelligence\Models\DiffCache;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
/**
|
||||
* Diff Analyzer Service - analyses differences between vendor versions.
|
||||
*
|
||||
* Detects file changes and caches diffs for AI analysis.
|
||||
*/
|
||||
class DiffAnalyzerService
|
||||
{
|
||||
protected Vendor $vendor;
|
||||
|
||||
protected string $previousPath;
|
||||
|
||||
protected string $currentPath;
|
||||
|
||||
public function __construct(Vendor $vendor)
|
||||
{
|
||||
$this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
474
Services/IssueGeneratorService.php
Normal file
474
Services/IssueGeneratorService.php
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use InvalidArgumentException;
|
||||
use Core\Uptelligence\Models\AnalysisLog;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
/**
|
||||
* Issue Generator Service - creates GitHub/Gitea issues from upstream todos.
|
||||
*
|
||||
* Generates individual issues and weekly digests for tracking porting work.
|
||||
*/
|
||||
class IssueGeneratorService
|
||||
{
|
||||
protected string $githubToken;
|
||||
|
||||
protected string $giteaUrl;
|
||||
|
||||
protected string $giteaToken;
|
||||
|
||||
protected array $defaultLabels;
|
||||
|
||||
protected array $assignees;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
433
Services/UpstreamPlanGeneratorService.php
Normal file
433
Services/UpstreamPlanGeneratorService.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
/**
|
||||
* Upstream Plan Generator Service - creates agent plans from version release analysis.
|
||||
*
|
||||
* Generates structured plans with phases grouped by change type for systematic porting.
|
||||
*
|
||||
* Note: This service has an optional dependency on the Agentic module. If the module
|
||||
* is not installed, plan generation methods will return null and log a warning.
|
||||
*/
|
||||
class UpstreamPlanGeneratorService
|
||||
{
|
||||
/**
|
||||
* Check if the Agentic module is available.
|
||||
*/
|
||||
protected function agenticModuleAvailable(): bool
|
||||
{
|
||||
return class_exists(\Mod\Agentic\Models\AgentPlan::class)
|
||||
&& class_exists(\Mod\Agentic\Models\AgentPhase::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an AgentPlan from a version release analysis.
|
||||
*
|
||||
* @return \Mod\Agentic\Models\AgentPlan|null Returns null if Agentic module unavailable or no todos
|
||||
*/
|
||||
public function generateFromRelease(VersionRelease $release, array $options = []): mixed
|
||||
{
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
||||
271
Services/UptelligenceDigestService.php
Normal file
271
Services/UptelligenceDigestService.php
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
use Core\Uptelligence\Notifications\SendUptelligenceDigest;
|
||||
|
||||
/**
|
||||
* UptelligenceDigestService - generates and sends digest email notifications.
|
||||
*
|
||||
* Collects new releases, pending todos, and security updates since the last
|
||||
* digest and sends summarised email notifications to subscribed users.
|
||||
*/
|
||||
class UptelligenceDigestService
|
||||
{
|
||||
/**
|
||||
* Generate digest content for a specific user's preferences.
|
||||
*
|
||||
* @return array{releases: Collection, todos: array, security_count: int, has_content: bool}
|
||||
*/
|
||||
public function generateDigestContent(UptelligenceDigest $digest): array
|
||||
{
|
||||
$sinceDate = $digest->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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
579
Services/VendorStorageService.php
Normal file
579
Services/VendorStorageService.php
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Vendor Storage Service - manages local and S3 cold storage for vendor versions.
|
||||
*
|
||||
* Handles archival, retrieval, and cleanup of upstream vendor source files.
|
||||
*/
|
||||
class VendorStorageService
|
||||
{
|
||||
protected string $storageMode;
|
||||
|
||||
protected string $bucket;
|
||||
|
||||
protected string $prefix;
|
||||
|
||||
protected string $tempPath;
|
||||
|
||||
protected string $s3Disk;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
467
Services/VendorUpdateCheckerService.php
Normal file
467
Services/VendorUpdateCheckerService.php
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor Update Checker Service - checks upstream sources for new releases.
|
||||
*
|
||||
* Supports GitHub releases, Packagist, and NPM registries.
|
||||
*/
|
||||
class VendorUpdateCheckerService
|
||||
{
|
||||
/**
|
||||
* Check all active vendors for updates.
|
||||
*
|
||||
* @return array<string, array{status: string, current: ?string, latest: ?string, has_update: bool, message?: string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
435
Services/WebhookReceiverService.php
Normal file
435
Services/WebhookReceiverService.php
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
/**
|
||||
* WebhookReceiverService - processes incoming vendor release webhooks.
|
||||
*
|
||||
* Handles webhook verification, payload parsing, and release record creation
|
||||
* for GitHub releases, GitLab releases, npm publish, and Packagist webhooks.
|
||||
*/
|
||||
class WebhookReceiverService
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Signature Verification
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verify webhook signature.
|
||||
*
|
||||
* Returns signature status for logging.
|
||||
*/
|
||||
public function verifySignature(UptelligenceWebhook $webhook, string $payload, ?string $signature): string
|
||||
{
|
||||
if (empty($webhook->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);
|
||||
}
|
||||
}
|
||||
194
View/Blade/admin/asset-manager.blade.php
Normal file
194
View/Blade/admin/asset-manager.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<admin:module title="Asset Manager" subtitle="Track installed packages, fonts, themes, and CDN resources">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="resetFilters" variant="ghost" size="sm" icon="x-mark">
|
||||
Reset Filters
|
||||
</flux:button>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||
Back
|
||||
</flux:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
{{-- Stats Summary --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Total Assets</div>
|
||||
<div class="text-2xl font-bold">{{ $this->assetStats['total'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Need Update</div>
|
||||
<div class="text-2xl font-bold text-orange-600">{{ $this->assetStats['needs_update'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Composer</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $this->assetStats['composer'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">NPM</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ $this->assetStats['npm'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Expiring Soon</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ $this->assetStats['expiring_soon'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Expired</div>
|
||||
<div class="text-2xl font-bold text-red-600">{{ $this->assetStats['expired'] }}</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search assets..." icon="magnifying-glass" />
|
||||
|
||||
<flux:select wire:model.live="type">
|
||||
<option value="">All Types</option>
|
||||
<option value="composer">Composer</option>
|
||||
<option value="npm">NPM</option>
|
||||
<option value="font">Font</option>
|
||||
<option value="theme">Theme</option>
|
||||
<option value="cdn">CDN</option>
|
||||
<option value="manual">Manual</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="licenceType">
|
||||
<option value="">All Licences</option>
|
||||
<option value="lifetime">Lifetime</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
<option value="oss">Open Source</option>
|
||||
<option value="trial">Trial</option>
|
||||
</flux:select>
|
||||
|
||||
<div class="flex items-center">
|
||||
<flux:checkbox wire:model.live="needsUpdate" label="Needs Update Only" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir" wire:click="sortBy('name')">
|
||||
Asset
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'type'" :direction="$sortDir" wire:click="sortBy('type')">
|
||||
Type
|
||||
</flux:table.column>
|
||||
<flux:table.column>Installed</flux:table.column>
|
||||
<flux:table.column>Latest</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'licence_type'" :direction="$sortDir" wire:click="sortBy('licence_type')">
|
||||
Licence
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'last_checked_at'" :direction="$sortDir" wire:click="sortBy('last_checked_at')">
|
||||
Last Checked
|
||||
</flux:table.column>
|
||||
<flux:table.column align="center">Status</flux:table.column>
|
||||
<flux:table.column align="end">Actions</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->assets as $asset)
|
||||
<flux:table.row wire:key="asset-{{ $asset->id }}" class="{{ $asset->isLicenceExpired() ? 'bg-red-50 dark:bg-red-900/10' : ($asset->hasUpdate() ? 'bg-orange-50 dark:bg-orange-900/10' : '') }}">
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ $asset->getTypeIcon() }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ $asset->name }}</div>
|
||||
@if($asset->package_name)
|
||||
<div class="text-xs text-zinc-500 font-mono">{{ $asset->package_name }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="{{ match($asset->type) {
|
||||
'composer' => 'blue',
|
||||
'npm' => 'green',
|
||||
'font' => 'purple',
|
||||
'theme' => 'pink',
|
||||
'cdn' => 'cyan',
|
||||
default => 'zinc'
|
||||
} }}" size="sm">
|
||||
{{ $asset->getTypeLabel() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-mono text-sm">
|
||||
{{ $asset->installed_version ?? 'N/A' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-mono text-sm">
|
||||
@if($asset->hasUpdate())
|
||||
<span class="text-orange-600 font-semibold">{{ $asset->latest_version }}</span>
|
||||
@else
|
||||
{{ $asset->latest_version ?? 'N/A' }}
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ $asset->getLicenceIcon() }}</span>
|
||||
<span class="text-sm">{{ ucfirst($asset->licence_type ?? 'N/A') }}</span>
|
||||
</div>
|
||||
@if($asset->licence_expires_at)
|
||||
<div class="text-xs {{ $asset->isLicenceExpired() ? 'text-red-600' : ($asset->isLicenceExpiringSoon() ? 'text-yellow-600' : 'text-zinc-500') }}">
|
||||
{{ $asset->licence_expires_at->format('d M Y') }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500 text-sm">
|
||||
{{ $asset->last_checked_at?->diffForHumans() ?? 'Never' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($asset->isLicenceExpired())
|
||||
<flux:badge color="red" size="sm">Expired</flux:badge>
|
||||
@elseif($asset->hasUpdate())
|
||||
<flux:badge color="orange" size="sm">Update Available</flux:badge>
|
||||
@elseif($asset->isLicenceExpiringSoon())
|
||||
<flux:badge color="yellow" size="sm">Expiring Soon</flux:badge>
|
||||
@elseif($asset->is_active)
|
||||
<flux:badge color="green" size="sm">Active</flux:badge>
|
||||
@else
|
||||
<flux:badge color="zinc" size="sm">Inactive</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
@if($asset->getUpdateCommand())
|
||||
<flux:menu.item icon="clipboard-document">
|
||||
Copy Update Command
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
@if($asset->registry_url)
|
||||
<flux:menu.item href="{{ $asset->registry_url }}" target="_blank" icon="arrow-top-right-on-square">
|
||||
View in Registry
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="toggleActive({{ $asset->id }})" icon="{{ $asset->is_active ? 'pause' : 'play' }}">
|
||||
{{ $asset->is_active ? 'Deactivate' : 'Activate' }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8" class="text-center py-12">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="cube" class="size-12 opacity-50" />
|
||||
<span class="text-lg">No assets found</span>
|
||||
<span class="text-sm">Try adjusting your filters</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->assets->hasPages())
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
{{ $this->assets->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</admin:module>
|
||||
298
View/Blade/admin/dashboard.blade.php
Normal file
298
View/Blade/admin/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<admin:module title="Uptelligence" subtitle="Upstream vendor tracking and todo management">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<core:button href="{{ route('hub.admin.uptelligence.vendors') }}" wire:navigate variant="primary" icon="building-office" size="sm">
|
||||
Manage Vendors
|
||||
</core:button>
|
||||
<core:button wire:click="refresh" icon="arrow-path" size="sm" variant="ghost">
|
||||
Refresh
|
||||
</core:button>
|
||||
<flux:dropdown>
|
||||
<flux:button icon="ellipsis-vertical" variant="ghost" size="sm" />
|
||||
<flux:menu>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.todos') }}" wire:navigate icon="clipboard-document-list">
|
||||
View Todos
|
||||
</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.diffs') }}" wire:navigate icon="document-magnifying-glass">
|
||||
View Diffs
|
||||
</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.assets') }}" wire:navigate icon="cube">
|
||||
Manage Assets
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.webhooks') }}" wire:navigate icon="globe-alt">
|
||||
Webhook Manager
|
||||
</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.digests') }}" wire:navigate icon="envelope">
|
||||
Digest Preferences
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
{{-- Summary Stats --}}
|
||||
<admin:stats :items="$this->statCards" />
|
||||
|
||||
{{-- Secondary Stats Row --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<flux:icon name="arrow-path" class="size-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<flux:subheading>In Progress</flux:subheading>
|
||||
<flux:heading>{{ $this->stats['in_progress'] }}</flux:heading>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<flux:icon name="cube" class="size-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<flux:subheading>Assets Tracked</flux:subheading>
|
||||
<flux:heading>{{ $this->stats['assets_tracked'] }}</flux:heading>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<flux:icon name="arrow-up-circle" class="size-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<flux:subheading>Assets Need Update</flux:subheading>
|
||||
<flux:heading>{{ $this->stats['assets_need_update'] }}</flux:heading>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Vendors Summary --}}
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">Top Vendors</flux:heading>
|
||||
<flux:subheading>By pending todos</flux:subheading>
|
||||
</div>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence.vendors') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||
View All
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Vendor</flux:table.column>
|
||||
<flux:table.column>Version</flux:table.column>
|
||||
<flux:table.column align="center">Pending</flux:table.column>
|
||||
<flux:table.column align="end">Last Checked</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->vendorSummary as $vendor)
|
||||
<flux:table.row>
|
||||
<flux:table.cell variant="strong">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($vendor['source_type'] === 'licensed')
|
||||
<flux:icon name="lock-closed" class="size-4 text-amber-500" />
|
||||
@elseif($vendor['source_type'] === 'oss')
|
||||
<flux:icon name="globe-alt" class="size-4 text-green-500" />
|
||||
@else
|
||||
<flux:icon name="puzzle-piece" class="size-4 text-blue-500" />
|
||||
@endif
|
||||
{{ $vendor['name'] }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500 font-mono text-sm">
|
||||
{{ $vendor['current_version'] ?? 'N/A' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($vendor['pending_todos'] > 0)
|
||||
<flux:badge color="{{ $vendor['pending_todos'] > 10 ? 'red' : ($vendor['pending_todos'] > 5 ? 'yellow' : 'blue') }}" size="sm">
|
||||
{{ $vendor['pending_todos'] }}
|
||||
</flux:badge>
|
||||
@else
|
||||
<flux:badge color="green" size="sm">0</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end" class="text-zinc-500 text-sm">
|
||||
{{ $vendor['last_checked'] }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="4" class="text-center py-8">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="building-office" class="size-8 opacity-50" />
|
||||
<span>No vendors tracked yet</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
|
||||
{{-- Todos by Type --}}
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">Todos by Type</flux:heading>
|
||||
<flux:subheading>Pending items breakdown</flux:subheading>
|
||||
</div>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||
View All
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
@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
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon name="{{ $config['icon'] }}" class="size-5 text-{{ $config['color'] }}-500" />
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $config['label'] }}</span>
|
||||
</div>
|
||||
<flux:badge color="{{ $config['color'] }}" size="sm">{{ $count }}</flux:badge>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-zinc-500">
|
||||
<flux:icon name="clipboard-document-list" class="size-8 opacity-50 mx-auto mb-2" />
|
||||
<span>No pending todos</span>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Recent Todos --}}
|
||||
<flux:card class="p-0 overflow-hidden mt-6">
|
||||
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">Recent High-Priority Todos</flux:heading>
|
||||
<flux:subheading>Pending items ordered by priority</flux:subheading>
|
||||
</div>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}?status=pending" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||
View All Pending
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Todo</flux:table.column>
|
||||
<flux:table.column>Vendor</flux:table.column>
|
||||
<flux:table.column>Type</flux:table.column>
|
||||
<flux:table.column align="center">Priority</flux:table.column>
|
||||
<flux:table.column align="center">Effort</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->recentTodos as $todo)
|
||||
<flux:table.row>
|
||||
<flux:table.cell variant="strong" class="max-w-xs truncate">
|
||||
{{ $todo->title }}
|
||||
@if($todo->isQuickWin())
|
||||
<flux:badge color="green" size="sm" class="ml-1">Quick Win</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500">
|
||||
{{ $todo->vendor->name }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-sm">{{ $todo->getTypeIcon() }} {{ ucfirst($todo->type) }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : ($todo->priority >= 4 ? 'yellow' : 'zinc')) }}" size="sm">
|
||||
{{ $todo->getPriorityLabel() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center" class="text-zinc-500 text-sm">
|
||||
{{ $todo->getEffortLabel() }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5" class="text-center py-8">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="check-circle" class="size-8 opacity-50" />
|
||||
<span>No pending todos</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
|
||||
{{-- Recent Releases --}}
|
||||
@if($this->recentReleases->isNotEmpty())
|
||||
<flux:card class="p-0 overflow-hidden mt-6">
|
||||
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">Recent Version Releases</flux:heading>
|
||||
<flux:subheading>Latest analysed vendor updates</flux:subheading>
|
||||
</div>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence.diffs') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||
View Diffs
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Vendor</flux:table.column>
|
||||
<flux:table.column>Version</flux:table.column>
|
||||
<flux:table.column align="center">Changes</flux:table.column>
|
||||
<flux:table.column align="center">Impact</flux:table.column>
|
||||
<flux:table.column align="end">Analysed</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($this->recentReleases as $release)
|
||||
<flux:table.row>
|
||||
<flux:table.cell variant="strong">
|
||||
{{ $release->vendor->name }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-mono text-sm">
|
||||
{{ $release->getVersionCompare() }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<div class="flex items-center justify-center gap-1 text-sm">
|
||||
<span class="text-green-600">+{{ $release->files_added }}</span>
|
||||
<span class="text-blue-600">~{{ $release->files_modified }}</span>
|
||||
<span class="text-red-600">-{{ $release->files_removed }}</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<flux:badge class="{{ $release->getImpactBadgeClass() }}" size="sm">
|
||||
{{ ucfirst($release->getImpactLevel()) }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end" class="text-zinc-500 text-sm">
|
||||
{{ $release->analyzed_at?->diffForHumans() ?? 'Pending' }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
@endif
|
||||
</admin:module>
|
||||
240
View/Blade/admin/diff-viewer.blade.php
Normal file
240
View/Blade/admin/diff-viewer.blade.php
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<admin:module title="Diff Viewer" subtitle="View file changes between vendor versions">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||
Back
|
||||
</flux:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
{{-- Vendor and Release Selection --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<flux:select wire:model.live="vendorId" label="Select Vendor">
|
||||
<option value="">Choose a vendor...</option>
|
||||
@foreach($this->vendors as $vendor)
|
||||
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
@if($this->releases->isNotEmpty())
|
||||
<flux:select wire:model.live="releaseId" label="Select Release">
|
||||
<option value="">Choose a release...</option>
|
||||
@foreach($this->releases as $release)
|
||||
<option value="{{ $release->id }}">
|
||||
{{ $release->getVersionCompare() }} - {{ $release->analyzed_at?->format('d M Y') }}
|
||||
</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@else
|
||||
<div class="flex items-end">
|
||||
<div class="p-3 bg-zinc-100 dark:bg-zinc-800 rounded-lg text-zinc-500 text-sm w-full">
|
||||
Select a vendor to view available releases
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($this->selectedRelease)
|
||||
{{-- Release Summary --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<flux:card class="p-4">
|
||||
<div class="text-sm text-zinc-500">Total Changes</div>
|
||||
<div class="text-2xl font-bold">{{ $this->diffStats['total'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-zinc-500">Added</div>
|
||||
<flux:badge color="green" size="sm">{{ $this->diffStats['added'] }}</flux:badge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-green-600">+{{ $this->diffStats['added'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-zinc-500">Modified</div>
|
||||
<flux:badge color="blue" size="sm">{{ $this->diffStats['modified'] }}</flux:badge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-blue-600">~{{ $this->diffStats['modified'] }}</div>
|
||||
</flux:card>
|
||||
<flux:card class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-zinc-500">Removed</div>
|
||||
<flux:badge color="red" size="sm">{{ $this->diffStats['removed'] }}</flux:badge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-red-600">-{{ $this->diffStats['removed'] }}</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Category Breakdown --}}
|
||||
@if(count($this->diffStats['by_category']) > 0)
|
||||
<div class="mb-6">
|
||||
<flux:heading size="sm" class="mb-3">Changes by Category</flux:heading>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($this->diffStats['by_category'] as $cat => $count)
|
||||
<flux:button
|
||||
wire:click="$set('category', '{{ $category === $cat ? '' : $cat }}')"
|
||||
variant="{{ $category === $cat ? 'filled' : 'ghost' }}"
|
||||
size="sm"
|
||||
>
|
||||
{{ ucfirst($cat) }}
|
||||
<flux:badge size="sm" class="ml-1">{{ $count }}</flux:badge>
|
||||
</flux:button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Filter by Change Type --}}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-sm text-zinc-500">Filter:</span>
|
||||
<flux:button wire:click="$set('changeType', '')" variant="{{ $changeType === '' ? 'filled' : 'ghost' }}" size="sm">
|
||||
All
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('changeType', 'added')" variant="{{ $changeType === 'added' ? 'filled' : 'ghost' }}" size="sm">
|
||||
<span class="text-green-600">Added</span>
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('changeType', 'modified')" variant="{{ $changeType === 'modified' ? 'filled' : 'ghost' }}" size="sm">
|
||||
<span class="text-blue-600">Modified</span>
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('changeType', 'removed')" variant="{{ $changeType === 'removed' ? 'filled' : 'ghost' }}" size="sm">
|
||||
<span class="text-red-600">Removed</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Diffs Table --}}
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column class="w-10">Type</flux:table.column>
|
||||
<flux:table.column>File Path</flux:table.column>
|
||||
<flux:table.column>Category</flux:table.column>
|
||||
<flux:table.column align="center">Lines</flux:table.column>
|
||||
<flux:table.column align="end">Actions</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->diffs as $diff)
|
||||
<flux:table.row wire:key="diff-{{ $diff->id }}">
|
||||
<flux:table.cell>
|
||||
@if($diff->change_type === 'added')
|
||||
<flux:badge color="green" size="sm">+</flux:badge>
|
||||
@elseif($diff->change_type === 'modified')
|
||||
<flux:badge color="blue" size="sm">~</flux:badge>
|
||||
@else
|
||||
<flux:badge color="red" size="sm">-</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="font-mono text-sm">
|
||||
<span class="text-zinc-500">{{ $diff->getDirectory() }}/</span>{{ $diff->getFileName() }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ $diff->getCategoryIcon() }}</span>
|
||||
<span class="text-sm">{{ ucfirst($diff->category) }}</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($diff->diff_content)
|
||||
<div class="text-sm">
|
||||
<span class="text-green-600">+{{ $diff->getAddedLines() }}</span>
|
||||
<span class="text-zinc-400">/</span>
|
||||
<span class="text-red-600">-{{ $diff->getRemovedLines() }}</span>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-zinc-400">-</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<flux:button wire:click="viewDiff({{ $diff->id }})" variant="ghost" size="sm" icon="eye">
|
||||
View
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5" class="text-center py-12">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="document-magnifying-glass" class="size-12 opacity-50" />
|
||||
<span class="text-lg">No diffs found</span>
|
||||
<span class="text-sm">Select a release to view file changes</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->diffs->hasPages())
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
{{ $this->diffs->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card class="p-12">
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
|
||||
<flux:icon name="document-magnifying-glass" class="size-8 text-blue-500" />
|
||||
</div>
|
||||
<flux:heading size="lg">Select a Vendor and Release</flux:heading>
|
||||
<flux:subheading class="mt-1">Choose a vendor and release version to view file diffs.</flux:subheading>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Diff Detail Modal --}}
|
||||
<flux:modal wire:model="showDiffModal" name="diff-detail" class="max-w-5xl">
|
||||
@if($this->selectedDiff)
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ $this->selectedDiff->getChangeTypeIcon() }}</span>
|
||||
<flux:heading size="lg" class="font-mono">{{ $this->selectedDiff->getFileName() }}</flux:heading>
|
||||
</div>
|
||||
<flux:subheading class="font-mono text-sm">{{ $this->selectedDiff->file_path }}</flux:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge class="{{ $this->selectedDiff->getChangeTypeBadgeClass() }}">
|
||||
{{ ucfirst($this->selectedDiff->change_type) }}
|
||||
</flux:badge>
|
||||
<flux:badge color="zinc">{{ ucfirst($this->selectedDiff->category) }}</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($this->selectedDiff->diff_content)
|
||||
<div class="bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<div class="p-3 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span class="text-sm text-zinc-400">Unified Diff</span>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-green-400">+{{ $this->selectedDiff->getAddedLines() }} added</span>
|
||||
<span class="text-red-400">-{{ $this->selectedDiff->getRemovedLines() }} removed</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="p-4 overflow-x-auto text-sm font-mono max-h-[60vh] overflow-y-auto"><code class="language-diff">{{ $this->selectedDiff->diff_content }}</code></pre>
|
||||
</div>
|
||||
@elseif($this->selectedDiff->new_content)
|
||||
<div class="bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<div class="p-3 border-b border-zinc-700">
|
||||
<span class="text-sm text-zinc-400">New File Content</span>
|
||||
</div>
|
||||
<pre class="p-4 overflow-x-auto text-sm font-mono max-h-[60vh] overflow-y-auto text-zinc-300"><code>{{ $this->selectedDiff->new_content }}</code></pre>
|
||||
</div>
|
||||
@else
|
||||
<div class="p-8 text-center text-zinc-500 bg-zinc-100 dark:bg-zinc-800 rounded-lg">
|
||||
<flux:icon name="document" class="size-12 opacity-50 mx-auto mb-2" />
|
||||
<p>No content available for this file change.</p>
|
||||
@if($this->selectedDiff->change_type === 'removed')
|
||||
<p class="text-sm mt-1">This file was removed in the new version.</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="closeDiffModal" variant="ghost">Close</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</admin:module>
|
||||
250
View/Blade/admin/digest-preferences.blade.php
Normal file
250
View/Blade/admin/digest-preferences.blade.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<admin:module title="Digest Preferences" subtitle="Configure email notifications for vendor updates">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<core:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" icon="arrow-left" size="sm">
|
||||
Back to Dashboard
|
||||
</core:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Settings Panel --}}
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{{-- Enable/Disable Card --}}
|
||||
<flux:card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">Email Digests</flux:heading>
|
||||
<flux:subheading>Receive periodic summaries of vendor updates and pending tasks</flux:subheading>
|
||||
</div>
|
||||
<flux:switch
|
||||
wire:click="toggleEnabled"
|
||||
:checked="$isEnabled"
|
||||
label="{{ $isEnabled ? 'Enabled' : 'Disabled' }}"
|
||||
/>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Frequency Selection --}}
|
||||
<flux:card class="p-6">
|
||||
<flux:heading size="lg" class="mb-4">Frequency</flux:heading>
|
||||
|
||||
<flux:radio.group wire:model.live="frequency" class="space-y-3">
|
||||
@foreach(\Core\Uptelligence\Models\UptelligenceDigest::getFrequencyOptions() as $value => $label)
|
||||
<flux:radio
|
||||
value="{{ $value }}"
|
||||
label="{{ $label }}"
|
||||
description="{{ match($value) {
|
||||
'daily' => 'Sent every morning at 9am UK time',
|
||||
'weekly' => 'Sent every Monday at 9am UK time',
|
||||
'monthly' => 'Sent on the 1st of each month at 9am UK time',
|
||||
default => ''
|
||||
} }}"
|
||||
/>
|
||||
@endforeach
|
||||
</flux:radio.group>
|
||||
</flux:card>
|
||||
|
||||
{{-- Content Types --}}
|
||||
<flux:card class="p-6">
|
||||
<flux:heading size="lg" class="mb-4">Include in Digest</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:checkbox
|
||||
wire:click="toggleType('releases')"
|
||||
:checked="in_array('releases', $selectedTypes)"
|
||||
label="New Releases"
|
||||
description="Version updates and changelog summaries from tracked vendors"
|
||||
/>
|
||||
|
||||
<flux:checkbox
|
||||
wire:click="toggleType('todos')"
|
||||
:checked="in_array('todos', $selectedTypes)"
|
||||
label="Pending Tasks"
|
||||
description="Summary of porting tasks grouped by priority"
|
||||
/>
|
||||
|
||||
<flux:checkbox
|
||||
wire:click="toggleType('security')"
|
||||
:checked="in_array('security', $selectedTypes)"
|
||||
label="Security Updates"
|
||||
description="Highlight security-related updates that need attention"
|
||||
/>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Vendor Filter --}}
|
||||
<flux:card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<flux:heading size="lg">Vendor Filter</flux:heading>
|
||||
<flux:subheading>Select which vendors to include (leave empty for all)</flux:subheading>
|
||||
</div>
|
||||
@if(!empty($selectedVendorIds))
|
||||
<flux:button wire:click="selectAllVendors" variant="ghost" size="sm">
|
||||
Clear Filter
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
||||
@foreach($this->vendors as $vendor)
|
||||
<flux:checkbox
|
||||
wire:click="toggleVendor({{ $vendor->id }})"
|
||||
:checked="empty($selectedVendorIds) || in_array($vendor->id, $selectedVendorIds)"
|
||||
label="{{ $vendor->name }}"
|
||||
>
|
||||
<x-slot:description>
|
||||
@if($vendor->source_type === 'licensed')
|
||||
<flux:icon name="lock-closed" class="size-3 inline text-amber-500" />
|
||||
@elseif($vendor->source_type === 'oss')
|
||||
<flux:icon name="globe-alt" class="size-3 inline text-green-500" />
|
||||
@else
|
||||
<flux:icon name="puzzle-piece" class="size-3 inline text-blue-500" />
|
||||
@endif
|
||||
{{ $vendor->slug }}
|
||||
</x-slot:description>
|
||||
</flux:checkbox>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($this->vendors->isEmpty())
|
||||
<div class="text-center py-8 text-zinc-500">
|
||||
<flux:icon name="building-office" class="size-8 opacity-50 mx-auto mb-2" />
|
||||
<span>No vendors tracked yet</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Priority Threshold --}}
|
||||
<flux:card class="p-6">
|
||||
<flux:heading size="lg" class="mb-4">Priority Threshold</flux:heading>
|
||||
<flux:subheading class="mb-4">Only include tasks at or above this priority level</flux:subheading>
|
||||
|
||||
<flux:select wire:model.live="minPriority">
|
||||
<flux:option :value="null">All priorities</flux:option>
|
||||
<flux:option value="4">Medium and above (4+)</flux:option>
|
||||
<flux:option value="6">High and above (6+)</flux:option>
|
||||
<flux:option value="8">Critical only (8+)</flux:option>
|
||||
</flux:select>
|
||||
</flux:card>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:button wire:click="sendTestDigest" variant="ghost" icon="paper-airplane">
|
||||
Send Test Digest
|
||||
</flux:button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:button wire:click="showPreview" variant="ghost" icon="eye">
|
||||
Preview
|
||||
</flux:button>
|
||||
<flux:button wire:click="save" variant="primary" icon="check">
|
||||
Save Preferences
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Preview Panel --}}
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card class="p-6 sticky top-6">
|
||||
<flux:heading size="lg" class="mb-4">Preview</flux:heading>
|
||||
<flux:subheading class="mb-6">What your next digest would include</flux:subheading>
|
||||
|
||||
@php $preview = $this->preview; @endphp
|
||||
|
||||
@if(!$preview['has_content'])
|
||||
<div class="text-center py-8 text-zinc-500">
|
||||
<flux:icon name="inbox" class="size-8 opacity-50 mx-auto mb-2" />
|
||||
<span>No content to preview</span>
|
||||
<p class="text-sm mt-1">There are no updates matching your filters</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
{{-- Security Alert --}}
|
||||
@if($preview['security_count'] > 0)
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<flux:icon name="shield-exclamation" class="size-5" />
|
||||
<span class="font-medium">{{ $preview['security_count'] }} security update{{ $preview['security_count'] !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Releases --}}
|
||||
@if($preview['releases']->isNotEmpty())
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Recent Releases</h4>
|
||||
<ul class="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
@foreach($preview['releases'] as $release)
|
||||
<li class="flex items-center justify-between">
|
||||
<span>{{ $release['vendor_name'] }}</span>
|
||||
<span class="font-mono text-xs">{{ $release['version'] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Todos Summary --}}
|
||||
@if(($preview['todos']['total'] ?? 0) > 0)
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Pending Tasks</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
@if($preview['todos']['critical'] > 0)
|
||||
<div class="flex items-center justify-between p-2 bg-red-50 dark:bg-red-900/20 rounded">
|
||||
<span class="text-red-700 dark:text-red-400">Critical</span>
|
||||
<span class="font-medium">{{ $preview['todos']['critical'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($preview['todos']['high'] > 0)
|
||||
<div class="flex items-center justify-between p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
|
||||
<span class="text-orange-700 dark:text-orange-400">High</span>
|
||||
<span class="font-medium">{{ $preview['todos']['high'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($preview['todos']['medium'] > 0)
|
||||
<div class="flex items-center justify-between p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded">
|
||||
<span class="text-yellow-700 dark:text-yellow-400">Medium</span>
|
||||
<span class="font-medium">{{ $preview['todos']['medium'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($preview['todos']['low'] > 0)
|
||||
<div class="flex items-center justify-between p-2 bg-zinc-50 dark:bg-zinc-800 rounded">
|
||||
<span class="text-zinc-600 dark:text-zinc-400">Low</span>
|
||||
<span class="font-medium">{{ $preview['todos']['low'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Top Vendors --}}
|
||||
@if($preview['top_vendors']->isNotEmpty())
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Top Vendors</h4>
|
||||
<ul class="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
@foreach($preview['top_vendors'] as $vendor)
|
||||
<li class="flex items-center justify-between">
|
||||
<span>{{ $vendor->name }}</span>
|
||||
<flux:badge size="sm" color="blue">{{ $vendor->pending_count }} pending</flux:badge>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Next Send --}}
|
||||
@if($preview['next_send'])
|
||||
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700 text-sm text-zinc-500">
|
||||
<p><strong>Frequency:</strong> {{ $preview['frequency_label'] }}</p>
|
||||
<p><strong>Next send:</strong> {{ $preview['next_send'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</admin:module>
|
||||
224
View/Blade/admin/todo-list.blade.php
Normal file
224
View/Blade/admin/todo-list.blade.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<admin:module title="Upstream Todos" subtitle="Manage porting tasks from vendor updates">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
@if(count($selectedTodos) > 0)
|
||||
<flux:dropdown>
|
||||
<flux:button variant="filled" size="sm" icon="check-circle">
|
||||
Bulk Actions ({{ count($selectedTodos) }})
|
||||
</flux:button>
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="bulkMarkStatus('in_progress')" icon="play">
|
||||
Mark In Progress
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="bulkMarkStatus('ported')" icon="check">
|
||||
Mark Ported
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="bulkMarkStatus('skipped')" icon="forward">
|
||||
Mark Skipped
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="bulkMarkStatus('wont_port')" icon="x-mark" class="text-red-600">
|
||||
Mark Won't Port
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endif
|
||||
<flux:button wire:click="resetFilters" variant="ghost" size="sm" icon="x-mark">
|
||||
Reset Filters
|
||||
</flux:button>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||
Back
|
||||
</flux:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
{{-- Status Tabs --}}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<flux:button wire:click="$set('status', 'pending')" variant="{{ $status === 'pending' ? 'filled' : 'ghost' }}" size="sm">
|
||||
Pending
|
||||
<flux:badge size="sm" class="ml-1">{{ $this->todoStats['pending'] }}</flux:badge>
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('status', 'quick_wins')" variant="{{ $status === 'quick_wins' ? 'filled' : 'ghost' }}" size="sm">
|
||||
Quick Wins
|
||||
<flux:badge color="green" size="sm" class="ml-1">{{ $this->todoStats['quick_wins'] }}</flux:badge>
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('status', 'in_progress')" variant="{{ $status === 'in_progress' ? 'filled' : 'ghost' }}" size="sm">
|
||||
In Progress
|
||||
<flux:badge color="blue" size="sm" class="ml-1">{{ $this->todoStats['in_progress'] }}</flux:badge>
|
||||
</flux:button>
|
||||
<flux:button wire:click="$set('status', 'completed')" variant="{{ $status === 'completed' ? 'filled' : 'ghost' }}" size="sm">
|
||||
Completed
|
||||
<flux:badge color="zinc" size="sm" class="ml-1">{{ $this->todoStats['completed'] }}</flux:badge>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search todos..." icon="magnifying-glass" />
|
||||
|
||||
<flux:select wire:model.live="vendorId">
|
||||
<option value="">All Vendors</option>
|
||||
@foreach($this->vendors as $vendor)
|
||||
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="type">
|
||||
<option value="">All Types</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="bugfix">Bug Fix</option>
|
||||
<option value="security">Security</option>
|
||||
<option value="ui">UI</option>
|
||||
<option value="api">API</option>
|
||||
<option value="refactor">Refactor</option>
|
||||
<option value="dependency">Dependency</option>
|
||||
<option value="block">Block</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="effort">
|
||||
<option value="">All Effort</option>
|
||||
<option value="low">Low (< 1 hour)</option>
|
||||
<option value="medium">Medium (1-4 hours)</option>
|
||||
<option value="high">High (4+ hours)</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="priority">
|
||||
<option value="">All Priority</option>
|
||||
<option value="critical">Critical (8-10)</option>
|
||||
<option value="high">High (6-7)</option>
|
||||
<option value="medium">Medium (4-5)</option>
|
||||
<option value="low">Low (1-3)</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column class="w-10">
|
||||
<flux:checkbox wire:model.live="selectAll" wire:click="toggleSelectAll" />
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir" wire:click="sortBy('title')">
|
||||
Todo
|
||||
</flux:table.column>
|
||||
<flux:table.column>Vendor</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'type'" :direction="$sortDir" wire:click="sortBy('type')">
|
||||
Type
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'priority'" :direction="$sortDir" wire:click="sortBy('priority')" align="center">
|
||||
Priority
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'effort'" :direction="$sortDir" wire:click="sortBy('effort')" align="center">
|
||||
Effort
|
||||
</flux:table.column>
|
||||
<flux:table.column align="center">Status</flux:table.column>
|
||||
<flux:table.column align="end">Actions</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->todos as $todo)
|
||||
<flux:table.row wire:key="todo-{{ $todo->id }}" class="{{ $todo->has_conflicts ? 'bg-red-50 dark:bg-red-900/10' : '' }}">
|
||||
<flux:table.cell>
|
||||
<flux:checkbox wire:model.live="selectedTodos" value="{{ $todo->id }}" />
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="max-w-md">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100 truncate flex items-center gap-2">
|
||||
{{ $todo->title }}
|
||||
@if($todo->isQuickWin())
|
||||
<flux:badge color="emerald" size="sm">Quick Win</flux:badge>
|
||||
@endif
|
||||
@if($todo->has_conflicts)
|
||||
<flux:icon name="exclamation-triangle" class="size-4 text-red-500" title="Has conflicts" />
|
||||
@endif
|
||||
</div>
|
||||
@if($todo->description)
|
||||
<div class="text-sm text-zinc-500 truncate">{{ Str::limit($todo->description, 80) }}</div>
|
||||
@endif
|
||||
@if($todo->files && count($todo->files) > 0)
|
||||
<div class="text-xs text-zinc-400 mt-1">
|
||||
{{ count($todo->files) }} file(s)
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500">
|
||||
{{ $todo->vendor->name }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ $todo->getTypeIcon() }}</span>
|
||||
<span class="text-sm">{{ ucfirst($todo->type) }}</span>
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : ($todo->priority >= 4 ? 'yellow' : 'zinc')) }}" size="sm">
|
||||
{{ $todo->getPriorityLabel() }} ({{ $todo->priority }})
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<flux:badge color="{{ $todo->effort === 'low' ? 'green' : ($todo->effort === 'medium' ? 'yellow' : 'red') }}" size="sm">
|
||||
{{ $todo->getEffortLabel() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
<flux:badge class="{{ $todo->getStatusBadgeClass() }}" size="sm">
|
||||
{{ ucfirst(str_replace('_', ' ', $todo->status)) }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
@if($todo->isPending())
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'in_progress')" icon="play">
|
||||
Start Progress
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'ported')" icon="check">
|
||||
Mark Ported
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'skipped')" icon="forward">
|
||||
Skip
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'wont_port')" icon="x-mark" class="text-red-600">
|
||||
Won't Port
|
||||
</flux:menu.item>
|
||||
@elseif($todo->status === 'in_progress')
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'ported')" icon="check">
|
||||
Mark Ported
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'skipped')" icon="forward">
|
||||
Skip
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
@if($todo->github_issue_number)
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item icon="arrow-top-right-on-square">
|
||||
View GitHub Issue
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8" class="text-center py-12">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="clipboard-document-list" class="size-12 opacity-50" />
|
||||
<span class="text-lg">No todos found</span>
|
||||
<span class="text-sm">Try adjusting your filters</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->todos->hasPages())
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
{{ $this->todos->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</admin:module>
|
||||
232
View/Blade/admin/vendor-manager.blade.php
Normal file
232
View/Blade/admin/vendor-manager.blade.php
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<admin:module title="Vendor Manager" subtitle="Track and manage upstream software vendors">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search vendors..." icon="magnifying-glass" size="sm" class="w-64" />
|
||||
<flux:select wire:model.live="sourceType" size="sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="licensed">Licensed</option>
|
||||
<option value="oss">Open Source</option>
|
||||
<option value="plugin">Plugin</option>
|
||||
</flux:select>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||
Back
|
||||
</flux:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir" wire:click="sortBy('name')">
|
||||
Vendor
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'source_type'" :direction="$sortDir" wire:click="sortBy('source_type')">
|
||||
Type
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'current_version'" :direction="$sortDir" wire:click="sortBy('current_version')">
|
||||
Current Version
|
||||
</flux:table.column>
|
||||
<flux:table.column align="center">Pending Todos</flux:table.column>
|
||||
<flux:table.column align="center">Quick Wins</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'last_checked_at'" :direction="$sortDir" wire:click="sortBy('last_checked_at')">
|
||||
Last Checked
|
||||
</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'is_active'" :direction="$sortDir" wire:click="sortBy('is_active')">
|
||||
Status
|
||||
</flux:table.column>
|
||||
<flux:table.column align="end">Actions</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->vendors as $vendor)
|
||||
<flux:table.row wire:key="vendor-{{ $vendor->id }}">
|
||||
<flux:table.cell variant="strong">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($vendor->source_type === 'licensed')
|
||||
<flux:icon name="lock-closed" class="size-4 text-amber-500" />
|
||||
@elseif($vendor->source_type === 'oss')
|
||||
<flux:icon name="globe-alt" class="size-4 text-green-500" />
|
||||
@else
|
||||
<flux:icon name="puzzle-piece" class="size-4 text-blue-500" />
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $vendor->name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $vendor->slug }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="{{ $vendor->source_type === 'licensed' ? 'amber' : ($vendor->source_type === 'oss' ? 'green' : 'blue') }}" size="sm">
|
||||
{{ $vendor->getSourceTypeLabel() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-mono text-sm">
|
||||
{{ $vendor->current_version ?? 'N/A' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($vendor->pending_todos_count > 0)
|
||||
<flux:badge color="{{ $vendor->pending_todos_count > 10 ? 'red' : ($vendor->pending_todos_count > 5 ? 'yellow' : 'blue') }}" size="sm">
|
||||
{{ $vendor->pending_todos_count }}
|
||||
</flux:badge>
|
||||
@else
|
||||
<flux:badge color="green" size="sm">0</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($vendor->quick_wins_count > 0)
|
||||
<flux:badge color="emerald" size="sm">{{ $vendor->quick_wins_count }}</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-400">-</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500 text-sm">
|
||||
{{ $vendor->last_checked_at?->diffForHumans() ?? 'Never' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if($vendor->is_active)
|
||||
<flux:badge color="green" size="sm">Active</flux:badge>
|
||||
@else
|
||||
<flux:badge color="zinc" size="sm">Inactive</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<flux:button wire:click="selectVendor({{ $vendor->id }})" variant="ghost" size="sm" icon="eye" />
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="selectVendor({{ $vendor->id }})" icon="eye">
|
||||
View Details
|
||||
</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.todos') }}?vendorId={{ $vendor->id }}" wire:navigate icon="clipboard-document-list">
|
||||
View Todos
|
||||
</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('hub.admin.uptelligence.diffs') }}?vendorId={{ $vendor->id }}" wire:navigate icon="document-magnifying-glass">
|
||||
View Diffs
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="toggleActive({{ $vendor->id }})" icon="{{ $vendor->is_active ? 'pause' : 'play' }}">
|
||||
{{ $vendor->is_active ? 'Deactivate' : 'Activate' }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8" class="text-center py-12">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="building-office" class="size-12 opacity-50" />
|
||||
<span class="text-lg">No vendors found</span>
|
||||
<span class="text-sm">Try adjusting your search or filters</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->vendors->hasPages())
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
{{ $this->vendors->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Vendor Detail Modal --}}
|
||||
<flux:modal wire:model="showVendorModal" name="vendor-detail" class="max-w-3xl">
|
||||
@if($this->selectedVendor)
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $this->selectedVendor->name }}</flux:heading>
|
||||
<flux:subheading>{{ $this->selectedVendor->vendor_name ?? $this->selectedVendor->slug }}</flux:subheading>
|
||||
</div>
|
||||
<flux:badge color="{{ $this->selectedVendor->source_type === 'licensed' ? 'amber' : ($this->selectedVendor->source_type === 'oss' ? 'green' : 'blue') }}">
|
||||
{{ $this->selectedVendor->getSourceTypeLabel() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Current Version</div>
|
||||
<div class="text-lg font-mono">{{ $this->selectedVendor->current_version ?? 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Previous Version</div>
|
||||
<div class="text-lg font-mono">{{ $this->selectedVendor->previous_version ?? 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Pending Todos</div>
|
||||
<div class="text-lg font-semibold">{{ $this->selectedVendor->pending_todos_count }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Last Checked</div>
|
||||
<div class="text-lg">{{ $this->selectedVendor->last_checked_at?->format('d M Y H:i') ?? 'Never' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($this->selectedVendor->git_repo_url)
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500 mb-1">Git Repository</div>
|
||||
<a href="{{ $this->selectedVendor->git_repo_url }}" target="_blank" class="text-blue-600 hover:underline flex items-center gap-1">
|
||||
{{ $this->selectedVendor->git_repo_url }}
|
||||
<flux:icon name="arrow-top-right-on-square" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($this->selectedVendor->todos->isNotEmpty())
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-3">Recent Todos</flux:heading>
|
||||
<div class="space-y-2">
|
||||
@foreach($this->selectedVendor->todos as $todo)
|
||||
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ $todo->getTypeIcon() }}</span>
|
||||
<span class="font-medium truncate max-w-sm">{{ $todo->title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : 'zinc') }}" size="sm">
|
||||
P{{ $todo->priority }}
|
||||
</flux:badge>
|
||||
<flux:badge color="{{ $todo->effort === 'low' ? 'green' : ($todo->effort === 'medium' ? 'yellow' : 'red') }}" size="sm">
|
||||
{{ ucfirst($todo->effort) }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($this->selectedVendorReleases->isNotEmpty())
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-3">Recent Releases</flux:heading>
|
||||
<div class="space-y-2">
|
||||
@foreach($this->selectedVendorReleases->take(5) as $release)
|
||||
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg flex items-center justify-between">
|
||||
<div class="font-mono text-sm">{{ $release->getVersionCompare() }}</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-green-600">+{{ $release->files_added }}</span>
|
||||
<span class="text-blue-600">~{{ $release->files_modified }}</span>
|
||||
<span class="text-red-600">-{{ $release->files_removed }}</span>
|
||||
<span class="text-zinc-500">{{ $release->analyzed_at?->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="closeVendorModal" variant="ghost">Close</flux:button>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}?vendorId={{ $this->selectedVendor->id }}" wire:navigate variant="primary">
|
||||
View All Todos
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</admin:module>
|
||||
391
View/Blade/admin/webhook-manager.blade.php
Normal file
391
View/Blade/admin/webhook-manager.blade.php
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
<admin:module title="Webhook Manager" subtitle="Receive vendor release notifications via webhooks">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:select wire:model.live="vendorId" size="sm" placeholder="All Vendors">
|
||||
<option value="">All Vendors</option>
|
||||
@foreach ($this->vendors as $vendor)
|
||||
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:select wire:model.live="provider" size="sm" placeholder="All Providers">
|
||||
<option value="">All Providers</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="npm">npm</option>
|
||||
<option value="packagist">Packagist</option>
|
||||
<option value="custom">Custom</option>
|
||||
</flux:select>
|
||||
<flux:select wire:model.live="status" size="sm" placeholder="All Status">
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</flux:select>
|
||||
<flux:button wire:click="openCreateModal" variant="primary" size="sm" icon="plus">
|
||||
New Webhook
|
||||
</flux:button>
|
||||
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||
Back
|
||||
</flux:button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<flux:card class="p-0 overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Vendor</flux:table.column>
|
||||
<flux:table.column>Provider</flux:table.column>
|
||||
<flux:table.column>Endpoint URL</flux:table.column>
|
||||
<flux:table.column align="center">Deliveries (24h)</flux:table.column>
|
||||
<flux:table.column>Last Received</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column align="end">Actions</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->webhooks as $webhook)
|
||||
<flux:table.row wire:key="webhook-{{ $webhook->id }}">
|
||||
<flux:table.cell variant="strong">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:icon name="{{ $webhook->getProviderIcon() }}" class="size-4 text-zinc-500" />
|
||||
<div>
|
||||
<div>{{ $webhook->vendor->name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $webhook->vendor->slug }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="zinc" size="sm">
|
||||
{{ $webhook->getProviderLabel() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="font-mono text-xs text-zinc-500 max-w-xs truncate">
|
||||
{{ $webhook->getEndpointUrl() }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="center">
|
||||
@if($webhook->recent_deliveries_count > 0)
|
||||
<flux:badge color="blue" size="sm">{{ $webhook->recent_deliveries_count }}</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-400">-</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-zinc-500 text-sm">
|
||||
{{ $webhook->last_received_at?->diffForHumans() ?? 'Never' }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="{{ $webhook->status_color }}" size="sm">
|
||||
{{ $webhook->status_label }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell align="end">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<flux:button wire:click="selectWebhook({{ $webhook->id }})" variant="ghost" size="sm" icon="eye" />
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="selectWebhook({{ $webhook->id }})" icon="eye">
|
||||
View Details
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="viewDeliveries({{ $webhook->id }})" icon="inbox-stack">
|
||||
View Deliveries
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="regenerateSecret({{ $webhook->id }})" icon="key">
|
||||
Rotate Secret
|
||||
</flux:menu.item>
|
||||
<flux:menu.item wire:click="toggleActive({{ $webhook->id }})" icon="{{ $webhook->is_active ? 'pause' : 'play' }}">
|
||||
{{ $webhook->is_active ? 'Disable' : 'Enable' }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="deleteWebhook({{ $webhook->id }})" wire:confirm="Are you sure you want to delete this webhook?" icon="trash" variant="danger">
|
||||
Delete
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="7" class="text-center py-12">
|
||||
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||
<flux:icon name="globe-alt" class="size-12 opacity-50" />
|
||||
<span class="text-lg">No webhooks configured</span>
|
||||
<span class="text-sm">Create a webhook to receive vendor release notifications</span>
|
||||
<flux:button wire:click="openCreateModal" variant="primary" size="sm" icon="plus" class="mt-2">
|
||||
Create Webhook
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->webhooks->hasPages())
|
||||
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
{{ $this->webhooks->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Webhook Detail Modal --}}
|
||||
<flux:modal wire:model="showWebhookModal" name="webhook-detail" class="max-w-2xl">
|
||||
@if($this->selectedWebhook)
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $this->selectedWebhook->vendor->name }}</flux:heading>
|
||||
<flux:subheading>{{ $this->selectedWebhook->getProviderLabel() }} Webhook</flux:subheading>
|
||||
</div>
|
||||
<flux:badge color="{{ $this->selectedWebhook->status_color }}">
|
||||
{{ $this->selectedWebhook->status_label }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500 mb-1">Webhook Endpoint URL</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-sm bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded flex-1 overflow-x-auto">
|
||||
{{ $this->selectedWebhook->getEndpointUrl() }}
|
||||
</code>
|
||||
<flux:button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="clipboard-document"
|
||||
x-on:click="navigator.clipboard.writeText('{{ $this->selectedWebhook->getEndpointUrl() }}')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Total Deliveries</div>
|
||||
<div class="text-lg font-semibold">{{ $this->selectedWebhook->deliveries_count }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Last 24 Hours</div>
|
||||
<div class="text-lg font-semibold">{{ $this->selectedWebhook->recent_deliveries_count }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Last Received</div>
|
||||
<div class="text-lg">{{ $this->selectedWebhook->last_received_at?->format('d M Y H:i') ?? 'Never' }}</div>
|
||||
</div>
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500">Failure Count</div>
|
||||
<div class="text-lg font-semibold {{ $this->selectedWebhook->failure_count > 0 ? 'text-red-600' : 'text-green-600' }}">
|
||||
{{ $this->selectedWebhook->failure_count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($this->selectedWebhook->isInGracePeriod())
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||
<span class="font-medium">Secret rotation in progress</span>
|
||||
</div>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
Both old and new secrets are accepted until {{ $this->selectedWebhook->grace_ends_at->format('d M Y H:i') }}.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500 mb-2">Setup Instructions</div>
|
||||
<div class="text-sm space-y-2">
|
||||
@if($this->selectedWebhook->provider === 'github')
|
||||
<p>1. Go to your GitHub repository Settings > Webhooks</p>
|
||||
<p>2. Click "Add webhook"</p>
|
||||
<p>3. Paste the endpoint URL above</p>
|
||||
<p>4. Set Content type to <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">application/json</code></p>
|
||||
<p>5. Enter your webhook secret</p>
|
||||
<p>6. Select "Let me select individual events" and choose "Releases"</p>
|
||||
@elseif($this->selectedWebhook->provider === 'gitlab')
|
||||
<p>1. Go to your GitLab project Settings > Webhooks</p>
|
||||
<p>2. Enter the endpoint URL</p>
|
||||
<p>3. Add your secret token</p>
|
||||
<p>4. Select "Releases events" trigger</p>
|
||||
@elseif($this->selectedWebhook->provider === 'npm')
|
||||
<p>1. Configure your npm package hooks using <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">npm hook add</code></p>
|
||||
<p>2. Use the endpoint URL and your webhook secret</p>
|
||||
@elseif($this->selectedWebhook->provider === 'packagist')
|
||||
<p>1. Go to your Packagist package page</p>
|
||||
<p>2. Edit the package settings</p>
|
||||
<p>3. Add a webhook URL pointing to this endpoint</p>
|
||||
@else
|
||||
<p>Configure your system to POST JSON payloads to the endpoint URL.</p>
|
||||
<p>Include the version in your payload as <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">version</code>, <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">tag</code>, or <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">tag_name</code>.</p>
|
||||
<p>Sign payloads with HMAC-SHA256 using your secret and include in <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">X-Signature</code> header.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="deleteWebhook({{ $this->selectedWebhook->id }})" wire:confirm="Are you sure?" variant="danger" icon="trash">
|
||||
Delete
|
||||
</flux:button>
|
||||
<div class="flex gap-2">
|
||||
<flux:button wire:click="regenerateSecret({{ $this->selectedWebhook->id }})" variant="ghost" icon="key">
|
||||
Rotate Secret
|
||||
</flux:button>
|
||||
<flux:button wire:click="viewDeliveries({{ $this->selectedWebhook->id }})" variant="ghost" icon="inbox-stack">
|
||||
View Deliveries
|
||||
</flux:button>
|
||||
<flux:button wire:click="closeWebhookModal" variant="primary">Close</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
|
||||
{{-- Create Webhook Modal --}}
|
||||
<flux:modal wire:model="showCreateModal" name="create-webhook" class="max-w-md">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Create Webhook</flux:heading>
|
||||
<flux:subheading>Configure a new vendor release webhook</flux:subheading>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:select wire:model="createVendorId" label="Vendor" :error="$errors->first('createVendorId')">
|
||||
<option value="">Select a vendor...</option>
|
||||
@foreach ($this->vendors as $vendor)
|
||||
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model="createProvider" label="Provider" :error="$errors->first('createProvider')">
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="npm">npm</option>
|
||||
<option value="packagist">Packagist</option>
|
||||
<option value="custom">Custom</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="closeCreateModal" variant="ghost">Cancel</flux:button>
|
||||
<flux:button wire:click="createWebhook" variant="primary">Create Webhook</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
{{-- Secret Display Modal --}}
|
||||
<flux:modal wire:model="showSecretModal" name="secret-display" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Webhook Secret</flux:heading>
|
||||
<flux:subheading>Copy this secret now - it will not be shown again</flux:subheading>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 text-amber-800 dark:text-amber-200 mb-2">
|
||||
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||
<span class="font-medium">Important</span>
|
||||
</div>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
This is the only time you will see this secret. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($displaySecret)
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="text-sm text-zinc-500 mb-2">Webhook Secret</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-sm bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded flex-1 font-mono break-all">
|
||||
{{ $displaySecret }}
|
||||
</code>
|
||||
<flux:button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="clipboard-document"
|
||||
x-on:click="navigator.clipboard.writeText('{{ $displaySecret }}')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="closeSecretModal" variant="primary">I have copied the secret</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
{{-- Deliveries Modal --}}
|
||||
<flux:modal wire:model="showDeliveriesModal" name="webhook-deliveries" class="max-w-4xl">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Webhook Deliveries</flux:heading>
|
||||
<flux:subheading>Recent webhook delivery history</flux:subheading>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Time</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Event</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Version</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Status</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Signature</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-zinc-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
@forelse ($this->selectedWebhookDeliveries as $delivery)
|
||||
<tr wire:key="delivery-{{ $delivery->id }}">
|
||||
<td class="px-3 py-2 text-sm text-zinc-500">
|
||||
{{ $delivery->created_at->format('d M H:i:s') }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<flux:badge color="{{ $delivery->event_color }}" size="sm">
|
||||
{{ $delivery->event_type }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-sm">
|
||||
{{ $delivery->version ?? '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<flux:badge color="{{ $delivery->status_color }}" size="sm">
|
||||
{{ ucfirst($delivery->status) }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<flux:badge color="{{ $delivery->signature_color }}" size="sm">
|
||||
{{ ucfirst($delivery->signature_status ?? 'unknown') }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
@if($delivery->canRetry())
|
||||
<flux:button wire:click="retryDelivery({{ $delivery->id }})" variant="ghost" size="sm" icon="arrow-path">
|
||||
Retry
|
||||
</flux:button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if($delivery->error_message)
|
||||
<tr wire:key="delivery-error-{{ $delivery->id }}">
|
||||
<td colspan="6" class="px-3 py-2 bg-red-50 dark:bg-red-900/20">
|
||||
<div class="text-sm text-red-700 dark:text-red-300">
|
||||
<strong>Error:</strong> {{ $delivery->error_message }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-3 py-8 text-center text-zinc-500">
|
||||
No deliveries recorded yet
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button wire:click="closeDeliveriesModal" variant="primary">Close</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</admin:module>
|
||||
129
View/Modal/Admin/AssetManager.php
Normal file
129
View/Modal/Admin/AssetManager.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Uptelligence\Models\Asset;
|
||||
|
||||
#[Title('Asset Manager')]
|
||||
class AssetManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $type = '';
|
||||
|
||||
#[Url]
|
||||
public string $licenceType = '';
|
||||
|
||||
#[Url]
|
||||
public bool $needsUpdate = false;
|
||||
|
||||
#[Url]
|
||||
public string $sortBy = 'name';
|
||||
|
||||
#[Url]
|
||||
public string $sortDir = 'asc';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
134
View/Modal/Admin/Dashboard.php
Normal file
134
View/Modal/Admin/Dashboard.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
use Core\Uptelligence\Models\Asset;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
#[Title('Uptelligence Dashboard')]
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
174
View/Modal/Admin/DiffViewer.php
Normal file
174
View/Modal/Admin/DiffViewer.php
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Uptelligence\Models\DiffCache;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
#[Title('Diff Viewer')]
|
||||
class DiffViewer extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public ?int $vendorId = null;
|
||||
|
||||
#[Url]
|
||||
public ?int $releaseId = null;
|
||||
|
||||
#[Url]
|
||||
public string $category = '';
|
||||
|
||||
#[Url]
|
||||
public string $changeType = '';
|
||||
|
||||
public ?int $selectedDiffId = null;
|
||||
|
||||
public bool $showDiffModal = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
237
View/Modal/Admin/DigestPreferences.php
Normal file
237
View/Modal/Admin/DigestPreferences.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Services\UptelligenceDigestService;
|
||||
|
||||
#[Title('Digest Preferences')]
|
||||
class DigestPreferences extends Component
|
||||
{
|
||||
// Form state
|
||||
public bool $isEnabled = false;
|
||||
|
||||
public string $frequency = 'weekly';
|
||||
|
||||
public array $selectedVendorIds = [];
|
||||
|
||||
public array $selectedTypes = ['releases', 'todos', 'security'];
|
||||
|
||||
public ?int $minPriority = null;
|
||||
|
||||
// UI state
|
||||
public bool $showPreview = false;
|
||||
|
||||
protected UptelligenceDigestService $digestService;
|
||||
|
||||
public function boot(UptelligenceDigestService $digestService): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
213
View/Modal/Admin/TodoList.php
Normal file
213
View/Modal/Admin/TodoList.php
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Uptelligence\Models\UpstreamTodo;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
#[Title('Upstream Todos')]
|
||||
class TodoList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public ?int $vendorId = null;
|
||||
|
||||
#[Url]
|
||||
public string $status = 'pending';
|
||||
|
||||
#[Url]
|
||||
public string $type = '';
|
||||
|
||||
#[Url]
|
||||
public string $effort = '';
|
||||
|
||||
#[Url]
|
||||
public string $priority = '';
|
||||
|
||||
#[Url]
|
||||
public string $sortBy = 'priority';
|
||||
|
||||
#[Url]
|
||||
public string $sortDir = 'desc';
|
||||
|
||||
public array $selectedTodos = [];
|
||||
|
||||
public bool $selectAll = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
140
View/Modal/Admin/VendorManager.php
Normal file
140
View/Modal/Admin/VendorManager.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
use Core\Uptelligence\Models\VersionRelease;
|
||||
|
||||
#[Title('Vendor Manager')]
|
||||
class VendorManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $sourceType = '';
|
||||
|
||||
#[Url]
|
||||
public string $sortBy = 'name';
|
||||
|
||||
#[Url]
|
||||
public string $sortDir = 'asc';
|
||||
|
||||
public ?int $selectedVendorId = null;
|
||||
|
||||
public bool $showVendorModal = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
253
View/Modal/Admin/WebhookManager.php
Normal file
253
View/Modal/Admin/WebhookManager.php
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Uptelligence\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||
use Core\Uptelligence\Models\Vendor;
|
||||
|
||||
#[Title('Webhook Manager')]
|
||||
class WebhookManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public ?int $vendorId = null;
|
||||
|
||||
#[Url]
|
||||
public string $provider = '';
|
||||
|
||||
#[Url]
|
||||
public string $status = '';
|
||||
|
||||
public ?int $selectedWebhookId = null;
|
||||
|
||||
public bool $showWebhookModal = false;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public bool $showDeliveriesModal = false;
|
||||
|
||||
public bool $showSecretModal = false;
|
||||
|
||||
// Create form fields
|
||||
public ?int $createVendorId = null;
|
||||
|
||||
public string $createProvider = UptelligenceWebhook::PROVIDER_GITHUB;
|
||||
|
||||
// Displayed secret after creation/regeneration
|
||||
public ?string $displaySecret = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
15
artisan
15
artisan
|
|
@ -1,15 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
$status = (require_once __DIR__.'/bootstrap/app.php')
|
||||
->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->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();
|
||||
2
bootstrap/cache/.gitignore
vendored
2
bootstrap/cache/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -1,78 +1,44 @@
|
|||
{
|
||||
"name": "host-uk/core-template",
|
||||
"type": "project",
|
||||
"description": "Core PHP Framework - Project Template",
|
||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
||||
"name": "host-uk/core-uptelligence",
|
||||
"description": "Upstream vendor tracking and dependency intelligence",
|
||||
"keywords": ["laravel", "vendor", "tracking", "dependencies", "upstream"],
|
||||
"license": "EUPL-1.2",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/livewire": "^3.0",
|
||||
"host-uk/core": "dev-main",
|
||||
"host-uk/core-admin": "dev-main",
|
||||
"host-uk/core-api": "dev-main",
|
||||
"host-uk/core-mcp": "dev-main"
|
||||
"host-uk/core": "dev-main"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0"
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
"Core\\Uptelligence\\": ""
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
"Core\\Uptelligence\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/host-uk/core-php.git"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
"providers": [
|
||||
"Core\\Uptelligence\\Boot"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "pint",
|
||||
"test": "pest"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
|
|
|||
300
config.php
Normal file
300
config.php
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Vendor Storage Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
| Supports local and S3 cold storage. When using S3, versions are archived
|
||||
| after import and downloaded on-demand for analysis.
|
||||
*/
|
||||
'storage' => [
|
||||
// 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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Core PHP Framework Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'module_paths' => [
|
||||
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'),
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Uptelligence module tables - uptime monitoring.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Monitors
|
||||
Schema::create('uptelligence_monitors', function (Blueprint $table) {
|
||||
$table->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();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Create the uptelligence_digests table for email digest preferences.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('uptelligence_digests', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Uptelligence webhooks tables - receive vendor release notifications.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Webhook endpoints per vendor
|
||||
Schema::create('uptelligence_webhooks', function (Blueprint $table) {
|
||||
$table->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();
|
||||
}
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Core modules handle their own seeding
|
||||
}
|
||||
}
|
||||
16
package.json
16
package.json
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.4",
|
||||
"laravel-vite-plugin": "^2.1.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
33
phpunit.xml
33
phpunit.xml
|
|
@ -1,33 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
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]
|
||||
</IfModule>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
(require_once __DIR__.'/../bootstrap/app.php')
|
||||
->handleRequest(Request::capture());
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1 +0,0 @@
|
|||
import './bootstrap';
|
||||
3
resources/js/bootstrap.js
vendored
3
resources/js/bootstrap.js
vendored
|
|
@ -1,3 +0,0 @@
|
|||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Core PHP Framework</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.version {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
a:hover {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Core PHP Framework</h1>
|
||||
<p class="version">Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}</p>
|
||||
<div class="links">
|
||||
<a href="https://github.com/host-uk/core-php">Documentation</a>
|
||||
<a href="/admin">Admin Panel</a>
|
||||
<a href="/api/docs">API Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
routes/admin.php
Normal file
30
routes/admin.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Core\Uptelligence\View\Modal\Admin\AssetManager;
|
||||
use Core\Uptelligence\View\Modal\Admin\Dashboard;
|
||||
use Core\Uptelligence\View\Modal\Admin\DiffViewer;
|
||||
use Core\Uptelligence\View\Modal\Admin\DigestPreferences;
|
||||
use Core\Uptelligence\View\Modal\Admin\TodoList;
|
||||
use Core\Uptelligence\View\Modal\Admin\VendorManager;
|
||||
use Core\Uptelligence\View\Modal\Admin\WebhookManager;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Uptelligence Admin Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Routes for the Uptelligence admin panel. All routes are prefixed with
|
||||
| /hub/admin/uptelligence and require Hades access.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('hub/admin/uptelligence')->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');
|
||||
});
|
||||
|
|
@ -1,5 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
declare(strict_types=1);
|
||||
|
||||
// API routes are registered via Core modules
|
||||
/**
|
||||
* Uptelligence Module API Routes
|
||||
*
|
||||
* Webhook endpoints for receiving vendor release notifications.
|
||||
*/
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Core\Uptelligence\Controllers\Api\WebhookController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Uptelligence Webhooks (Public - No Auth Required)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| External webhook endpoints for receiving release notifications from
|
||||
| GitHub, GitLab, npm, Packagist, and other vendor systems.
|
||||
| Authentication is handled via signature verification using the
|
||||
| webhook's secret key.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('uptelligence/webhook')->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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
<?php
|
||||
|
||||
// Console commands are registered via Core modules
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
3
storage/app/.gitignore
vendored
3
storage/app/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
*
|
||||
!public/
|
||||
!.gitignore
|
||||
2
storage/app/public/.gitignore
vendored
2
storage/app/public/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
9
storage/framework/.gitignore
vendored
9
storage/framework/.gitignore
vendored
|
|
@ -1,9 +0,0 @@
|
|||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
3
storage/framework/cache/.gitignore
vendored
3
storage/framework/cache/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
2
storage/framework/cache/data/.gitignore
vendored
2
storage/framework/cache/data/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
storage/framework/sessions/.gitignore
vendored
2
storage/framework/sessions/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
storage/framework/testing/.gitignore
vendored
2
storage/framework/testing/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
storage/framework/views/.gitignore
vendored
2
storage/framework/views/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
storage/logs/.gitignore
vendored
2
storage/logs/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./resources/**/*.blade.php",
|
||||
"./resources/**/*.js",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue