monorepo sepration

This commit is contained in:
Snider 2026-01-26 23:56:46 +00:00
parent 737e705755
commit 40d893af44
94 changed files with 12359 additions and 654 deletions

View file

@ -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=

View file

@ -1,62 +0,0 @@
# Package Workflows
These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects.
## README Badges
Add these badges to your package README (replace `{package}` with your package name):
```markdown
[![CI](https://github.com/host-uk/{package}/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/{package}/graph/badge.svg)](https://codecov.io/gh/host-uk/{package})
[![Latest Version](https://img.shields.io/packagist/v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/{package})](https://packagist.org/packages/host-uk/{package})
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
```
## Usage
Copy the relevant workflows to your library's `.github/workflows/` directory:
```bash
# In your library repo
mkdir -p .github/workflows
cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/
cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/
```
## Workflows
### ci.yml
- Runs on push/PR to main
- Tests against PHP 8.2, 8.3, 8.4
- Tests against Laravel 11 and 12
- Runs Pint linting
- Runs Pest tests
### release.yml
- Triggers on version tags (v*)
- Generates changelog using git-cliff
- Creates GitHub release
## Requirements
For these workflows to work, your package needs:
1. **cliff.toml** - Copy from core-template root
2. **Pest configured** - `composer require pestphp/pest --dev`
3. **Pint configured** - `composer require laravel/pint --dev`
4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads
5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button
## Recommended composer.json scripts
```json
{
"scripts": {
"lint": "pint",
"test": "pest",
"test:coverage": "pest --coverage"
}
}
```

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 name: CI
on: on:
@ -8,19 +11,22 @@ on:
jobs: jobs:
tests: tests:
if: github.event.repository.visibility == 'public'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
php: [8.2, 8.3, 8.4] 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@ -30,7 +36,9 @@ jobs:
coverage: pcov coverage: pcov
- name: Install dependencies - 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 - name: Run Pint
run: vendor/bin/pint --test run: vendor/bin/pint --test
@ -39,30 +47,9 @@ jobs:
run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: matrix.php == '8.3' if: matrix.php == '8.3' && matrix.laravel == '12.*'
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v4
with: with:
files: coverage.xml files: coverage.xml
fail_ci_if_error: false fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }} 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

View file

@ -1,3 +1,6 @@
# Release workflow for library packages
# Copy this to .github/workflows/release.yml in library repos
name: Release name: Release
on: on:
@ -10,19 +13,18 @@ permissions:
jobs: jobs:
release: release:
if: github.event.repository.visibility == 'public'
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Create Release name: Create Release
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog
uses: orhun/git-cliff-action@v4 uses: orhun/git-cliff-action@v3
with: with:
config: cliff.toml config: cliff.toml
args: --latest --strip header args: --latest --strip header

173
Boot.php Normal file
View 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
View 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
View 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;
}
}

View 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
View 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;
}
}

View 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']++;
}
}
}

View 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),
]);
}
}

View 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;
}
}

View 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
View 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
View 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
View 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
View 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
View 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(),
];
}
}

View 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
View 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
View 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',
};
}
}

View 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',
];
}
}

View 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);
}
}

View 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
View 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
View 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',
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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.'.';
}
}

View 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;
}
}

View 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(),
];
}
}

View 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;
}
}

View 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;
}
}

View 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
]
);
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 &gt; 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 &gt; 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>

View 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']);
}
}

View 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']);
}
}

View 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']);
}
}

View 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']);
}
}

View 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']);
}
}

View 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']);
}
}

View 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']);
}
}

View file

View file

View file

@ -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
View file

@ -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);

View file

@ -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();

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,5 +0,0 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -1,78 +1,44 @@
{ {
"name": "host-uk/core-template", "name": "host-uk/core-uptelligence",
"type": "project", "description": "Upstream vendor tracking and dependency intelligence",
"description": "Core PHP Framework - Project Template", "keywords": ["laravel", "vendor", "tracking", "dependencies", "upstream"],
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
"license": "EUPL-1.2", "license": "EUPL-1.2",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/framework": "^12.0", "host-uk/core": "dev-main"
"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"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"laravel/sail": "^1.41", "orchestra/testbench": "^9.0|^10.0",
"mockery/mockery": "^1.6", "pestphp/pest": "^3.0"
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"App\\": "app/", "Core\\Uptelligence\\": ""
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "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": { "extra": {
"laravel": { "laravel": {
"dont-discover": [] "providers": [
"Core\\Uptelligence\\Boot"
]
} }
}, },
"scripts": {
"lint": "pint",
"test": "pest"
},
"config": { "config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true, "sort-packages": true,
"allow-plugins": { "allow-plugins": {
"php-http/discovery": true "pestphp/pest-plugin": true
} }
}, },
"minimum-stability": "stable", "minimum-stability": "dev",
"prefer-stable": true "prefer-stable": true
} }

300
config.php Normal file
View 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',
],
],
];

View file

@ -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'),
],
];

View file

@ -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();
}
};

View file

@ -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');
}
};

View file

@ -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();
}
};

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -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>

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -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>

View file

@ -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());

View file

@ -1,2 +0,0 @@
User-agent: *
Disallow:

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1 +0,0 @@
import './bootstrap';

View file

@ -1,3 +0,0 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View file

@ -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
View 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');
});

View file

@ -1,5 +1,34 @@
<?php <?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');
});

View file

@ -1,3 +0,0 @@
<?php
// Console commands are registered via Core modules

View file

@ -1,7 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});

View file

@ -1,3 +0,0 @@
*
!public/
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,9 +0,0 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

View file

@ -1,3 +0,0 @@
*
!data/
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -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,
}),
],
});