monorepo sepration
This commit is contained in:
parent
737e705755
commit
40d893af44
94 changed files with 12359 additions and 654 deletions
76
.env.example
76
.env.example
|
|
@ -1,76 +0,0 @@
|
||||||
APP_NAME="Core PHP App"
|
|
||||||
APP_ENV=local
|
|
||||||
APP_KEY=
|
|
||||||
APP_DEBUG=true
|
|
||||||
APP_TIMEZONE=UTC
|
|
||||||
APP_URL=http://localhost
|
|
||||||
|
|
||||||
APP_LOCALE=en_GB
|
|
||||||
APP_FALLBACK_LOCALE=en_GB
|
|
||||||
APP_FAKER_LOCALE=en_GB
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
|
||||||
# DB_HOST=127.0.0.1
|
|
||||||
# DB_PORT=3306
|
|
||||||
# DB_DATABASE=core
|
|
||||||
# DB_USERNAME=root
|
|
||||||
# DB_PASSWORD=
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
|
|
||||||
CACHE_STORE=database
|
|
||||||
CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_ENCRYPTION=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
# Core PHP Framework
|
|
||||||
CORE_CACHE_DISCOVERY=true
|
|
||||||
|
|
||||||
# CDN Configuration (optional)
|
|
||||||
CDN_ENABLED=false
|
|
||||||
CDN_DRIVER=bunny
|
|
||||||
BUNNYCDN_API_KEY=
|
|
||||||
BUNNYCDN_STORAGE_ZONE=
|
|
||||||
BUNNYCDN_PULL_ZONE=
|
|
||||||
|
|
||||||
# Flux Pro (optional)
|
|
||||||
FLUX_LICENSE_KEY=
|
|
||||||
62
.github/package-workflows/README.md
vendored
62
.github/package-workflows/README.md
vendored
|
|
@ -1,62 +0,0 @@
|
||||||
# Package Workflows
|
|
||||||
|
|
||||||
These workflow templates are for **library packages** (host-uk/core, host-uk/core-api, etc.), not application projects.
|
|
||||||
|
|
||||||
## README Badges
|
|
||||||
|
|
||||||
Add these badges to your package README (replace `{package}` with your package name):
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[](https://github.com/host-uk/{package}/actions/workflows/ci.yml)
|
|
||||||
[](https://codecov.io/gh/host-uk/{package})
|
|
||||||
[](https://packagist.org/packages/host-uk/{package})
|
|
||||||
[](https://packagist.org/packages/host-uk/{package})
|
|
||||||
[](LICENSE)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Copy the relevant workflows to your library's `.github/workflows/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In your library repo
|
|
||||||
mkdir -p .github/workflows
|
|
||||||
cp path/to/core-template/.github/package-workflows/ci.yml .github/workflows/
|
|
||||||
cp path/to/core-template/.github/package-workflows/release.yml .github/workflows/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflows
|
|
||||||
|
|
||||||
### ci.yml
|
|
||||||
- Runs on push/PR to main
|
|
||||||
- Tests against PHP 8.2, 8.3, 8.4
|
|
||||||
- Tests against Laravel 11 and 12
|
|
||||||
- Runs Pint linting
|
|
||||||
- Runs Pest tests
|
|
||||||
|
|
||||||
### release.yml
|
|
||||||
- Triggers on version tags (v*)
|
|
||||||
- Generates changelog using git-cliff
|
|
||||||
- Creates GitHub release
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
For these workflows to work, your package needs:
|
|
||||||
|
|
||||||
1. **cliff.toml** - Copy from core-template root
|
|
||||||
2. **Pest configured** - `composer require pestphp/pest --dev`
|
|
||||||
3. **Pint configured** - `composer require laravel/pint --dev`
|
|
||||||
4. **CODECOV_TOKEN** - Add to repo secrets for coverage uploads
|
|
||||||
5. **FUNDING.yml** - Copy `.github/FUNDING.yml` for sponsor button
|
|
||||||
|
|
||||||
## Recommended composer.json scripts
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"scripts": {
|
|
||||||
"lint": "pint",
|
|
||||||
"test": "pest",
|
|
||||||
"test:coverage": "pest --coverage"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
55
.github/package-workflows/ci.yml
vendored
55
.github/package-workflows/ci.yml
vendored
|
|
@ -1,55 +0,0 @@
|
||||||
# CI workflow for library packages (host-uk/core-*, etc.)
|
|
||||||
# Copy this to .github/workflows/ci.yml in library repos
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
php: [8.2, 8.3, 8.4]
|
|
||||||
laravel: [11.*, 12.*]
|
|
||||||
exclude:
|
|
||||||
- php: 8.2
|
|
||||||
laravel: 12.*
|
|
||||||
|
|
||||||
name: PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php }}
|
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
|
|
||||||
coverage: pcov
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
|
|
||||||
composer update --prefer-dist --no-interaction --no-progress
|
|
||||||
|
|
||||||
- name: Run Pint
|
|
||||||
run: vendor/bin/pint --test
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: vendor/bin/pest --ci --coverage --coverage-clover coverage.xml
|
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
if: matrix.php == '8.3' && matrix.laravel == '12.*'
|
|
||||||
uses: codecov/codecov-action@v4
|
|
||||||
with:
|
|
||||||
files: coverage.xml
|
|
||||||
fail_ci_if_error: false
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
40
.github/package-workflows/release.yml
vendored
40
.github/package-workflows/release.yml
vendored
|
|
@ -1,40 +0,0 @@
|
||||||
# Release workflow for library packages
|
|
||||||
# Copy this to .github/workflows/release.yml in library repos
|
|
||||||
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Create Release
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
id: changelog
|
|
||||||
uses: orhun/git-cliff-action@v3
|
|
||||||
with:
|
|
||||||
config: cliff.toml
|
|
||||||
args: --latest --strip header
|
|
||||||
env:
|
|
||||||
OUTPUT: CHANGELOG.md
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
body_path: CHANGELOG.md
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
|
# CI workflow for library packages (host-uk/core-*, etc.)
|
||||||
|
# Copy this to .github/workflows/ci.yml in library repos
|
||||||
|
|
||||||
name: CI
|
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
|
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Release workflow for library packages
|
||||||
|
# Copy this to .github/workflows/release.yml in library repos
|
||||||
|
|
||||||
name: Release
|
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
173
Boot.php
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence;
|
||||||
|
|
||||||
|
use Core\Events\AdminPanelBooting;
|
||||||
|
use Core\Events\ApiRoutesRegistering;
|
||||||
|
use Core\Events\ConsoleBooting;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uptelligence Module Boot
|
||||||
|
*
|
||||||
|
* Upstream vendor tracking and dependency intelligence.
|
||||||
|
* Manages vendor versions, diffs, todos, and asset tracking.
|
||||||
|
*/
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
protected string $moduleName = 'uptelligence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events this module listens to for lazy loading.
|
||||||
|
*
|
||||||
|
* @var array<class-string, string>
|
||||||
|
*/
|
||||||
|
public static array $listens = [
|
||||||
|
AdminPanelBooting::class => 'onAdminPanel',
|
||||||
|
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||||
|
ConsoleBooting::class => 'onConsole',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/database/migrations');
|
||||||
|
$this->configureRateLimiting();
|
||||||
|
$this->validateConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->mergeConfigFrom(
|
||||||
|
__DIR__.'/config.php',
|
||||||
|
'upstream'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\IssueGeneratorService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\UpstreamPlanGeneratorService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\VendorStorageService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\DiffAnalyzerService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\AssetTrackerService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\AIAnalyzerService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\VendorUpdateCheckerService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\UptelligenceDigestService::class);
|
||||||
|
$this->app->singleton(\Core\Uptelligence\Services\WebhookReceiverService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Event-driven handlers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function onAdminPanel(AdminPanelBooting $event): void
|
||||||
|
{
|
||||||
|
$event->views($this->moduleName, __DIR__.'/View/Blade');
|
||||||
|
|
||||||
|
if (file_exists(__DIR__.'/routes/admin.php')) {
|
||||||
|
$event->routes(fn () => require __DIR__.'/routes/admin.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin components
|
||||||
|
$event->livewire('uptelligence.admin.dashboard', View\Modal\Admin\Dashboard::class);
|
||||||
|
$event->livewire('uptelligence.admin.vendor-manager', View\Modal\Admin\VendorManager::class);
|
||||||
|
$event->livewire('uptelligence.admin.todo-list', View\Modal\Admin\TodoList::class);
|
||||||
|
$event->livewire('uptelligence.admin.diff-viewer', View\Modal\Admin\DiffViewer::class);
|
||||||
|
$event->livewire('uptelligence.admin.asset-manager', View\Modal\Admin\AssetManager::class);
|
||||||
|
$event->livewire('uptelligence.admin.digest-preferences', View\Modal\Admin\DigestPreferences::class);
|
||||||
|
$event->livewire('uptelligence.admin.webhook-manager', View\Modal\Admin\WebhookManager::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle API routes registration event.
|
||||||
|
*/
|
||||||
|
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||||
|
{
|
||||||
|
if (file_exists(__DIR__.'/routes/api.php')) {
|
||||||
|
$event->routes(fn () => require __DIR__.'/routes/api.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onConsole(ConsoleBooting $event): void
|
||||||
|
{
|
||||||
|
$event->command(Console\CheckCommand::class);
|
||||||
|
$event->command(Console\AnalyzeCommand::class);
|
||||||
|
$event->command(Console\IssuesCommand::class);
|
||||||
|
$event->command(Console\CheckUpdatesCommand::class);
|
||||||
|
$event->command(Console\SendDigestsCommand::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure rate limiting for AI API calls.
|
||||||
|
*/
|
||||||
|
protected function configureRateLimiting(): void
|
||||||
|
{
|
||||||
|
// Rate limit for AI API calls: 10 per minute
|
||||||
|
// Prevents excessive API costs and respects provider rate limits
|
||||||
|
RateLimiter::for('upstream-ai-api', function () {
|
||||||
|
return Limit::perMinute(config('upstream.ai.rate_limit', 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit for external registry checks (Packagist, NPM): 30 per minute
|
||||||
|
// Prevents hammering public registries
|
||||||
|
RateLimiter::for('upstream-registry', function () {
|
||||||
|
return Limit::perMinute(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit for GitHub/Gitea issue creation: 10 per minute
|
||||||
|
// Respects GitHub API rate limits
|
||||||
|
RateLimiter::for('upstream-issues', function () {
|
||||||
|
return Limit::perMinute(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limit for incoming webhooks: 60 per minute per endpoint
|
||||||
|
// Webhooks from external vendor systems need reasonable limits
|
||||||
|
RateLimiter::for('uptelligence-webhooks', function (Request $request) {
|
||||||
|
// Use webhook UUID or IP for rate limiting
|
||||||
|
$webhook = $request->route('webhook');
|
||||||
|
|
||||||
|
return $webhook
|
||||||
|
? Limit::perMinute(60)->by('uptelligence-webhook:'.$webhook)
|
||||||
|
: Limit::perMinute(30)->by('uptelligence-webhook-ip:'.$request->ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate configuration and warn about missing API keys.
|
||||||
|
*/
|
||||||
|
protected function validateConfig(): void
|
||||||
|
{
|
||||||
|
// Only validate in non-testing environments
|
||||||
|
if ($this->app->environment('testing')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
// Check AI provider configuration
|
||||||
|
$aiProvider = config('upstream.ai.provider', 'anthropic');
|
||||||
|
if ($aiProvider === 'anthropic' && empty(config('services.anthropic.api_key'))) {
|
||||||
|
$warnings[] = 'Anthropic API key not configured - AI analysis will be disabled';
|
||||||
|
} elseif ($aiProvider === 'openai' && empty(config('services.openai.api_key'))) {
|
||||||
|
$warnings[] = 'OpenAI API key not configured - AI analysis will be disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check GitHub configuration
|
||||||
|
if (config('upstream.github.enabled', true) && empty(config('upstream.github.token'))) {
|
||||||
|
$warnings[] = 'GitHub token not configured - issue creation will be disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Gitea configuration
|
||||||
|
if (config('upstream.gitea.enabled', true) && empty(config('upstream.gitea.token'))) {
|
||||||
|
$warnings[] = 'Gitea token not configured - Gitea issue creation will be disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warnings
|
||||||
|
foreach ($warnings as $warning) {
|
||||||
|
Log::warning("Uptelligence: {$warning}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
Console/AnalyzeCommand.php
Normal file
141
Console/AnalyzeCommand.php
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
use Core\Uptelligence\Services\DiffAnalyzerService;
|
||||||
|
use Core\Uptelligence\Services\VendorStorageService;
|
||||||
|
|
||||||
|
class AnalyzeCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'upstream:analyze
|
||||||
|
{vendor : Vendor slug to analyze}
|
||||||
|
{--from= : Previous version (defaults to vendor.previous_version)}
|
||||||
|
{--to= : Current version (defaults to vendor.current_version)}
|
||||||
|
{--summary : Show summary only, no file details}';
|
||||||
|
|
||||||
|
protected $description = 'Analyze differences between vendor versions';
|
||||||
|
|
||||||
|
public function handle(VendorStorageService $storageService): int
|
||||||
|
{
|
||||||
|
$vendorSlug = $this->argument('vendor');
|
||||||
|
$vendor = Vendor::where('slug', $vendorSlug)->first();
|
||||||
|
|
||||||
|
if (! $vendor) {
|
||||||
|
$this->error("Vendor not found: {$vendorSlug}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromVersion = $this->option('from') ?? $vendor->previous_version;
|
||||||
|
$toVersion = $this->option('to') ?? $vendor->current_version;
|
||||||
|
|
||||||
|
if (! $fromVersion || ! $toVersion) {
|
||||||
|
$this->error('Both from and to versions are required.');
|
||||||
|
$this->line('Use --from and --to options, or ensure vendor has previous_version and current_version set.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both versions exist locally
|
||||||
|
if (! $storageService->existsLocally($vendor, $fromVersion)) {
|
||||||
|
$this->error("Version not found locally: {$fromVersion}");
|
||||||
|
$this->line("Expected path: {$vendor->getStoragePath($fromVersion)}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $storageService->existsLocally($vendor, $toVersion)) {
|
||||||
|
$this->error("Version not found locally: {$toVersion}");
|
||||||
|
$this->line("Expected path: {$vendor->getStoragePath($toVersion)}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Analyzing {$vendor->name}: {$fromVersion} → {$toVersion}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Check if we have an existing release or need to create one
|
||||||
|
$release = VersionRelease::where('vendor_id', $vendor->id)
|
||||||
|
->where('version', $toVersion)
|
||||||
|
->where('previous_version', $fromVersion)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($release && $release->analyzed_at) {
|
||||||
|
$this->line('Using cached analysis from '.$release->analyzed_at->diffForHumans());
|
||||||
|
} else {
|
||||||
|
$this->line('Running diff analysis...');
|
||||||
|
|
||||||
|
$analyzer = new DiffAnalyzerService($vendor);
|
||||||
|
$release = $analyzer->analyze($fromVersion, $toVersion);
|
||||||
|
|
||||||
|
$this->info('Analysis complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display summary
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Files Added', $release->files_added ?? 0],
|
||||||
|
['Files Modified', $release->files_modified ?? 0],
|
||||||
|
['Files Removed', $release->files_removed ?? 0],
|
||||||
|
['Total Changes', ($release->files_added ?? 0) + ($release->files_modified ?? 0) + ($release->files_removed ?? 0)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show file details unless --summary
|
||||||
|
if (! $this->option('summary') && $release->diffs) {
|
||||||
|
$diffs = $release->diffs()->get();
|
||||||
|
|
||||||
|
if ($diffs->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=cyan>Changes by category:</>');
|
||||||
|
|
||||||
|
$byCategory = $diffs->groupBy('category');
|
||||||
|
foreach ($byCategory as $category => $categoryDiffs) {
|
||||||
|
$this->line(" {$category}: {$categoryDiffs->count()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modified files
|
||||||
|
$modified = $diffs->where('change_type', 'modified')->take(20);
|
||||||
|
if ($modified->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=yellow>Modified files (up to 20):</>');
|
||||||
|
foreach ($modified as $diff) {
|
||||||
|
$priority = $vendor->isPriorityPath($diff->file_path) ? ' <fg=magenta>[PRIORITY]</>' : '';
|
||||||
|
$this->line(" M {$diff->file_path}{$priority}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show added files
|
||||||
|
$added = $diffs->where('change_type', 'added')->take(10);
|
||||||
|
if ($added->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=green>Added files (up to 10):</>');
|
||||||
|
foreach ($added as $diff) {
|
||||||
|
$this->line(" A {$diff->file_path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show removed files
|
||||||
|
$removed = $diffs->where('change_type', 'removed')->take(10);
|
||||||
|
if ($removed->isNotEmpty()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=red>Removed files (up to 10):</>');
|
||||||
|
foreach ($removed as $diff) {
|
||||||
|
$this->line(" D {$diff->file_path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendor->update(['last_analyzed_at' => now()]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Console/CheckCommand.php
Normal file
109
Console/CheckCommand.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Services\AssetTrackerService;
|
||||||
|
use Core\Uptelligence\Services\VendorStorageService;
|
||||||
|
|
||||||
|
class CheckCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'upstream:check
|
||||||
|
{vendor? : Vendor slug to check (optional, checks all if omitted)}
|
||||||
|
{--assets : Also check package assets for updates}';
|
||||||
|
|
||||||
|
protected $description = 'Check vendors for upstream updates';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
VendorStorageService $storageService,
|
||||||
|
AssetTrackerService $assetService
|
||||||
|
): int {
|
||||||
|
$vendorSlug = $this->argument('vendor');
|
||||||
|
|
||||||
|
if ($vendorSlug) {
|
||||||
|
$vendors = Vendor::where('slug', $vendorSlug)->get();
|
||||||
|
if ($vendors->isEmpty()) {
|
||||||
|
$this->error("Vendor not found: {$vendorSlug}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$vendors = Vendor::active()->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vendors->isEmpty()) {
|
||||||
|
$this->warn('No active vendors found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Checking vendors for updates...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$table = [];
|
||||||
|
foreach ($vendors as $vendor) {
|
||||||
|
$localExists = $storageService->existsLocally($vendor, $vendor->current_version ?? 'current');
|
||||||
|
$hasCurrentVersion = ! empty($vendor->current_version);
|
||||||
|
$hasPreviousVersion = ! empty($vendor->previous_version);
|
||||||
|
|
||||||
|
$status = match (true) {
|
||||||
|
! $hasCurrentVersion => '<fg=yellow>No version tracked</>',
|
||||||
|
$localExists && $hasPreviousVersion => '<fg=green>Ready to analyze</>',
|
||||||
|
$localExists => '<fg=blue>Current only</>',
|
||||||
|
default => '<fg=red>Files missing</>',
|
||||||
|
};
|
||||||
|
|
||||||
|
$table[] = [
|
||||||
|
$vendor->slug,
|
||||||
|
$vendor->name,
|
||||||
|
$vendor->getSourceTypeLabel(),
|
||||||
|
$vendor->current_version ?? '-',
|
||||||
|
$vendor->previous_version ?? '-',
|
||||||
|
$vendor->getPendingTodosCount(),
|
||||||
|
$status,
|
||||||
|
];
|
||||||
|
|
||||||
|
$vendor->update(['last_checked_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Slug', 'Name', 'Type', 'Current', 'Previous', 'Pending', 'Status'],
|
||||||
|
$table
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->option('assets')) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Checking package assets...');
|
||||||
|
|
||||||
|
$results = $assetService->checkAllForUpdates();
|
||||||
|
$assetTable = [];
|
||||||
|
|
||||||
|
foreach ($results as $slug => $result) {
|
||||||
|
$statusIcon = match ($result['status']) {
|
||||||
|
'success' => $result['has_update'] ?? false
|
||||||
|
? '<fg=yellow>Update available</>'
|
||||||
|
: '<fg=green>Up to date</>',
|
||||||
|
'rate_limited' => '<fg=red>Rate limited</>',
|
||||||
|
'skipped' => '<fg=gray>Skipped</>',
|
||||||
|
default => '<fg=red>Error</>',
|
||||||
|
};
|
||||||
|
|
||||||
|
$assetTable[] = [
|
||||||
|
$slug,
|
||||||
|
$result['latest'] ?? $result['installed'] ?? '-',
|
||||||
|
$statusIcon,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(['Asset', 'Version', 'Status'], $assetTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Check complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
Console/CheckUpdatesCommand.php
Normal file
202
Console/CheckUpdatesCommand.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Services\AssetTrackerService;
|
||||||
|
use Core\Uptelligence\Services\VendorUpdateCheckerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artisan command to check vendors and assets for upstream updates.
|
||||||
|
*
|
||||||
|
* Can be run manually or scheduled via the scheduler.
|
||||||
|
*/
|
||||||
|
class CheckUpdatesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'uptelligence:check-updates
|
||||||
|
{--vendor= : Specific vendor slug to check}
|
||||||
|
{--assets : Also check package assets for updates}
|
||||||
|
{--no-todos : Do not create todos for updates found}
|
||||||
|
{--json : Output results as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'Check vendors and assets for upstream updates';
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
VendorUpdateCheckerService $vendorChecker,
|
||||||
|
AssetTrackerService $assetChecker
|
||||||
|
): int {
|
||||||
|
$vendorSlug = $this->option('vendor');
|
||||||
|
$checkAssets = $this->option('assets');
|
||||||
|
$jsonOutput = $this->option('json');
|
||||||
|
|
||||||
|
if (! $jsonOutput) {
|
||||||
|
$this->info('Checking for upstream updates...');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check vendors
|
||||||
|
$vendorResults = $this->checkVendors($vendorChecker, $vendorSlug);
|
||||||
|
|
||||||
|
// Check assets if requested
|
||||||
|
$assetResults = [];
|
||||||
|
if ($checkAssets) {
|
||||||
|
$assetResults = $this->checkAssets($assetChecker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if ($jsonOutput) {
|
||||||
|
$this->outputJson($vendorResults, $assetResults);
|
||||||
|
} else {
|
||||||
|
$this->outputTable($vendorResults, $assetResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return appropriate exit code
|
||||||
|
$hasUpdates = collect($vendorResults)->contains(fn ($r) => $r['has_update'] ?? false)
|
||||||
|
|| collect($assetResults)->contains(fn ($r) => $r['has_update'] ?? false);
|
||||||
|
|
||||||
|
return $hasUpdates ? self::SUCCESS : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check vendors for updates.
|
||||||
|
*/
|
||||||
|
protected function checkVendors(VendorUpdateCheckerService $checker, ?string $vendorSlug): array
|
||||||
|
{
|
||||||
|
if ($vendorSlug) {
|
||||||
|
$vendor = Vendor::where('slug', $vendorSlug)->first();
|
||||||
|
if (! $vendor) {
|
||||||
|
$this->error("Vendor not found: {$vendorSlug}");
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("Checking vendor: {$vendor->name}");
|
||||||
|
|
||||||
|
return [$vendor->slug => $checker->checkVendor($vendor)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendors = Vendor::active()->get();
|
||||||
|
if ($vendors->isEmpty()) {
|
||||||
|
$this->warn('No active vendors found.');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("Checking {$vendors->count()} vendor(s)...");
|
||||||
|
|
||||||
|
return $checker->checkAllVendors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check assets for updates.
|
||||||
|
*/
|
||||||
|
protected function checkAssets(AssetTrackerService $checker): array
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Checking package assets...');
|
||||||
|
|
||||||
|
return $checker->checkAllForUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output results as a table.
|
||||||
|
*/
|
||||||
|
protected function outputTable(array $vendorResults, array $assetResults): void
|
||||||
|
{
|
||||||
|
if (! empty($vendorResults)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=cyan>Vendor Update Check Results:</>');
|
||||||
|
|
||||||
|
$table = [];
|
||||||
|
foreach ($vendorResults as $slug => $result) {
|
||||||
|
$status = match ($result['status'] ?? 'unknown') {
|
||||||
|
'success' => $result['has_update']
|
||||||
|
? '<fg=yellow>Update available</>'
|
||||||
|
: '<fg=green>Up to date</>',
|
||||||
|
'skipped' => '<fg=gray>Skipped</>',
|
||||||
|
'rate_limited' => '<fg=red>Rate limited</>',
|
||||||
|
'error' => '<fg=red>Error</>',
|
||||||
|
default => '<fg=gray>Unknown</>',
|
||||||
|
};
|
||||||
|
|
||||||
|
$table[] = [
|
||||||
|
$slug,
|
||||||
|
$result['current'] ?? '-',
|
||||||
|
$result['latest'] ?? '-',
|
||||||
|
$status,
|
||||||
|
$result['message'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Vendor', 'Current', 'Latest', 'Status', 'Message'],
|
||||||
|
$table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($assetResults)) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=cyan>Asset Update Check Results:</>');
|
||||||
|
|
||||||
|
$table = [];
|
||||||
|
foreach ($assetResults as $slug => $result) {
|
||||||
|
$status = match ($result['status'] ?? 'unknown') {
|
||||||
|
'success' => $result['has_update'] ?? false
|
||||||
|
? '<fg=yellow>Update available</>'
|
||||||
|
: '<fg=green>Up to date</>',
|
||||||
|
'skipped' => '<fg=gray>Skipped</>',
|
||||||
|
'rate_limited' => '<fg=red>Rate limited</>',
|
||||||
|
'info' => '<fg=blue>Info</>',
|
||||||
|
'error' => '<fg=red>Error</>',
|
||||||
|
default => '<fg=gray>Unknown</>',
|
||||||
|
};
|
||||||
|
|
||||||
|
$table[] = [
|
||||||
|
$slug,
|
||||||
|
$result['installed'] ?? $result['latest'] ?? '-',
|
||||||
|
$status,
|
||||||
|
$result['message'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Asset', 'Version', 'Status', 'Message'],
|
||||||
|
$table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
$this->newLine();
|
||||||
|
$vendorUpdates = collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count();
|
||||||
|
$assetUpdates = collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count();
|
||||||
|
$totalUpdates = $vendorUpdates + $assetUpdates;
|
||||||
|
|
||||||
|
if ($totalUpdates > 0) {
|
||||||
|
$this->warn("Found {$totalUpdates} update(s) available.");
|
||||||
|
} else {
|
||||||
|
$this->info('All vendors and assets are up to date.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output results as JSON.
|
||||||
|
*/
|
||||||
|
protected function outputJson(array $vendorResults, array $assetResults): void
|
||||||
|
{
|
||||||
|
$output = [
|
||||||
|
'vendors' => $vendorResults,
|
||||||
|
'assets' => $assetResults,
|
||||||
|
'summary' => [
|
||||||
|
'vendors_checked' => count($vendorResults),
|
||||||
|
'vendors_with_updates' => collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(),
|
||||||
|
'assets_checked' => count($assetResults),
|
||||||
|
'assets_with_updates' => collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->line(json_encode($output, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
}
|
||||||
139
Console/IssuesCommand.php
Normal file
139
Console/IssuesCommand.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
class IssuesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'upstream:issues
|
||||||
|
{vendor? : Filter by vendor slug}
|
||||||
|
{--status=pending : Filter by status (pending, in_progress, ported, skipped, wont_port, all)}
|
||||||
|
{--type= : Filter by type (feature, bugfix, security, ui, api, refactor, dependency)}
|
||||||
|
{--quick-wins : Show only quick wins (low effort, high priority)}
|
||||||
|
{--limit=50 : Maximum number of issues to display}';
|
||||||
|
|
||||||
|
protected $description = 'List upstream todos/issues for tracking';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$vendorSlug = $this->argument('vendor');
|
||||||
|
$status = $this->option('status');
|
||||||
|
$type = $this->option('type');
|
||||||
|
$quickWins = $this->option('quick-wins');
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
|
||||||
|
$query = UpstreamTodo::with('vendor');
|
||||||
|
|
||||||
|
// Filter by vendor
|
||||||
|
if ($vendorSlug) {
|
||||||
|
$vendor = Vendor::where('slug', $vendorSlug)->first();
|
||||||
|
if (! $vendor) {
|
||||||
|
$this->error("Vendor not found: {$vendorSlug}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$query->where('vendor_id', $vendor->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if ($status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type
|
||||||
|
if ($type) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick wins filter
|
||||||
|
if ($quickWins) {
|
||||||
|
$query->quickWins();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by priority
|
||||||
|
$query->orderByDesc('priority')->orderByDesc('created_at');
|
||||||
|
|
||||||
|
$todos = $query->limit($limit)->get();
|
||||||
|
|
||||||
|
if ($todos->isEmpty()) {
|
||||||
|
$this->info('No issues found matching criteria.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show summary stats
|
||||||
|
$this->line('<fg=cyan>Issue Summary:</>');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$totalPending = UpstreamTodo::pending()->count();
|
||||||
|
$totalInProgress = UpstreamTodo::inProgress()->count();
|
||||||
|
$totalQuickWins = UpstreamTodo::quickWins()->count();
|
||||||
|
$totalSecurity = UpstreamTodo::securityRelated()->pending()->count();
|
||||||
|
|
||||||
|
$this->line(" Pending: {$totalPending}");
|
||||||
|
$this->line(" In Progress: {$totalInProgress}");
|
||||||
|
$this->line(" Quick Wins: {$totalQuickWins}");
|
||||||
|
$this->line(" Security: {$totalSecurity}");
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("Showing {$todos->count()} issues:");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$table = [];
|
||||||
|
foreach ($todos as $todo) {
|
||||||
|
$icon = $todo->getTypeIcon();
|
||||||
|
$priorityColor = match (true) {
|
||||||
|
$todo->priority >= 8 => 'red',
|
||||||
|
$todo->priority >= 5 => 'yellow',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
$statusBadge = match ($todo->status) {
|
||||||
|
'pending' => '<fg=yellow>pending</>',
|
||||||
|
'in_progress' => '<fg=blue>in progress</>',
|
||||||
|
'ported' => '<fg=green>ported</>',
|
||||||
|
'skipped' => '<fg=gray>skipped</>',
|
||||||
|
'wont_port' => '<fg=red>wont port</>',
|
||||||
|
default => $todo->status,
|
||||||
|
};
|
||||||
|
|
||||||
|
$quickWinBadge = $todo->isQuickWin() ? ' <fg=green>[QW]</>' : '';
|
||||||
|
|
||||||
|
$table[] = [
|
||||||
|
$todo->id,
|
||||||
|
$todo->vendor->slug,
|
||||||
|
"{$icon} {$todo->type}",
|
||||||
|
"<fg={$priorityColor}>{$todo->priority}</>",
|
||||||
|
$todo->effort,
|
||||||
|
mb_substr($todo->title, 0, 40).(mb_strlen($todo->title) > 40 ? '...' : '').$quickWinBadge,
|
||||||
|
$statusBadge,
|
||||||
|
$todo->github_issue_number ? "#{$todo->github_issue_number}" : '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Vendor', 'Type', 'Pri', 'Effort', 'Title', 'Status', 'Issue'],
|
||||||
|
$table
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show vendor breakdown if not filtered
|
||||||
|
if (! $vendorSlug) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<fg=cyan>By Vendor:</>');
|
||||||
|
|
||||||
|
$byVendor = $todos->groupBy(fn ($t) => $t->vendor->slug);
|
||||||
|
foreach ($byVendor as $slug => $vendorTodos) {
|
||||||
|
$quickWinCount = $vendorTodos->filter->isQuickWin()->count();
|
||||||
|
$qwInfo = $quickWinCount > 0 ? " ({$quickWinCount} quick wins)" : '';
|
||||||
|
$this->line(" {$slug}: {$vendorTodos->count()}{$qwInfo}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
Console/SendDigestsCommand.php
Normal file
146
Console/SendDigestsCommand.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||||
|
use Core\Uptelligence\Services\UptelligenceDigestService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Uptelligence digest emails to subscribed users.
|
||||||
|
*
|
||||||
|
* Processes all digest subscriptions based on their configured frequency
|
||||||
|
* and sends email summaries of vendor updates, new releases, and pending todos.
|
||||||
|
*/
|
||||||
|
class SendDigestsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'uptelligence:send-digests
|
||||||
|
{--frequency= : Process only a specific frequency (daily, weekly, monthly)}
|
||||||
|
{--dry-run : Show what would happen without sending}';
|
||||||
|
|
||||||
|
protected $description = 'Send Uptelligence digest emails to subscribed users';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected UptelligenceDigestService $digestService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$frequency = $this->option('frequency');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No emails will be sent');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$frequencies = $frequency
|
||||||
|
? [$frequency]
|
||||||
|
: [
|
||||||
|
UptelligenceDigest::FREQUENCY_DAILY,
|
||||||
|
UptelligenceDigest::FREQUENCY_WEEKLY,
|
||||||
|
UptelligenceDigest::FREQUENCY_MONTHLY,
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalStats = ['sent' => 0, 'skipped' => 0, 'failed' => 0];
|
||||||
|
|
||||||
|
foreach ($frequencies as $freq) {
|
||||||
|
$this->processFrequency($freq, $dryRun, $totalStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('Digest processing complete.');
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Sent', $totalStats['sent']],
|
||||||
|
['Skipped (no content)', $totalStats['skipped']],
|
||||||
|
['Failed', $totalStats['failed']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $totalStats['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process digests for a specific frequency.
|
||||||
|
*/
|
||||||
|
protected function processFrequency(string $frequency, bool $dryRun, array &$totalStats): void
|
||||||
|
{
|
||||||
|
$this->info("Processing {$frequency} digests...");
|
||||||
|
|
||||||
|
$digests = UptelligenceDigest::dueForDigest($frequency)
|
||||||
|
->with(['user', 'workspace'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($digests->isEmpty()) {
|
||||||
|
$this->line(" No {$frequency} digests due.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" Found {$digests->count()} digest(s) to process.");
|
||||||
|
|
||||||
|
foreach ($digests as $digest) {
|
||||||
|
$this->processDigest($digest, $dryRun, $totalStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single digest.
|
||||||
|
*/
|
||||||
|
protected function processDigest(UptelligenceDigest $digest, bool $dryRun, array &$totalStats): void
|
||||||
|
{
|
||||||
|
$email = $digest->user?->email ?? 'unknown';
|
||||||
|
$workspaceName = $digest->workspace?->name ?? 'unknown';
|
||||||
|
|
||||||
|
// Skip if user or workspace deleted
|
||||||
|
if (! $digest->user || ! $digest->workspace) {
|
||||||
|
$this->warn(" Skipping digest {$digest->id} - user or workspace deleted");
|
||||||
|
$digest->delete();
|
||||||
|
$totalStats['skipped']++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content preview
|
||||||
|
$content = $this->digestService->generateDigestContent($digest);
|
||||||
|
|
||||||
|
if (! $content['has_content']) {
|
||||||
|
$this->line(" Skipping {$email} ({$workspaceName}) - no content to report");
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$digest->markAsSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalStats['skipped']++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$releasesCount = $content['releases']->count();
|
||||||
|
$todosCount = $content['todos']['total'] ?? 0;
|
||||||
|
$securityCount = $content['security_count'];
|
||||||
|
|
||||||
|
$this->line(" Sending to {$email} ({$workspaceName}): {$releasesCount} releases, {$todosCount} todos, {$securityCount} security");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$totalStats['sent']++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->digestService->sendDigest($digest);
|
||||||
|
$this->info(' Sent successfully');
|
||||||
|
$totalStats['sent']++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" Failed: {$e->getMessage()}");
|
||||||
|
$totalStats['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
268
Controllers/Api/WebhookController.php
Normal file
268
Controllers/Api/WebhookController.php
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Controllers\Api;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Core\Uptelligence\Jobs\ProcessUptelligenceWebhook;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||||
|
use Core\Uptelligence\Services\WebhookReceiverService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebhookController - receives incoming vendor release webhooks.
|
||||||
|
*
|
||||||
|
* Handles webhooks from GitHub, GitLab, npm, Packagist, and custom sources.
|
||||||
|
* Webhooks are validated, logged, and dispatched to a job for async processing.
|
||||||
|
*/
|
||||||
|
class WebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected WebhookReceiverService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive a webhook for a vendor.
|
||||||
|
*
|
||||||
|
* POST /api/uptelligence/webhook/{webhook}
|
||||||
|
*/
|
||||||
|
public function receive(Request $request, UptelligenceWebhook $webhook): Response
|
||||||
|
{
|
||||||
|
// Check if webhook is enabled
|
||||||
|
if (! $webhook->isActive()) {
|
||||||
|
Log::warning('Uptelligence webhook received for disabled endpoint', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'vendor_id' => $webhook->vendor_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Webhook disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check circuit breaker
|
||||||
|
if ($webhook->isCircuitBroken()) {
|
||||||
|
Log::warning('Uptelligence webhook endpoint circuit breaker open', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'failure_count' => $webhook->failure_count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Service unavailable', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get raw payload
|
||||||
|
$payload = $request->getContent();
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
$signature = $this->extractSignature($request, $webhook->provider);
|
||||||
|
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
|
||||||
|
|
||||||
|
if ($signatureStatus === UptelligenceWebhookDelivery::SIGNATURE_INVALID) {
|
||||||
|
Log::warning('Uptelligence webhook signature verification failed', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'vendor_id' => $webhook->vendor_id,
|
||||||
|
'source_ip' => $request->ip(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Invalid signature', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON payload
|
||||||
|
$data = json_decode($payload, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
Log::warning('Uptelligence webhook invalid JSON payload', [
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'error' => json_last_error_msg(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('Invalid JSON payload', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine event type
|
||||||
|
$eventType = $this->determineEventType($request, $data, $webhook->provider);
|
||||||
|
|
||||||
|
// Create delivery log
|
||||||
|
$delivery = UptelligenceWebhookDelivery::create([
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'vendor_id' => $webhook->vendor_id,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'provider' => $webhook->provider,
|
||||||
|
'payload' => $data,
|
||||||
|
'status' => UptelligenceWebhookDelivery::STATUS_PENDING,
|
||||||
|
'source_ip' => $request->ip(),
|
||||||
|
'signature_status' => $signatureStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Uptelligence webhook received', [
|
||||||
|
'delivery_id' => $delivery->id,
|
||||||
|
'webhook_id' => $webhook->id,
|
||||||
|
'vendor_id' => $webhook->vendor_id,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update webhook last received timestamp
|
||||||
|
$webhook->markReceived();
|
||||||
|
|
||||||
|
// Dispatch job for async processing
|
||||||
|
ProcessUptelligenceWebhook::dispatch($delivery);
|
||||||
|
|
||||||
|
return response('Accepted', 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract signature from request headers based on provider.
|
||||||
|
*/
|
||||||
|
protected function extractSignature(Request $request, string $provider): ?string
|
||||||
|
{
|
||||||
|
return match ($provider) {
|
||||||
|
UptelligenceWebhook::PROVIDER_GITHUB => $this->extractGitHubSignature($request),
|
||||||
|
UptelligenceWebhook::PROVIDER_GITLAB => $request->header('X-Gitlab-Token'),
|
||||||
|
UptelligenceWebhook::PROVIDER_NPM => $request->header('X-Npm-Signature'),
|
||||||
|
UptelligenceWebhook::PROVIDER_PACKAGIST => $request->header('X-Hub-Signature'),
|
||||||
|
default => $this->extractGenericSignature($request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract GitHub signature (prefers SHA-256).
|
||||||
|
*/
|
||||||
|
protected function extractGitHubSignature(Request $request): ?string
|
||||||
|
{
|
||||||
|
// Prefer SHA-256
|
||||||
|
$signature = $request->header('X-Hub-Signature-256');
|
||||||
|
if ($signature) {
|
||||||
|
return $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to SHA-1 (legacy)
|
||||||
|
return $request->header('X-Hub-Signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract signature from generic headers.
|
||||||
|
*/
|
||||||
|
protected function extractGenericSignature(Request $request): ?string
|
||||||
|
{
|
||||||
|
$signatureHeaders = [
|
||||||
|
'X-Signature',
|
||||||
|
'X-Hub-Signature-256',
|
||||||
|
'X-Hub-Signature',
|
||||||
|
'X-Webhook-Signature',
|
||||||
|
'Signature',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($signatureHeaders as $header) {
|
||||||
|
$value = $request->header($header);
|
||||||
|
if ($value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the event type from request and payload.
|
||||||
|
*/
|
||||||
|
protected function determineEventType(Request $request, array $data, string $provider): string
|
||||||
|
{
|
||||||
|
return match ($provider) {
|
||||||
|
UptelligenceWebhook::PROVIDER_GITHUB => $this->determineGitHubEventType($request, $data),
|
||||||
|
UptelligenceWebhook::PROVIDER_GITLAB => $this->determineGitLabEventType($request, $data),
|
||||||
|
UptelligenceWebhook::PROVIDER_NPM => $this->determineNpmEventType($data),
|
||||||
|
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->determinePackagistEventType($data),
|
||||||
|
default => $this->determineGenericEventType($request, $data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine GitHub event type.
|
||||||
|
*/
|
||||||
|
protected function determineGitHubEventType(Request $request, array $data): string
|
||||||
|
{
|
||||||
|
$event = $request->header('X-GitHub-Event', 'unknown');
|
||||||
|
$action = $data['action'] ?? 'unknown';
|
||||||
|
|
||||||
|
return "github.{$event}.{$action}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine GitLab event type.
|
||||||
|
*/
|
||||||
|
protected function determineGitLabEventType(Request $request, array $data): string
|
||||||
|
{
|
||||||
|
$objectKind = $data['object_kind'] ?? 'unknown';
|
||||||
|
$action = $data['action'] ?? 'unknown';
|
||||||
|
|
||||||
|
return "gitlab.{$objectKind}.{$action}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine npm event type.
|
||||||
|
*/
|
||||||
|
protected function determineNpmEventType(array $data): string
|
||||||
|
{
|
||||||
|
$event = $data['event'] ?? 'package:unknown';
|
||||||
|
$normalised = str_replace(':', '.', $event);
|
||||||
|
|
||||||
|
return "npm.{$normalised}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine Packagist event type.
|
||||||
|
*/
|
||||||
|
protected function determinePackagistEventType(array $data): string
|
||||||
|
{
|
||||||
|
// Packagist webhooks typically indicate an update
|
||||||
|
return 'packagist.package.update';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine generic event type.
|
||||||
|
*/
|
||||||
|
protected function determineGenericEventType(Request $request, array $data): string
|
||||||
|
{
|
||||||
|
// Check headers
|
||||||
|
$eventType = $request->header('X-Event-Type')
|
||||||
|
?? $request->header('X-Webhook-Event');
|
||||||
|
|
||||||
|
if ($eventType) {
|
||||||
|
return "custom.{$eventType}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check payload
|
||||||
|
$event = $data['event']
|
||||||
|
?? $data['event_type']
|
||||||
|
?? $data['action']
|
||||||
|
?? 'unknown';
|
||||||
|
|
||||||
|
return "custom.{$event}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test endpoint to verify webhook configuration.
|
||||||
|
*
|
||||||
|
* POST /api/uptelligence/webhook/{webhook}/test
|
||||||
|
*/
|
||||||
|
public function test(Request $request, UptelligenceWebhook $webhook): Response
|
||||||
|
{
|
||||||
|
// This endpoint is for testing - requires the webhook to exist
|
||||||
|
// and optionally verifies signature
|
||||||
|
|
||||||
|
$payload = $request->getContent();
|
||||||
|
$signature = $this->extractSignature($request, $webhook->provider);
|
||||||
|
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'ok',
|
||||||
|
'webhook_id' => $webhook->uuid,
|
||||||
|
'vendor_id' => $webhook->vendor_id,
|
||||||
|
'provider' => $webhook->provider,
|
||||||
|
'is_active' => $webhook->is_active,
|
||||||
|
'signature_status' => $signatureStatus,
|
||||||
|
'has_secret' => ! empty($webhook->secret),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Jobs/CheckVendorUpdatesJob.php
Normal file
143
Jobs/CheckVendorUpdatesJob.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Services\AssetTrackerService;
|
||||||
|
use Core\Uptelligence\Services\VendorUpdateCheckerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job to check vendors and assets for upstream updates.
|
||||||
|
*
|
||||||
|
* Can be scheduled to run daily/weekly via the scheduler.
|
||||||
|
* Checks OSS vendors via GitHub/Gitea APIs and assets via registries.
|
||||||
|
*/
|
||||||
|
class CheckVendorUpdatesJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to also check package assets.
|
||||||
|
*/
|
||||||
|
protected bool $checkAssets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific vendor slug to check (null = all vendors).
|
||||||
|
*/
|
||||||
|
protected ?string $vendorSlug;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(bool $checkAssets = true, ?string $vendorSlug = null)
|
||||||
|
{
|
||||||
|
$this->checkAssets = $checkAssets;
|
||||||
|
$this->vendorSlug = $vendorSlug;
|
||||||
|
$this->onQueue('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
VendorUpdateCheckerService $vendorChecker,
|
||||||
|
AssetTrackerService $assetChecker
|
||||||
|
): void {
|
||||||
|
$vendorResults = $this->checkVendors($vendorChecker);
|
||||||
|
$assetResults = $this->checkAssets ? $this->checkAssets($assetChecker) : [];
|
||||||
|
|
||||||
|
$this->logSummary($vendorResults, $assetResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check vendors for updates.
|
||||||
|
*/
|
||||||
|
protected function checkVendors(VendorUpdateCheckerService $checker): array
|
||||||
|
{
|
||||||
|
if ($this->vendorSlug) {
|
||||||
|
$vendor = Vendor::where('slug', $this->vendorSlug)->first();
|
||||||
|
if (! $vendor) {
|
||||||
|
Log::warning('Uptelligence: Vendor not found for update check', [
|
||||||
|
'slug' => $this->vendorSlug,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$vendor->slug => $checker->checkVendor($vendor)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $checker->checkAllVendors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check assets for updates.
|
||||||
|
*/
|
||||||
|
protected function checkAssets(AssetTrackerService $checker): array
|
||||||
|
{
|
||||||
|
return $checker->checkAllForUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a summary of the check results.
|
||||||
|
*/
|
||||||
|
protected function logSummary(array $vendorResults, array $assetResults): void
|
||||||
|
{
|
||||||
|
$vendorUpdates = collect($vendorResults)->filter(fn ($r) => $r['has_update'] ?? false)->count();
|
||||||
|
$vendorErrors = collect($vendorResults)->filter(fn ($r) => ($r['status'] ?? '') === 'error')->count();
|
||||||
|
$vendorSkipped = collect($vendorResults)->filter(fn ($r) => ($r['status'] ?? '') === 'skipped')->count();
|
||||||
|
|
||||||
|
$assetUpdates = collect($assetResults)->filter(fn ($r) => $r['has_update'] ?? false)->count();
|
||||||
|
$assetErrors = collect($assetResults)->filter(fn ($r) => ($r['status'] ?? '') === 'error')->count();
|
||||||
|
|
||||||
|
Log::info('Uptelligence: Update check complete', [
|
||||||
|
'vendors_checked' => count($vendorResults),
|
||||||
|
'vendors_with_updates' => $vendorUpdates,
|
||||||
|
'vendors_skipped' => $vendorSkipped,
|
||||||
|
'vendor_errors' => $vendorErrors,
|
||||||
|
'assets_checked' => count($assetResults),
|
||||||
|
'assets_with_updates' => $assetUpdates,
|
||||||
|
'asset_errors' => $assetErrors,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Log individual updates found
|
||||||
|
foreach ($vendorResults as $slug => $result) {
|
||||||
|
if ($result['has_update'] ?? false) {
|
||||||
|
Log::info("Uptelligence: Vendor update available - {$slug}", [
|
||||||
|
'current' => $result['current'] ?? 'unknown',
|
||||||
|
'latest' => $result['latest'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($assetResults as $slug => $result) {
|
||||||
|
if ($result['has_update'] ?? false) {
|
||||||
|
Log::info("Uptelligence: Asset update available - {$slug}", [
|
||||||
|
'latest' => $result['latest'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags that should be assigned to the job.
|
||||||
|
*/
|
||||||
|
public function tags(): array
|
||||||
|
{
|
||||||
|
$tags = ['uptelligence', 'update-check'];
|
||||||
|
|
||||||
|
if ($this->vendorSlug) {
|
||||||
|
$tags[] = "vendor:{$this->vendorSlug}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
Jobs/ProcessUptelligenceWebhook.php
Normal file
198
Jobs/ProcessUptelligenceWebhook.php
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||||
|
use Core\Uptelligence\Notifications\NewReleaseDetected;
|
||||||
|
use Core\Uptelligence\Services\WebhookReceiverService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProcessUptelligenceWebhook - async processing of incoming vendor webhooks.
|
||||||
|
*
|
||||||
|
* Handles payload parsing, release creation, and notification dispatch.
|
||||||
|
*/
|
||||||
|
class ProcessUptelligenceWebhook implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of seconds the job can run.
|
||||||
|
*/
|
||||||
|
public int $timeout = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of seconds to wait before retrying.
|
||||||
|
*/
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [10, 30, 60];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public UptelligenceWebhookDelivery $delivery,
|
||||||
|
) {
|
||||||
|
$this->onQueue('uptelligence-webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(WebhookReceiverService $service): void
|
||||||
|
{
|
||||||
|
$this->delivery->markProcessing();
|
||||||
|
|
||||||
|
Log::info('Processing Uptelligence webhook', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'webhook_id' => $this->delivery->webhook_id,
|
||||||
|
'vendor_id' => $this->delivery->vendor_id,
|
||||||
|
'event_type' => $this->delivery->event_type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get webhook and vendor
|
||||||
|
$webhook = $this->delivery->webhook;
|
||||||
|
$vendor = $this->delivery->vendor;
|
||||||
|
|
||||||
|
if (! $webhook || ! $vendor) {
|
||||||
|
throw new \RuntimeException('Webhook or vendor not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the payload
|
||||||
|
$parsedData = $service->parsePayload(
|
||||||
|
$this->delivery->provider,
|
||||||
|
$this->delivery->payload
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $parsedData) {
|
||||||
|
$this->delivery->markSkipped('Not a release event or unable to parse');
|
||||||
|
Log::info('Uptelligence webhook skipped (not a release event)', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the release
|
||||||
|
$result = $service->processRelease(
|
||||||
|
$this->delivery,
|
||||||
|
$vendor,
|
||||||
|
$parsedData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update delivery record
|
||||||
|
$this->delivery->update([
|
||||||
|
'version' => $parsedData['version'] ?? null,
|
||||||
|
'tag_name' => $parsedData['tag_name'] ?? null,
|
||||||
|
'parsed_data' => $parsedData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
$this->delivery->markCompleted($parsedData);
|
||||||
|
|
||||||
|
// Reset failure count on webhook
|
||||||
|
$webhook->resetFailureCount();
|
||||||
|
|
||||||
|
// Send notification if new release was created
|
||||||
|
if ($result['action'] === 'created') {
|
||||||
|
$this->sendReleaseNotification($vendor, $parsedData, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Uptelligence webhook processed successfully', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'action' => $result['action'],
|
||||||
|
'version' => $result['version'] ?? null,
|
||||||
|
'release_id' => $result['release_id'] ?? null,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->handleFailure($e);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when a new release is detected.
|
||||||
|
*/
|
||||||
|
protected function sendReleaseNotification(
|
||||||
|
\Core\Uptelligence\Models\Vendor $vendor,
|
||||||
|
array $parsedData,
|
||||||
|
array $result
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
// Get users subscribed to digest notifications for this vendor
|
||||||
|
$digests = \Core\Uptelligence\Models\UptelligenceDigest::where('is_enabled', true)
|
||||||
|
->with('user')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($digests as $digest) {
|
||||||
|
// Check if this digest includes releases and this vendor
|
||||||
|
if ($digest->user && $digest->includesReleases() && $digest->includesVendor($vendor->id)) {
|
||||||
|
$digest->user->notify(new NewReleaseDetected(
|
||||||
|
vendor: $vendor,
|
||||||
|
version: $parsedData['version'],
|
||||||
|
releaseData: $parsedData,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail the webhook processing if notification fails
|
||||||
|
Log::warning('Failed to send release notification', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle job failure.
|
||||||
|
*/
|
||||||
|
protected function handleFailure(\Exception $e): void
|
||||||
|
{
|
||||||
|
$this->delivery->markFailed($e->getMessage());
|
||||||
|
|
||||||
|
// Increment failure count on webhook
|
||||||
|
if ($webhook = $this->delivery->webhook) {
|
||||||
|
$webhook->incrementFailureCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Uptelligence webhook processing failed', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'webhook_id' => $this->delivery->webhook_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'attempts' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure (called by Laravel).
|
||||||
|
*/
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('Uptelligence webhook job failed permanently', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'webhook_id' => $this->delivery->webhook_id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'attempts' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->delivery->markFailed(
|
||||||
|
"Processing failed after {$this->attempts()} attempts: {$exception->getMessage()}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
Models/AnalysisLog.php
Normal file
187
Models/AnalysisLog.php
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysis Log - audit trail for upstream analysis operations.
|
||||||
|
*
|
||||||
|
* Tracks version detection, analysis runs, todos created, and porting progress.
|
||||||
|
*/
|
||||||
|
class AnalysisLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
public const ACTION_VERSION_DETECTED = 'version_detected';
|
||||||
|
|
||||||
|
public const ACTION_ANALYSIS_STARTED = 'analysis_started';
|
||||||
|
|
||||||
|
public const ACTION_ANALYSIS_COMPLETED = 'analysis_completed';
|
||||||
|
|
||||||
|
public const ACTION_ANALYSIS_FAILED = 'analysis_failed';
|
||||||
|
|
||||||
|
public const ACTION_TODO_CREATED = 'todo_created';
|
||||||
|
|
||||||
|
public const ACTION_TODO_UPDATED = 'todo_updated';
|
||||||
|
|
||||||
|
public const ACTION_ISSUE_CREATED = 'issue_created';
|
||||||
|
|
||||||
|
public const ACTION_PORT_STARTED = 'port_started';
|
||||||
|
|
||||||
|
public const ACTION_PORT_COMPLETED = 'port_completed';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'vendor_id',
|
||||||
|
'version_release_id',
|
||||||
|
'action',
|
||||||
|
'context',
|
||||||
|
'error_message',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'context' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function vendor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function versionRelease(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(VersionRelease::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeErrors($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('error_message');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRecent($query, int $limit = 50)
|
||||||
|
{
|
||||||
|
return $query->latest()->limit($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByAction($query, string $action)
|
||||||
|
{
|
||||||
|
return $query->where('action', $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory methods
|
||||||
|
public static function logVersionDetected(Vendor $vendor, string $version, ?string $previousVersion = null): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'action' => self::ACTION_VERSION_DETECTED,
|
||||||
|
'context' => [
|
||||||
|
'version' => $version,
|
||||||
|
'previous_version' => $previousVersion,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logAnalysisStarted(VersionRelease $release): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $release->vendor_id,
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'action' => self::ACTION_ANALYSIS_STARTED,
|
||||||
|
'context' => [
|
||||||
|
'version' => $release->version,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logAnalysisCompleted(VersionRelease $release, array $stats): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $release->vendor_id,
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'action' => self::ACTION_ANALYSIS_COMPLETED,
|
||||||
|
'context' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logAnalysisFailed(VersionRelease $release, string $error): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $release->vendor_id,
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'action' => self::ACTION_ANALYSIS_FAILED,
|
||||||
|
'error_message' => $error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logTodoCreated(UpstreamTodo $todo): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $todo->vendor_id,
|
||||||
|
'action' => self::ACTION_TODO_CREATED,
|
||||||
|
'context' => [
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'title' => $todo->title,
|
||||||
|
'type' => $todo->type,
|
||||||
|
'priority' => $todo->priority,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logIssueCreated(UpstreamTodo $todo, string $issueUrl): self
|
||||||
|
{
|
||||||
|
return self::create([
|
||||||
|
'vendor_id' => $todo->vendor_id,
|
||||||
|
'action' => self::ACTION_ISSUE_CREATED,
|
||||||
|
'context' => [
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'issue_url' => $issueUrl,
|
||||||
|
'issue_number' => $todo->github_issue_number,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function isError(): bool
|
||||||
|
{
|
||||||
|
return $this->error_message !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActionIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->action) {
|
||||||
|
self::ACTION_VERSION_DETECTED => '📦',
|
||||||
|
self::ACTION_ANALYSIS_STARTED => '🔍',
|
||||||
|
self::ACTION_ANALYSIS_COMPLETED => '✅',
|
||||||
|
self::ACTION_ANALYSIS_FAILED => '❌',
|
||||||
|
self::ACTION_TODO_CREATED => '📝',
|
||||||
|
self::ACTION_TODO_UPDATED => '✏️',
|
||||||
|
self::ACTION_ISSUE_CREATED => '🎫',
|
||||||
|
self::ACTION_PORT_STARTED => '🚀',
|
||||||
|
self::ACTION_PORT_COMPLETED => '🎉',
|
||||||
|
default => '📌',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActionLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->action) {
|
||||||
|
self::ACTION_VERSION_DETECTED => 'New Version Detected',
|
||||||
|
self::ACTION_ANALYSIS_STARTED => 'Analysis Started',
|
||||||
|
self::ACTION_ANALYSIS_COMPLETED => 'Analysis Completed',
|
||||||
|
self::ACTION_ANALYSIS_FAILED => 'Analysis Failed',
|
||||||
|
self::ACTION_TODO_CREATED => 'Todo Created',
|
||||||
|
self::ACTION_TODO_UPDATED => 'Todo Updated',
|
||||||
|
self::ACTION_ISSUE_CREATED => 'Issue Created',
|
||||||
|
self::ACTION_PORT_STARTED => 'Port Started',
|
||||||
|
self::ACTION_PORT_COMPLETED => 'Port Completed',
|
||||||
|
default => ucfirst(str_replace('_', ' ', $this->action)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Models/Asset.php
Normal file
214
Models/Asset.php
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset - tracks installed packages, fonts, themes, and CDN resources.
|
||||||
|
*
|
||||||
|
* Monitors versions, licences, and update availability.
|
||||||
|
*/
|
||||||
|
class Asset extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
// Asset types
|
||||||
|
public const TYPE_COMPOSER = 'composer';
|
||||||
|
|
||||||
|
public const TYPE_NPM = 'npm';
|
||||||
|
|
||||||
|
public const TYPE_FONT = 'font';
|
||||||
|
|
||||||
|
public const TYPE_THEME = 'theme';
|
||||||
|
|
||||||
|
public const TYPE_CDN = 'cdn';
|
||||||
|
|
||||||
|
public const TYPE_MANUAL = 'manual';
|
||||||
|
|
||||||
|
// Licence types
|
||||||
|
public const LICENCE_LIFETIME = 'lifetime';
|
||||||
|
|
||||||
|
public const LICENCE_SUBSCRIPTION = 'subscription';
|
||||||
|
|
||||||
|
public const LICENCE_OSS = 'oss';
|
||||||
|
|
||||||
|
public const LICENCE_TRIAL = 'trial';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'type',
|
||||||
|
'package_name',
|
||||||
|
'registry_url',
|
||||||
|
'licence_type',
|
||||||
|
'licence_expires_at',
|
||||||
|
'licence_meta',
|
||||||
|
'installed_version',
|
||||||
|
'latest_version',
|
||||||
|
'last_checked_at',
|
||||||
|
'auto_update',
|
||||||
|
'install_path',
|
||||||
|
'build_config',
|
||||||
|
'used_in_projects',
|
||||||
|
'setup_notes',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'licence_meta' => 'array',
|
||||||
|
'build_config' => 'array',
|
||||||
|
'used_in_projects' => 'array',
|
||||||
|
'licence_expires_at' => 'date',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
'auto_update' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function versions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AssetVersion::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeComposer($query)
|
||||||
|
{
|
||||||
|
return $query->where('type', self::TYPE_COMPOSER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNpm($query)
|
||||||
|
{
|
||||||
|
return $query->where('type', self::TYPE_NPM);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNeedsUpdate($query)
|
||||||
|
{
|
||||||
|
return $query->whereColumn('installed_version', '!=', 'latest_version')
|
||||||
|
->whereNotNull('latest_version');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeAutoUpdate($query)
|
||||||
|
{
|
||||||
|
return $query->where('auto_update', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function hasUpdate(): bool
|
||||||
|
{
|
||||||
|
return $this->latest_version
|
||||||
|
&& $this->installed_version
|
||||||
|
&& version_compare($this->latest_version, $this->installed_version, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLicenceExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->licence_expires_at && $this->licence_expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLicenceExpiringSoon(int $days = 30): bool
|
||||||
|
{
|
||||||
|
return $this->licence_expires_at
|
||||||
|
&& $this->licence_expires_at->isFuture()
|
||||||
|
&& $this->licence_expires_at->diffInDays(now()) <= $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_COMPOSER => '📦',
|
||||||
|
self::TYPE_NPM => '📦',
|
||||||
|
self::TYPE_FONT => '🔤',
|
||||||
|
self::TYPE_THEME => '🎨',
|
||||||
|
self::TYPE_CDN => '🌐',
|
||||||
|
self::TYPE_MANUAL => '📁',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_COMPOSER => 'Composer',
|
||||||
|
self::TYPE_NPM => 'NPM',
|
||||||
|
self::TYPE_FONT => 'Font',
|
||||||
|
self::TYPE_THEME => 'Theme',
|
||||||
|
self::TYPE_CDN => 'CDN',
|
||||||
|
self::TYPE_MANUAL => 'Manual',
|
||||||
|
default => ucfirst($this->type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLicenceIcon(): string
|
||||||
|
{
|
||||||
|
if ($this->isLicenceExpired()) {
|
||||||
|
return '🔴';
|
||||||
|
}
|
||||||
|
if ($this->isLicenceExpiringSoon()) {
|
||||||
|
return '🟡';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->licence_type) {
|
||||||
|
self::LICENCE_LIFETIME => '♾️',
|
||||||
|
self::LICENCE_SUBSCRIPTION => '🔄',
|
||||||
|
self::LICENCE_OSS => '🌐',
|
||||||
|
self::LICENCE_TRIAL => '⏳',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInstallCommand(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->package_name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_COMPOSER => "composer require {$this->package_name}",
|
||||||
|
self::TYPE_NPM => "npm install {$this->package_name}",
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdateCommand(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->package_name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_COMPOSER => "composer update {$this->package_name}",
|
||||||
|
self::TYPE_NPM => "npm update {$this->package_name}",
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For MCP context
|
||||||
|
public function toMcpContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'type' => $this->type,
|
||||||
|
'package' => $this->package_name,
|
||||||
|
'version' => $this->installed_version,
|
||||||
|
'latest' => $this->latest_version,
|
||||||
|
'has_update' => $this->hasUpdate(),
|
||||||
|
'licence' => $this->licence_type,
|
||||||
|
'install_path' => $this->install_path,
|
||||||
|
'install_command' => $this->getInstallCommand(),
|
||||||
|
'setup_notes' => $this->setup_notes,
|
||||||
|
'build_config' => $this->build_config,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Models/AssetVersion.php
Normal file
49
Models/AssetVersion.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset Version - tracks version history for assets.
|
||||||
|
*
|
||||||
|
* Stores changelog, breaking changes, and download information.
|
||||||
|
*/
|
||||||
|
class AssetVersion extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'asset_id',
|
||||||
|
'version',
|
||||||
|
'changelog',
|
||||||
|
'breaking_changes',
|
||||||
|
'download_url',
|
||||||
|
'local_path',
|
||||||
|
'released_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'breaking_changes' => 'array',
|
||||||
|
'released_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function asset(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Asset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasBreakingChanges(): bool
|
||||||
|
{
|
||||||
|
return ! empty($this->breaking_changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStored(): bool
|
||||||
|
{
|
||||||
|
return $this->local_path && file_exists($this->local_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
Models/DiffCache.php
Normal file
251
Models/DiffCache.php
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff Cache - stores file changes for version releases.
|
||||||
|
*
|
||||||
|
* Auto-categorises files for filtering and prioritisation.
|
||||||
|
*/
|
||||||
|
class DiffCache extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'diff_cache';
|
||||||
|
|
||||||
|
// Change types
|
||||||
|
public const CHANGE_ADDED = 'added';
|
||||||
|
|
||||||
|
public const CHANGE_MODIFIED = 'modified';
|
||||||
|
|
||||||
|
public const CHANGE_REMOVED = 'removed';
|
||||||
|
|
||||||
|
// Categories (auto-detected)
|
||||||
|
public const CATEGORY_CONTROLLER = 'controller';
|
||||||
|
|
||||||
|
public const CATEGORY_MODEL = 'model';
|
||||||
|
|
||||||
|
public const CATEGORY_VIEW = 'view';
|
||||||
|
|
||||||
|
public const CATEGORY_MIGRATION = 'migration';
|
||||||
|
|
||||||
|
public const CATEGORY_CONFIG = 'config';
|
||||||
|
|
||||||
|
public const CATEGORY_ROUTE = 'route';
|
||||||
|
|
||||||
|
public const CATEGORY_LANGUAGE = 'language';
|
||||||
|
|
||||||
|
public const CATEGORY_ASSET = 'asset';
|
||||||
|
|
||||||
|
public const CATEGORY_PLUGIN = 'plugin';
|
||||||
|
|
||||||
|
public const CATEGORY_BLOCK = 'block';
|
||||||
|
|
||||||
|
public const CATEGORY_SECURITY = 'security';
|
||||||
|
|
||||||
|
public const CATEGORY_API = 'api';
|
||||||
|
|
||||||
|
public const CATEGORY_OTHER = 'other';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'version_release_id',
|
||||||
|
'file_path',
|
||||||
|
'change_type',
|
||||||
|
'diff_content',
|
||||||
|
'new_content',
|
||||||
|
'category',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function versionRelease(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(VersionRelease::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeAdded($query)
|
||||||
|
{
|
||||||
|
return $query->where('change_type', self::CHANGE_ADDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeModified($query)
|
||||||
|
{
|
||||||
|
return $query->where('change_type', self::CHANGE_MODIFIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRemoved($query)
|
||||||
|
{
|
||||||
|
return $query->where('change_type', self::CHANGE_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByCategory($query, string $category)
|
||||||
|
{
|
||||||
|
return $query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSecurityRelated($query)
|
||||||
|
{
|
||||||
|
return $query->where('category', self::CATEGORY_SECURITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public static function detectCategory(string $filePath): string
|
||||||
|
{
|
||||||
|
$path = strtolower($filePath);
|
||||||
|
|
||||||
|
// Security-related files
|
||||||
|
if (str_contains($path, 'security') ||
|
||||||
|
str_contains($path, 'auth') ||
|
||||||
|
str_contains($path, 'password') ||
|
||||||
|
str_contains($path, 'permission') ||
|
||||||
|
str_contains($path, 'middleware')) {
|
||||||
|
return self::CATEGORY_SECURITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
if (str_contains($path, '/controllers/') || str_ends_with($path, 'controller.php')) {
|
||||||
|
return self::CATEGORY_CONTROLLER;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models
|
||||||
|
if (str_contains($path, '/models/') || str_ends_with($path, 'model.php')) {
|
||||||
|
return self::CATEGORY_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Views/Templates
|
||||||
|
if (str_contains($path, '/views/') ||
|
||||||
|
str_contains($path, '/themes/') ||
|
||||||
|
str_ends_with($path, '.blade.php')) {
|
||||||
|
return self::CATEGORY_VIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations
|
||||||
|
if (str_contains($path, '/migrations/') || str_contains($path, '/database/')) {
|
||||||
|
return self::CATEGORY_MIGRATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config
|
||||||
|
if (str_contains($path, '/config/') || str_ends_with($path, 'config.php')) {
|
||||||
|
return self::CATEGORY_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
if (str_contains($path, '/routes/') || str_ends_with($path, 'routes.php')) {
|
||||||
|
return self::CATEGORY_ROUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Languages
|
||||||
|
if (str_contains($path, '/languages/') || str_contains($path, '/lang/')) {
|
||||||
|
return self::CATEGORY_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
if (preg_match('/\.(css|js|scss|less|png|jpg|gif|svg|woff|ttf)$/', $path)) {
|
||||||
|
return self::CATEGORY_ASSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
if (str_contains($path, '/plugins/')) {
|
||||||
|
return self::CATEGORY_PLUGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks (BioLinks specific)
|
||||||
|
if (str_contains($path, '/blocks/') || str_contains($path, 'biolink')) {
|
||||||
|
return self::CATEGORY_BLOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
if (str_contains($path, '/api/') || str_contains($path, 'api.php')) {
|
||||||
|
return self::CATEGORY_API;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::CATEGORY_OTHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFileName(): string
|
||||||
|
{
|
||||||
|
return basename($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirectory(): string
|
||||||
|
{
|
||||||
|
return dirname($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExtension(): string
|
||||||
|
{
|
||||||
|
return pathinfo($this->file_path, PATHINFO_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDiffLineCount(): int
|
||||||
|
{
|
||||||
|
if (! $this->diff_content) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr_count($this->diff_content, "\n") + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAddedLines(): int
|
||||||
|
{
|
||||||
|
if (! $this->diff_content) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match_all('/^\+[^+]/m', $this->diff_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemovedLines(): int
|
||||||
|
{
|
||||||
|
if (! $this->diff_content) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match_all('/^-[^-]/m', $this->diff_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeTypeIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->change_type) {
|
||||||
|
self::CHANGE_ADDED => '➕',
|
||||||
|
self::CHANGE_MODIFIED => '✏️',
|
||||||
|
self::CHANGE_REMOVED => '➖',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeTypeBadgeClass(): string
|
||||||
|
{
|
||||||
|
return match ($this->change_type) {
|
||||||
|
self::CHANGE_ADDED => 'bg-green-100 text-green-800',
|
||||||
|
self::CHANGE_MODIFIED => 'bg-blue-100 text-blue-800',
|
||||||
|
self::CHANGE_REMOVED => 'bg-red-100 text-red-800',
|
||||||
|
default => 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategoryIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->category) {
|
||||||
|
self::CATEGORY_CONTROLLER => '🎮',
|
||||||
|
self::CATEGORY_MODEL => '📊',
|
||||||
|
self::CATEGORY_VIEW => '👁️',
|
||||||
|
self::CATEGORY_MIGRATION => '🗄️',
|
||||||
|
self::CATEGORY_CONFIG => '⚙️',
|
||||||
|
self::CATEGORY_ROUTE => '🛤️',
|
||||||
|
self::CATEGORY_LANGUAGE => '🌐',
|
||||||
|
self::CATEGORY_ASSET => '🎨',
|
||||||
|
self::CATEGORY_PLUGIN => '🔌',
|
||||||
|
self::CATEGORY_BLOCK => '🧱',
|
||||||
|
self::CATEGORY_SECURITY => '🔒',
|
||||||
|
self::CATEGORY_API => '🔌',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
172
Models/Pattern.php
Normal file
172
Models/Pattern.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern - reusable code patterns for development.
|
||||||
|
*
|
||||||
|
* Stores components, layouts, snippets with variants and required assets.
|
||||||
|
*/
|
||||||
|
class Pattern extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
public const CATEGORY_COMPONENT = 'component';
|
||||||
|
|
||||||
|
public const CATEGORY_LAYOUT = 'layout';
|
||||||
|
|
||||||
|
public const CATEGORY_THEME = 'theme';
|
||||||
|
|
||||||
|
public const CATEGORY_SNIPPET = 'snippet';
|
||||||
|
|
||||||
|
public const CATEGORY_WORKFLOW = 'workflow';
|
||||||
|
|
||||||
|
public const CATEGORY_TEMPLATE = 'template';
|
||||||
|
|
||||||
|
// Source types
|
||||||
|
public const SOURCE_PURCHASED = 'purchased';
|
||||||
|
|
||||||
|
public const SOURCE_OSS = 'oss';
|
||||||
|
|
||||||
|
public const SOURCE_INTERNAL = 'internal';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'category',
|
||||||
|
'tags',
|
||||||
|
'language',
|
||||||
|
'code',
|
||||||
|
'usage_example',
|
||||||
|
'required_assets',
|
||||||
|
'source_url',
|
||||||
|
'source_type',
|
||||||
|
'author',
|
||||||
|
'usage_count',
|
||||||
|
'quality_score',
|
||||||
|
'is_vetted',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'tags' => 'array',
|
||||||
|
'required_assets' => 'array',
|
||||||
|
'quality_score' => 'decimal:2',
|
||||||
|
'is_vetted' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function variants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PatternVariant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeVetted($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_vetted', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCategory($query, string $category)
|
||||||
|
{
|
||||||
|
return $query->where('category', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLanguage($query, string $language)
|
||||||
|
{
|
||||||
|
return $query->where('language', $language);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithTag($query, string $tag)
|
||||||
|
{
|
||||||
|
return $query->whereJsonContains('tags', $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSearch($query, string $search)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('description', 'like', "%{$search}%")
|
||||||
|
->orWhereJsonContains('tags', $search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function getCategoryIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->category) {
|
||||||
|
self::CATEGORY_COMPONENT => '🧩',
|
||||||
|
self::CATEGORY_LAYOUT => '📐',
|
||||||
|
self::CATEGORY_THEME => '🎨',
|
||||||
|
self::CATEGORY_SNIPPET => '📝',
|
||||||
|
self::CATEGORY_WORKFLOW => '⚙️',
|
||||||
|
self::CATEGORY_TEMPLATE => '📄',
|
||||||
|
default => '📦',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLanguageIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->language) {
|
||||||
|
'blade' => '🔹',
|
||||||
|
'vue' => '💚',
|
||||||
|
'react' => '⚛️',
|
||||||
|
'css' => '🎨',
|
||||||
|
'php' => '🐘',
|
||||||
|
'javascript', 'js' => '💛',
|
||||||
|
'typescript', 'ts' => '💙',
|
||||||
|
default => '📄',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incrementUsage(): void
|
||||||
|
{
|
||||||
|
$this->increment('usage_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredAssetsObjects(): array
|
||||||
|
{
|
||||||
|
if (empty($this->required_assets)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Asset::whereIn('slug', $this->required_assets)->get()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For MCP context
|
||||||
|
public function toMcpContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'category' => $this->category,
|
||||||
|
'language' => $this->language,
|
||||||
|
'description' => $this->description,
|
||||||
|
'tags' => $this->tags,
|
||||||
|
'code' => $this->code,
|
||||||
|
'usage_example' => $this->usage_example,
|
||||||
|
'required_assets' => $this->required_assets,
|
||||||
|
'source' => $this->source_type,
|
||||||
|
'is_vetted' => $this->is_vetted,
|
||||||
|
'variants' => $this->variants->map(fn ($v) => [
|
||||||
|
'name' => $v->name,
|
||||||
|
'code' => $v->code,
|
||||||
|
'notes' => $v->notes,
|
||||||
|
])->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Models/PatternCollection.php
Normal file
70
Models/PatternCollection.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern Collection - groups related patterns together.
|
||||||
|
*
|
||||||
|
* Useful for bundling patterns that work together.
|
||||||
|
*/
|
||||||
|
class PatternCollection extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'pattern_ids',
|
||||||
|
'required_assets',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'pattern_ids' => 'array',
|
||||||
|
'required_assets' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function getPatterns()
|
||||||
|
{
|
||||||
|
if (empty($this->pattern_ids)) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pattern::whereIn('id', $this->pattern_ids)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredAssetsObjects(): array
|
||||||
|
{
|
||||||
|
if (empty($this->required_assets)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Asset::whereIn('slug', $this->required_assets)->get()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For MCP context
|
||||||
|
public function toMcpContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'description' => $this->description,
|
||||||
|
'patterns' => $this->getPatterns()->map(fn ($p) => $p->toMcpContext())->all(),
|
||||||
|
'required_assets' => $this->required_assets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Models/PatternVariant.php
Normal file
31
Models/PatternVariant.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern Variant - alternative implementations of a pattern.
|
||||||
|
*
|
||||||
|
* Allows storing different versions (e.g. dark mode, compact, etc.)
|
||||||
|
*/
|
||||||
|
class PatternVariant extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'pattern_id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function pattern(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Pattern::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
241
Models/UpstreamTodo.php
Normal file
241
Models/UpstreamTodo.php
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upstream Todo - tracks porting tasks from upstream vendors.
|
||||||
|
*
|
||||||
|
* Includes AI analysis, priority, effort, and GitHub issue tracking.
|
||||||
|
*/
|
||||||
|
class UpstreamTodo extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
// Types
|
||||||
|
public const TYPE_FEATURE = 'feature';
|
||||||
|
|
||||||
|
public const TYPE_BUGFIX = 'bugfix';
|
||||||
|
|
||||||
|
public const TYPE_SECURITY = 'security';
|
||||||
|
|
||||||
|
public const TYPE_UI = 'ui';
|
||||||
|
|
||||||
|
public const TYPE_BLOCK = 'block';
|
||||||
|
|
||||||
|
public const TYPE_API = 'api';
|
||||||
|
|
||||||
|
public const TYPE_REFACTOR = 'refactor';
|
||||||
|
|
||||||
|
public const TYPE_DEPENDENCY = 'dependency';
|
||||||
|
|
||||||
|
// Statuses
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||||
|
|
||||||
|
public const STATUS_PORTED = 'ported';
|
||||||
|
|
||||||
|
public const STATUS_SKIPPED = 'skipped';
|
||||||
|
|
||||||
|
public const STATUS_WONT_PORT = 'wont_port';
|
||||||
|
|
||||||
|
// Effort levels
|
||||||
|
public const EFFORT_LOW = 'low';
|
||||||
|
|
||||||
|
public const EFFORT_MEDIUM = 'medium';
|
||||||
|
|
||||||
|
public const EFFORT_HIGH = 'high';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'vendor_id',
|
||||||
|
'from_version',
|
||||||
|
'to_version',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'port_notes',
|
||||||
|
'priority',
|
||||||
|
'effort',
|
||||||
|
'has_conflicts',
|
||||||
|
'conflict_reason',
|
||||||
|
'files',
|
||||||
|
'dependencies',
|
||||||
|
'tags',
|
||||||
|
'github_issue_number',
|
||||||
|
'branch_name',
|
||||||
|
'assigned_to',
|
||||||
|
'ai_analysis',
|
||||||
|
'ai_confidence',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'files' => 'array',
|
||||||
|
'dependencies' => 'array',
|
||||||
|
'tags' => 'array',
|
||||||
|
'ai_analysis' => 'array',
|
||||||
|
'ai_confidence' => 'decimal:2',
|
||||||
|
'has_conflicts' => 'boolean',
|
||||||
|
'priority' => 'integer',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function vendor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopePending($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeInProgress($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_IN_PROGRESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCompleted($query)
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', [self::STATUS_PORTED, self::STATUS_SKIPPED, self::STATUS_WONT_PORT]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeQuickWins($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_PENDING)
|
||||||
|
->where('effort', self::EFFORT_LOW)
|
||||||
|
->where('priority', '>=', 5)
|
||||||
|
->orderByDesc('priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeHighPriority($query)
|
||||||
|
{
|
||||||
|
return $query->where('priority', '>=', 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByType($query, string $type)
|
||||||
|
{
|
||||||
|
return $query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSecurityRelated($query)
|
||||||
|
{
|
||||||
|
return $query->where('type', self::TYPE_SECURITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function isQuickWin(): bool
|
||||||
|
{
|
||||||
|
return $this->effort === self::EFFORT_LOW && $this->priority >= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCompleted(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, [self::STATUS_PORTED, self::STATUS_SKIPPED, self::STATUS_WONT_PORT]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markInProgress(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_IN_PROGRESS,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markPorted(?string $notes = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_PORTED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'port_notes' => $notes ?? $this->port_notes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markSkipped(?string $reason = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_SKIPPED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'port_notes' => $reason ?? $this->port_notes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markWontPort(?string $reason = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_WONT_PORT,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'port_notes' => $reason ?? $this->port_notes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilesCount(): int
|
||||||
|
{
|
||||||
|
return count($this->files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriorityLabel(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->priority >= 8 => 'Critical',
|
||||||
|
$this->priority >= 6 => 'High',
|
||||||
|
$this->priority >= 4 => 'Medium',
|
||||||
|
default => 'Low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEffortLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->effort) {
|
||||||
|
self::EFFORT_LOW => '< 1 hour',
|
||||||
|
self::EFFORT_MEDIUM => '1-4 hours',
|
||||||
|
self::EFFORT_HIGH => '4+ hours',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_FEATURE => '✨',
|
||||||
|
self::TYPE_BUGFIX => '🐛',
|
||||||
|
self::TYPE_SECURITY => '🔒',
|
||||||
|
self::TYPE_UI => '🎨',
|
||||||
|
self::TYPE_BLOCK => '🧱',
|
||||||
|
self::TYPE_API => '🔌',
|
||||||
|
self::TYPE_REFACTOR => '♻️',
|
||||||
|
self::TYPE_DEPENDENCY => '📦',
|
||||||
|
default => '📝',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusBadgeClass(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800',
|
||||||
|
self::STATUS_IN_PROGRESS => 'bg-blue-100 text-blue-800',
|
||||||
|
self::STATUS_PORTED => 'bg-green-100 text-green-800',
|
||||||
|
self::STATUS_SKIPPED => 'bg-gray-100 text-gray-800',
|
||||||
|
self::STATUS_WONT_PORT => 'bg-red-100 text-red-800',
|
||||||
|
default => 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
285
Models/UptelligenceDigest.php
Normal file
285
Models/UptelligenceDigest.php
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UptelligenceDigest - stores user preferences for digest email notifications.
|
||||||
|
*
|
||||||
|
* Tracks which users want to receive periodic summaries of vendor updates,
|
||||||
|
* new releases, and pending todos from the Uptelligence module.
|
||||||
|
*/
|
||||||
|
class UptelligenceDigest extends Model
|
||||||
|
{
|
||||||
|
use BelongsToWorkspace;
|
||||||
|
|
||||||
|
// Frequency options
|
||||||
|
public const FREQUENCY_DAILY = 'daily';
|
||||||
|
|
||||||
|
public const FREQUENCY_WEEKLY = 'weekly';
|
||||||
|
|
||||||
|
public const FREQUENCY_MONTHLY = 'monthly';
|
||||||
|
|
||||||
|
protected $table = 'uptelligence_digests';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'workspace_id',
|
||||||
|
'frequency',
|
||||||
|
'last_sent_at',
|
||||||
|
'preferences',
|
||||||
|
'is_enabled',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'preferences' => 'array',
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'last_sent_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'frequency' => self::FREQUENCY_WEEKLY,
|
||||||
|
'is_enabled' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to enabled digests only.
|
||||||
|
*/
|
||||||
|
public function scopeEnabled(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to digests with specific frequency.
|
||||||
|
*/
|
||||||
|
public function scopeWithFrequency(Builder $query, string $frequency): Builder
|
||||||
|
{
|
||||||
|
return $query->where('frequency', $frequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to digests that are due to be sent.
|
||||||
|
*
|
||||||
|
* Daily: last_sent_at is null or older than 24 hours
|
||||||
|
* Weekly: last_sent_at is null or older than 7 days
|
||||||
|
* Monthly: last_sent_at is null or older than 30 days
|
||||||
|
*/
|
||||||
|
public function scopeDueForDigest(Builder $query, string $frequency): Builder
|
||||||
|
{
|
||||||
|
$cutoff = match ($frequency) {
|
||||||
|
self::FREQUENCY_DAILY => now()->subDay(),
|
||||||
|
self::FREQUENCY_WEEKLY => now()->subWeek(),
|
||||||
|
self::FREQUENCY_MONTHLY => now()->subMonth(),
|
||||||
|
default => now()->subWeek(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return $query->enabled()
|
||||||
|
->withFrequency($frequency)
|
||||||
|
->where(function (Builder $q) use ($cutoff) {
|
||||||
|
$q->whereNull('last_sent_at')
|
||||||
|
->orWhere('last_sent_at', '<=', $cutoff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Preferences Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of vendor IDs to include in the digest.
|
||||||
|
* Returns null if all vendors should be included.
|
||||||
|
*/
|
||||||
|
public function getVendorIds(): ?array
|
||||||
|
{
|
||||||
|
return $this->preferences['vendor_ids'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the vendor IDs to include in the digest.
|
||||||
|
*/
|
||||||
|
public function setVendorIds(?array $vendorIds): void
|
||||||
|
{
|
||||||
|
$this->preferences = array_merge($this->preferences ?? [], [
|
||||||
|
'vendor_ids' => $vendorIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific vendor should be included in the digest.
|
||||||
|
*/
|
||||||
|
public function includesVendor(int $vendorId): bool
|
||||||
|
{
|
||||||
|
$vendorIds = $this->getVendorIds();
|
||||||
|
|
||||||
|
// If no filter set, include all vendors
|
||||||
|
if ($vendorIds === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($vendorId, $vendorIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the update types to include (releases, todos, security).
|
||||||
|
* Returns all types if not specified.
|
||||||
|
*/
|
||||||
|
public function getIncludedTypes(): array
|
||||||
|
{
|
||||||
|
return $this->preferences['include_types'] ?? [
|
||||||
|
'releases',
|
||||||
|
'todos',
|
||||||
|
'security',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set which update types to include.
|
||||||
|
*/
|
||||||
|
public function setIncludedTypes(array $types): void
|
||||||
|
{
|
||||||
|
$this->preferences = array_merge($this->preferences ?? [], [
|
||||||
|
'include_types' => $types,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if releases should be included.
|
||||||
|
*/
|
||||||
|
public function includesReleases(): bool
|
||||||
|
{
|
||||||
|
return in_array('releases', $this->getIncludedTypes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if todos should be included.
|
||||||
|
*/
|
||||||
|
public function includesTodos(): bool
|
||||||
|
{
|
||||||
|
return in_array('todos', $this->getIncludedTypes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if security updates should be highlighted.
|
||||||
|
*/
|
||||||
|
public function includesSecurity(): bool
|
||||||
|
{
|
||||||
|
return in_array('security', $this->getIncludedTypes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get minimum priority threshold for todos.
|
||||||
|
* Returns null if no threshold (include all priorities).
|
||||||
|
*/
|
||||||
|
public function getMinPriority(): ?int
|
||||||
|
{
|
||||||
|
return $this->preferences['min_priority'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set minimum priority threshold.
|
||||||
|
*/
|
||||||
|
public function setMinPriority(?int $priority): void
|
||||||
|
{
|
||||||
|
$this->preferences = array_merge($this->preferences ?? [], [
|
||||||
|
'min_priority' => $priority,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this digest is due to be sent.
|
||||||
|
*/
|
||||||
|
public function isDue(): bool
|
||||||
|
{
|
||||||
|
if (! $this->is_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->last_sent_at === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->frequency) {
|
||||||
|
self::FREQUENCY_DAILY => $this->last_sent_at->lte(now()->subDay()),
|
||||||
|
self::FREQUENCY_WEEKLY => $this->last_sent_at->lte(now()->subWeek()),
|
||||||
|
self::FREQUENCY_MONTHLY => $this->last_sent_at->lte(now()->subMonth()),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the digest as sent.
|
||||||
|
*/
|
||||||
|
public function markAsSent(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_sent_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable frequency label.
|
||||||
|
*/
|
||||||
|
public function getFrequencyLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->frequency) {
|
||||||
|
self::FREQUENCY_DAILY => 'Daily',
|
||||||
|
self::FREQUENCY_WEEKLY => 'Weekly',
|
||||||
|
self::FREQUENCY_MONTHLY => 'Monthly',
|
||||||
|
default => ucfirst($this->frequency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next scheduled send date.
|
||||||
|
*/
|
||||||
|
public function getNextSendDate(): ?\Carbon\Carbon
|
||||||
|
{
|
||||||
|
if (! $this->is_enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastSent = $this->last_sent_at ?? now();
|
||||||
|
|
||||||
|
return match ($this->frequency) {
|
||||||
|
self::FREQUENCY_DAILY => $lastSent->copy()->addDay(),
|
||||||
|
self::FREQUENCY_WEEKLY => $lastSent->copy()->addWeek(),
|
||||||
|
self::FREQUENCY_MONTHLY => $lastSent->copy()->addMonth(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available frequency options for forms.
|
||||||
|
*/
|
||||||
|
public static function getFrequencyOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::FREQUENCY_DAILY => 'Daily',
|
||||||
|
self::FREQUENCY_WEEKLY => 'Weekly',
|
||||||
|
self::FREQUENCY_MONTHLY => 'Monthly',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
475
Models/UptelligenceWebhook.php
Normal file
475
Models/UptelligenceWebhook.php
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UptelligenceWebhook - webhook endpoint for receiving vendor release notifications.
|
||||||
|
*
|
||||||
|
* Each vendor can have a webhook endpoint configured to receive release
|
||||||
|
* notifications from GitHub, GitLab, npm, Packagist, or custom sources.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $uuid
|
||||||
|
* @property int $vendor_id
|
||||||
|
* @property string $provider
|
||||||
|
* @property string|null $secret
|
||||||
|
* @property string|null $previous_secret
|
||||||
|
* @property Carbon|null $secret_rotated_at
|
||||||
|
* @property int $grace_period_seconds
|
||||||
|
* @property bool $is_active
|
||||||
|
* @property int $failure_count
|
||||||
|
* @property Carbon|null $last_received_at
|
||||||
|
* @property array|null $settings
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
* @property Carbon|null $deleted_at
|
||||||
|
*/
|
||||||
|
class UptelligenceWebhook extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'uptelligence_webhooks';
|
||||||
|
|
||||||
|
// Supported providers
|
||||||
|
public const PROVIDER_GITHUB = 'github';
|
||||||
|
|
||||||
|
public const PROVIDER_GITLAB = 'gitlab';
|
||||||
|
|
||||||
|
public const PROVIDER_NPM = 'npm';
|
||||||
|
|
||||||
|
public const PROVIDER_PACKAGIST = 'packagist';
|
||||||
|
|
||||||
|
public const PROVIDER_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
public const PROVIDERS = [
|
||||||
|
self::PROVIDER_GITHUB,
|
||||||
|
self::PROVIDER_GITLAB,
|
||||||
|
self::PROVIDER_NPM,
|
||||||
|
self::PROVIDER_PACKAGIST,
|
||||||
|
self::PROVIDER_CUSTOM,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maximum consecutive failures before auto-disable
|
||||||
|
public const MAX_FAILURES = 10;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'uuid',
|
||||||
|
'vendor_id',
|
||||||
|
'provider',
|
||||||
|
'secret',
|
||||||
|
'previous_secret',
|
||||||
|
'secret_rotated_at',
|
||||||
|
'grace_period_seconds',
|
||||||
|
'is_active',
|
||||||
|
'failure_count',
|
||||||
|
'last_received_at',
|
||||||
|
'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'failure_count' => 'integer',
|
||||||
|
'grace_period_seconds' => 'integer',
|
||||||
|
'last_received_at' => 'datetime',
|
||||||
|
'secret_rotated_at' => 'datetime',
|
||||||
|
'secret' => 'encrypted',
|
||||||
|
'previous_secret' => 'encrypted',
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'secret',
|
||||||
|
'previous_secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (UptelligenceWebhook $webhook) {
|
||||||
|
if (empty($webhook->uuid)) {
|
||||||
|
$webhook->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a secret if not provided
|
||||||
|
if (empty($webhook->secret)) {
|
||||||
|
$webhook->secret = Str::random(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default grace period: 24 hours
|
||||||
|
if (empty($webhook->grace_period_seconds)) {
|
||||||
|
$webhook->grace_period_seconds = 86400;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function vendor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliveries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UptelligenceWebhookDelivery::class, 'webhook_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForVendor($query, int $vendorId)
|
||||||
|
{
|
||||||
|
return $query->where('vendor_id', $vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByProvider($query, string $provider)
|
||||||
|
{
|
||||||
|
return $query->where('provider', $provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// State Checks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->is_active === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCircuitBroken(): bool
|
||||||
|
{
|
||||||
|
return $this->failure_count >= self::MAX_FAILURES;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInGracePeriod(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->secret_rotated_at)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rotatedAt = Carbon::parse($this->secret_rotated_at);
|
||||||
|
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
|
||||||
|
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
|
||||||
|
|
||||||
|
return now()->isBefore($graceEndsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Signature Verification
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature based on provider.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - GitHub: X-Hub-Signature-256 (sha256=...)
|
||||||
|
* - GitLab: X-Gitlab-Token (token comparison)
|
||||||
|
* - npm: npm registry webhooks
|
||||||
|
* - Packagist: Packagist webhooks
|
||||||
|
* - Custom: HMAC-SHA256
|
||||||
|
*/
|
||||||
|
public function verifySignature(string $payload, ?string $signature): bool
|
||||||
|
{
|
||||||
|
// If no secret configured, skip verification
|
||||||
|
if (empty($this->secret)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature required when secret is set
|
||||||
|
if (empty($signature)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against current secret
|
||||||
|
if ($this->verifyAgainstSecret($payload, $signature, $this->secret)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against previous secret if in grace period
|
||||||
|
if ($this->isInGracePeriod() && ! empty($this->previous_secret)) {
|
||||||
|
if ($this->verifyAgainstSecret($payload, $signature, $this->previous_secret)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify signature against a specific secret.
|
||||||
|
*/
|
||||||
|
protected function verifyAgainstSecret(string $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
return match ($this->provider) {
|
||||||
|
self::PROVIDER_GITHUB => $this->verifyGitHubSignature($payload, $signature, $secret),
|
||||||
|
self::PROVIDER_GITLAB => $this->verifyGitLabSignature($signature, $secret),
|
||||||
|
self::PROVIDER_NPM => $this->verifyNpmSignature($payload, $signature, $secret),
|
||||||
|
self::PROVIDER_PACKAGIST => $this->verifyPackagistSignature($payload, $signature, $secret),
|
||||||
|
default => $this->verifyHmacSignature($payload, $signature, $secret),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify GitHub-style signature (sha256=...).
|
||||||
|
*/
|
||||||
|
protected function verifyGitHubSignature(string $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
// Handle sha256= prefix
|
||||||
|
if (str_starts_with($signature, 'sha256=')) {
|
||||||
|
$signature = substr($signature, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify GitLab-style signature (X-Gitlab-Token header).
|
||||||
|
*/
|
||||||
|
protected function verifyGitLabSignature(string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
return hash_equals($secret, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify npm webhook signature.
|
||||||
|
*/
|
||||||
|
protected function verifyNpmSignature(string $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
// npm uses sha256 HMAC
|
||||||
|
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Packagist webhook signature.
|
||||||
|
*/
|
||||||
|
protected function verifyPackagistSignature(string $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
// Packagist uses sha1 HMAC
|
||||||
|
$expectedSignature = hash_hmac('sha1', $payload, $secret);
|
||||||
|
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify generic HMAC-SHA256 signature.
|
||||||
|
*/
|
||||||
|
protected function verifyHmacSignature(string $payload, string $signature, string $secret): bool
|
||||||
|
{
|
||||||
|
// Handle sha256= prefix
|
||||||
|
if (str_starts_with($signature, 'sha256=')) {
|
||||||
|
$signature = substr($signature, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status Management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function incrementFailureCount(): void
|
||||||
|
{
|
||||||
|
$this->increment('failure_count');
|
||||||
|
|
||||||
|
// Auto-disable after too many failures (circuit breaker)
|
||||||
|
if ($this->failure_count >= self::MAX_FAILURES) {
|
||||||
|
$this->update(['is_active' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFailureCount(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'failure_count' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markReceived(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_received_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Secret Management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the secret and keep the previous one for grace period.
|
||||||
|
*/
|
||||||
|
public function rotateSecret(): string
|
||||||
|
{
|
||||||
|
$newSecret = Str::random(64);
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'previous_secret' => $this->secret,
|
||||||
|
'secret' => $newSecret,
|
||||||
|
'secret_rotated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate the secret without keeping the previous one.
|
||||||
|
*/
|
||||||
|
public function regenerateSecret(): string
|
||||||
|
{
|
||||||
|
$newSecret = Str::random(64);
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'secret' => $newSecret,
|
||||||
|
'previous_secret' => null,
|
||||||
|
'secret_rotated_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// URL Generation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the webhook endpoint URL.
|
||||||
|
*/
|
||||||
|
public function getEndpointUrl(): string
|
||||||
|
{
|
||||||
|
return route('api.uptelligence.webhooks.receive', ['webhook' => $this->uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Utilities
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider label.
|
||||||
|
*/
|
||||||
|
public function getProviderLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->provider) {
|
||||||
|
self::PROVIDER_GITHUB => 'GitHub',
|
||||||
|
self::PROVIDER_GITLAB => 'GitLab',
|
||||||
|
self::PROVIDER_NPM => 'npm',
|
||||||
|
self::PROVIDER_PACKAGIST => 'Packagist',
|
||||||
|
self::PROVIDER_CUSTOM => 'Custom',
|
||||||
|
default => ucfirst($this->provider),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider icon name.
|
||||||
|
*/
|
||||||
|
public function getProviderIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->provider) {
|
||||||
|
self::PROVIDER_GITHUB => 'code-bracket',
|
||||||
|
self::PROVIDER_GITLAB => 'code-bracket-square',
|
||||||
|
self::PROVIDER_NPM => 'cube',
|
||||||
|
self::PROVIDER_PACKAGIST => 'archive-box',
|
||||||
|
self::PROVIDER_CUSTOM => 'cog-6-tooth',
|
||||||
|
default => 'globe-alt',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Flux badge colour for status.
|
||||||
|
*/
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
if (! $this->is_active) {
|
||||||
|
return 'zinc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isCircuitBroken()) {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->failure_count > 0) {
|
||||||
|
return 'yellow';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status label.
|
||||||
|
*/
|
||||||
|
public function getStatusLabelAttribute(): string
|
||||||
|
{
|
||||||
|
if (! $this->is_active) {
|
||||||
|
return 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isCircuitBroken()) {
|
||||||
|
return 'Circuit Open';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->failure_count > 0) {
|
||||||
|
return "Active ({$this->failure_count} failures)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time remaining in grace period.
|
||||||
|
*/
|
||||||
|
public function getGraceTimeRemainingAttribute(): ?int
|
||||||
|
{
|
||||||
|
if (! $this->isInGracePeriod()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rotatedAt = Carbon::parse($this->secret_rotated_at);
|
||||||
|
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
|
||||||
|
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
|
||||||
|
|
||||||
|
return (int) now()->diffInSeconds($graceEndsAt, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get when the grace period ends.
|
||||||
|
*/
|
||||||
|
public function getGraceEndsAtAttribute(): ?Carbon
|
||||||
|
{
|
||||||
|
if (empty($this->secret_rotated_at)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rotatedAt = Carbon::parse($this->secret_rotated_at);
|
||||||
|
$gracePeriodSeconds = $this->grace_period_seconds ?? 86400;
|
||||||
|
|
||||||
|
return $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
356
Models/UptelligenceWebhookDelivery.php
Normal file
356
Models/UptelligenceWebhookDelivery.php
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UptelligenceWebhookDelivery - log of incoming webhook deliveries.
|
||||||
|
*
|
||||||
|
* Records each webhook delivery, its payload, parsing results,
|
||||||
|
* and processing status for debugging and audit purposes.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $webhook_id
|
||||||
|
* @property int $vendor_id
|
||||||
|
* @property string $event_type
|
||||||
|
* @property string $provider
|
||||||
|
* @property string|null $version
|
||||||
|
* @property string|null $tag_name
|
||||||
|
* @property array $payload
|
||||||
|
* @property array|null $parsed_data
|
||||||
|
* @property string $status
|
||||||
|
* @property string|null $error_message
|
||||||
|
* @property string|null $source_ip
|
||||||
|
* @property string|null $signature_status
|
||||||
|
* @property Carbon|null $processed_at
|
||||||
|
* @property int $retry_count
|
||||||
|
* @property int $max_retries
|
||||||
|
* @property Carbon|null $next_retry_at
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class UptelligenceWebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'uptelligence_webhook_deliveries';
|
||||||
|
|
||||||
|
// Status values
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_PROCESSING = 'processing';
|
||||||
|
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_SKIPPED = 'skipped';
|
||||||
|
|
||||||
|
// Signature status values
|
||||||
|
public const SIGNATURE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const SIGNATURE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const SIGNATURE_MISSING = 'missing';
|
||||||
|
|
||||||
|
// Default max retries
|
||||||
|
public const DEFAULT_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'webhook_id',
|
||||||
|
'vendor_id',
|
||||||
|
'event_type',
|
||||||
|
'provider',
|
||||||
|
'version',
|
||||||
|
'tag_name',
|
||||||
|
'payload',
|
||||||
|
'parsed_data',
|
||||||
|
'status',
|
||||||
|
'error_message',
|
||||||
|
'source_ip',
|
||||||
|
'signature_status',
|
||||||
|
'processed_at',
|
||||||
|
'retry_count',
|
||||||
|
'max_retries',
|
||||||
|
'next_retry_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'parsed_data' => 'array',
|
||||||
|
'processed_at' => 'datetime',
|
||||||
|
'next_retry_at' => 'datetime',
|
||||||
|
'retry_count' => 'integer',
|
||||||
|
'max_retries' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'retry_count' => 0,
|
||||||
|
'max_retries' => self::DEFAULT_MAX_RETRIES,
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Relationships
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function webhook(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UptelligenceWebhook::class, 'webhook_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vendor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scopes
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function scopePending($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeProcessing($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_PROCESSING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCompleted($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_COMPLETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFailed($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWebhook($query, int $webhookId)
|
||||||
|
{
|
||||||
|
return $query->where('webhook_id', $webhookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForVendor($query, int $vendorId)
|
||||||
|
{
|
||||||
|
return $query->where('vendor_id', $vendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRecent($query, int $hours = 24)
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subHours($hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to webhooks that are ready for retry.
|
||||||
|
*/
|
||||||
|
public function scopeRetryable($query)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) {
|
||||||
|
$q->where('status', self::STATUS_PENDING)
|
||||||
|
->orWhere('status', self::STATUS_FAILED);
|
||||||
|
})
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('next_retry_at')
|
||||||
|
->orWhere('next_retry_at', '<=', now());
|
||||||
|
})
|
||||||
|
->whereColumn('retry_count', '<', 'max_retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status Management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function markProcessing(): void
|
||||||
|
{
|
||||||
|
$this->update(['status' => self::STATUS_PROCESSING]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markCompleted(?array $parsedData = null): void
|
||||||
|
{
|
||||||
|
$update = [
|
||||||
|
'status' => self::STATUS_COMPLETED,
|
||||||
|
'processed_at' => now(),
|
||||||
|
'error_message' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($parsedData !== null) {
|
||||||
|
$update['parsed_data'] = $parsedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update($update);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(string $error): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_FAILED,
|
||||||
|
'processed_at' => now(),
|
||||||
|
'error_message' => $error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markSkipped(string $reason): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_SKIPPED,
|
||||||
|
'processed_at' => now(),
|
||||||
|
'error_message' => $reason,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a retry with exponential backoff.
|
||||||
|
*/
|
||||||
|
public function scheduleRetry(): void
|
||||||
|
{
|
||||||
|
$retryCount = $this->retry_count + 1;
|
||||||
|
$delaySeconds = (int) pow(2, $retryCount) * 30; // 30s, 60s, 120s, 240s...
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'retry_count' => $retryCount,
|
||||||
|
'next_retry_at' => now()->addSeconds($delaySeconds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// State Checks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function isPending(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProcessing(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_PROCESSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCompleted(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_COMPLETED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFailed(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasExceededMaxRetries(): bool
|
||||||
|
{
|
||||||
|
return $this->retry_count >= $this->max_retries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRetry(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_FAILED])
|
||||||
|
&& ! $this->hasExceededMaxRetries();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Display Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Flux badge colour for status.
|
||||||
|
*/
|
||||||
|
public function getStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
self::STATUS_PENDING => 'yellow',
|
||||||
|
self::STATUS_PROCESSING => 'blue',
|
||||||
|
self::STATUS_COMPLETED => 'green',
|
||||||
|
self::STATUS_FAILED => 'red',
|
||||||
|
self::STATUS_SKIPPED => 'zinc',
|
||||||
|
default => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon for status.
|
||||||
|
*/
|
||||||
|
public function getStatusIconAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->status) {
|
||||||
|
self::STATUS_PENDING => 'clock',
|
||||||
|
self::STATUS_PROCESSING => 'arrow-path',
|
||||||
|
self::STATUS_COMPLETED => 'check',
|
||||||
|
self::STATUS_FAILED => 'x-mark',
|
||||||
|
self::STATUS_SKIPPED => 'minus',
|
||||||
|
default => 'question-mark-circle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Flux badge colour for event type.
|
||||||
|
*/
|
||||||
|
public function getEventColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
str_contains($this->event_type, 'release') => 'green',
|
||||||
|
str_contains($this->event_type, 'publish') => 'blue',
|
||||||
|
str_contains($this->event_type, 'tag') => 'purple',
|
||||||
|
str_contains($this->event_type, 'update') => 'blue',
|
||||||
|
default => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Flux badge colour for signature status.
|
||||||
|
*/
|
||||||
|
public function getSignatureColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->signature_status) {
|
||||||
|
self::SIGNATURE_VALID => 'green',
|
||||||
|
self::SIGNATURE_INVALID => 'red',
|
||||||
|
self::SIGNATURE_MISSING => 'yellow',
|
||||||
|
default => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retry progress as a percentage.
|
||||||
|
*/
|
||||||
|
public function getRetryProgressAttribute(): int
|
||||||
|
{
|
||||||
|
if ($this->max_retries === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(($this->retry_count / $this->max_retries) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable retry status.
|
||||||
|
*/
|
||||||
|
public function getRetryStatusAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->status === self::STATUS_COMPLETED) {
|
||||||
|
return 'Completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasExceededMaxRetries()) {
|
||||||
|
return 'Exhausted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->next_retry_at && $this->next_retry_at->isFuture()) {
|
||||||
|
return "Retry #{$this->retry_count} at ".$this->next_retry_at->format('H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->retry_count > 0) {
|
||||||
|
return "Failed after {$this->retry_count} retries";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
230
Models/Vendor.php
Normal file
230
Models/Vendor.php
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendor - tracks upstream software sources.
|
||||||
|
*
|
||||||
|
* Supports licensed software, OSS repos, and plugin platforms.
|
||||||
|
*/
|
||||||
|
class Vendor extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
// Source types
|
||||||
|
public const SOURCE_LICENSED = 'licensed';
|
||||||
|
|
||||||
|
public const SOURCE_OSS = 'oss';
|
||||||
|
|
||||||
|
public const SOURCE_PLUGIN = 'plugin';
|
||||||
|
|
||||||
|
// Plugin platforms
|
||||||
|
public const PLATFORM_ALTUM = 'altum';
|
||||||
|
|
||||||
|
public const PLATFORM_WORDPRESS = 'wordpress';
|
||||||
|
|
||||||
|
public const PLATFORM_LARAVEL = 'laravel';
|
||||||
|
|
||||||
|
public const PLATFORM_OTHER = 'other';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
'vendor_name',
|
||||||
|
'source_type',
|
||||||
|
'plugin_platform',
|
||||||
|
'git_repo_url',
|
||||||
|
'current_version',
|
||||||
|
'previous_version',
|
||||||
|
'path_mapping',
|
||||||
|
'ignored_paths',
|
||||||
|
'priority_paths',
|
||||||
|
'target_repo',
|
||||||
|
'target_branch',
|
||||||
|
'is_active',
|
||||||
|
'last_checked_at',
|
||||||
|
'last_analyzed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'path_mapping' => 'array',
|
||||||
|
'ignored_paths' => 'array',
|
||||||
|
'priority_paths' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
'last_analyzed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function todos(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UpstreamTodo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VersionRelease::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AnalysisLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhooks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UptelligenceWebhook::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhookDeliveries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UptelligenceWebhookDelivery::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLicensed($query)
|
||||||
|
{
|
||||||
|
return $query->where('source_type', self::SOURCE_LICENSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOss($query)
|
||||||
|
{
|
||||||
|
return $query->where('source_type', self::SOURCE_OSS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePlugins($query)
|
||||||
|
{
|
||||||
|
return $query->where('source_type', self::SOURCE_PLUGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByPlatform($query, string $platform)
|
||||||
|
{
|
||||||
|
return $query->where('plugin_platform', $platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function getStoragePath(string $version = 'current'): string
|
||||||
|
{
|
||||||
|
return storage_path("app/vendors/{$this->slug}/{$version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldIgnorePath(string $path): bool
|
||||||
|
{
|
||||||
|
foreach ($this->ignored_paths ?? [] as $pattern) {
|
||||||
|
if (fnmatch($pattern, $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPriorityPath(string $path): bool
|
||||||
|
{
|
||||||
|
foreach ($this->priority_paths ?? [] as $pattern) {
|
||||||
|
if (fnmatch($pattern, $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mapToHostHub(string $upstreamPath): ?string
|
||||||
|
{
|
||||||
|
foreach ($this->path_mapping ?? [] as $from => $to) {
|
||||||
|
if (str_starts_with($upstreamPath, $from)) {
|
||||||
|
return str_replace($from, $to, $upstreamPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingTodosCount(): int
|
||||||
|
{
|
||||||
|
return $this->todos()->where('status', 'pending')->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuickWinsCount(): int
|
||||||
|
{
|
||||||
|
return $this->todos()
|
||||||
|
->where('status', 'pending')
|
||||||
|
->where('effort', 'low')
|
||||||
|
->where('priority', '>=', 5)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source type helpers
|
||||||
|
public function isLicensed(): bool
|
||||||
|
{
|
||||||
|
return $this->source_type === self::SOURCE_LICENSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOss(): bool
|
||||||
|
{
|
||||||
|
return $this->source_type === self::SOURCE_OSS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPlugin(): bool
|
||||||
|
{
|
||||||
|
return $this->source_type === self::SOURCE_PLUGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canGitSync(): bool
|
||||||
|
{
|
||||||
|
return $this->isOss() && ! empty($this->git_repo_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresManualUpload(): bool
|
||||||
|
{
|
||||||
|
return $this->isLicensed() || $this->isPlugin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSourceTypeLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->source_type) {
|
||||||
|
self::SOURCE_LICENSED => 'Licensed Software',
|
||||||
|
self::SOURCE_OSS => 'Open Source',
|
||||||
|
self::SOURCE_PLUGIN => 'Plugin',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSourceTypeIcon(): string
|
||||||
|
{
|
||||||
|
return match ($this->source_type) {
|
||||||
|
self::SOURCE_LICENSED => '🔐',
|
||||||
|
self::SOURCE_OSS => '🌐',
|
||||||
|
self::SOURCE_PLUGIN => '🔌',
|
||||||
|
default => '📦',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPlatformLabel(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->plugin_platform) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->plugin_platform) {
|
||||||
|
self::PLATFORM_ALTUM => 'Altum/phpBioLinks',
|
||||||
|
self::PLATFORM_WORDPRESS => 'WordPress',
|
||||||
|
self::PLATFORM_LARAVEL => 'Laravel Package',
|
||||||
|
default => 'Other',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
219
Models/VersionRelease.php
Normal file
219
Models/VersionRelease.php
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version Release - tracks a specific version of upstream software.
|
||||||
|
*
|
||||||
|
* Stores file changes, analysis results, and S3 archive status.
|
||||||
|
*/
|
||||||
|
class VersionRelease extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
// Storage disk options
|
||||||
|
public const DISK_LOCAL = 'local';
|
||||||
|
|
||||||
|
public const DISK_S3 = 's3';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'vendor_id',
|
||||||
|
'version',
|
||||||
|
'previous_version',
|
||||||
|
'files_added',
|
||||||
|
'files_modified',
|
||||||
|
'files_removed',
|
||||||
|
'todos_created',
|
||||||
|
'summary',
|
||||||
|
'storage_path',
|
||||||
|
'storage_disk',
|
||||||
|
's3_key',
|
||||||
|
'file_hash',
|
||||||
|
'file_size',
|
||||||
|
'metadata_json',
|
||||||
|
'analyzed_at',
|
||||||
|
'archived_at',
|
||||||
|
'last_downloaded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'summary' => 'array',
|
||||||
|
'metadata_json' => 'array',
|
||||||
|
'files_added' => 'integer',
|
||||||
|
'files_modified' => 'integer',
|
||||||
|
'files_removed' => 'integer',
|
||||||
|
'todos_created' => 'integer',
|
||||||
|
'file_size' => 'integer',
|
||||||
|
'analyzed_at' => 'datetime',
|
||||||
|
'archived_at' => 'datetime',
|
||||||
|
'last_downloaded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function vendor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Vendor::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function diffs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DiffCache::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AnalysisLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeAnalyzed($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('analyzed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePendingAnalysis($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('analyzed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRecent($query, int $days = 30)
|
||||||
|
{
|
||||||
|
return $query->where('created_at', '>=', now()->subDays($days));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeArchived($query)
|
||||||
|
{
|
||||||
|
return $query->where('storage_disk', self::DISK_S3)->whereNotNull('archived_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLocal($query)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) {
|
||||||
|
$q->where('storage_disk', self::DISK_LOCAL)
|
||||||
|
->orWhereNull('storage_disk');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNotArchived($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('archived_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
public function getTotalChanges(): int
|
||||||
|
{
|
||||||
|
return $this->files_added + $this->files_modified + $this->files_removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAnalyzed(): bool
|
||||||
|
{
|
||||||
|
return $this->analyzed_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersionCompare(): string
|
||||||
|
{
|
||||||
|
if ($this->previous_version) {
|
||||||
|
return "{$this->previous_version} → {$this->version}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStoragePath(): string
|
||||||
|
{
|
||||||
|
return $this->storage_path ?? storage_path("app/vendors/{$this->vendor->slug}/{$this->version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSummaryHighlights(): array
|
||||||
|
{
|
||||||
|
$summary = $this->summary ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'features' => $summary['features'] ?? [],
|
||||||
|
'fixes' => $summary['fixes'] ?? [],
|
||||||
|
'security' => $summary['security'] ?? [],
|
||||||
|
'breaking' => $summary['breaking_changes'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getImpactLevel(): string
|
||||||
|
{
|
||||||
|
$total = $this->getTotalChanges();
|
||||||
|
$security = $this->diffs()->where('category', 'security')->count();
|
||||||
|
|
||||||
|
if ($security > 0) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$total >= 100 => 'major',
|
||||||
|
$total >= 20 => 'moderate',
|
||||||
|
default => 'minor',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getImpactBadgeClass(): string
|
||||||
|
{
|
||||||
|
return match ($this->getImpactLevel()) {
|
||||||
|
'critical' => 'bg-red-100 text-red-800',
|
||||||
|
'major' => 'bg-orange-100 text-orange-800',
|
||||||
|
'moderate' => 'bg-yellow-100 text-yellow-800',
|
||||||
|
default => 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage helpers
|
||||||
|
public function isArchivedToS3(): bool
|
||||||
|
{
|
||||||
|
return $this->storage_disk === self::DISK_S3 && ! empty($this->s3_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocal(): bool
|
||||||
|
{
|
||||||
|
return $this->storage_disk === self::DISK_LOCAL || empty($this->storage_disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMetadata(): bool
|
||||||
|
{
|
||||||
|
return ! empty($this->metadata_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFileSizeForHumans(): string
|
||||||
|
{
|
||||||
|
if (! $this->file_size) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = $this->file_size;
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
|
||||||
|
|
||||||
|
return number_format($bytes / pow(1024, $power), 2).' '.$units[$power];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStorageStatusBadge(): array
|
||||||
|
{
|
||||||
|
if ($this->isArchivedToS3()) {
|
||||||
|
return [
|
||||||
|
'label' => 'S3 Archived',
|
||||||
|
'class' => 'bg-blue-100 text-blue-800',
|
||||||
|
'icon' => 'cloud',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => 'Local',
|
||||||
|
'class' => 'bg-gray-100 text-gray-800',
|
||||||
|
'icon' => 'folder',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
155
Notifications/NewReleaseDetected.php
Normal file
155
Notifications/NewReleaseDetected.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NewReleaseDetected - notification when a vendor releases a new version.
|
||||||
|
*
|
||||||
|
* Sent via webhook detection for immediate awareness of new releases.
|
||||||
|
*/
|
||||||
|
class NewReleaseDetected extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Vendor $vendor,
|
||||||
|
public string $version,
|
||||||
|
public array $releaseData = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$message = (new MailMessage)
|
||||||
|
->subject($this->getSubject())
|
||||||
|
->greeting('New Release Detected');
|
||||||
|
|
||||||
|
// Main announcement
|
||||||
|
$message->line("**{$this->vendor->name}** has released version **{$this->version}**.");
|
||||||
|
|
||||||
|
// Previous version context
|
||||||
|
if ($this->vendor->previous_version) {
|
||||||
|
$message->line("Previous version: {$this->vendor->previous_version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release details
|
||||||
|
if (! empty($this->releaseData['release_name'])) {
|
||||||
|
$message->line('---');
|
||||||
|
$message->line("**Release:** {$this->releaseData['release_name']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release notes excerpt
|
||||||
|
if (! empty($this->releaseData['body'])) {
|
||||||
|
$excerpt = $this->getBodyExcerpt($this->releaseData['body'], 200);
|
||||||
|
$message->line('**Notes:**');
|
||||||
|
$message->line($excerpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prerelease warning
|
||||||
|
if ($this->releaseData['prerelease'] ?? false) {
|
||||||
|
$message->line('---');
|
||||||
|
$message->line('This is a **pre-release** version.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call to action
|
||||||
|
$message->action('View in Dashboard', route('hub.admin.uptelligence.vendors'));
|
||||||
|
|
||||||
|
// Release URL
|
||||||
|
if (! empty($this->releaseData['url'])) {
|
||||||
|
$message->line('---');
|
||||||
|
$message->line("[View release on {$this->getProviderName()}]({$this->releaseData['url']})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$message->salutation('Host UK - Uptelligence');
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the subject line.
|
||||||
|
*/
|
||||||
|
protected function getSubject(): string
|
||||||
|
{
|
||||||
|
$prerelease = ($this->releaseData['prerelease'] ?? false) ? ' (pre-release)' : '';
|
||||||
|
|
||||||
|
return "New release: {$this->vendor->name} {$this->version}{$prerelease}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider name for display.
|
||||||
|
*/
|
||||||
|
protected function getProviderName(): string
|
||||||
|
{
|
||||||
|
$eventType = $this->releaseData['event_type'] ?? '';
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_starts_with($eventType, 'github.') => 'GitHub',
|
||||||
|
str_starts_with($eventType, 'gitlab.') => 'GitLab',
|
||||||
|
str_starts_with($eventType, 'npm.') => 'npm',
|
||||||
|
str_starts_with($eventType, 'packagist.') => 'Packagist',
|
||||||
|
default => 'source',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a truncated excerpt of the body text.
|
||||||
|
*/
|
||||||
|
protected function getBodyExcerpt(string $body, int $maxLength): string
|
||||||
|
{
|
||||||
|
// Remove markdown links for cleaner display
|
||||||
|
$text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $body);
|
||||||
|
|
||||||
|
// Remove excessive newlines
|
||||||
|
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||||
|
|
||||||
|
// Truncate
|
||||||
|
if (strlen($text) > $maxLength) {
|
||||||
|
$text = substr($text, 0, $maxLength);
|
||||||
|
// Try to end at a sentence or word boundary
|
||||||
|
if (preg_match('/^(.+[.!?])\s/', $text, $matches)) {
|
||||||
|
$text = $matches[1];
|
||||||
|
} else {
|
||||||
|
$text = preg_replace('/\s+\S*$/', '', $text).'...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vendor_id' => $this->vendor->id,
|
||||||
|
'vendor_name' => $this->vendor->name,
|
||||||
|
'version' => $this->version,
|
||||||
|
'previous_version' => $this->vendor->previous_version,
|
||||||
|
'prerelease' => $this->releaseData['prerelease'] ?? false,
|
||||||
|
'release_url' => $this->releaseData['url'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
257
Notifications/SendUptelligenceDigest.php
Normal file
257
Notifications/SendUptelligenceDigest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SendUptelligenceDigest - email notification for vendor update summaries.
|
||||||
|
*
|
||||||
|
* Sends a periodic digest of new releases, pending todos, and security
|
||||||
|
* updates from tracked upstream vendors.
|
||||||
|
*/
|
||||||
|
class SendUptelligenceDigest extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public UptelligenceDigest $digest,
|
||||||
|
public Collection $releases,
|
||||||
|
public array $todosByPriority,
|
||||||
|
public int $securityCount,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$message = (new MailMessage)
|
||||||
|
->subject($this->getSubject())
|
||||||
|
->greeting($this->getGreeting());
|
||||||
|
|
||||||
|
// Add security alert if there are security updates
|
||||||
|
if ($this->securityCount > 0) {
|
||||||
|
$message->line($this->formatSecurityAlert());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary overview
|
||||||
|
$message->line($this->formatSummary());
|
||||||
|
|
||||||
|
// New releases section
|
||||||
|
if ($this->releases->isNotEmpty()) {
|
||||||
|
$message->line('---');
|
||||||
|
$message->line('**New Releases**');
|
||||||
|
|
||||||
|
foreach ($this->releases->take(5) as $release) {
|
||||||
|
$message->line($this->formatRelease($release));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->releases->count() > 5) {
|
||||||
|
$remaining = $this->releases->count() - 5;
|
||||||
|
$message->line("*...and {$remaining} more release(s)*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todos summary section
|
||||||
|
if (($this->todosByPriority['total'] ?? 0) > 0) {
|
||||||
|
$message->line('---');
|
||||||
|
$message->line('**Pending Work**');
|
||||||
|
$message->line($this->formatTodosBreakdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call to action
|
||||||
|
$message->action('View Dashboard', route('hub.admin.uptelligence.dashboard'));
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
$message->line('---');
|
||||||
|
$message->line($this->formatFrequencyNote());
|
||||||
|
$message->salutation('Host UK');
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the subject line based on content.
|
||||||
|
*/
|
||||||
|
protected function getSubject(): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($this->securityCount > 0) {
|
||||||
|
$parts[] = "{$this->securityCount} security";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->releases->isNotEmpty()) {
|
||||||
|
$count = $this->releases->count();
|
||||||
|
$parts[] = "{$count} release".($count !== 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalTodos = $this->todosByPriority['total'] ?? 0;
|
||||||
|
if ($totalTodos > 0) {
|
||||||
|
$parts[] = "{$totalTodos} pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
return 'Uptelligence digest - no new updates';
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = implode(', ', $parts);
|
||||||
|
|
||||||
|
return "Uptelligence digest - {$summary}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the greeting based on time of day.
|
||||||
|
*/
|
||||||
|
protected function getGreeting(): string
|
||||||
|
{
|
||||||
|
$hour = now()->hour;
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$hour < 12 => 'Good morning',
|
||||||
|
$hour < 17 => 'Good afternoon',
|
||||||
|
default => 'Good evening',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the security alert message.
|
||||||
|
*/
|
||||||
|
protected function formatSecurityAlert(): string
|
||||||
|
{
|
||||||
|
$plural = $this->securityCount !== 1 ? 's' : '';
|
||||||
|
|
||||||
|
return "**Security Alert:** {$this->securityCount} security-related update{$plural} "
|
||||||
|
.'require attention. Review these items as a priority.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the summary overview.
|
||||||
|
*/
|
||||||
|
protected function formatSummary(): string
|
||||||
|
{
|
||||||
|
$frequency = $this->digest->getFrequencyLabel();
|
||||||
|
$period = match ($this->digest->frequency) {
|
||||||
|
UptelligenceDigest::FREQUENCY_DAILY => 'the past day',
|
||||||
|
UptelligenceDigest::FREQUENCY_WEEKLY => 'the past week',
|
||||||
|
UptelligenceDigest::FREQUENCY_MONTHLY => 'the past month',
|
||||||
|
default => 'recently',
|
||||||
|
};
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($this->releases->isNotEmpty()) {
|
||||||
|
$count = $this->releases->count();
|
||||||
|
$parts[] = "{$count} new release".($count !== 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalTodos = $this->todosByPriority['total'] ?? 0;
|
||||||
|
if ($totalTodos > 0) {
|
||||||
|
$parts[] = "{$totalTodos} pending task".($totalTodos !== 1 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
return "Your {$frequency} summary for {$period}: No significant updates.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = implode(' and ', $parts);
|
||||||
|
|
||||||
|
return "Your {$frequency} summary for {$period}: {$summary}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single release for the email.
|
||||||
|
*/
|
||||||
|
protected function formatRelease(array $release): string
|
||||||
|
{
|
||||||
|
$version = $release['version'];
|
||||||
|
$vendor = $release['vendor_name'];
|
||||||
|
$impact = ucfirst($release['impact_level']);
|
||||||
|
$changes = $release['files_changed'];
|
||||||
|
|
||||||
|
$previousVersion = $release['previous_version'];
|
||||||
|
$versionText = $previousVersion
|
||||||
|
? "{$previousVersion} to {$version}"
|
||||||
|
: $version;
|
||||||
|
|
||||||
|
return "- **{$vendor}** updated to {$versionText} ({$changes} files, {$impact} impact)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the todos breakdown.
|
||||||
|
*/
|
||||||
|
protected function formatTodosBreakdown(): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
$critical = $this->todosByPriority['critical'] ?? 0;
|
||||||
|
$high = $this->todosByPriority['high'] ?? 0;
|
||||||
|
$medium = $this->todosByPriority['medium'] ?? 0;
|
||||||
|
$low = $this->todosByPriority['low'] ?? 0;
|
||||||
|
|
||||||
|
if ($critical > 0) {
|
||||||
|
$parts[] = "{$critical} critical";
|
||||||
|
}
|
||||||
|
if ($high > 0) {
|
||||||
|
$parts[] = "{$high} high priority";
|
||||||
|
}
|
||||||
|
if ($medium > 0) {
|
||||||
|
$parts[] = "{$medium} medium";
|
||||||
|
}
|
||||||
|
if ($low > 0) {
|
||||||
|
$parts[] = "{$low} low";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
return 'No pending tasks at this time.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $parts).' items awaiting review.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the frequency note.
|
||||||
|
*/
|
||||||
|
protected function formatFrequencyNote(): string
|
||||||
|
{
|
||||||
|
$frequency = strtolower($this->digest->getFrequencyLabel());
|
||||||
|
|
||||||
|
return "You receive this digest {$frequency}. "
|
||||||
|
.'Update your preferences in the Uptelligence settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'digest_id' => $this->digest->id,
|
||||||
|
'workspace_id' => $this->digest->workspace_id,
|
||||||
|
'releases_count' => $this->releases->count(),
|
||||||
|
'todos_total' => $this->todosByPriority['total'] ?? 0,
|
||||||
|
'security_count' => $this->securityCount,
|
||||||
|
'frequency' => $this->digest->frequency,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
479
Services/AIAnalyzerService.php
Normal file
479
Services/AIAnalyzerService.php
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Core\Uptelligence\Models\AnalysisLog;
|
||||||
|
use Core\Uptelligence\Models\DiffCache;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Analyzer Service - uses AI to analyse version releases and create todos.
|
||||||
|
*
|
||||||
|
* Supports both Anthropic Claude and OpenAI APIs.
|
||||||
|
*/
|
||||||
|
class AIAnalyzerService
|
||||||
|
{
|
||||||
|
protected string $provider;
|
||||||
|
|
||||||
|
protected string $model;
|
||||||
|
|
||||||
|
protected string $apiKey;
|
||||||
|
|
||||||
|
protected int $maxTokens;
|
||||||
|
|
||||||
|
protected float $temperature;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$config = config('upstream.ai');
|
||||||
|
$this->provider = $config['provider'] ?? 'anthropic';
|
||||||
|
$this->model = $config['model'] ?? 'claude-sonnet-4-20250514';
|
||||||
|
$this->maxTokens = $config['max_tokens'] ?? 4096;
|
||||||
|
$this->temperature = $config['temperature'] ?? 0.3;
|
||||||
|
$this->apiKey = $this->provider === 'anthropic'
|
||||||
|
? config('services.anthropic.api_key')
|
||||||
|
: config('services.openai.api_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse a version release and create todos.
|
||||||
|
*/
|
||||||
|
public function analyzeRelease(VersionRelease $release): Collection
|
||||||
|
{
|
||||||
|
$diffs = $release->diffs;
|
||||||
|
$todos = collect();
|
||||||
|
|
||||||
|
// Group related diffs for batch analysis
|
||||||
|
$groups = $this->groupRelatedDiffs($diffs);
|
||||||
|
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$analysis = $this->analyzeGroup($release, $group);
|
||||||
|
|
||||||
|
if ($analysis && $this->shouldCreateTodo($analysis)) {
|
||||||
|
$todo = $this->createTodo($release, $group, $analysis);
|
||||||
|
$todos->push($todo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update release with AI-generated summary
|
||||||
|
$summary = $this->generateReleaseSummary($release, $todos);
|
||||||
|
$release->update(['summary' => $summary]);
|
||||||
|
|
||||||
|
return $todos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group related diffs together (e.g., controller + view + route).
|
||||||
|
*/
|
||||||
|
protected function groupRelatedDiffs(Collection $diffs): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
$processed = [];
|
||||||
|
|
||||||
|
foreach ($diffs as $diff) {
|
||||||
|
if (in_array($diff->id, $processed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$group = [$diff];
|
||||||
|
$processed[] = $diff->id;
|
||||||
|
|
||||||
|
// Find related files by common patterns
|
||||||
|
$baseName = $this->extractBaseName($diff->file_path);
|
||||||
|
|
||||||
|
foreach ($diffs as $related) {
|
||||||
|
if (in_array($related->id, $processed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->areRelated($diff, $related, $baseName)) {
|
||||||
|
$group[] = $related;
|
||||||
|
$processed[] = $related->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups[] = $group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract base name from file path for grouping.
|
||||||
|
*/
|
||||||
|
protected function extractBaseName(string $path): string
|
||||||
|
{
|
||||||
|
$filename = pathinfo($path, PATHINFO_FILENAME);
|
||||||
|
|
||||||
|
// Remove common suffixes
|
||||||
|
$filename = preg_replace('/(Controller|Model|Service|View|Block)$/i', '', $filename);
|
||||||
|
|
||||||
|
return strtolower($filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two diffs are related.
|
||||||
|
*/
|
||||||
|
protected function areRelated(DiffCache $diff1, DiffCache $diff2, string $baseName): bool
|
||||||
|
{
|
||||||
|
// Same directory
|
||||||
|
if (dirname($diff1->file_path) === dirname($diff2->file_path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same base name in different directories
|
||||||
|
$name2 = $this->extractBaseName($diff2->file_path);
|
||||||
|
if ($baseName && $baseName === $name2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse a group of related diffs using AI.
|
||||||
|
*/
|
||||||
|
protected function analyzeGroup(VersionRelease $release, array $diffs): ?array
|
||||||
|
{
|
||||||
|
// Build context for AI
|
||||||
|
$context = $this->buildContext($release, $diffs);
|
||||||
|
|
||||||
|
// Call AI API
|
||||||
|
$prompt = $this->buildAnalysisPrompt($context);
|
||||||
|
$response = $this->callAI($prompt);
|
||||||
|
|
||||||
|
if (! $response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseAnalysisResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build context string for AI.
|
||||||
|
*/
|
||||||
|
protected function buildContext(VersionRelease $release, array $diffs): string
|
||||||
|
{
|
||||||
|
$context = "Vendor: {$release->vendor->name}\n";
|
||||||
|
$context .= "Version: {$release->previous_version} → {$release->version}\n\n";
|
||||||
|
$context .= "Changed files:\n";
|
||||||
|
|
||||||
|
foreach ($diffs as $diff) {
|
||||||
|
$context .= "- [{$diff->change_type}] {$diff->file_path} ({$diff->category})\n";
|
||||||
|
|
||||||
|
// Include diff content for modified files (truncated)
|
||||||
|
if ($diff->diff_content && strlen($diff->diff_content) < 5000) {
|
||||||
|
$context .= "```diff\n".$diff->diff_content."\n```\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the analysis prompt.
|
||||||
|
*/
|
||||||
|
protected function buildAnalysisPrompt(string $context): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
Analyse the following code changes from an upstream vendor and categorise them for potential porting to our codebase.
|
||||||
|
|
||||||
|
{$context}
|
||||||
|
|
||||||
|
Please provide your analysis in the following JSON format:
|
||||||
|
{
|
||||||
|
"type": "feature|bugfix|security|ui|block|api|refactor|dependency",
|
||||||
|
"title": "Brief title describing the change",
|
||||||
|
"description": "Detailed description of what changed and why it might be valuable",
|
||||||
|
"priority": 1-10 (10 = most important, consider security > features > bugfixes > refactors),
|
||||||
|
"effort": "low|medium|high" (low = < 1 hour, medium = 1-4 hours, high = 4+ hours),
|
||||||
|
"has_conflicts": true|false (likely to conflict with our customisations?),
|
||||||
|
"conflict_reason": "If has_conflicts is true, explain why",
|
||||||
|
"port_notes": "Any specific notes for the developer who will port this",
|
||||||
|
"tags": ["relevant", "tags"],
|
||||||
|
"dependencies": ["list of other features this depends on"],
|
||||||
|
"skip_reason": null or "reason to skip this change"
|
||||||
|
}
|
||||||
|
|
||||||
|
Only return the JSON, no additional text.
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the AI API with rate limiting.
|
||||||
|
*/
|
||||||
|
protected function callAI(string $prompt): ?string
|
||||||
|
{
|
||||||
|
if (! $this->apiKey) {
|
||||||
|
Log::debug('Uptelligence: AI API key not configured, skipping analysis');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit before making API call
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-ai-api', 10)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-ai-api');
|
||||||
|
Log::warning('Uptelligence: AI API rate limit exceeded', [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'retry_after_seconds' => $seconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit('upstream-ai-api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($this->provider === 'anthropic') {
|
||||||
|
return $this->callAnthropic($prompt);
|
||||||
|
} else {
|
||||||
|
return $this->callOpenAI($prompt);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Uptelligence: AI API call failed', [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'model' => $this->model,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'exception_class' => get_class($e),
|
||||||
|
]);
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call Anthropic API with retry logic.
|
||||||
|
*/
|
||||||
|
protected function callAnthropic(string $prompt): ?string
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'x-api-key' => $this->apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
])
|
||||||
|
->timeout(60)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: Anthropic API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
// Only retry on connection/timeout errors or 5xx responses
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->post('https://api.anthropic.com/v1/messages', [
|
||||||
|
'model' => $this->model,
|
||||||
|
'max_tokens' => $this->maxTokens,
|
||||||
|
'temperature' => $this->temperature,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => $prompt],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json('content.0.text');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Uptelligence: Anthropic API request failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => substr($response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call OpenAI API with retry logic.
|
||||||
|
*/
|
||||||
|
protected function callOpenAI(string $prompt): ?string
|
||||||
|
{
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
])
|
||||||
|
->timeout(60)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: OpenAI API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
// Only retry on connection/timeout errors or 5xx responses
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->post('https://api.openai.com/v1/chat/completions', [
|
||||||
|
'model' => $this->model,
|
||||||
|
'max_tokens' => $this->maxTokens,
|
||||||
|
'temperature' => $this->temperature,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => $prompt],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json('choices.0.message.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Uptelligence: OpenAI API request failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => substr($response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse AI response into structured data.
|
||||||
|
*/
|
||||||
|
protected function parseAnalysisResponse(string $response): ?array
|
||||||
|
{
|
||||||
|
// Extract JSON from response
|
||||||
|
$json = $response;
|
||||||
|
if (preg_match('/```json\s*(.*?)\s*```/s', $response, $matches)) {
|
||||||
|
$json = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if we should create a todo from analysis.
|
||||||
|
*/
|
||||||
|
protected function shouldCreateTodo(array $analysis): bool
|
||||||
|
{
|
||||||
|
// Skip if explicitly marked to skip
|
||||||
|
if (! empty($analysis['skip_reason'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip very low priority refactors
|
||||||
|
if ($analysis['type'] === 'refactor' && ($analysis['priority'] ?? 5) < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a todo from analysis.
|
||||||
|
*/
|
||||||
|
protected function createTodo(VersionRelease $release, array $diffs, array $analysis): UpstreamTodo
|
||||||
|
{
|
||||||
|
$files = array_map(fn ($d) => $d->file_path, $diffs);
|
||||||
|
|
||||||
|
$todo = UpstreamTodo::create([
|
||||||
|
'vendor_id' => $release->vendor_id,
|
||||||
|
'from_version' => $release->previous_version,
|
||||||
|
'to_version' => $release->version,
|
||||||
|
'type' => $analysis['type'] ?? 'feature',
|
||||||
|
'status' => UpstreamTodo::STATUS_PENDING,
|
||||||
|
'title' => $analysis['title'] ?? 'Untitled change',
|
||||||
|
'description' => $analysis['description'] ?? null,
|
||||||
|
'port_notes' => $analysis['port_notes'] ?? null,
|
||||||
|
'priority' => $analysis['priority'] ?? 5,
|
||||||
|
'effort' => $analysis['effort'] ?? UpstreamTodo::EFFORT_MEDIUM,
|
||||||
|
'has_conflicts' => $analysis['has_conflicts'] ?? false,
|
||||||
|
'conflict_reason' => $analysis['conflict_reason'] ?? null,
|
||||||
|
'files' => $files,
|
||||||
|
'dependencies' => $analysis['dependencies'] ?? [],
|
||||||
|
'tags' => $analysis['tags'] ?? [],
|
||||||
|
'ai_analysis' => $analysis,
|
||||||
|
'ai_confidence' => 0.85, // Default confidence
|
||||||
|
]);
|
||||||
|
|
||||||
|
AnalysisLog::logTodoCreated($todo);
|
||||||
|
|
||||||
|
// Update release todos count
|
||||||
|
$release->increment('todos_created');
|
||||||
|
|
||||||
|
return $todo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI summary of the release.
|
||||||
|
*/
|
||||||
|
protected function generateReleaseSummary(VersionRelease $release, Collection $todos): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'overview' => $this->generateOverviewText($release, $todos),
|
||||||
|
'features' => $todos->where('type', 'feature')->pluck('title')->toArray(),
|
||||||
|
'fixes' => $todos->where('type', 'bugfix')->pluck('title')->toArray(),
|
||||||
|
'security' => $todos->where('type', 'security')->pluck('title')->toArray(),
|
||||||
|
'breaking_changes' => $todos->where('has_conflicts', true)->pluck('title')->toArray(),
|
||||||
|
'quick_wins' => $todos->filter->isQuickWin()->pluck('title')->toArray(),
|
||||||
|
'stats' => [
|
||||||
|
'total_todos' => $todos->count(),
|
||||||
|
'by_type' => $todos->groupBy('type')->map->count()->toArray(),
|
||||||
|
'by_effort' => $todos->groupBy('effort')->map->count()->toArray(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate overview text.
|
||||||
|
*/
|
||||||
|
protected function generateOverviewText(VersionRelease $release, Collection $todos): string
|
||||||
|
{
|
||||||
|
$features = $todos->where('type', 'feature')->count();
|
||||||
|
$security = $todos->where('type', 'security')->count();
|
||||||
|
$quickWins = $todos->filter->isQuickWin()->count();
|
||||||
|
|
||||||
|
$text = "Version {$release->version} contains {$todos->count()} notable changes";
|
||||||
|
|
||||||
|
if ($features > 0) {
|
||||||
|
$text .= ", including {$features} new feature(s)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($security > 0) {
|
||||||
|
$text .= ". {$security} security-related update(s) require attention";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quickWins > 0) {
|
||||||
|
$text .= ". {$quickWins} quick win(s) can be ported easily";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text.'.';
|
||||||
|
}
|
||||||
|
}
|
||||||
439
Services/AssetTrackerService.php
Normal file
439
Services/AssetTrackerService.php
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Core\Uptelligence\Models\Asset;
|
||||||
|
use Core\Uptelligence\Models\AssetVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset Tracker Service - monitors and updates package dependencies.
|
||||||
|
*
|
||||||
|
* Checks Packagist, NPM, and custom registries for updates.
|
||||||
|
*/
|
||||||
|
class AssetTrackerService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check all active assets for updates.
|
||||||
|
*/
|
||||||
|
public function checkAllForUpdates(): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach (Asset::active()->get() as $asset) {
|
||||||
|
$results[$asset->slug] = $this->checkForUpdate($asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a single asset for updates.
|
||||||
|
*/
|
||||||
|
public function checkForUpdate(Asset $asset): array
|
||||||
|
{
|
||||||
|
$result = match ($asset->type) {
|
||||||
|
Asset::TYPE_COMPOSER => $this->checkComposerPackage($asset),
|
||||||
|
Asset::TYPE_NPM => $this->checkNpmPackage($asset),
|
||||||
|
Asset::TYPE_FONT => $this->checkFontAsset($asset),
|
||||||
|
default => ['status' => 'skipped', 'message' => 'No auto-check for this type'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$asset->update(['last_checked_at' => now()]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Composer package for updates with rate limiting and retry logic.
|
||||||
|
*/
|
||||||
|
protected function checkComposerPackage(Asset $asset): array
|
||||||
|
{
|
||||||
|
if (! $asset->package_name) {
|
||||||
|
return ['status' => 'error', 'message' => 'No package name configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit before making API call
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'rate_limited',
|
||||||
|
'message' => "Rate limit exceeded. Retry after {$seconds} seconds",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit('upstream-registry');
|
||||||
|
|
||||||
|
// Try Packagist first with retry logic
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: Packagist API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->get("https://repo.packagist.org/p2/{$asset->package_name}.json");
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json();
|
||||||
|
$packages = $data['packages'][$asset->package_name] ?? [];
|
||||||
|
|
||||||
|
if (! empty($packages)) {
|
||||||
|
// Get latest stable version
|
||||||
|
$latest = collect($packages)
|
||||||
|
->filter(fn ($p) => ! str_contains($p['version'] ?? '', 'dev'))
|
||||||
|
->sortByDesc('version')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latest) {
|
||||||
|
$latestVersion = ltrim($latest['version'], 'v');
|
||||||
|
$hasUpdate = $asset->installed_version &&
|
||||||
|
version_compare($latestVersion, $asset->installed_version, '>');
|
||||||
|
|
||||||
|
$asset->update(['latest_version' => $latestVersion]);
|
||||||
|
|
||||||
|
// Record version if new
|
||||||
|
$this->recordVersion($asset, $latestVersion, $latest);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'latest' => $latestVersion,
|
||||||
|
'has_update' => $hasUpdate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::warning('Uptelligence: Packagist API request failed', [
|
||||||
|
'package' => $asset->package_name,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try custom registry (e.g., Flux Pro)
|
||||||
|
if ($asset->registry_url) {
|
||||||
|
return $this->checkCustomComposerRegistry($asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'error', 'message' => 'Could not fetch package info'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check custom Composer registry (like Flux Pro).
|
||||||
|
*/
|
||||||
|
protected function checkCustomComposerRegistry(Asset $asset): array
|
||||||
|
{
|
||||||
|
// For licensed packages, we need to check the installed version via composer show
|
||||||
|
$result = Process::run("composer show {$asset->package_name} --format=json 2>/dev/null");
|
||||||
|
|
||||||
|
if ($result->successful()) {
|
||||||
|
$data = json_decode($result->output(), true);
|
||||||
|
$installedVersion = $data['versions'][0] ?? null;
|
||||||
|
|
||||||
|
if ($installedVersion) {
|
||||||
|
$asset->update(['installed_version' => $installedVersion]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'installed' => $installedVersion,
|
||||||
|
'message' => 'Check registry manually for latest version',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'info', 'message' => 'Licensed package - check registry manually'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check NPM package for updates with rate limiting and retry logic.
|
||||||
|
*/
|
||||||
|
protected function checkNpmPackage(Asset $asset): array
|
||||||
|
{
|
||||||
|
if (! $asset->package_name) {
|
||||||
|
return ['status' => 'error', 'message' => 'No package name configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit before making API call
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'rate_limited',
|
||||||
|
'message' => "Rate limit exceeded. Retry after {$seconds} seconds",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit('upstream-registry');
|
||||||
|
|
||||||
|
// Check npm registry with retry logic
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: NPM registry API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->get("https://registry.npmjs.org/{$asset->package_name}");
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json();
|
||||||
|
$latestVersion = $data['dist-tags']['latest'] ?? null;
|
||||||
|
|
||||||
|
if ($latestVersion) {
|
||||||
|
$hasUpdate = $asset->installed_version &&
|
||||||
|
version_compare($latestVersion, $asset->installed_version, '>');
|
||||||
|
|
||||||
|
$asset->update(['latest_version' => $latestVersion]);
|
||||||
|
|
||||||
|
// Record version if new
|
||||||
|
$versionData = $data['versions'][$latestVersion] ?? [];
|
||||||
|
$this->recordVersion($asset, $latestVersion, $versionData);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'latest' => $latestVersion,
|
||||||
|
'has_update' => $hasUpdate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::warning('Uptelligence: NPM registry API request failed', [
|
||||||
|
'package' => $asset->package_name,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for scoped/private packages via npm view
|
||||||
|
$result = Process::run("npm view {$asset->package_name} version 2>/dev/null");
|
||||||
|
if ($result->successful()) {
|
||||||
|
$latestVersion = trim($result->output());
|
||||||
|
if ($latestVersion) {
|
||||||
|
$asset->update(['latest_version' => $latestVersion]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'latest' => $latestVersion,
|
||||||
|
'has_update' => $asset->installed_version &&
|
||||||
|
version_compare($latestVersion, $asset->installed_version, '>'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'error', 'message' => 'Could not fetch package info'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Font Awesome kit for updates.
|
||||||
|
*/
|
||||||
|
protected function checkFontAsset(Asset $asset): array
|
||||||
|
{
|
||||||
|
// Font Awesome kits auto-update, just verify the kit is valid
|
||||||
|
$kitId = $asset->licence_meta['kit_id'] ?? null;
|
||||||
|
|
||||||
|
if (! $kitId) {
|
||||||
|
return ['status' => 'info', 'message' => 'No kit ID configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't easily check FA API without auth, mark as checked
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Font kit configured - auto-updates via CDN',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a release timestamp safely.
|
||||||
|
*
|
||||||
|
* Handles various timestamp formats from Packagist and NPM.
|
||||||
|
*/
|
||||||
|
protected function parseReleaseTimestamp(?string $time): ?Carbon
|
||||||
|
{
|
||||||
|
if (empty($time)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($time);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('Uptelligence: Failed to parse release timestamp', [
|
||||||
|
'time' => $time,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a new version in history.
|
||||||
|
*/
|
||||||
|
protected function recordVersion(Asset $asset, string $version, array $data = []): void
|
||||||
|
{
|
||||||
|
$releasedAt = $this->parseReleaseTimestamp($data['time'] ?? null);
|
||||||
|
|
||||||
|
AssetVersion::updateOrCreate(
|
||||||
|
[
|
||||||
|
'asset_id' => $asset->id,
|
||||||
|
'version' => $version,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'changelog' => $data['description'] ?? null,
|
||||||
|
'download_url' => $data['dist']['url'] ?? null,
|
||||||
|
'released_at' => $releasedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an asset to its latest version.
|
||||||
|
*/
|
||||||
|
public function updateAsset(Asset $asset): array
|
||||||
|
{
|
||||||
|
return match ($asset->type) {
|
||||||
|
Asset::TYPE_COMPOSER => $this->updateComposerPackage($asset),
|
||||||
|
Asset::TYPE_NPM => $this->updateNpmPackage($asset),
|
||||||
|
default => ['status' => 'skipped', 'message' => 'Manual update required'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a Composer package.
|
||||||
|
*/
|
||||||
|
protected function updateComposerPackage(Asset $asset): array
|
||||||
|
{
|
||||||
|
if (! $asset->package_name) {
|
||||||
|
return ['status' => 'error', 'message' => 'No package name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Process::timeout(300)->run(
|
||||||
|
"composer update {$asset->package_name} --no-interaction"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->successful()) {
|
||||||
|
// Get new installed version
|
||||||
|
$showResult = Process::run("composer show {$asset->package_name} --format=json");
|
||||||
|
if ($showResult->successful()) {
|
||||||
|
$data = json_decode($showResult->output(), true);
|
||||||
|
$newVersion = $data['versions'][0] ?? $asset->latest_version;
|
||||||
|
$asset->update(['installed_version' => $newVersion]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'success', 'message' => 'Package updated'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'error', 'message' => $result->errorOutput()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an NPM package.
|
||||||
|
*/
|
||||||
|
protected function updateNpmPackage(Asset $asset): array
|
||||||
|
{
|
||||||
|
if (! $asset->package_name) {
|
||||||
|
return ['status' => 'error', 'message' => 'No package name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Process::timeout(300)->run("npm update {$asset->package_name}");
|
||||||
|
|
||||||
|
if ($result->successful()) {
|
||||||
|
$asset->update(['installed_version' => $asset->latest_version]);
|
||||||
|
|
||||||
|
return ['status' => 'success', 'message' => 'Package updated'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'error', 'message' => $result->errorOutput()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync installed versions from composer.lock and package-lock.json.
|
||||||
|
*/
|
||||||
|
public function syncInstalledVersions(string $projectPath): array
|
||||||
|
{
|
||||||
|
$synced = [];
|
||||||
|
|
||||||
|
// Sync from composer.lock
|
||||||
|
$composerLock = $projectPath.'/composer.lock';
|
||||||
|
if (file_exists($composerLock)) {
|
||||||
|
$lock = json_decode(file_get_contents($composerLock), true);
|
||||||
|
$packages = array_merge(
|
||||||
|
$lock['packages'] ?? [],
|
||||||
|
$lock['packages-dev'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($packages as $package) {
|
||||||
|
$asset = Asset::where('package_name', $package['name'])
|
||||||
|
->where('type', Asset::TYPE_COMPOSER)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($asset) {
|
||||||
|
$version = ltrim($package['version'], 'v');
|
||||||
|
$asset->update(['installed_version' => $version]);
|
||||||
|
$synced[] = $asset->slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync from package-lock.json
|
||||||
|
$packageLock = $projectPath.'/package-lock.json';
|
||||||
|
if (file_exists($packageLock)) {
|
||||||
|
$lock = json_decode(file_get_contents($packageLock), true);
|
||||||
|
$packages = $lock['packages'] ?? [];
|
||||||
|
|
||||||
|
foreach ($packages as $name => $data) {
|
||||||
|
// Skip root package and nested deps
|
||||||
|
if (! $name || str_starts_with($name, 'node_modules/node_modules')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageName = str_replace('node_modules/', '', $name);
|
||||||
|
$asset = Asset::where('package_name', $packageName)
|
||||||
|
->where('type', Asset::TYPE_NPM)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($asset) {
|
||||||
|
$asset->update(['installed_version' => $data['version']]);
|
||||||
|
$synced[] = $asset->slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $synced;
|
||||||
|
}
|
||||||
|
}
|
||||||
334
Services/DiffAnalyzerService.php
Normal file
334
Services/DiffAnalyzerService.php
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Core\Uptelligence\Models\AnalysisLog;
|
||||||
|
use Core\Uptelligence\Models\DiffCache;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff Analyzer Service - analyses differences between vendor versions.
|
||||||
|
*
|
||||||
|
* Detects file changes and caches diffs for AI analysis.
|
||||||
|
*/
|
||||||
|
class DiffAnalyzerService
|
||||||
|
{
|
||||||
|
protected Vendor $vendor;
|
||||||
|
|
||||||
|
protected string $previousPath;
|
||||||
|
|
||||||
|
protected string $currentPath;
|
||||||
|
|
||||||
|
public function __construct(Vendor $vendor)
|
||||||
|
{
|
||||||
|
$this->vendor = $vendor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse differences between two versions.
|
||||||
|
*/
|
||||||
|
public function analyze(string $previousVersion, string $currentVersion): VersionRelease
|
||||||
|
{
|
||||||
|
$this->previousPath = $this->vendor->getStoragePath($previousVersion);
|
||||||
|
$this->currentPath = $this->vendor->getStoragePath($currentVersion);
|
||||||
|
|
||||||
|
// Create version release record
|
||||||
|
$release = VersionRelease::create([
|
||||||
|
'vendor_id' => $this->vendor->id,
|
||||||
|
'version' => $currentVersion,
|
||||||
|
'previous_version' => $previousVersion,
|
||||||
|
'storage_path' => $this->currentPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AnalysisLog::logAnalysisStarted($release);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all file changes
|
||||||
|
$changes = $this->getFileChanges();
|
||||||
|
|
||||||
|
// Cache the diffs
|
||||||
|
$stats = $this->cacheDiffs($release, $changes);
|
||||||
|
|
||||||
|
// Update release with stats
|
||||||
|
$release->update([
|
||||||
|
'files_added' => $stats['added'],
|
||||||
|
'files_modified' => $stats['modified'],
|
||||||
|
'files_removed' => $stats['removed'],
|
||||||
|
'analyzed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AnalysisLog::logAnalysisCompleted($release, $stats);
|
||||||
|
|
||||||
|
return $release;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
AnalysisLog::logAnalysisFailed($release, $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all file changes between versions using diff.
|
||||||
|
*/
|
||||||
|
protected function getFileChanges(): array
|
||||||
|
{
|
||||||
|
$changes = [
|
||||||
|
'added' => [],
|
||||||
|
'modified' => [],
|
||||||
|
'removed' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get list of all files in both versions
|
||||||
|
$previousFiles = $this->getFileList($this->previousPath);
|
||||||
|
$currentFiles = $this->getFileList($this->currentPath);
|
||||||
|
|
||||||
|
// Find added files
|
||||||
|
$addedFiles = array_diff($currentFiles, $previousFiles);
|
||||||
|
foreach ($addedFiles as $file) {
|
||||||
|
if (! $this->shouldIgnore($file)) {
|
||||||
|
$changes['added'][] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find removed files
|
||||||
|
$removedFiles = array_diff($previousFiles, $currentFiles);
|
||||||
|
foreach ($removedFiles as $file) {
|
||||||
|
if (! $this->shouldIgnore($file)) {
|
||||||
|
$changes['removed'][] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find modified files
|
||||||
|
$commonFiles = array_intersect($previousFiles, $currentFiles);
|
||||||
|
foreach ($commonFiles as $file) {
|
||||||
|
if ($this->shouldIgnore($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prevPath = $this->previousPath.'/'.$file;
|
||||||
|
$currPath = $this->currentPath.'/'.$file;
|
||||||
|
|
||||||
|
if ($this->filesAreDifferent($prevPath, $currPath)) {
|
||||||
|
$changes['modified'][] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all files in a directory recursively.
|
||||||
|
*/
|
||||||
|
protected function getFileList(string $basePath): array
|
||||||
|
{
|
||||||
|
if (! File::isDirectory($basePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$relativePath = str_replace($basePath.'/', '', $file->getPathname());
|
||||||
|
$files[] = $relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file should be ignored.
|
||||||
|
*/
|
||||||
|
protected function shouldIgnore(string $path): bool
|
||||||
|
{
|
||||||
|
return $this->vendor->shouldIgnorePath($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two files are different.
|
||||||
|
*/
|
||||||
|
protected function filesAreDifferent(string $path1, string $path2): bool
|
||||||
|
{
|
||||||
|
if (! File::exists($path1) || ! File::exists($path2)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick hash comparison
|
||||||
|
return md5_file($path1) !== md5_file($path2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache all diffs in the database.
|
||||||
|
*
|
||||||
|
* Uses a database transaction to ensure atomic operations -
|
||||||
|
* if any diff fails to save, all changes are rolled back.
|
||||||
|
*/
|
||||||
|
protected function cacheDiffs(VersionRelease $release, array $changes): array
|
||||||
|
{
|
||||||
|
$stats = ['added' => 0, 'modified' => 0, 'removed' => 0];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($release, $changes, &$stats) {
|
||||||
|
// Cache added files
|
||||||
|
foreach ($changes['added'] as $file) {
|
||||||
|
$filePath = $this->currentPath.'/'.$file;
|
||||||
|
$content = File::exists($filePath) ? File::get($filePath) : null;
|
||||||
|
|
||||||
|
DiffCache::create([
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'file_path' => $file,
|
||||||
|
'change_type' => DiffCache::CHANGE_ADDED,
|
||||||
|
'new_content' => $content,
|
||||||
|
'category' => DiffCache::detectCategory($file),
|
||||||
|
]);
|
||||||
|
$stats['added']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache modified files with diff
|
||||||
|
foreach ($changes['modified'] as $file) {
|
||||||
|
$diff = $this->generateDiff($file);
|
||||||
|
|
||||||
|
DiffCache::create([
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'file_path' => $file,
|
||||||
|
'change_type' => DiffCache::CHANGE_MODIFIED,
|
||||||
|
'diff_content' => $diff,
|
||||||
|
'category' => DiffCache::detectCategory($file),
|
||||||
|
]);
|
||||||
|
$stats['modified']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache removed files
|
||||||
|
foreach ($changes['removed'] as $file) {
|
||||||
|
DiffCache::create([
|
||||||
|
'version_release_id' => $release->id,
|
||||||
|
'file_path' => $file,
|
||||||
|
'change_type' => DiffCache::CHANGE_REMOVED,
|
||||||
|
'category' => DiffCache::detectCategory($file),
|
||||||
|
]);
|
||||||
|
$stats['removed']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a path is safe and doesn't contain path traversal attempts.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException if path is invalid
|
||||||
|
*/
|
||||||
|
protected function validatePath(string $path, string $basePath): string
|
||||||
|
{
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if (str_contains($path, '..') || str_contains($path, "\0")) {
|
||||||
|
Log::warning('Uptelligence: Path traversal attempt detected', [
|
||||||
|
'path' => $path,
|
||||||
|
'basePath' => $basePath,
|
||||||
|
]);
|
||||||
|
throw new InvalidArgumentException('Invalid path: path traversal not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = $basePath.'/'.$path;
|
||||||
|
$realPath = realpath($fullPath);
|
||||||
|
$realBasePath = realpath($basePath);
|
||||||
|
|
||||||
|
// If path doesn't exist yet, validate the directory portion
|
||||||
|
if ($realPath === false) {
|
||||||
|
$dirPath = dirname($fullPath);
|
||||||
|
$realDirPath = realpath($dirPath);
|
||||||
|
|
||||||
|
if ($realDirPath === false || ! str_starts_with($realDirPath, $realBasePath)) {
|
||||||
|
Log::warning('Uptelligence: Path escapes base directory', [
|
||||||
|
'path' => $path,
|
||||||
|
'basePath' => $basePath,
|
||||||
|
]);
|
||||||
|
throw new InvalidArgumentException('Invalid path: must be within base directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the real path is within the base path
|
||||||
|
if (! str_starts_with($realPath, $realBasePath)) {
|
||||||
|
Log::warning('Uptelligence: Path escapes base directory', [
|
||||||
|
'path' => $path,
|
||||||
|
'realPath' => $realPath,
|
||||||
|
'basePath' => $basePath,
|
||||||
|
]);
|
||||||
|
throw new InvalidArgumentException('Invalid path: must be within base directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $realPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate diff for a file.
|
||||||
|
*
|
||||||
|
* Uses array-based Process invocation to prevent shell injection.
|
||||||
|
* Validates paths to prevent path traversal attacks.
|
||||||
|
*/
|
||||||
|
protected function generateDiff(string $file): string
|
||||||
|
{
|
||||||
|
// Validate paths before using them
|
||||||
|
$prevPath = $this->validatePath($file, $this->previousPath);
|
||||||
|
$currPath = $this->validatePath($file, $this->currentPath);
|
||||||
|
|
||||||
|
// Use array syntax to prevent shell injection - paths are passed as separate arguments
|
||||||
|
// rather than being interpolated into a shell command string
|
||||||
|
$result = Process::run(['diff', '-u', $prevPath, $currPath]);
|
||||||
|
|
||||||
|
return $result->output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get priority files that changed.
|
||||||
|
*/
|
||||||
|
public function getPriorityChanges(VersionRelease $release): Collection
|
||||||
|
{
|
||||||
|
return $release->diffs()
|
||||||
|
->get()
|
||||||
|
->filter(fn ($diff) => $this->vendor->isPriorityPath($diff->file_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security-related changes.
|
||||||
|
*/
|
||||||
|
public function getSecurityChanges(VersionRelease $release): Collection
|
||||||
|
{
|
||||||
|
return $release->diffs()
|
||||||
|
->where('category', DiffCache::CATEGORY_SECURITY)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate summary statistics.
|
||||||
|
*/
|
||||||
|
public function getSummary(VersionRelease $release): array
|
||||||
|
{
|
||||||
|
$diffs = $release->diffs;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_changes' => $diffs->count(),
|
||||||
|
'by_type' => [
|
||||||
|
'added' => $diffs->where('change_type', DiffCache::CHANGE_ADDED)->count(),
|
||||||
|
'modified' => $diffs->where('change_type', DiffCache::CHANGE_MODIFIED)->count(),
|
||||||
|
'removed' => $diffs->where('change_type', DiffCache::CHANGE_REMOVED)->count(),
|
||||||
|
],
|
||||||
|
'by_category' => $diffs->groupBy('category')->map->count()->toArray(),
|
||||||
|
'priority_files' => $this->getPriorityChanges($release)->count(),
|
||||||
|
'security_files' => $this->getSecurityChanges($release)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
474
Services/IssueGeneratorService.php
Normal file
474
Services/IssueGeneratorService.php
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Core\Uptelligence\Models\AnalysisLog;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue Generator Service - creates GitHub/Gitea issues from upstream todos.
|
||||||
|
*
|
||||||
|
* Generates individual issues and weekly digests for tracking porting work.
|
||||||
|
*/
|
||||||
|
class IssueGeneratorService
|
||||||
|
{
|
||||||
|
protected string $githubToken;
|
||||||
|
|
||||||
|
protected string $giteaUrl;
|
||||||
|
|
||||||
|
protected string $giteaToken;
|
||||||
|
|
||||||
|
protected array $defaultLabels;
|
||||||
|
|
||||||
|
protected array $assignees;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->githubToken = config('upstream.github.token', '');
|
||||||
|
$this->giteaUrl = config('upstream.gitea.url', '');
|
||||||
|
$this->giteaToken = config('upstream.gitea.token', '');
|
||||||
|
$this->defaultLabels = config('upstream.github.default_labels', ['upstream']);
|
||||||
|
$this->assignees = array_filter(config('upstream.github.assignees', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate target_repo format (should be 'owner/repo').
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException if format is invalid
|
||||||
|
*/
|
||||||
|
protected function validateTargetRepo(?string $targetRepo): bool
|
||||||
|
{
|
||||||
|
if (empty($targetRepo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be in format 'owner/repo' with no extra slashes
|
||||||
|
if (! preg_match('#^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$#', $targetRepo)) {
|
||||||
|
Log::warning('Uptelligence: Invalid target_repo format', [
|
||||||
|
'target_repo' => $targetRepo,
|
||||||
|
'expected_format' => 'owner/repo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create GitHub issues for all pending todos.
|
||||||
|
*/
|
||||||
|
public function createIssuesForVendor(Vendor $vendor, bool $useGitea = false): Collection
|
||||||
|
{
|
||||||
|
// Validate target_repo format before processing
|
||||||
|
if (! $this->validateTargetRepo($vendor->target_repo)) {
|
||||||
|
Log::error('Uptelligence: Cannot create issues - invalid target_repo', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'target_repo' => $vendor->target_repo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$todos = $vendor->todos()
|
||||||
|
->where('status', UpstreamTodo::STATUS_PENDING)
|
||||||
|
->whereNull('github_issue_number')
|
||||||
|
->orderByDesc('priority')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$issues = collect();
|
||||||
|
|
||||||
|
foreach ($todos as $todo) {
|
||||||
|
// Check rate limit before creating issue
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-issues', 10)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-issues');
|
||||||
|
Log::warning('Uptelligence: Issue creation rate limit exceeded', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'retry_after_seconds' => $seconds,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($useGitea) {
|
||||||
|
$issue = $this->createGiteaIssue($todo);
|
||||||
|
} else {
|
||||||
|
$issue = $this->createGitHubIssue($todo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issue) {
|
||||||
|
$issues->push($issue);
|
||||||
|
RateLimiter::hit('upstream-issues');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Uptelligence: Failed to create issue', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'todo_title' => $todo->title,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'exception_class' => get_class($e),
|
||||||
|
]);
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a GitHub issue for a todo with retry logic.
|
||||||
|
*/
|
||||||
|
public function createGitHubIssue(UpstreamTodo $todo): ?array
|
||||||
|
{
|
||||||
|
if (! $this->githubToken || ! $this->validateTargetRepo($todo->vendor->target_repo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $this->buildIssueBody($todo);
|
||||||
|
$labels = $this->buildLabels($todo);
|
||||||
|
|
||||||
|
[$owner, $repo] = explode('/', $todo->vendor->target_repo);
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->githubToken,
|
||||||
|
'Accept' => 'application/vnd.github.v3+json',
|
||||||
|
])
|
||||||
|
->timeout(30)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: GitHub API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
// Only retry on connection/timeout errors or 5xx/429 responses
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [
|
||||||
|
'title' => $this->buildIssueTitle($todo),
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => $labels,
|
||||||
|
'assignees' => $this->assignees,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$issue = $response->json();
|
||||||
|
|
||||||
|
$todo->update([
|
||||||
|
'github_issue_number' => $issue['number'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
AnalysisLog::logIssueCreated($todo, $issue['html_url']);
|
||||||
|
|
||||||
|
return $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Uptelligence: GitHub issue creation failed', [
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => substr($response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Gitea issue for a todo with retry logic.
|
||||||
|
*/
|
||||||
|
public function createGiteaIssue(UpstreamTodo $todo): ?array
|
||||||
|
{
|
||||||
|
if (! $this->giteaToken || ! $this->giteaUrl || ! $this->validateTargetRepo($todo->vendor->target_repo)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $this->buildIssueBody($todo);
|
||||||
|
|
||||||
|
[$owner, $repo] = explode('/', $todo->vendor->target_repo);
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'token '.$this->giteaToken,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])
|
||||||
|
->timeout(30)
|
||||||
|
->retry(3, function (int $attempt, \Exception $exception) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
$delay = (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
|
||||||
|
Log::warning('Uptelligence: Gitea API retry', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'delay_ms' => $delay,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
// Only retry on connection/timeout errors or 5xx/429 responses
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
->post("{$this->giteaUrl}/api/v1/repos/{$owner}/{$repo}/issues", [
|
||||||
|
'title' => $this->buildIssueTitle($todo),
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => [], // Gitea handles labels differently
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$issue = $response->json();
|
||||||
|
|
||||||
|
$todo->update([
|
||||||
|
'github_issue_number' => (string) $issue['number'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$issueUrl = "{$this->giteaUrl}/{$owner}/{$repo}/issues/{$issue['number']}";
|
||||||
|
AnalysisLog::logIssueCreated($todo, $issueUrl);
|
||||||
|
|
||||||
|
return $issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::error('Uptelligence: Gitea issue creation failed', [
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => substr($response->body(), 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build issue title.
|
||||||
|
*/
|
||||||
|
protected function buildIssueTitle(UpstreamTodo $todo): string
|
||||||
|
{
|
||||||
|
$icon = $todo->getTypeIcon();
|
||||||
|
$prefix = '[Upstream] ';
|
||||||
|
|
||||||
|
return $prefix.$icon.' '.$todo->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build issue body with all relevant info.
|
||||||
|
*/
|
||||||
|
protected function buildIssueBody(UpstreamTodo $todo): string
|
||||||
|
{
|
||||||
|
$body = "## Upstream Change\n\n";
|
||||||
|
$body .= "**Vendor:** {$todo->vendor->name} ({$todo->vendor->vendor_name})\n";
|
||||||
|
$body .= "**Version:** {$todo->from_version} → {$todo->to_version}\n";
|
||||||
|
$body .= "**Type:** {$todo->type}\n";
|
||||||
|
$body .= "**Priority:** {$todo->priority}/10 ({$todo->getPriorityLabel()})\n";
|
||||||
|
$body .= "**Effort:** {$todo->getEffortLabel()}\n\n";
|
||||||
|
|
||||||
|
if ($todo->description) {
|
||||||
|
$body .= "## Description\n\n{$todo->description}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($todo->port_notes) {
|
||||||
|
$body .= "## Porting Notes\n\n{$todo->port_notes}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($todo->has_conflicts) {
|
||||||
|
$body .= "## ⚠️ Potential Conflicts\n\n{$todo->conflict_reason}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($todo->files)) {
|
||||||
|
$body .= "## Files Changed\n\n";
|
||||||
|
foreach ($todo->files as $file) {
|
||||||
|
$mapped = $todo->vendor->mapToHostHub($file);
|
||||||
|
if ($mapped) {
|
||||||
|
$body .= "- `{$file}` → `{$mapped}`\n";
|
||||||
|
} else {
|
||||||
|
$body .= "- `{$file}`\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($todo->dependencies)) {
|
||||||
|
$body .= "## Dependencies\n\n";
|
||||||
|
foreach ($todo->dependencies as $dep) {
|
||||||
|
$body .= "- {$dep}\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($todo->tags)) {
|
||||||
|
$body .= "## Tags\n\n";
|
||||||
|
$body .= implode(', ', array_map(fn ($t) => "`{$t}`", $todo->tags))."\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$body .= "---\n";
|
||||||
|
$body .= "_Auto-generated by Upstream Intelligence Pipeline_\n";
|
||||||
|
$body .= '_AI Confidence: '.round(($todo->ai_confidence ?? 0.85) * 100)."%_\n";
|
||||||
|
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build labels for the issue.
|
||||||
|
*/
|
||||||
|
protected function buildLabels(UpstreamTodo $todo): array
|
||||||
|
{
|
||||||
|
$labels = $this->defaultLabels;
|
||||||
|
|
||||||
|
// Add type label
|
||||||
|
$labels[] = 'type:'.$todo->type;
|
||||||
|
|
||||||
|
// Add priority label
|
||||||
|
if ($todo->priority >= 8) {
|
||||||
|
$labels[] = 'priority:high';
|
||||||
|
} elseif ($todo->priority >= 5) {
|
||||||
|
$labels[] = 'priority:medium';
|
||||||
|
} else {
|
||||||
|
$labels[] = 'priority:low';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add effort label
|
||||||
|
$labels[] = 'effort:'.$todo->effort;
|
||||||
|
|
||||||
|
// Add quick-win label
|
||||||
|
if ($todo->isQuickWin()) {
|
||||||
|
$labels[] = 'quick-win';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add vendor label
|
||||||
|
$labels[] = 'vendor:'.$todo->vendor->slug;
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a weekly digest issue.
|
||||||
|
*/
|
||||||
|
public function createWeeklyDigest(Vendor $vendor): ?array
|
||||||
|
{
|
||||||
|
$todos = $vendor->todos()
|
||||||
|
->where('status', UpstreamTodo::STATUS_PENDING)
|
||||||
|
->whereNull('github_issue_number')
|
||||||
|
->where('created_at', '>=', now()->subWeek())
|
||||||
|
->orderByDesc('priority')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($todos->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = "[Weekly Digest] {$vendor->name} - ".now()->format('M d, Y');
|
||||||
|
$body = $this->buildDigestBody($vendor, $todos);
|
||||||
|
|
||||||
|
if (! $this->githubToken || ! $vendor->target_repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$owner, $repo] = explode('/', $vendor->target_repo);
|
||||||
|
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$this->githubToken,
|
||||||
|
'Accept' => 'application/vnd.github.v3+json',
|
||||||
|
])->post("https://api.github.com/repos/{$owner}/{$repo}/issues", [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
'labels' => ['upstream', 'digest'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build weekly digest body.
|
||||||
|
*/
|
||||||
|
protected function buildDigestBody(Vendor $vendor, Collection $todos): string
|
||||||
|
{
|
||||||
|
$body = "# Weekly Upstream Digest\n\n";
|
||||||
|
$body .= "**Vendor:** {$vendor->name}\n";
|
||||||
|
$body .= '**Week of:** '.now()->subWeek()->format('M d').' - '.now()->format('M d, Y')."\n";
|
||||||
|
$body .= "**Total Changes:** {$todos->count()}\n\n";
|
||||||
|
|
||||||
|
// Quick wins
|
||||||
|
$quickWins = $todos->filter->isQuickWin();
|
||||||
|
if ($quickWins->isNotEmpty()) {
|
||||||
|
$body .= "## 🚀 Quick Wins ({$quickWins->count()})\n\n";
|
||||||
|
foreach ($quickWins as $todo) {
|
||||||
|
$body .= "- {$todo->getTypeIcon()} {$todo->title}\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security
|
||||||
|
$security = $todos->where('type', 'security');
|
||||||
|
if ($security->isNotEmpty()) {
|
||||||
|
$body .= "## 🔒 Security Updates ({$security->count()})\n\n";
|
||||||
|
foreach ($security as $todo) {
|
||||||
|
$body .= "- {$todo->title}\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features
|
||||||
|
$features = $todos->where('type', 'feature');
|
||||||
|
if ($features->isNotEmpty()) {
|
||||||
|
$body .= "## ✨ New Features ({$features->count()})\n\n";
|
||||||
|
foreach ($features as $todo) {
|
||||||
|
$body .= "- {$todo->title} (Priority: {$todo->priority}/10)\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug fixes
|
||||||
|
$bugfixes = $todos->where('type', 'bugfix');
|
||||||
|
if ($bugfixes->isNotEmpty()) {
|
||||||
|
$body .= "## 🐛 Bug Fixes ({$bugfixes->count()})\n\n";
|
||||||
|
foreach ($bugfixes as $todo) {
|
||||||
|
$body .= "- {$todo->title}\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other
|
||||||
|
$other = $todos->whereNotIn('type', ['feature', 'bugfix', 'security'])->where(fn ($t) => ! $t->isQuickWin());
|
||||||
|
if ($other->isNotEmpty()) {
|
||||||
|
$body .= "## 📝 Other Changes ({$other->count()})\n\n";
|
||||||
|
foreach ($other as $todo) {
|
||||||
|
$body .= "- {$todo->getTypeIcon()} {$todo->title}\n";
|
||||||
|
}
|
||||||
|
$body .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$body .= "---\n";
|
||||||
|
$body .= "_Auto-generated by Upstream Intelligence Pipeline_\n";
|
||||||
|
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
433
Services/UpstreamPlanGeneratorService.php
Normal file
433
Services/UpstreamPlanGeneratorService.php
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upstream Plan Generator Service - creates agent plans from version release analysis.
|
||||||
|
*
|
||||||
|
* Generates structured plans with phases grouped by change type for systematic porting.
|
||||||
|
*
|
||||||
|
* Note: This service has an optional dependency on the Agentic module. If the module
|
||||||
|
* is not installed, plan generation methods will return null and log a warning.
|
||||||
|
*/
|
||||||
|
class UpstreamPlanGeneratorService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the Agentic module is available.
|
||||||
|
*/
|
||||||
|
protected function agenticModuleAvailable(): bool
|
||||||
|
{
|
||||||
|
return class_exists(\Mod\Agentic\Models\AgentPlan::class)
|
||||||
|
&& class_exists(\Mod\Agentic\Models\AgentPhase::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AgentPlan from a version release analysis.
|
||||||
|
*
|
||||||
|
* @return \Mod\Agentic\Models\AgentPlan|null Returns null if Agentic module unavailable or no todos
|
||||||
|
*/
|
||||||
|
public function generateFromRelease(VersionRelease $release, array $options = []): mixed
|
||||||
|
{
|
||||||
|
if (! $this->agenticModuleAvailable()) {
|
||||||
|
report(new \RuntimeException('Agentic module not available - cannot generate plan from release'));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendor = $release->vendor;
|
||||||
|
$todos = UpstreamTodo::where('vendor_id', $vendor->id)
|
||||||
|
->where('from_version', $release->previous_version)
|
||||||
|
->where('to_version', $release->version)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->orderByDesc('priority')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($todos->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createPlanFromTodos($vendor, $release, $todos, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AgentPlan from vendor's pending todos.
|
||||||
|
*
|
||||||
|
* @return \Mod\Agentic\Models\AgentPlan|null Returns null if Agentic module unavailable or no todos
|
||||||
|
*/
|
||||||
|
public function generateFromVendor(Vendor $vendor, array $options = []): mixed
|
||||||
|
{
|
||||||
|
if (! $this->agenticModuleAvailable()) {
|
||||||
|
report(new \RuntimeException('Agentic module not available - cannot generate plan from vendor'));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todos = UpstreamTodo::where('vendor_id', $vendor->id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->orderByDesc('priority')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($todos->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $vendor->releases()->latest()->first();
|
||||||
|
|
||||||
|
return $this->createPlanFromTodos($vendor, $release, $todos, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AgentPlan from a collection of todos.
|
||||||
|
*
|
||||||
|
* @return \Mod\Agentic\Models\AgentPlan
|
||||||
|
*/
|
||||||
|
protected function createPlanFromTodos(
|
||||||
|
Vendor $vendor,
|
||||||
|
?VersionRelease $release,
|
||||||
|
Collection $todos,
|
||||||
|
array $options = []
|
||||||
|
): mixed {
|
||||||
|
$version = $release?->version ?? $vendor->current_version ?? 'latest';
|
||||||
|
$activateImmediately = $options['activate'] ?? false;
|
||||||
|
$includeContext = $options['include_context'] ?? true;
|
||||||
|
|
||||||
|
// Create plan title
|
||||||
|
$title = $options['title'] ?? "Port {$vendor->name} {$version}";
|
||||||
|
$slug = \Mod\Agentic\Models\AgentPlan::generateSlug($title);
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
$context = $includeContext ? $this->buildContext($vendor, $release, $todos) : null;
|
||||||
|
|
||||||
|
// Group todos by type for phases
|
||||||
|
$groupedTodos = $this->groupTodosForPhases($todos);
|
||||||
|
|
||||||
|
// Create the plan
|
||||||
|
$plan = \Mod\Agentic\Models\AgentPlan::create([
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $this->buildDescription($vendor, $release, $todos),
|
||||||
|
'context' => $context,
|
||||||
|
'status' => $activateImmediately ? \Mod\Agentic\Models\AgentPlan::STATUS_ACTIVE : \Mod\Agentic\Models\AgentPlan::STATUS_DRAFT,
|
||||||
|
'metadata' => [
|
||||||
|
'source' => 'upstream_analysis',
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'vendor_slug' => $vendor->slug,
|
||||||
|
'version_release_id' => $release?->id,
|
||||||
|
'version' => $version,
|
||||||
|
'todo_count' => $todos->count(),
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create phases
|
||||||
|
$this->createPhasesFromGroupedTodos($plan, $groupedTodos);
|
||||||
|
|
||||||
|
return $plan->fresh(['agentPhases']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group todos into logical phases.
|
||||||
|
*/
|
||||||
|
protected function groupTodosForPhases(Collection $todos): array
|
||||||
|
{
|
||||||
|
// Define phase order and groupings
|
||||||
|
$phaseConfig = [
|
||||||
|
'security' => [
|
||||||
|
'name' => 'Security Updates',
|
||||||
|
'description' => 'Critical security patches that should be applied first',
|
||||||
|
'types' => ['security'],
|
||||||
|
'priority' => 1,
|
||||||
|
],
|
||||||
|
'database' => [
|
||||||
|
'name' => 'Database & Schema Changes',
|
||||||
|
'description' => 'Database migrations and schema updates',
|
||||||
|
'types' => ['migration', 'database'],
|
||||||
|
'priority' => 2,
|
||||||
|
],
|
||||||
|
'core' => [
|
||||||
|
'name' => 'Core Feature Updates',
|
||||||
|
'description' => 'Main feature implementations and bug fixes',
|
||||||
|
'types' => ['feature', 'bugfix', 'block'],
|
||||||
|
'priority' => 3,
|
||||||
|
],
|
||||||
|
'api' => [
|
||||||
|
'name' => 'API Changes',
|
||||||
|
'description' => 'API endpoint and integration updates',
|
||||||
|
'types' => ['api'],
|
||||||
|
'priority' => 4,
|
||||||
|
],
|
||||||
|
'ui' => [
|
||||||
|
'name' => 'UI & Frontend Changes',
|
||||||
|
'description' => 'User interface and visual updates',
|
||||||
|
'types' => ['ui', 'view'],
|
||||||
|
'priority' => 5,
|
||||||
|
],
|
||||||
|
'refactor' => [
|
||||||
|
'name' => 'Refactoring & Dependencies',
|
||||||
|
'description' => 'Code refactoring and dependency updates',
|
||||||
|
'types' => ['refactor', 'dependency'],
|
||||||
|
'priority' => 6,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$phases = [];
|
||||||
|
$assignedTodoIds = [];
|
||||||
|
|
||||||
|
// Assign todos to phases based on type
|
||||||
|
foreach ($phaseConfig as $phaseKey => $config) {
|
||||||
|
$phaseTodos = $todos->filter(function ($todo) use ($config, $assignedTodoIds) {
|
||||||
|
return in_array($todo->type, $config['types']) &&
|
||||||
|
! in_array($todo->id, $assignedTodoIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($phaseTodos->isNotEmpty()) {
|
||||||
|
$phases[$phaseKey] = [
|
||||||
|
'config' => $config,
|
||||||
|
'todos' => $phaseTodos,
|
||||||
|
];
|
||||||
|
$assignedTodoIds = array_merge($assignedTodoIds, $phaseTodos->pluck('id')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any remaining unassigned todos
|
||||||
|
$remainingTodos = $todos->filter(fn ($todo) => ! in_array($todo->id, $assignedTodoIds));
|
||||||
|
if ($remainingTodos->isNotEmpty()) {
|
||||||
|
$phases['other'] = [
|
||||||
|
'config' => [
|
||||||
|
'name' => 'Other Changes',
|
||||||
|
'description' => 'Additional updates and changes',
|
||||||
|
'priority' => 99,
|
||||||
|
],
|
||||||
|
'todos' => $remainingTodos,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority
|
||||||
|
uasort($phases, fn ($a, $b) => ($a['config']['priority'] ?? 99) <=> ($b['config']['priority'] ?? 99));
|
||||||
|
|
||||||
|
return $phases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AgentPhases from grouped todos.
|
||||||
|
*
|
||||||
|
* @param \Mod\Agentic\Models\AgentPlan $plan
|
||||||
|
*/
|
||||||
|
protected function createPhasesFromGroupedTodos(mixed $plan, array $groupedPhases): void
|
||||||
|
{
|
||||||
|
$order = 1;
|
||||||
|
|
||||||
|
foreach ($groupedPhases as $phaseKey => $phaseData) {
|
||||||
|
$config = $phaseData['config'];
|
||||||
|
$todos = $phaseData['todos'];
|
||||||
|
|
||||||
|
// Build tasks from todos
|
||||||
|
$tasks = $todos->map(function ($todo) {
|
||||||
|
return [
|
||||||
|
'name' => $todo->title,
|
||||||
|
'status' => 'pending',
|
||||||
|
'notes' => $todo->description,
|
||||||
|
'todo_id' => $todo->id,
|
||||||
|
'priority' => $todo->priority,
|
||||||
|
'effort' => $todo->effort,
|
||||||
|
'files' => $todo->files,
|
||||||
|
];
|
||||||
|
})->sortByDesc('priority')->values()->toArray();
|
||||||
|
|
||||||
|
// Create the phase
|
||||||
|
\Mod\Agentic\Models\AgentPhase::create([
|
||||||
|
'agent_plan_id' => $plan->id,
|
||||||
|
'order' => $order,
|
||||||
|
'name' => $config['name'],
|
||||||
|
'description' => $config['description'] ?? null,
|
||||||
|
'tasks' => $tasks,
|
||||||
|
'status' => \Mod\Agentic\Models\AgentPhase::STATUS_PENDING,
|
||||||
|
'metadata' => [
|
||||||
|
'phase_key' => $phaseKey,
|
||||||
|
'todo_count' => $todos->count(),
|
||||||
|
'todo_ids' => $todos->pluck('id')->toArray(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$order++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add review phase
|
||||||
|
\Mod\Agentic\Models\AgentPhase::create([
|
||||||
|
'agent_plan_id' => $plan->id,
|
||||||
|
'order' => $order,
|
||||||
|
'name' => 'Review & Testing',
|
||||||
|
'description' => 'Final review, testing, and documentation updates',
|
||||||
|
'tasks' => [
|
||||||
|
['name' => 'Run test suite', 'status' => 'pending'],
|
||||||
|
['name' => 'Review all changes', 'status' => 'pending'],
|
||||||
|
['name' => 'Update documentation', 'status' => 'pending'],
|
||||||
|
['name' => 'Create PR/merge request', 'status' => 'pending'],
|
||||||
|
],
|
||||||
|
'status' => \Mod\Agentic\Models\AgentPhase::STATUS_PENDING,
|
||||||
|
'metadata' => [
|
||||||
|
'phase_key' => 'review',
|
||||||
|
'is_final' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build context string for the plan.
|
||||||
|
*/
|
||||||
|
protected function buildContext(Vendor $vendor, ?VersionRelease $release, Collection $todos): string
|
||||||
|
{
|
||||||
|
$context = "## Upstream Porting Context\n\n";
|
||||||
|
$context .= "**Vendor:** {$vendor->name} ({$vendor->vendor_name})\n";
|
||||||
|
$context .= "**Source Type:** {$vendor->getSourceTypeLabel()}\n";
|
||||||
|
|
||||||
|
if ($release) {
|
||||||
|
$context .= "**Version:** {$release->getVersionCompare()}\n";
|
||||||
|
$context .= "**Files Changed:** {$release->getTotalChanges()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$context .= "**Total Todos:** {$todos->count()}\n\n";
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
$byType = $todos->groupBy('type');
|
||||||
|
$context .= "### Changes by Type\n\n";
|
||||||
|
foreach ($byType as $type => $items) {
|
||||||
|
$context .= "- **{$type}:** {$items->count()}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path mapping info
|
||||||
|
if ($vendor->path_mapping) {
|
||||||
|
$context .= "\n### Path Mapping\n\n";
|
||||||
|
foreach ($vendor->path_mapping as $from => $to) {
|
||||||
|
$context .= "- `{$from}` → `{$to}`\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target repo
|
||||||
|
if ($vendor->target_repo) {
|
||||||
|
$context .= "\n**Target Repository:** {$vendor->target_repo}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick wins
|
||||||
|
$quickWins = $todos->filter(fn ($t) => $t->effort === 'low' && $t->priority >= 5);
|
||||||
|
if ($quickWins->isNotEmpty()) {
|
||||||
|
$context .= "\n### Quick Wins ({$quickWins->count()})\n\n";
|
||||||
|
foreach ($quickWins->take(5) as $todo) {
|
||||||
|
$context .= "- {$todo->title}\n";
|
||||||
|
}
|
||||||
|
if ($quickWins->count() > 5) {
|
||||||
|
$context .= '- ... and '.($quickWins->count() - 5)." more\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security items
|
||||||
|
$security = $todos->where('type', 'security');
|
||||||
|
if ($security->isNotEmpty()) {
|
||||||
|
$context .= "\n### Security Updates ({$security->count()})\n\n";
|
||||||
|
foreach ($security as $todo) {
|
||||||
|
$context .= "- {$todo->title}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build description for the plan.
|
||||||
|
*/
|
||||||
|
protected function buildDescription(Vendor $vendor, ?VersionRelease $release, Collection $todos): string
|
||||||
|
{
|
||||||
|
$desc = "Auto-generated plan for porting {$vendor->name} updates";
|
||||||
|
|
||||||
|
if ($release) {
|
||||||
|
$desc .= " from version {$release->previous_version} to {$release->version}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$desc .= ". Contains {$todos->count()} items";
|
||||||
|
|
||||||
|
$security = $todos->where('type', 'security')->count();
|
||||||
|
if ($security > 0) {
|
||||||
|
$desc .= " including {$security} security update(s)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$desc .= '.';
|
||||||
|
|
||||||
|
return $desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync AgentPlan tasks with UpstreamTodo status.
|
||||||
|
*
|
||||||
|
* @param \Mod\Agentic\Models\AgentPlan $plan
|
||||||
|
*/
|
||||||
|
public function syncPlanWithTodos(mixed $plan): int
|
||||||
|
{
|
||||||
|
if (! $this->agenticModuleAvailable()) {
|
||||||
|
report(new \RuntimeException('Agentic module not available - cannot sync plan with todos'));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$synced = 0;
|
||||||
|
|
||||||
|
foreach ($plan->agentPhases as $phase) {
|
||||||
|
$tasks = $phase->tasks ?? [];
|
||||||
|
$updated = false;
|
||||||
|
|
||||||
|
foreach ($tasks as $i => $task) {
|
||||||
|
if (! isset($task['todo_id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todo = UpstreamTodo::find($task['todo_id']);
|
||||||
|
if (! $todo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync status
|
||||||
|
$newStatus = match ($todo->status) {
|
||||||
|
'ported', 'wont_port', 'skipped' => 'completed',
|
||||||
|
'in_progress' => 'in_progress',
|
||||||
|
default => 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (($task['status'] ?? 'pending') !== $newStatus) {
|
||||||
|
$tasks[$i]['status'] = $newStatus;
|
||||||
|
$updated = true;
|
||||||
|
$synced++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updated) {
|
||||||
|
$phase->update(['tasks' => $tasks]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark upstream todo as ported when task is completed.
|
||||||
|
*/
|
||||||
|
public function markTodoAsPorted(int $todoId): bool
|
||||||
|
{
|
||||||
|
$todo = UpstreamTodo::find($todoId);
|
||||||
|
if (! $todo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todo->update([
|
||||||
|
'status' => 'ported',
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
271
Services/UptelligenceDigestService.php
Normal file
271
Services/UptelligenceDigestService.php
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
use Core\Uptelligence\Notifications\SendUptelligenceDigest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UptelligenceDigestService - generates and sends digest email notifications.
|
||||||
|
*
|
||||||
|
* Collects new releases, pending todos, and security updates since the last
|
||||||
|
* digest and sends summarised email notifications to subscribed users.
|
||||||
|
*/
|
||||||
|
class UptelligenceDigestService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate digest content for a specific user's preferences.
|
||||||
|
*
|
||||||
|
* @return array{releases: Collection, todos: array, security_count: int, has_content: bool}
|
||||||
|
*/
|
||||||
|
public function generateDigestContent(UptelligenceDigest $digest): array
|
||||||
|
{
|
||||||
|
$sinceDate = $digest->last_sent_at ?? now()->subMonth();
|
||||||
|
$vendorIds = $digest->getVendorIds();
|
||||||
|
$minPriority = $digest->getMinPriority();
|
||||||
|
|
||||||
|
// Build base vendor query
|
||||||
|
$vendorQuery = Vendor::active();
|
||||||
|
if ($vendorIds !== null) {
|
||||||
|
$vendorQuery->whereIn('id', $vendorIds);
|
||||||
|
}
|
||||||
|
$trackedVendorIds = $vendorQuery->pluck('id');
|
||||||
|
|
||||||
|
// Gather new releases
|
||||||
|
$releases = collect();
|
||||||
|
if ($digest->includesReleases()) {
|
||||||
|
$releases = $this->getNewReleases($trackedVendorIds, $sinceDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather pending todos grouped by priority
|
||||||
|
$todosByPriority = [];
|
||||||
|
if ($digest->includesTodos()) {
|
||||||
|
$todosByPriority = $this->getTodosByPriority($trackedVendorIds, $minPriority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count security-related updates
|
||||||
|
$securityCount = 0;
|
||||||
|
if ($digest->includesSecurity()) {
|
||||||
|
$securityCount = $this->getSecurityUpdatesCount($trackedVendorIds, $sinceDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasContent = $releases->isNotEmpty()
|
||||||
|
|| ! empty(array_filter($todosByPriority))
|
||||||
|
|| $securityCount > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'releases' => $releases,
|
||||||
|
'todos' => $todosByPriority,
|
||||||
|
'security_count' => $securityCount,
|
||||||
|
'has_content' => $hasContent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get new releases since the given date.
|
||||||
|
*/
|
||||||
|
protected function getNewReleases(Collection $vendorIds, \DateTimeInterface $since): Collection
|
||||||
|
{
|
||||||
|
return VersionRelease::whereIn('vendor_id', $vendorIds)
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->analyzed()
|
||||||
|
->with('vendor:id,name,slug')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->take(20)
|
||||||
|
->get()
|
||||||
|
->map(fn (VersionRelease $release) => [
|
||||||
|
'vendor_name' => $release->vendor->name,
|
||||||
|
'vendor_slug' => $release->vendor->slug,
|
||||||
|
'version' => $release->version,
|
||||||
|
'previous_version' => $release->previous_version,
|
||||||
|
'files_changed' => $release->getTotalChanges(),
|
||||||
|
'impact_level' => $release->getImpactLevel(),
|
||||||
|
'todos_created' => $release->todos_created ?? 0,
|
||||||
|
'analyzed_at' => $release->analyzed_at,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending todos grouped by priority level.
|
||||||
|
*
|
||||||
|
* @return array{critical: int, high: int, medium: int, low: int, total: int}
|
||||||
|
*/
|
||||||
|
protected function getTodosByPriority(Collection $vendorIds, ?int $minPriority): array
|
||||||
|
{
|
||||||
|
$query = UpstreamTodo::whereIn('vendor_id', $vendorIds)
|
||||||
|
->pending();
|
||||||
|
|
||||||
|
if ($minPriority !== null) {
|
||||||
|
$query->where('priority', '>=', $minPriority);
|
||||||
|
}
|
||||||
|
|
||||||
|
$todos = $query->get(['priority']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'critical' => $todos->where('priority', '>=', 8)->count(),
|
||||||
|
'high' => $todos->whereBetween('priority', [6, 7])->count(),
|
||||||
|
'medium' => $todos->whereBetween('priority', [4, 5])->count(),
|
||||||
|
'low' => $todos->where('priority', '<', 4)->count(),
|
||||||
|
'total' => $todos->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of security-related updates since the given date.
|
||||||
|
*/
|
||||||
|
protected function getSecurityUpdatesCount(Collection $vendorIds, \DateTimeInterface $since): int
|
||||||
|
{
|
||||||
|
return UpstreamTodo::whereIn('vendor_id', $vendorIds)
|
||||||
|
->securityRelated()
|
||||||
|
->pending()
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a digest notification to a user.
|
||||||
|
*/
|
||||||
|
public function sendDigest(UptelligenceDigest $digest): bool
|
||||||
|
{
|
||||||
|
$content = $this->generateDigestContent($digest);
|
||||||
|
|
||||||
|
// Skip if there's nothing to report
|
||||||
|
if (! $content['has_content']) {
|
||||||
|
Log::debug('Uptelligence: Skipping empty digest', [
|
||||||
|
'user_id' => $digest->user_id,
|
||||||
|
'workspace_id' => $digest->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Still mark as sent to prevent re-checking
|
||||||
|
$digest->markAsSent();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$digest->user->notify(new SendUptelligenceDigest(
|
||||||
|
digest: $digest,
|
||||||
|
releases: $content['releases'],
|
||||||
|
todosByPriority: $content['todos'],
|
||||||
|
securityCount: $content['security_count'],
|
||||||
|
));
|
||||||
|
|
||||||
|
$digest->markAsSent();
|
||||||
|
|
||||||
|
Log::info('Uptelligence: Digest sent successfully', [
|
||||||
|
'user_id' => $digest->user_id,
|
||||||
|
'workspace_id' => $digest->workspace_id,
|
||||||
|
'releases_count' => $content['releases']->count(),
|
||||||
|
'todos_count' => $content['todos']['total'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Uptelligence: Failed to send digest', [
|
||||||
|
'user_id' => $digest->user_id,
|
||||||
|
'workspace_id' => $digest->workspace_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all digests due for a specific frequency.
|
||||||
|
*
|
||||||
|
* @return array{sent: int, skipped: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function processDigests(string $frequency): array
|
||||||
|
{
|
||||||
|
$stats = ['sent' => 0, 'skipped' => 0, 'failed' => 0];
|
||||||
|
|
||||||
|
$digests = UptelligenceDigest::dueForDigest($frequency)
|
||||||
|
->with(['user', 'workspace'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($digests as $digest) {
|
||||||
|
// Skip if user or workspace no longer exists
|
||||||
|
if (! $digest->user || ! $digest->workspace) {
|
||||||
|
$digest->delete();
|
||||||
|
$stats['skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->sendDigest($digest);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$stats['sent']++;
|
||||||
|
} else {
|
||||||
|
// Check if it was skipped (empty) or failed
|
||||||
|
if (! $this->generateDigestContent($digest)['has_content']) {
|
||||||
|
$stats['skipped']++;
|
||||||
|
} else {
|
||||||
|
$stats['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a preview of what would be included in a digest.
|
||||||
|
*
|
||||||
|
* Useful for showing users what they'll receive before enabling.
|
||||||
|
*/
|
||||||
|
public function getDigestPreview(UptelligenceDigest $digest): array
|
||||||
|
{
|
||||||
|
$content = $this->generateDigestContent($digest);
|
||||||
|
|
||||||
|
// Get top vendors with pending work
|
||||||
|
$vendorIds = $digest->getVendorIds();
|
||||||
|
$vendorQuery = Vendor::active()
|
||||||
|
->withCount(['todos as pending_count' => fn ($q) => $q->pending()]);
|
||||||
|
|
||||||
|
if ($vendorIds !== null) {
|
||||||
|
$vendorQuery->whereIn('id', $vendorIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$topVendors = $vendorQuery
|
||||||
|
->having('pending_count', '>', 0)
|
||||||
|
->orderByDesc('pending_count')
|
||||||
|
->take(5)
|
||||||
|
->get(['id', 'name', 'slug', 'current_version']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'releases' => $content['releases']->take(5),
|
||||||
|
'todos' => $content['todos'],
|
||||||
|
'security_count' => $content['security_count'],
|
||||||
|
'top_vendors' => $topVendors,
|
||||||
|
'has_content' => $content['has_content'],
|
||||||
|
'frequency_label' => $digest->getFrequencyLabel(),
|
||||||
|
'next_send' => $digest->getNextSendDate()?->format('j F Y'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a digest preference for a user in a workspace.
|
||||||
|
*/
|
||||||
|
public function getOrCreateDigest(int $userId, int $workspaceId): UptelligenceDigest
|
||||||
|
{
|
||||||
|
return UptelligenceDigest::firstOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $userId,
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'frequency' => UptelligenceDigest::FREQUENCY_WEEKLY,
|
||||||
|
'is_enabled' => false, // Start disabled, user must opt-in
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
579
Services/VendorStorageService.php
Normal file
579
Services/VendorStorageService.php
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendor Storage Service - manages local and S3 cold storage for vendor versions.
|
||||||
|
*
|
||||||
|
* Handles archival, retrieval, and cleanup of upstream vendor source files.
|
||||||
|
*/
|
||||||
|
class VendorStorageService
|
||||||
|
{
|
||||||
|
protected string $storageMode;
|
||||||
|
|
||||||
|
protected string $bucket;
|
||||||
|
|
||||||
|
protected string $prefix;
|
||||||
|
|
||||||
|
protected string $tempPath;
|
||||||
|
|
||||||
|
protected string $s3Disk;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->storageMode = config('upstream.storage.disk', 'local');
|
||||||
|
$this->bucket = config('upstream.storage.s3.bucket', 'hostuk');
|
||||||
|
$this->prefix = config('upstream.storage.s3.prefix', 'upstream/vendors/');
|
||||||
|
$this->tempPath = config('upstream.storage.temp_path', storage_path('app/temp/upstream'));
|
||||||
|
$this->s3Disk = config('upstream.storage.s3.disk', 's3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if S3 storage is enabled.
|
||||||
|
*/
|
||||||
|
public function isS3Enabled(): bool
|
||||||
|
{
|
||||||
|
return $this->storageMode === 's3';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the S3 storage disk instance.
|
||||||
|
*/
|
||||||
|
protected function s3(): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk($this->s3Disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local storage disk instance.
|
||||||
|
*/
|
||||||
|
protected function local(): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk('local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local path for a vendor version.
|
||||||
|
*/
|
||||||
|
public function getLocalPath(Vendor $vendor, string $version): string
|
||||||
|
{
|
||||||
|
return storage_path("app/vendors/{$vendor->slug}/{$version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get S3 key for a vendor version archive.
|
||||||
|
*/
|
||||||
|
public function getS3Key(Vendor $vendor, string $version): string
|
||||||
|
{
|
||||||
|
return "{$this->prefix}{$vendor->slug}/{$version}.tar.gz";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get temp directory for processing.
|
||||||
|
*/
|
||||||
|
public function getTempPath(?string $suffix = null): string
|
||||||
|
{
|
||||||
|
$path = $this->tempPath.'/'.Str::uuid();
|
||||||
|
if ($suffix) {
|
||||||
|
$path .= '/'.$suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure version is available locally for processing.
|
||||||
|
* Downloads from S3 if needed.
|
||||||
|
*/
|
||||||
|
public function ensureLocal(VersionRelease $release): string
|
||||||
|
{
|
||||||
|
$localPath = $this->getLocalPath($release->vendor, $release->version);
|
||||||
|
$relativePath = $this->getRelativeLocalPath($release->vendor, $release->version);
|
||||||
|
|
||||||
|
// Already available locally
|
||||||
|
if ($this->local()->exists($relativePath) && $this->local()->exists("{$relativePath}/.version_marker")) {
|
||||||
|
return $localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to download from S3
|
||||||
|
if ($release->storage_disk === 's3' && $release->s3_key) {
|
||||||
|
$this->downloadFromS3($release, $localPath);
|
||||||
|
$release->update(['last_downloaded_at' => now()]);
|
||||||
|
|
||||||
|
return $localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have local files but no marker
|
||||||
|
if ($this->local()->exists($relativePath)) {
|
||||||
|
$this->local()->put("{$relativePath}/.version_marker", $release->version);
|
||||||
|
|
||||||
|
return $localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Version {$release->version} not available locally or in S3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative path for local storage (relative to storage/app).
|
||||||
|
*/
|
||||||
|
protected function getRelativeLocalPath(Vendor $vendor, string $version): string
|
||||||
|
{
|
||||||
|
return "vendors/{$vendor->slug}/{$version}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a version to S3 cold storage.
|
||||||
|
*/
|
||||||
|
public function archiveToS3(VersionRelease $release): bool
|
||||||
|
{
|
||||||
|
if (! $this->isS3Enabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localPath = $this->getLocalPath($release->vendor, $release->version);
|
||||||
|
$relativePath = $this->getRelativeLocalPath($release->vendor, $release->version);
|
||||||
|
|
||||||
|
if (! $this->local()->exists($relativePath)) {
|
||||||
|
throw new RuntimeException("Local path not found: {$localPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar.gz archive
|
||||||
|
$archivePath = $this->createArchive($localPath, $release->vendor->slug, $release->version);
|
||||||
|
|
||||||
|
// Calculate hash before upload
|
||||||
|
$hash = hash_file('sha256', $archivePath);
|
||||||
|
$size = filesize($archivePath);
|
||||||
|
|
||||||
|
// Upload to S3
|
||||||
|
$s3Key = $this->getS3Key($release->vendor, $release->version);
|
||||||
|
$this->uploadToS3($archivePath, $s3Key);
|
||||||
|
|
||||||
|
// Update release record
|
||||||
|
$release->update([
|
||||||
|
'storage_disk' => 's3',
|
||||||
|
's3_key' => $s3Key,
|
||||||
|
'file_hash' => $hash,
|
||||||
|
'file_size' => $size,
|
||||||
|
'archived_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cleanup archive file using Storage facade
|
||||||
|
$this->local()->delete($this->getRelativeTempPath($archivePath));
|
||||||
|
|
||||||
|
// Optionally delete local files
|
||||||
|
if (config('upstream.storage.archive.delete_local_after_archive', true)) {
|
||||||
|
$this->deleteLocalIfAllowed($release);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative path for a temp file.
|
||||||
|
*/
|
||||||
|
protected function getRelativeTempPath(string $absolutePath): string
|
||||||
|
{
|
||||||
|
$storagePath = storage_path('app/');
|
||||||
|
|
||||||
|
return str_starts_with($absolutePath, $storagePath)
|
||||||
|
? substr($absolutePath, strlen($storagePath))
|
||||||
|
: $absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a version from S3.
|
||||||
|
*/
|
||||||
|
public function downloadFromS3(VersionRelease $release, ?string $targetPath = null): string
|
||||||
|
{
|
||||||
|
if (! $release->s3_key) {
|
||||||
|
throw new RuntimeException("No S3 key for version {$release->version}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $targetPath ?? $this->getLocalPath($release->vendor, $release->version);
|
||||||
|
$relativeTempPath = 'temp/upstream/'.Str::uuid().'.tar.gz';
|
||||||
|
|
||||||
|
// Ensure temp directory exists via Storage facade
|
||||||
|
$this->local()->makeDirectory(dirname($relativeTempPath));
|
||||||
|
|
||||||
|
$contents = $this->s3()->get($release->s3_key);
|
||||||
|
if ($contents === null) {
|
||||||
|
Log::error('Uptelligence: Failed to download from S3', [
|
||||||
|
's3_key' => $release->s3_key,
|
||||||
|
'version' => $release->version,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException("Failed to download from S3: {$release->s3_key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->local()->put($relativeTempPath, $contents);
|
||||||
|
$tempArchive = storage_path("app/{$relativeTempPath}");
|
||||||
|
|
||||||
|
// Verify hash if available
|
||||||
|
if ($release->file_hash) {
|
||||||
|
$downloadedHash = hash_file('sha256', $tempArchive);
|
||||||
|
if ($downloadedHash !== $release->file_hash) {
|
||||||
|
$this->local()->delete($relativeTempPath);
|
||||||
|
Log::error('Uptelligence: S3 download hash mismatch', [
|
||||||
|
'version' => $release->version,
|
||||||
|
'expected' => $release->file_hash,
|
||||||
|
'actual' => $downloadedHash,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Hash mismatch for {$release->version}: expected {$release->file_hash}, got {$downloadedHash}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure target directory exists
|
||||||
|
$relativeTargetPath = $this->getRelativeLocalPath($release->vendor, $release->version);
|
||||||
|
$this->local()->makeDirectory($relativeTargetPath);
|
||||||
|
|
||||||
|
// Extract archive
|
||||||
|
$this->extractArchive($tempArchive, $targetPath);
|
||||||
|
|
||||||
|
// Cleanup temp archive
|
||||||
|
$this->local()->delete($relativeTempPath);
|
||||||
|
|
||||||
|
// Add version marker
|
||||||
|
$this->local()->put("{$relativeTargetPath}/.version_marker", $release->version);
|
||||||
|
|
||||||
|
return $targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tar.gz archive of a directory.
|
||||||
|
*/
|
||||||
|
public function createArchive(string $sourcePath, string $vendorSlug, string $version): string
|
||||||
|
{
|
||||||
|
$relativePath = 'temp/upstream/'.Str::uuid();
|
||||||
|
$archiveRelativePath = "{$relativePath}/{$vendorSlug}-{$version}.tar.gz";
|
||||||
|
|
||||||
|
// Ensure directory exists via Storage facade
|
||||||
|
$this->local()->makeDirectory($relativePath);
|
||||||
|
|
||||||
|
$archivePath = storage_path("app/{$archiveRelativePath}");
|
||||||
|
|
||||||
|
// Use Symfony Process for safe command execution
|
||||||
|
$process = new Process(['tar', '-czf', $archivePath, '-C', $sourcePath, '.']);
|
||||||
|
$process->setTimeout(300);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
Log::error('Uptelligence: Failed to create archive', [
|
||||||
|
'source' => $sourcePath,
|
||||||
|
'error' => $process->getErrorOutput(),
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to create archive: '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $archivePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a tar.gz archive.
|
||||||
|
*/
|
||||||
|
public function extractArchive(string $archivePath, string $targetPath): void
|
||||||
|
{
|
||||||
|
// Ensure target directory exists via Storage facade
|
||||||
|
$relativeTargetPath = str_replace(storage_path('app/'), '', $targetPath);
|
||||||
|
if (str_starts_with($relativeTargetPath, '/')) {
|
||||||
|
// Absolute path outside storage - use direct mkdir
|
||||||
|
if (! is_dir($targetPath)) {
|
||||||
|
mkdir($targetPath, 0755, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->local()->makeDirectory($relativeTargetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Symfony Process for safe command execution
|
||||||
|
$process = new Process(['tar', '-xzf', $archivePath, '-C', $targetPath]);
|
||||||
|
$process->setTimeout(300);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
Log::error('Uptelligence: Failed to extract archive', [
|
||||||
|
'archive' => $archivePath,
|
||||||
|
'target' => $targetPath,
|
||||||
|
'error' => $process->getErrorOutput(),
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to extract archive: '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to S3.
|
||||||
|
*/
|
||||||
|
protected function uploadToS3(string $localPath, string $s3Key): void
|
||||||
|
{
|
||||||
|
// Read file using Storage facade if path is within storage/app
|
||||||
|
$relativePath = $this->getRelativeTempPath($localPath);
|
||||||
|
|
||||||
|
if ($this->local()->exists($relativePath)) {
|
||||||
|
$contents = $this->local()->get($relativePath);
|
||||||
|
} else {
|
||||||
|
// Fallback for absolute paths outside storage
|
||||||
|
$contents = file_get_contents($localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploaded = $this->s3()->put($s3Key, $contents, [
|
||||||
|
'ContentType' => 'application/gzip',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $uploaded) {
|
||||||
|
Log::error('Uptelligence: Failed to upload to S3', ['s3_key' => $s3Key]);
|
||||||
|
throw new RuntimeException("Failed to upload to S3: {$s3Key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete local version files if allowed by retention policy.
|
||||||
|
*/
|
||||||
|
public function deleteLocalIfAllowed(VersionRelease $release): bool
|
||||||
|
{
|
||||||
|
$keepVersions = config('upstream.storage.archive.keep_local_versions', 2);
|
||||||
|
|
||||||
|
// Get vendor's recent versions
|
||||||
|
$recentVersions = VersionRelease::where('vendor_id', $release->vendor_id)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->take($keepVersions)
|
||||||
|
->pluck('version')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Don't delete if in recent list
|
||||||
|
if (in_array($release->version, $recentVersions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't delete current or previous version
|
||||||
|
$vendor = $release->vendor;
|
||||||
|
if ($release->version === $vendor->current_version ||
|
||||||
|
$release->version === $vendor->previous_version) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = $this->getRelativeLocalPath($vendor, $release->version);
|
||||||
|
|
||||||
|
if ($this->local()->exists($relativePath)) {
|
||||||
|
$this->local()->deleteDirectory($relativePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract metadata from a version directory.
|
||||||
|
* This metadata can be used for analysis without downloading.
|
||||||
|
*/
|
||||||
|
public function extractMetadata(string $path): array
|
||||||
|
{
|
||||||
|
$metadata = [
|
||||||
|
'file_count' => 0,
|
||||||
|
'total_size' => 0,
|
||||||
|
'directories' => [],
|
||||||
|
'file_types' => [],
|
||||||
|
'key_files' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! File::isDirectory($path)) {
|
||||||
|
return $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$metadata['file_count']++;
|
||||||
|
$metadata['total_size'] += $file->getSize();
|
||||||
|
|
||||||
|
$ext = strtolower($file->getExtension());
|
||||||
|
$metadata['file_types'][$ext] = ($metadata['file_types'][$ext] ?? 0) + 1;
|
||||||
|
|
||||||
|
// Track key files
|
||||||
|
$relativePath = str_replace($path.'/', '', $file->getPathname());
|
||||||
|
if ($this->isKeyFile($relativePath)) {
|
||||||
|
$metadata['key_files'][] = $relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top-level directories
|
||||||
|
$dirs = File::directories($path);
|
||||||
|
$metadata['directories'] = array_map(fn ($d) => basename($d), $dirs);
|
||||||
|
|
||||||
|
return $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is considered a "key file" worth tracking in metadata.
|
||||||
|
*/
|
||||||
|
protected function isKeyFile(string $path): bool
|
||||||
|
{
|
||||||
|
$keyPatterns = [
|
||||||
|
'composer.json',
|
||||||
|
'package.json',
|
||||||
|
'readme.md',
|
||||||
|
'readme.txt',
|
||||||
|
'changelog.md',
|
||||||
|
'changelog.txt',
|
||||||
|
'version.php',
|
||||||
|
'config/*.php',
|
||||||
|
'database/migrations/*',
|
||||||
|
];
|
||||||
|
|
||||||
|
$lowercasePath = strtolower($path);
|
||||||
|
foreach ($keyPatterns as $pattern) {
|
||||||
|
if (fnmatch($pattern, $lowercasePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a version exists in S3.
|
||||||
|
*/
|
||||||
|
public function existsInS3(Vendor $vendor, string $version): bool
|
||||||
|
{
|
||||||
|
$s3Key = $this->getS3Key($vendor, $version);
|
||||||
|
|
||||||
|
return $this->s3()->exists($s3Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a version exists locally.
|
||||||
|
*/
|
||||||
|
public function existsLocally(Vendor $vendor, string $version): bool
|
||||||
|
{
|
||||||
|
$relativePath = $this->getRelativeLocalPath($vendor, $version);
|
||||||
|
|
||||||
|
return $this->local()->exists($relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage status for a version.
|
||||||
|
*/
|
||||||
|
public function getStorageStatus(VersionRelease $release): array
|
||||||
|
{
|
||||||
|
$relativePath = $this->getRelativeLocalPath($release->vendor, $release->version);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $release->version,
|
||||||
|
'storage_disk' => $release->storage_disk,
|
||||||
|
'local_exists' => $this->local()->exists($relativePath),
|
||||||
|
's3_exists' => $release->s3_key ? $this->s3()->exists($release->s3_key) : false,
|
||||||
|
's3_key' => $release->s3_key,
|
||||||
|
'file_size' => $release->file_size,
|
||||||
|
'file_hash' => $release->file_hash,
|
||||||
|
'archived_at' => $release->archived_at?->toIso8601String(),
|
||||||
|
'last_downloaded_at' => $release->last_downloaded_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old temp files.
|
||||||
|
*/
|
||||||
|
public function cleanupTemp(): int
|
||||||
|
{
|
||||||
|
$maxAge = config('upstream.storage.archive.cleanup_after_hours', 24);
|
||||||
|
$cutoff = now()->subHours($maxAge);
|
||||||
|
$cleaned = 0;
|
||||||
|
|
||||||
|
$tempRelativePath = 'temp/upstream';
|
||||||
|
|
||||||
|
if (! $this->local()->exists($tempRelativePath)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$directories = $this->local()->directories($tempRelativePath);
|
||||||
|
foreach ($directories as $dir) {
|
||||||
|
$mtime = $this->local()->lastModified($dir);
|
||||||
|
if ($mtime < $cutoff->timestamp) {
|
||||||
|
$this->local()->deleteDirectory($dir);
|
||||||
|
$cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics for dashboard.
|
||||||
|
*/
|
||||||
|
public function getStorageStats(): array
|
||||||
|
{
|
||||||
|
$releases = VersionRelease::with('vendor')->get();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_versions' => $releases->count(),
|
||||||
|
'local_only' => 0,
|
||||||
|
's3_only' => 0,
|
||||||
|
'both' => 0,
|
||||||
|
'local_size' => 0,
|
||||||
|
's3_size' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($releases as $release) {
|
||||||
|
$localExists = $this->existsLocally($release->vendor, $release->version);
|
||||||
|
$s3Exists = $release->storage_disk === 's3';
|
||||||
|
|
||||||
|
if ($localExists && $s3Exists) {
|
||||||
|
$stats['both']++;
|
||||||
|
} elseif ($localExists) {
|
||||||
|
$stats['local_only']++;
|
||||||
|
} elseif ($s3Exists) {
|
||||||
|
$stats['s3_only']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($release->file_size) {
|
||||||
|
$stats['s3_size'] += $release->file_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($localExists) {
|
||||||
|
$localPath = $this->getLocalPath($release->vendor, $release->version);
|
||||||
|
$stats['local_size'] += $this->getDirectorySize($localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get size of a directory in bytes.
|
||||||
|
*/
|
||||||
|
protected function getDirectorySize(string $path): int
|
||||||
|
{
|
||||||
|
if (! File::isDirectory($path)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = 0;
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$size += $file->getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $size;
|
||||||
|
}
|
||||||
|
}
|
||||||
467
Services/VendorUpdateCheckerService.php
Normal file
467
Services/VendorUpdateCheckerService.php
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendor Update Checker Service - checks upstream sources for new releases.
|
||||||
|
*
|
||||||
|
* Supports GitHub releases, Packagist, and NPM registries.
|
||||||
|
*/
|
||||||
|
class VendorUpdateCheckerService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check all active vendors for updates.
|
||||||
|
*
|
||||||
|
* @return array<string, array{status: string, current: ?string, latest: ?string, has_update: bool, message?: string}>
|
||||||
|
*/
|
||||||
|
public function checkAllVendors(): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach (Vendor::active()->get() as $vendor) {
|
||||||
|
$results[$vendor->slug] = $this->checkVendor($vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a single vendor for updates.
|
||||||
|
*
|
||||||
|
* @return array{status: string, current: ?string, latest: ?string, has_update: bool, message?: string}
|
||||||
|
*/
|
||||||
|
public function checkVendor(Vendor $vendor): array
|
||||||
|
{
|
||||||
|
// Determine check method based on source type and git URL
|
||||||
|
$result = match (true) {
|
||||||
|
$vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor),
|
||||||
|
$vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor),
|
||||||
|
default => $this->skipCheck($vendor),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update last_checked_at
|
||||||
|
$vendor->update(['last_checked_at' => now()]);
|
||||||
|
|
||||||
|
// If update found and it's significant, create a todo
|
||||||
|
if ($result['has_update'] && $result['latest']) {
|
||||||
|
$this->createUpdateTodo($vendor, $result['latest']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check GitHub repository for new releases.
|
||||||
|
*/
|
||||||
|
protected function checkGitHub(Vendor $vendor): array
|
||||||
|
{
|
||||||
|
if (! $vendor->git_repo_url) {
|
||||||
|
return $this->errorResult('No Git repository URL configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||||||
|
|
||||||
|
return $this->rateLimitedResult($seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit('upstream-registry');
|
||||||
|
|
||||||
|
// Parse owner/repo from URL
|
||||||
|
$parsed = $this->parseGitHubUrl($vendor->git_repo_url);
|
||||||
|
if (! $parsed) {
|
||||||
|
return $this->errorResult('Invalid GitHub URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$owner, $repo] = $parsed;
|
||||||
|
|
||||||
|
// Build request with optional token
|
||||||
|
$request = Http::timeout(30)
|
||||||
|
->retry(3, function (int $attempt) {
|
||||||
|
return (int) pow(2, $attempt - 1) * 1000;
|
||||||
|
}, function (\Exception $exception) {
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return $status >= 500 || $status === 429;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token if configured
|
||||||
|
$token = config('upstream.github.token');
|
||||||
|
if ($token) {
|
||||||
|
$request->withToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest release
|
||||||
|
$response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest");
|
||||||
|
|
||||||
|
if ($response->status() === 404) {
|
||||||
|
// No releases - try tags instead
|
||||||
|
return $this->checkGitHubTags($vendor, $owner, $repo, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::warning('Uptelligence: GitHub API request failed', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->errorResult("GitHub API error: {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$latestVersion = $this->normaliseVersion($data['tag_name'] ?? '');
|
||||||
|
|
||||||
|
if (! $latestVersion) {
|
||||||
|
return $this->errorResult('Could not determine latest version');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildResult(
|
||||||
|
vendor: $vendor,
|
||||||
|
latestVersion: $latestVersion,
|
||||||
|
releaseInfo: [
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
'body' => $data['body'] ?? null,
|
||||||
|
'published_at' => $data['published_at'] ?? null,
|
||||||
|
'html_url' => $data['html_url'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check GitHub tags when no releases exist.
|
||||||
|
*/
|
||||||
|
protected function checkGitHubTags(Vendor $vendor, string $owner, string $repo, ?string $token): array
|
||||||
|
{
|
||||||
|
$request = Http::timeout(30);
|
||||||
|
if ($token) {
|
||||||
|
$request->withToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $request->get("https://api.github.com/repos/{$owner}/{$repo}/tags", [
|
||||||
|
'per_page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return $this->errorResult("GitHub tags API error: {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $response->json();
|
||||||
|
if (empty($tags)) {
|
||||||
|
return $this->errorResult('No releases or tags found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersion = $this->normaliseVersion($tags[0]['name'] ?? '');
|
||||||
|
|
||||||
|
return $this->buildResult(
|
||||||
|
vendor: $vendor,
|
||||||
|
latestVersion: $latestVersion,
|
||||||
|
releaseInfo: ['tag' => $tags[0]['name'] ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Gitea repository for new releases.
|
||||||
|
*/
|
||||||
|
protected function checkGitea(Vendor $vendor): array
|
||||||
|
{
|
||||||
|
if (! $vendor->git_repo_url) {
|
||||||
|
return $this->errorResult('No Git repository URL configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
if (RateLimiter::tooManyAttempts('upstream-registry', 30)) {
|
||||||
|
$seconds = RateLimiter::availableIn('upstream-registry');
|
||||||
|
|
||||||
|
return $this->rateLimitedResult($seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit('upstream-registry');
|
||||||
|
|
||||||
|
// Parse owner/repo from URL
|
||||||
|
$parsed = $this->parseGiteaUrl($vendor->git_repo_url);
|
||||||
|
if (! $parsed) {
|
||||||
|
return $this->errorResult('Invalid Gitea URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$baseUrl, $owner, $repo] = $parsed;
|
||||||
|
|
||||||
|
$request = Http::timeout(30);
|
||||||
|
|
||||||
|
// Add auth token if configured
|
||||||
|
$token = config('upstream.gitea.token');
|
||||||
|
if ($token) {
|
||||||
|
$request->withHeaders(['Authorization' => "token {$token}"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest release
|
||||||
|
$response = $request->get("{$baseUrl}/api/v1/repos/{$owner}/{$repo}/releases/latest");
|
||||||
|
|
||||||
|
if ($response->status() === 404) {
|
||||||
|
// No releases - try tags
|
||||||
|
return $this->checkGiteaTags($vendor, $baseUrl, $owner, $repo, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::warning('Uptelligence: Gitea API request failed', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->errorResult("Gitea API error: {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$latestVersion = $this->normaliseVersion($data['tag_name'] ?? '');
|
||||||
|
|
||||||
|
return $this->buildResult(
|
||||||
|
vendor: $vendor,
|
||||||
|
latestVersion: $latestVersion,
|
||||||
|
releaseInfo: [
|
||||||
|
'name' => $data['name'] ?? null,
|
||||||
|
'body' => $data['body'] ?? null,
|
||||||
|
'published_at' => $data['published_at'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Gitea tags when no releases exist.
|
||||||
|
*/
|
||||||
|
protected function checkGiteaTags(Vendor $vendor, string $baseUrl, string $owner, string $repo, ?string $token): array
|
||||||
|
{
|
||||||
|
$request = Http::timeout(30);
|
||||||
|
if ($token) {
|
||||||
|
$request->withHeaders(['Authorization' => "token {$token}"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $request->get("{$baseUrl}/api/v1/repos/{$owner}/{$repo}/tags", [
|
||||||
|
'limit' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return $this->errorResult("Gitea tags API error: {$response->status()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $response->json();
|
||||||
|
if (empty($tags)) {
|
||||||
|
return $this->errorResult('No releases or tags found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestVersion = $this->normaliseVersion($tags[0]['name'] ?? '');
|
||||||
|
|
||||||
|
return $this->buildResult(
|
||||||
|
vendor: $vendor,
|
||||||
|
latestVersion: $latestVersion,
|
||||||
|
releaseInfo: ['tag' => $tags[0]['name'] ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip check for vendors that don't support auto-checking.
|
||||||
|
*/
|
||||||
|
protected function skipCheck(Vendor $vendor): array
|
||||||
|
{
|
||||||
|
$message = match (true) {
|
||||||
|
$vendor->isLicensed() => 'Licensed software - manual check required',
|
||||||
|
$vendor->isPlugin() => 'Plugin - check vendor marketplace manually',
|
||||||
|
! $vendor->git_repo_url => 'No Git repository URL configured',
|
||||||
|
default => 'Unsupported source type for auto-checking',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'skipped',
|
||||||
|
'current' => $vendor->current_version,
|
||||||
|
'latest' => null,
|
||||||
|
'has_update' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the result array.
|
||||||
|
*/
|
||||||
|
protected function buildResult(Vendor $vendor, ?string $latestVersion, array $releaseInfo = []): array
|
||||||
|
{
|
||||||
|
if (! $latestVersion) {
|
||||||
|
return $this->errorResult('Could not determine latest version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentVersion = $this->normaliseVersion($vendor->current_version ?? '');
|
||||||
|
$hasUpdate = $currentVersion && version_compare($latestVersion, $currentVersion, '>');
|
||||||
|
|
||||||
|
// Store latest version info on vendor if new
|
||||||
|
if ($hasUpdate) {
|
||||||
|
Log::info('Uptelligence: New version detected', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'current' => $currentVersion,
|
||||||
|
'latest' => $latestVersion,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'current' => $currentVersion,
|
||||||
|
'latest' => $latestVersion,
|
||||||
|
'has_update' => $hasUpdate,
|
||||||
|
'release_info' => $releaseInfo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an update todo when new version is detected.
|
||||||
|
*/
|
||||||
|
protected function createUpdateTodo(Vendor $vendor, string $newVersion): void
|
||||||
|
{
|
||||||
|
// Check if we already have a pending todo for this version
|
||||||
|
$existing = UpstreamTodo::where('vendor_id', $vendor->id)
|
||||||
|
->where('to_version', $newVersion)
|
||||||
|
->whereIn('status', [UpstreamTodo::STATUS_PENDING, UpstreamTodo::STATUS_IN_PROGRESS])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new todo
|
||||||
|
UpstreamTodo::create([
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'from_version' => $vendor->current_version,
|
||||||
|
'to_version' => $newVersion,
|
||||||
|
'type' => UpstreamTodo::TYPE_DEPENDENCY,
|
||||||
|
'status' => UpstreamTodo::STATUS_PENDING,
|
||||||
|
'title' => "Update {$vendor->name} to {$newVersion}",
|
||||||
|
'description' => "A new version of {$vendor->name} is available.\n\n"
|
||||||
|
."Current: {$vendor->current_version}\n"
|
||||||
|
."Latest: {$newVersion}\n\n"
|
||||||
|
.'Review the changelog and update as appropriate.',
|
||||||
|
'priority' => 5,
|
||||||
|
'effort' => UpstreamTodo::EFFORT_MEDIUM,
|
||||||
|
'tags' => ['auto-detected', 'update-available'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Uptelligence: Created update todo', [
|
||||||
|
'vendor' => $vendor->slug,
|
||||||
|
'from' => $vendor->current_version,
|
||||||
|
'to' => $newVersion,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an error result.
|
||||||
|
*/
|
||||||
|
protected function errorResult(string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'current' => null,
|
||||||
|
'latest' => null,
|
||||||
|
'has_update' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rate-limited result.
|
||||||
|
*/
|
||||||
|
protected function rateLimitedResult(int $seconds): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'rate_limited',
|
||||||
|
'current' => null,
|
||||||
|
'latest' => null,
|
||||||
|
'has_update' => false,
|
||||||
|
'message' => "Rate limit exceeded. Retry after {$seconds} seconds",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a GitHub URL.
|
||||||
|
*/
|
||||||
|
protected function isGitHubUrl(?string $url): bool
|
||||||
|
{
|
||||||
|
if (! $url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_contains($url, 'github.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a Gitea URL.
|
||||||
|
*/
|
||||||
|
protected function isGiteaUrl(?string $url): bool
|
||||||
|
{
|
||||||
|
if (! $url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$giteaUrl = config('upstream.gitea.url', 'https://git.host.uk');
|
||||||
|
|
||||||
|
return str_contains($url, parse_url($giteaUrl, PHP_URL_HOST) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GitHub URL to extract owner/repo.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string}|null
|
||||||
|
*/
|
||||||
|
protected function parseGitHubUrl(string $url): ?array
|
||||||
|
{
|
||||||
|
// Match github.com/owner/repo patterns
|
||||||
|
if (preg_match('#github\.com[/:]([^/]+)/([^/.]+)#i', $url, $matches)) {
|
||||||
|
return [$matches[1], rtrim($matches[2], '.git')];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Gitea URL to extract base URL, owner, and repo.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string, 2: string}|null
|
||||||
|
*/
|
||||||
|
protected function parseGiteaUrl(string $url): ?array
|
||||||
|
{
|
||||||
|
// Match gitea URLs like https://git.host.uk/owner/repo
|
||||||
|
if (preg_match('#(https?://[^/]+)/([^/]+)/([^/.]+)#i', $url, $matches)) {
|
||||||
|
return [$matches[1], $matches[2], rtrim($matches[3], '.git')];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise version string (remove 'v' prefix, etc.).
|
||||||
|
*/
|
||||||
|
protected function normaliseVersion(?string $version): ?string
|
||||||
|
{
|
||||||
|
if (! $version) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading 'v' or 'V'
|
||||||
|
$version = ltrim($version, 'vV');
|
||||||
|
|
||||||
|
// Remove any leading/trailing whitespace
|
||||||
|
$version = trim($version);
|
||||||
|
|
||||||
|
return $version ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
435
Services/WebhookReceiverService.php
Normal file
435
Services/WebhookReceiverService.php
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebhookReceiverService - processes incoming vendor release webhooks.
|
||||||
|
*
|
||||||
|
* Handles webhook verification, payload parsing, and release record creation
|
||||||
|
* for GitHub releases, GitLab releases, npm publish, and Packagist webhooks.
|
||||||
|
*/
|
||||||
|
class WebhookReceiverService
|
||||||
|
{
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Signature Verification
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature.
|
||||||
|
*
|
||||||
|
* Returns signature status for logging.
|
||||||
|
*/
|
||||||
|
public function verifySignature(UptelligenceWebhook $webhook, string $payload, ?string $signature): string
|
||||||
|
{
|
||||||
|
if (empty($webhook->secret)) {
|
||||||
|
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($signature)) {
|
||||||
|
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValid = $webhook->verifySignature($payload, $signature);
|
||||||
|
|
||||||
|
return $isValid
|
||||||
|
? UptelligenceWebhookDelivery::SIGNATURE_VALID
|
||||||
|
: UptelligenceWebhookDelivery::SIGNATURE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Payload Parsing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse payload based on provider.
|
||||||
|
*
|
||||||
|
* Returns normalised release data or null if not a release event.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* event_type: string,
|
||||||
|
* version: string|null,
|
||||||
|
* tag_name: string|null,
|
||||||
|
* release_name: string|null,
|
||||||
|
* body: string|null,
|
||||||
|
* url: string|null,
|
||||||
|
* prerelease: bool,
|
||||||
|
* draft: bool,
|
||||||
|
* published_at: string|null,
|
||||||
|
* author: string|null,
|
||||||
|
* raw: array
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function parsePayload(string $provider, array $payload): ?array
|
||||||
|
{
|
||||||
|
return match ($provider) {
|
||||||
|
UptelligenceWebhook::PROVIDER_GITHUB => $this->parseGitHubPayload($payload),
|
||||||
|
UptelligenceWebhook::PROVIDER_GITLAB => $this->parseGitLabPayload($payload),
|
||||||
|
UptelligenceWebhook::PROVIDER_NPM => $this->parseNpmPayload($payload),
|
||||||
|
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->parsePackagistPayload($payload),
|
||||||
|
default => $this->parseCustomPayload($payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GitHub release webhook payload.
|
||||||
|
*
|
||||||
|
* GitHub sends:
|
||||||
|
* - action: published, created, edited, deleted, prereleased, released
|
||||||
|
* - release: { tag_name, name, body, draft, prerelease, created_at, published_at, author }
|
||||||
|
*/
|
||||||
|
protected function parseGitHubPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
// Only process release events
|
||||||
|
$action = $payload['action'] ?? null;
|
||||||
|
if (! in_array($action, ['published', 'released', 'created'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $payload['release'] ?? [];
|
||||||
|
if (empty($release)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagName = $release['tag_name'] ?? null;
|
||||||
|
$version = $this->normaliseVersion($tagName);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => "github.release.{$action}",
|
||||||
|
'version' => $version,
|
||||||
|
'tag_name' => $tagName,
|
||||||
|
'release_name' => $release['name'] ?? $tagName,
|
||||||
|
'body' => $release['body'] ?? null,
|
||||||
|
'url' => $release['html_url'] ?? null,
|
||||||
|
'prerelease' => (bool) ($release['prerelease'] ?? false),
|
||||||
|
'draft' => (bool) ($release['draft'] ?? false),
|
||||||
|
'published_at' => $release['published_at'] ?? $release['created_at'] ?? null,
|
||||||
|
'author' => $release['author']['login'] ?? null,
|
||||||
|
'raw' => $release,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GitLab release webhook payload.
|
||||||
|
*
|
||||||
|
* GitLab sends:
|
||||||
|
* - object_kind: release
|
||||||
|
* - action: create, update, delete
|
||||||
|
* - tag: tag name
|
||||||
|
* - name, description, released_at
|
||||||
|
*/
|
||||||
|
protected function parseGitLabPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
$objectKind = $payload['object_kind'] ?? null;
|
||||||
|
$action = $payload['action'] ?? null;
|
||||||
|
|
||||||
|
// Handle release events
|
||||||
|
if ($objectKind === 'release' && in_array($action, ['create', 'update'])) {
|
||||||
|
$tagName = $payload['tag'] ?? null;
|
||||||
|
$version = $this->normaliseVersion($tagName);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => "gitlab.release.{$action}",
|
||||||
|
'version' => $version,
|
||||||
|
'tag_name' => $tagName,
|
||||||
|
'release_name' => $payload['name'] ?? $tagName,
|
||||||
|
'body' => $payload['description'] ?? null,
|
||||||
|
'url' => $payload['url'] ?? null,
|
||||||
|
'prerelease' => false,
|
||||||
|
'draft' => false,
|
||||||
|
'published_at' => $payload['released_at'] ?? $payload['created_at'] ?? null,
|
||||||
|
'author' => null,
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag push events (may indicate release)
|
||||||
|
if ($objectKind === 'tag_push') {
|
||||||
|
$ref = $payload['ref'] ?? '';
|
||||||
|
$tagName = str_replace('refs/tags/', '', $ref);
|
||||||
|
$version = $this->normaliseVersion($tagName);
|
||||||
|
|
||||||
|
// Only process if it looks like a version tag
|
||||||
|
if ($version && $this->isVersionTag($tagName)) {
|
||||||
|
return [
|
||||||
|
'event_type' => 'gitlab.tag.push',
|
||||||
|
'version' => $version,
|
||||||
|
'tag_name' => $tagName,
|
||||||
|
'release_name' => $tagName,
|
||||||
|
'body' => null,
|
||||||
|
'url' => null,
|
||||||
|
'prerelease' => false,
|
||||||
|
'draft' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
'author' => $payload['user_name'] ?? null,
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse npm publish webhook payload.
|
||||||
|
*
|
||||||
|
* npm sends:
|
||||||
|
* - event: package:publish
|
||||||
|
* - name: package name
|
||||||
|
* - version: version number
|
||||||
|
* - dist-tags: { latest, next, etc. }
|
||||||
|
*/
|
||||||
|
protected function parseNpmPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
$event = $payload['event'] ?? null;
|
||||||
|
|
||||||
|
// Handle package publish events
|
||||||
|
if ($event !== 'package:publish') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = $payload['version'] ?? null;
|
||||||
|
if (empty($version)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$distTags = $payload['dist-tags'] ?? [];
|
||||||
|
$isLatest = ($distTags['latest'] ?? null) === $version;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => 'npm.package.publish',
|
||||||
|
'version' => $version,
|
||||||
|
'tag_name' => $version,
|
||||||
|
'release_name' => ($payload['name'] ?? 'Package')." v{$version}",
|
||||||
|
'body' => null,
|
||||||
|
'url' => isset($payload['name']) ? "https://www.npmjs.com/package/{$payload['name']}/v/{$version}" : null,
|
||||||
|
'prerelease' => ! $isLatest,
|
||||||
|
'draft' => false,
|
||||||
|
'published_at' => $payload['time'] ?? null,
|
||||||
|
'author' => $payload['maintainers'][0]['name'] ?? null,
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Packagist webhook payload.
|
||||||
|
*
|
||||||
|
* Packagist sends:
|
||||||
|
* - package: { name, url }
|
||||||
|
* - versions: array of version objects
|
||||||
|
*/
|
||||||
|
protected function parsePackagistPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
$package = $payload['package'] ?? $payload['repository'] ?? [];
|
||||||
|
$versions = $payload['versions'] ?? [];
|
||||||
|
|
||||||
|
// Find the latest version
|
||||||
|
if (empty($versions)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent version (first in array or highest semver)
|
||||||
|
$latestVersion = null;
|
||||||
|
$latestVersionData = null;
|
||||||
|
|
||||||
|
foreach ($versions as $versionKey => $versionData) {
|
||||||
|
// Skip dev versions
|
||||||
|
if (str_contains($versionKey, 'dev-')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = $this->normaliseVersion($versionKey);
|
||||||
|
if ($normalised && (! $latestVersion || version_compare($normalised, $latestVersion, '>'))) {
|
||||||
|
$latestVersion = $normalised;
|
||||||
|
$latestVersionData = $versionData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $latestVersion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => 'packagist.package.update',
|
||||||
|
'version' => $latestVersion,
|
||||||
|
'tag_name' => $latestVersionData['version'] ?? $latestVersion,
|
||||||
|
'release_name' => ($package['name'] ?? 'Package')." {$latestVersion}",
|
||||||
|
'body' => $latestVersionData['description'] ?? null,
|
||||||
|
'url' => $package['url'] ?? null,
|
||||||
|
'prerelease' => false,
|
||||||
|
'draft' => false,
|
||||||
|
'published_at' => $latestVersionData['time'] ?? null,
|
||||||
|
'author' => $latestVersionData['authors'][0]['name'] ?? null,
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse custom webhook payload.
|
||||||
|
*
|
||||||
|
* Accepts a flexible format for custom integrations.
|
||||||
|
*/
|
||||||
|
protected function parseCustomPayload(array $payload): ?array
|
||||||
|
{
|
||||||
|
// Try common field names for version
|
||||||
|
$version = $payload['version']
|
||||||
|
?? $payload['tag']
|
||||||
|
?? $payload['tag_name']
|
||||||
|
?? $payload['release']['version']
|
||||||
|
?? $payload['release']['tag_name']
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (empty($version)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = $this->normaliseVersion($version);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'custom.release',
|
||||||
|
'version' => $normalised ?? $version,
|
||||||
|
'tag_name' => $version,
|
||||||
|
'release_name' => $payload['name'] ?? $payload['release_name'] ?? $version,
|
||||||
|
'body' => $payload['body'] ?? $payload['description'] ?? $payload['changelog'] ?? null,
|
||||||
|
'url' => $payload['url'] ?? $payload['release_url'] ?? null,
|
||||||
|
'prerelease' => (bool) ($payload['prerelease'] ?? false),
|
||||||
|
'draft' => (bool) ($payload['draft'] ?? false),
|
||||||
|
'published_at' => $payload['published_at'] ?? $payload['released_at'] ?? $payload['timestamp'] ?? null,
|
||||||
|
'author' => $payload['author'] ?? null,
|
||||||
|
'raw' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Release Processing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a parsed release and create/update vendor version record.
|
||||||
|
*
|
||||||
|
* @return array{action: string, release_id: int|null, version: string|null}
|
||||||
|
*/
|
||||||
|
public function processRelease(
|
||||||
|
UptelligenceWebhookDelivery $delivery,
|
||||||
|
Vendor $vendor,
|
||||||
|
array $parsedData
|
||||||
|
): array {
|
||||||
|
$version = $parsedData['version'] ?? null;
|
||||||
|
|
||||||
|
if (empty($version)) {
|
||||||
|
return [
|
||||||
|
'action' => 'skipped',
|
||||||
|
'release_id' => null,
|
||||||
|
'version' => null,
|
||||||
|
'reason' => 'No version found in payload',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip draft releases
|
||||||
|
if ($parsedData['draft'] ?? false) {
|
||||||
|
return [
|
||||||
|
'action' => 'skipped',
|
||||||
|
'release_id' => null,
|
||||||
|
'version' => $version,
|
||||||
|
'reason' => 'Draft release',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this version already exists
|
||||||
|
$existingRelease = VersionRelease::where('vendor_id', $vendor->id)
|
||||||
|
->where('version', $version)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingRelease) {
|
||||||
|
Log::info('Uptelligence webhook: Version already exists', [
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'version' => $version,
|
||||||
|
'release_id' => $existingRelease->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => 'exists',
|
||||||
|
'release_id' => $existingRelease->id,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new version release record
|
||||||
|
$release = VersionRelease::create([
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'version' => $version,
|
||||||
|
'previous_version' => $vendor->current_version,
|
||||||
|
'metadata_json' => [
|
||||||
|
'release_name' => $parsedData['release_name'] ?? null,
|
||||||
|
'body' => $parsedData['body'] ?? null,
|
||||||
|
'url' => $parsedData['url'] ?? null,
|
||||||
|
'prerelease' => $parsedData['prerelease'] ?? false,
|
||||||
|
'published_at' => $parsedData['published_at'] ?? null,
|
||||||
|
'author' => $parsedData['author'] ?? null,
|
||||||
|
'webhook_delivery_id' => $delivery->id,
|
||||||
|
'event_type' => $parsedData['event_type'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update vendor's current version
|
||||||
|
$vendor->update([
|
||||||
|
'previous_version' => $vendor->current_version,
|
||||||
|
'current_version' => $version,
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Uptelligence webhook: New release recorded', [
|
||||||
|
'vendor_id' => $vendor->id,
|
||||||
|
'vendor_name' => $vendor->name,
|
||||||
|
'version' => $version,
|
||||||
|
'release_id' => $release->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => 'created',
|
||||||
|
'release_id' => $release->id,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a version string by removing common prefixes.
|
||||||
|
*/
|
||||||
|
protected function normaliseVersion(?string $version): ?string
|
||||||
|
{
|
||||||
|
if (empty($version)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common prefixes
|
||||||
|
$normalised = preg_replace('/^v(?:ersion)?[.\-]?/i', '', $version);
|
||||||
|
|
||||||
|
// Validate it looks like a version number
|
||||||
|
if (preg_match('/^\d+\.\d+/', $normalised)) {
|
||||||
|
return $normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it doesn't look like a version, return as-is
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tag name looks like a version tag.
|
||||||
|
*/
|
||||||
|
protected function isVersionTag(string $tagName): bool
|
||||||
|
{
|
||||||
|
// Common version patterns
|
||||||
|
return (bool) preg_match('/^v?\d+\.\d+(\.\d+)?/', $tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
View/Blade/admin/asset-manager.blade.php
Normal file
194
View/Blade/admin/asset-manager.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<admin:module title="Asset Manager" subtitle="Track installed packages, fonts, themes, and CDN resources">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:button wire:click="resetFilters" variant="ghost" size="sm" icon="x-mark">
|
||||||
|
Reset Filters
|
||||||
|
</flux:button>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||||
|
Back
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
{{-- Stats Summary --}}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Total Assets</div>
|
||||||
|
<div class="text-2xl font-bold">{{ $this->assetStats['total'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Need Update</div>
|
||||||
|
<div class="text-2xl font-bold text-orange-600">{{ $this->assetStats['needs_update'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Composer</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ $this->assetStats['composer'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">NPM</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ $this->assetStats['npm'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Expiring Soon</div>
|
||||||
|
<div class="text-2xl font-bold text-yellow-600">{{ $this->assetStats['expiring_soon'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Expired</div>
|
||||||
|
<div class="text-2xl font-bold text-red-600">{{ $this->assetStats['expired'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search assets..." icon="magnifying-glass" />
|
||||||
|
|
||||||
|
<flux:select wire:model.live="type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="composer">Composer</option>
|
||||||
|
<option value="npm">NPM</option>
|
||||||
|
<option value="font">Font</option>
|
||||||
|
<option value="theme">Theme</option>
|
||||||
|
<option value="cdn">CDN</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="licenceType">
|
||||||
|
<option value="">All Licences</option>
|
||||||
|
<option value="lifetime">Lifetime</option>
|
||||||
|
<option value="subscription">Subscription</option>
|
||||||
|
<option value="oss">Open Source</option>
|
||||||
|
<option value="trial">Trial</option>
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<flux:checkbox wire:model.live="needsUpdate" label="Needs Update Only" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir" wire:click="sortBy('name')">
|
||||||
|
Asset
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'type'" :direction="$sortDir" wire:click="sortBy('type')">
|
||||||
|
Type
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column>Installed</flux:table.column>
|
||||||
|
<flux:table.column>Latest</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'licence_type'" :direction="$sortDir" wire:click="sortBy('licence_type')">
|
||||||
|
Licence
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'last_checked_at'" :direction="$sortDir" wire:click="sortBy('last_checked_at')">
|
||||||
|
Last Checked
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column align="center">Status</flux:table.column>
|
||||||
|
<flux:table.column align="end">Actions</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->assets as $asset)
|
||||||
|
<flux:table.row wire:key="asset-{{ $asset->id }}" class="{{ $asset->isLicenceExpired() ? 'bg-red-50 dark:bg-red-900/10' : ($asset->hasUpdate() ? 'bg-orange-50 dark:bg-orange-900/10' : '') }}">
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ $asset->getTypeIcon() }}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ $asset->name }}</div>
|
||||||
|
@if($asset->package_name)
|
||||||
|
<div class="text-xs text-zinc-500 font-mono">{{ $asset->package_name }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge color="{{ match($asset->type) {
|
||||||
|
'composer' => 'blue',
|
||||||
|
'npm' => 'green',
|
||||||
|
'font' => 'purple',
|
||||||
|
'theme' => 'pink',
|
||||||
|
'cdn' => 'cyan',
|
||||||
|
default => 'zinc'
|
||||||
|
} }}" size="sm">
|
||||||
|
{{ $asset->getTypeLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="font-mono text-sm">
|
||||||
|
{{ $asset->installed_version ?? 'N/A' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="font-mono text-sm">
|
||||||
|
@if($asset->hasUpdate())
|
||||||
|
<span class="text-orange-600 font-semibold">{{ $asset->latest_version }}</span>
|
||||||
|
@else
|
||||||
|
{{ $asset->latest_version ?? 'N/A' }}
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span>{{ $asset->getLicenceIcon() }}</span>
|
||||||
|
<span class="text-sm">{{ ucfirst($asset->licence_type ?? 'N/A') }}</span>
|
||||||
|
</div>
|
||||||
|
@if($asset->licence_expires_at)
|
||||||
|
<div class="text-xs {{ $asset->isLicenceExpired() ? 'text-red-600' : ($asset->isLicenceExpiringSoon() ? 'text-yellow-600' : 'text-zinc-500') }}">
|
||||||
|
{{ $asset->licence_expires_at->format('d M Y') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500 text-sm">
|
||||||
|
{{ $asset->last_checked_at?->diffForHumans() ?? 'Never' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($asset->isLicenceExpired())
|
||||||
|
<flux:badge color="red" size="sm">Expired</flux:badge>
|
||||||
|
@elseif($asset->hasUpdate())
|
||||||
|
<flux:badge color="orange" size="sm">Update Available</flux:badge>
|
||||||
|
@elseif($asset->isLicenceExpiringSoon())
|
||||||
|
<flux:badge color="yellow" size="sm">Expiring Soon</flux:badge>
|
||||||
|
@elseif($asset->is_active)
|
||||||
|
<flux:badge color="green" size="sm">Active</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="zinc" size="sm">Inactive</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end">
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||||
|
<flux:menu>
|
||||||
|
@if($asset->getUpdateCommand())
|
||||||
|
<flux:menu.item icon="clipboard-document">
|
||||||
|
Copy Update Command
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
@if($asset->registry_url)
|
||||||
|
<flux:menu.item href="{{ $asset->registry_url }}" target="_blank" icon="arrow-top-right-on-square">
|
||||||
|
View in Registry
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="toggleActive({{ $asset->id }})" icon="{{ $asset->is_active ? 'pause' : 'play' }}">
|
||||||
|
{{ $asset->is_active ? 'Deactivate' : 'Activate' }}
|
||||||
|
</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="8" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="cube" class="size-12 opacity-50" />
|
||||||
|
<span class="text-lg">No assets found</span>
|
||||||
|
<span class="text-sm">Try adjusting your filters</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->assets->hasPages())
|
||||||
|
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
{{ $this->assets->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
</admin:module>
|
||||||
298
View/Blade/admin/dashboard.blade.php
Normal file
298
View/Blade/admin/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<admin:module title="Uptelligence" subtitle="Upstream vendor tracking and todo management">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:button href="{{ route('hub.admin.uptelligence.vendors') }}" wire:navigate variant="primary" icon="building-office" size="sm">
|
||||||
|
Manage Vendors
|
||||||
|
</core:button>
|
||||||
|
<core:button wire:click="refresh" icon="arrow-path" size="sm" variant="ghost">
|
||||||
|
Refresh
|
||||||
|
</core:button>
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button icon="ellipsis-vertical" variant="ghost" size="sm" />
|
||||||
|
<flux:menu>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.todos') }}" wire:navigate icon="clipboard-document-list">
|
||||||
|
View Todos
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.diffs') }}" wire:navigate icon="document-magnifying-glass">
|
||||||
|
View Diffs
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.assets') }}" wire:navigate icon="cube">
|
||||||
|
Manage Assets
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.webhooks') }}" wire:navigate icon="globe-alt">
|
||||||
|
Webhook Manager
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.digests') }}" wire:navigate icon="envelope">
|
||||||
|
Digest Preferences
|
||||||
|
</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
{{-- Summary Stats --}}
|
||||||
|
<admin:stats :items="$this->statCards" />
|
||||||
|
|
||||||
|
{{-- Secondary Stats Row --}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||||
|
<flux:icon name="arrow-path" class="size-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:subheading>In Progress</flux:subheading>
|
||||||
|
<flux:heading>{{ $this->stats['in_progress'] }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||||
|
<flux:icon name="cube" class="size-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:subheading>Assets Tracked</flux:subheading>
|
||||||
|
<flux:heading>{{ $this->stats['assets_tracked'] }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||||
|
<flux:icon name="arrow-up-circle" class="size-5 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:subheading>Assets Need Update</flux:subheading>
|
||||||
|
<flux:heading>{{ $this->stats['assets_need_update'] }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{{-- Vendors Summary --}}
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Top Vendors</flux:heading>
|
||||||
|
<flux:subheading>By pending todos</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence.vendors') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||||
|
View All
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>Vendor</flux:table.column>
|
||||||
|
<flux:table.column>Version</flux:table.column>
|
||||||
|
<flux:table.column align="center">Pending</flux:table.column>
|
||||||
|
<flux:table.column align="end">Last Checked</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->vendorSummary as $vendor)
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell variant="strong">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if($vendor['source_type'] === 'licensed')
|
||||||
|
<flux:icon name="lock-closed" class="size-4 text-amber-500" />
|
||||||
|
@elseif($vendor['source_type'] === 'oss')
|
||||||
|
<flux:icon name="globe-alt" class="size-4 text-green-500" />
|
||||||
|
@else
|
||||||
|
<flux:icon name="puzzle-piece" class="size-4 text-blue-500" />
|
||||||
|
@endif
|
||||||
|
{{ $vendor['name'] }}
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500 font-mono text-sm">
|
||||||
|
{{ $vendor['current_version'] ?? 'N/A' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($vendor['pending_todos'] > 0)
|
||||||
|
<flux:badge color="{{ $vendor['pending_todos'] > 10 ? 'red' : ($vendor['pending_todos'] > 5 ? 'yellow' : 'blue') }}" size="sm">
|
||||||
|
{{ $vendor['pending_todos'] }}
|
||||||
|
</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="green" size="sm">0</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end" class="text-zinc-500 text-sm">
|
||||||
|
{{ $vendor['last_checked'] }}
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="4" class="text-center py-8">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="building-office" class="size-8 opacity-50" />
|
||||||
|
<span>No vendors tracked yet</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Todos by Type --}}
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Todos by Type</flux:heading>
|
||||||
|
<flux:subheading>Pending items breakdown</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||||
|
View All
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
@php
|
||||||
|
$typeConfig = [
|
||||||
|
'feature' => ['icon' => 'sparkles', 'color' => 'blue', 'label' => 'Features'],
|
||||||
|
'bugfix' => ['icon' => 'bug-ant', 'color' => 'yellow', 'label' => 'Bug Fixes'],
|
||||||
|
'security' => ['icon' => 'shield-check', 'color' => 'red', 'label' => 'Security'],
|
||||||
|
'ui' => ['icon' => 'paint-brush', 'color' => 'purple', 'label' => 'UI Changes'],
|
||||||
|
'api' => ['icon' => 'code-bracket', 'color' => 'cyan', 'label' => 'API Changes'],
|
||||||
|
'refactor' => ['icon' => 'arrow-path-rounded-square', 'color' => 'green', 'label' => 'Refactors'],
|
||||||
|
'dependency' => ['icon' => 'cube', 'color' => 'orange', 'label' => 'Dependencies'],
|
||||||
|
'block' => ['icon' => 'square-3-stack-3d', 'color' => 'pink', 'label' => 'Blocks'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@forelse ($this->todosByType as $type => $count)
|
||||||
|
@php $config = $typeConfig[$type] ?? ['icon' => 'document', 'color' => 'zinc', 'label' => ucfirst($type)]; @endphp
|
||||||
|
<div class="flex items-center justify-between p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:icon name="{{ $config['icon'] }}" class="size-5 text-{{ $config['color'] }}-500" />
|
||||||
|
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $config['label'] }}</span>
|
||||||
|
</div>
|
||||||
|
<flux:badge color="{{ $config['color'] }}" size="sm">{{ $count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-8 text-zinc-500">
|
||||||
|
<flux:icon name="clipboard-document-list" class="size-8 opacity-50 mx-auto mb-2" />
|
||||||
|
<span>No pending todos</span>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Recent Todos --}}
|
||||||
|
<flux:card class="p-0 overflow-hidden mt-6">
|
||||||
|
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Recent High-Priority Todos</flux:heading>
|
||||||
|
<flux:subheading>Pending items ordered by priority</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}?status=pending" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||||
|
View All Pending
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>Todo</flux:table.column>
|
||||||
|
<flux:table.column>Vendor</flux:table.column>
|
||||||
|
<flux:table.column>Type</flux:table.column>
|
||||||
|
<flux:table.column align="center">Priority</flux:table.column>
|
||||||
|
<flux:table.column align="center">Effort</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->recentTodos as $todo)
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell variant="strong" class="max-w-xs truncate">
|
||||||
|
{{ $todo->title }}
|
||||||
|
@if($todo->isQuickWin())
|
||||||
|
<flux:badge color="green" size="sm" class="ml-1">Quick Win</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500">
|
||||||
|
{{ $todo->vendor->name }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<span class="text-sm">{{ $todo->getTypeIcon() }} {{ ucfirst($todo->type) }}</span>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : ($todo->priority >= 4 ? 'yellow' : 'zinc')) }}" size="sm">
|
||||||
|
{{ $todo->getPriorityLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center" class="text-zinc-500 text-sm">
|
||||||
|
{{ $todo->getEffortLabel() }}
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="5" class="text-center py-8">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="check-circle" class="size-8 opacity-50" />
|
||||||
|
<span>No pending todos</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Recent Releases --}}
|
||||||
|
@if($this->recentReleases->isNotEmpty())
|
||||||
|
<flux:card class="p-0 overflow-hidden mt-6">
|
||||||
|
<div class="p-4 border-b border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Recent Version Releases</flux:heading>
|
||||||
|
<flux:subheading>Latest analysed vendor updates</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence.diffs') }}" wire:navigate variant="ghost" size="sm" icon-trailing="arrow-right">
|
||||||
|
View Diffs
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>Vendor</flux:table.column>
|
||||||
|
<flux:table.column>Version</flux:table.column>
|
||||||
|
<flux:table.column align="center">Changes</flux:table.column>
|
||||||
|
<flux:table.column align="center">Impact</flux:table.column>
|
||||||
|
<flux:table.column align="end">Analysed</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach ($this->recentReleases as $release)
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell variant="strong">
|
||||||
|
{{ $release->vendor->name }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="font-mono text-sm">
|
||||||
|
{{ $release->getVersionCompare() }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<div class="flex items-center justify-center gap-1 text-sm">
|
||||||
|
<span class="text-green-600">+{{ $release->files_added }}</span>
|
||||||
|
<span class="text-blue-600">~{{ $release->files_modified }}</span>
|
||||||
|
<span class="text-red-600">-{{ $release->files_removed }}</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<flux:badge class="{{ $release->getImpactBadgeClass() }}" size="sm">
|
||||||
|
{{ ucfirst($release->getImpactLevel()) }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end" class="text-zinc-500 text-sm">
|
||||||
|
{{ $release->analyzed_at?->diffForHumans() ?? 'Pending' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
|
</admin:module>
|
||||||
240
View/Blade/admin/diff-viewer.blade.php
Normal file
240
View/Blade/admin/diff-viewer.blade.php
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
<admin:module title="Diff Viewer" subtitle="View file changes between vendor versions">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||||
|
Back
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
{{-- Vendor and Release Selection --}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<flux:select wire:model.live="vendorId" label="Select Vendor">
|
||||||
|
<option value="">Choose a vendor...</option>
|
||||||
|
@foreach($this->vendors as $vendor)
|
||||||
|
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
@if($this->releases->isNotEmpty())
|
||||||
|
<flux:select wire:model.live="releaseId" label="Select Release">
|
||||||
|
<option value="">Choose a release...</option>
|
||||||
|
@foreach($this->releases as $release)
|
||||||
|
<option value="{{ $release->id }}">
|
||||||
|
{{ $release->getVersionCompare() }} - {{ $release->analyzed_at?->format('d M Y') }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
@else
|
||||||
|
<div class="flex items-end">
|
||||||
|
<div class="p-3 bg-zinc-100 dark:bg-zinc-800 rounded-lg text-zinc-500 text-sm w-full">
|
||||||
|
Select a vendor to view available releases
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->selectedRelease)
|
||||||
|
{{-- Release Summary --}}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="text-sm text-zinc-500">Total Changes</div>
|
||||||
|
<div class="text-2xl font-bold">{{ $this->diffStats['total'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-zinc-500">Added</div>
|
||||||
|
<flux:badge color="green" size="sm">{{ $this->diffStats['added'] }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-green-600">+{{ $this->diffStats['added'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-zinc-500">Modified</div>
|
||||||
|
<flux:badge color="blue" size="sm">{{ $this->diffStats['modified'] }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-600">~{{ $this->diffStats['modified'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-zinc-500">Removed</div>
|
||||||
|
<flux:badge color="red" size="sm">{{ $this->diffStats['removed'] }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-red-600">-{{ $this->diffStats['removed'] }}</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Category Breakdown --}}
|
||||||
|
@if(count($this->diffStats['by_category']) > 0)
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:heading size="sm" class="mb-3">Changes by Category</flux:heading>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($this->diffStats['by_category'] as $cat => $count)
|
||||||
|
<flux:button
|
||||||
|
wire:click="$set('category', '{{ $category === $cat ? '' : $cat }}')"
|
||||||
|
variant="{{ $category === $cat ? 'filled' : 'ghost' }}"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ ucfirst($cat) }}
|
||||||
|
<flux:badge size="sm" class="ml-1">{{ $count }}</flux:badge>
|
||||||
|
</flux:button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Filter by Change Type --}}
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<span class="text-sm text-zinc-500">Filter:</span>
|
||||||
|
<flux:button wire:click="$set('changeType', '')" variant="{{ $changeType === '' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
All
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('changeType', 'added')" variant="{{ $changeType === 'added' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
<span class="text-green-600">Added</span>
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('changeType', 'modified')" variant="{{ $changeType === 'modified' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
<span class="text-blue-600">Modified</span>
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('changeType', 'removed')" variant="{{ $changeType === 'removed' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
<span class="text-red-600">Removed</span>
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Diffs Table --}}
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column class="w-10">Type</flux:table.column>
|
||||||
|
<flux:table.column>File Path</flux:table.column>
|
||||||
|
<flux:table.column>Category</flux:table.column>
|
||||||
|
<flux:table.column align="center">Lines</flux:table.column>
|
||||||
|
<flux:table.column align="end">Actions</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->diffs as $diff)
|
||||||
|
<flux:table.row wire:key="diff-{{ $diff->id }}">
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($diff->change_type === 'added')
|
||||||
|
<flux:badge color="green" size="sm">+</flux:badge>
|
||||||
|
@elseif($diff->change_type === 'modified')
|
||||||
|
<flux:badge color="blue" size="sm">~</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="red" size="sm">-</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="font-mono text-sm">
|
||||||
|
<span class="text-zinc-500">{{ $diff->getDirectory() }}/</span>{{ $diff->getFileName() }}
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span>{{ $diff->getCategoryIcon() }}</span>
|
||||||
|
<span class="text-sm">{{ ucfirst($diff->category) }}</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($diff->diff_content)
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-green-600">+{{ $diff->getAddedLines() }}</span>
|
||||||
|
<span class="text-zinc-400">/</span>
|
||||||
|
<span class="text-red-600">-{{ $diff->getRemovedLines() }}</span>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end">
|
||||||
|
<flux:button wire:click="viewDiff({{ $diff->id }})" variant="ghost" size="sm" icon="eye">
|
||||||
|
View
|
||||||
|
</flux:button>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="5" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="document-magnifying-glass" class="size-12 opacity-50" />
|
||||||
|
<span class="text-lg">No diffs found</span>
|
||||||
|
<span class="text-sm">Select a release to view file changes</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->diffs->hasPages())
|
||||||
|
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
{{ $this->diffs->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
@else
|
||||||
|
<flux:card class="p-12">
|
||||||
|
<div class="flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
|
||||||
|
<flux:icon name="document-magnifying-glass" class="size-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="lg">Select a Vendor and Release</flux:heading>
|
||||||
|
<flux:subheading class="mt-1">Choose a vendor and release version to view file diffs.</flux:subheading>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Diff Detail Modal --}}
|
||||||
|
<flux:modal wire:model="showDiffModal" name="diff-detail" class="max-w-5xl">
|
||||||
|
@if($this->selectedDiff)
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ $this->selectedDiff->getChangeTypeIcon() }}</span>
|
||||||
|
<flux:heading size="lg" class="font-mono">{{ $this->selectedDiff->getFileName() }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
<flux:subheading class="font-mono text-sm">{{ $this->selectedDiff->file_path }}</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:badge class="{{ $this->selectedDiff->getChangeTypeBadgeClass() }}">
|
||||||
|
{{ ucfirst($this->selectedDiff->change_type) }}
|
||||||
|
</flux:badge>
|
||||||
|
<flux:badge color="zinc">{{ ucfirst($this->selectedDiff->category) }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->selectedDiff->diff_content)
|
||||||
|
<div class="bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
|
<div class="p-3 border-b border-zinc-700 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-zinc-400">Unified Diff</span>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-green-400">+{{ $this->selectedDiff->getAddedLines() }} added</span>
|
||||||
|
<span class="text-red-400">-{{ $this->selectedDiff->getRemovedLines() }} removed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 overflow-x-auto text-sm font-mono max-h-[60vh] overflow-y-auto"><code class="language-diff">{{ $this->selectedDiff->diff_content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
@elseif($this->selectedDiff->new_content)
|
||||||
|
<div class="bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
|
<div class="p-3 border-b border-zinc-700">
|
||||||
|
<span class="text-sm text-zinc-400">New File Content</span>
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 overflow-x-auto text-sm font-mono max-h-[60vh] overflow-y-auto text-zinc-300"><code>{{ $this->selectedDiff->new_content }}</code></pre>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="p-8 text-center text-zinc-500 bg-zinc-100 dark:bg-zinc-800 rounded-lg">
|
||||||
|
<flux:icon name="document" class="size-12 opacity-50 mx-auto mb-2" />
|
||||||
|
<p>No content available for this file change.</p>
|
||||||
|
@if($this->selectedDiff->change_type === 'removed')
|
||||||
|
<p class="text-sm mt-1">This file was removed in the new version.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="closeDiffModal" variant="ghost">Close</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:modal>
|
||||||
|
</admin:module>
|
||||||
250
View/Blade/admin/digest-preferences.blade.php
Normal file
250
View/Blade/admin/digest-preferences.blade.php
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
<admin:module title="Digest Preferences" subtitle="Configure email notifications for vendor updates">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" icon="arrow-left" size="sm">
|
||||||
|
Back to Dashboard
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{{-- Settings Panel --}}
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
{{-- Enable/Disable Card --}}
|
||||||
|
<flux:card class="p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Email Digests</flux:heading>
|
||||||
|
<flux:subheading>Receive periodic summaries of vendor updates and pending tasks</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:switch
|
||||||
|
wire:click="toggleEnabled"
|
||||||
|
:checked="$isEnabled"
|
||||||
|
label="{{ $isEnabled ? 'Enabled' : 'Disabled' }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Frequency Selection --}}
|
||||||
|
<flux:card class="p-6">
|
||||||
|
<flux:heading size="lg" class="mb-4">Frequency</flux:heading>
|
||||||
|
|
||||||
|
<flux:radio.group wire:model.live="frequency" class="space-y-3">
|
||||||
|
@foreach(\Core\Uptelligence\Models\UptelligenceDigest::getFrequencyOptions() as $value => $label)
|
||||||
|
<flux:radio
|
||||||
|
value="{{ $value }}"
|
||||||
|
label="{{ $label }}"
|
||||||
|
description="{{ match($value) {
|
||||||
|
'daily' => 'Sent every morning at 9am UK time',
|
||||||
|
'weekly' => 'Sent every Monday at 9am UK time',
|
||||||
|
'monthly' => 'Sent on the 1st of each month at 9am UK time',
|
||||||
|
default => ''
|
||||||
|
} }}"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</flux:radio.group>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Content Types --}}
|
||||||
|
<flux:card class="p-6">
|
||||||
|
<flux:heading size="lg" class="mb-4">Include in Digest</flux:heading>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<flux:checkbox
|
||||||
|
wire:click="toggleType('releases')"
|
||||||
|
:checked="in_array('releases', $selectedTypes)"
|
||||||
|
label="New Releases"
|
||||||
|
description="Version updates and changelog summaries from tracked vendors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<flux:checkbox
|
||||||
|
wire:click="toggleType('todos')"
|
||||||
|
:checked="in_array('todos', $selectedTypes)"
|
||||||
|
label="Pending Tasks"
|
||||||
|
description="Summary of porting tasks grouped by priority"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<flux:checkbox
|
||||||
|
wire:click="toggleType('security')"
|
||||||
|
:checked="in_array('security', $selectedTypes)"
|
||||||
|
label="Security Updates"
|
||||||
|
description="Highlight security-related updates that need attention"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Vendor Filter --}}
|
||||||
|
<flux:card class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Vendor Filter</flux:heading>
|
||||||
|
<flux:subheading>Select which vendors to include (leave empty for all)</flux:subheading>
|
||||||
|
</div>
|
||||||
|
@if(!empty($selectedVendorIds))
|
||||||
|
<flux:button wire:click="selectAllVendors" variant="ghost" size="sm">
|
||||||
|
Clear Filter
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
||||||
|
@foreach($this->vendors as $vendor)
|
||||||
|
<flux:checkbox
|
||||||
|
wire:click="toggleVendor({{ $vendor->id }})"
|
||||||
|
:checked="empty($selectedVendorIds) || in_array($vendor->id, $selectedVendorIds)"
|
||||||
|
label="{{ $vendor->name }}"
|
||||||
|
>
|
||||||
|
<x-slot:description>
|
||||||
|
@if($vendor->source_type === 'licensed')
|
||||||
|
<flux:icon name="lock-closed" class="size-3 inline text-amber-500" />
|
||||||
|
@elseif($vendor->source_type === 'oss')
|
||||||
|
<flux:icon name="globe-alt" class="size-3 inline text-green-500" />
|
||||||
|
@else
|
||||||
|
<flux:icon name="puzzle-piece" class="size-3 inline text-blue-500" />
|
||||||
|
@endif
|
||||||
|
{{ $vendor->slug }}
|
||||||
|
</x-slot:description>
|
||||||
|
</flux:checkbox>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->vendors->isEmpty())
|
||||||
|
<div class="text-center py-8 text-zinc-500">
|
||||||
|
<flux:icon name="building-office" class="size-8 opacity-50 mx-auto mb-2" />
|
||||||
|
<span>No vendors tracked yet</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Priority Threshold --}}
|
||||||
|
<flux:card class="p-6">
|
||||||
|
<flux:heading size="lg" class="mb-4">Priority Threshold</flux:heading>
|
||||||
|
<flux:subheading class="mb-4">Only include tasks at or above this priority level</flux:subheading>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="minPriority">
|
||||||
|
<flux:option :value="null">All priorities</flux:option>
|
||||||
|
<flux:option value="4">Medium and above (4+)</flux:option>
|
||||||
|
<flux:option value="6">High and above (6+)</flux:option>
|
||||||
|
<flux:option value="8">Critical only (8+)</flux:option>
|
||||||
|
</flux:select>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:button wire:click="sendTestDigest" variant="ghost" icon="paper-airplane">
|
||||||
|
Send Test Digest
|
||||||
|
</flux:button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<flux:button wire:click="showPreview" variant="ghost" icon="eye">
|
||||||
|
Preview
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="save" variant="primary" icon="check">
|
||||||
|
Save Preferences
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Preview Panel --}}
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<flux:card class="p-6 sticky top-6">
|
||||||
|
<flux:heading size="lg" class="mb-4">Preview</flux:heading>
|
||||||
|
<flux:subheading class="mb-6">What your next digest would include</flux:subheading>
|
||||||
|
|
||||||
|
@php $preview = $this->preview; @endphp
|
||||||
|
|
||||||
|
@if(!$preview['has_content'])
|
||||||
|
<div class="text-center py-8 text-zinc-500">
|
||||||
|
<flux:icon name="inbox" class="size-8 opacity-50 mx-auto mb-2" />
|
||||||
|
<span>No content to preview</span>
|
||||||
|
<p class="text-sm mt-1">There are no updates matching your filters</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{-- Security Alert --}}
|
||||||
|
@if($preview['security_count'] > 0)
|
||||||
|
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||||
|
<flux:icon name="shield-exclamation" class="size-5" />
|
||||||
|
<span class="font-medium">{{ $preview['security_count'] }} security update{{ $preview['security_count'] !== 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Releases --}}
|
||||||
|
@if($preview['releases']->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Recent Releases</h4>
|
||||||
|
<ul class="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
@foreach($preview['releases'] as $release)
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>{{ $release['vendor_name'] }}</span>
|
||||||
|
<span class="font-mono text-xs">{{ $release['version'] }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Todos Summary --}}
|
||||||
|
@if(($preview['todos']['total'] ?? 0) > 0)
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Pending Tasks</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
@if($preview['todos']['critical'] > 0)
|
||||||
|
<div class="flex items-center justify-between p-2 bg-red-50 dark:bg-red-900/20 rounded">
|
||||||
|
<span class="text-red-700 dark:text-red-400">Critical</span>
|
||||||
|
<span class="font-medium">{{ $preview['todos']['critical'] }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($preview['todos']['high'] > 0)
|
||||||
|
<div class="flex items-center justify-between p-2 bg-orange-50 dark:bg-orange-900/20 rounded">
|
||||||
|
<span class="text-orange-700 dark:text-orange-400">High</span>
|
||||||
|
<span class="font-medium">{{ $preview['todos']['high'] }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($preview['todos']['medium'] > 0)
|
||||||
|
<div class="flex items-center justify-between p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded">
|
||||||
|
<span class="text-yellow-700 dark:text-yellow-400">Medium</span>
|
||||||
|
<span class="font-medium">{{ $preview['todos']['medium'] }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($preview['todos']['low'] > 0)
|
||||||
|
<div class="flex items-center justify-between p-2 bg-zinc-50 dark:bg-zinc-800 rounded">
|
||||||
|
<span class="text-zinc-600 dark:text-zinc-400">Low</span>
|
||||||
|
<span class="font-medium">{{ $preview['todos']['low'] }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Top Vendors --}}
|
||||||
|
@if($preview['top_vendors']->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Top Vendors</h4>
|
||||||
|
<ul class="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
@foreach($preview['top_vendors'] as $vendor)
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>{{ $vendor->name }}</span>
|
||||||
|
<flux:badge size="sm" color="blue">{{ $vendor->pending_count }} pending</flux:badge>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Next Send --}}
|
||||||
|
@if($preview['next_send'])
|
||||||
|
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700 text-sm text-zinc-500">
|
||||||
|
<p><strong>Frequency:</strong> {{ $preview['frequency_label'] }}</p>
|
||||||
|
<p><strong>Next send:</strong> {{ $preview['next_send'] }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</admin:module>
|
||||||
224
View/Blade/admin/todo-list.blade.php
Normal file
224
View/Blade/admin/todo-list.blade.php
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<admin:module title="Upstream Todos" subtitle="Manage porting tasks from vendor updates">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if(count($selectedTodos) > 0)
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button variant="filled" size="sm" icon="check-circle">
|
||||||
|
Bulk Actions ({{ count($selectedTodos) }})
|
||||||
|
</flux:button>
|
||||||
|
<flux:menu>
|
||||||
|
<flux:menu.item wire:click="bulkMarkStatus('in_progress')" icon="play">
|
||||||
|
Mark In Progress
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="bulkMarkStatus('ported')" icon="check">
|
||||||
|
Mark Ported
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="bulkMarkStatus('skipped')" icon="forward">
|
||||||
|
Mark Skipped
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="bulkMarkStatus('wont_port')" icon="x-mark" class="text-red-600">
|
||||||
|
Mark Won't Port
|
||||||
|
</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
@endif
|
||||||
|
<flux:button wire:click="resetFilters" variant="ghost" size="sm" icon="x-mark">
|
||||||
|
Reset Filters
|
||||||
|
</flux:button>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||||
|
Back
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
{{-- Status Tabs --}}
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<flux:button wire:click="$set('status', 'pending')" variant="{{ $status === 'pending' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
Pending
|
||||||
|
<flux:badge size="sm" class="ml-1">{{ $this->todoStats['pending'] }}</flux:badge>
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('status', 'quick_wins')" variant="{{ $status === 'quick_wins' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
Quick Wins
|
||||||
|
<flux:badge color="green" size="sm" class="ml-1">{{ $this->todoStats['quick_wins'] }}</flux:badge>
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('status', 'in_progress')" variant="{{ $status === 'in_progress' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
In Progress
|
||||||
|
<flux:badge color="blue" size="sm" class="ml-1">{{ $this->todoStats['in_progress'] }}</flux:badge>
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="$set('status', 'completed')" variant="{{ $status === 'completed' ? 'filled' : 'ghost' }}" size="sm">
|
||||||
|
Completed
|
||||||
|
<flux:badge color="zinc" size="sm" class="ml-1">{{ $this->todoStats['completed'] }}</flux:badge>
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search todos..." icon="magnifying-glass" />
|
||||||
|
|
||||||
|
<flux:select wire:model.live="vendorId">
|
||||||
|
<option value="">All Vendors</option>
|
||||||
|
@foreach($this->vendors as $vendor)
|
||||||
|
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="feature">Feature</option>
|
||||||
|
<option value="bugfix">Bug Fix</option>
|
||||||
|
<option value="security">Security</option>
|
||||||
|
<option value="ui">UI</option>
|
||||||
|
<option value="api">API</option>
|
||||||
|
<option value="refactor">Refactor</option>
|
||||||
|
<option value="dependency">Dependency</option>
|
||||||
|
<option value="block">Block</option>
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="effort">
|
||||||
|
<option value="">All Effort</option>
|
||||||
|
<option value="low">Low (< 1 hour)</option>
|
||||||
|
<option value="medium">Medium (1-4 hours)</option>
|
||||||
|
<option value="high">High (4+ hours)</option>
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="priority">
|
||||||
|
<option value="">All Priority</option>
|
||||||
|
<option value="critical">Critical (8-10)</option>
|
||||||
|
<option value="high">High (6-7)</option>
|
||||||
|
<option value="medium">Medium (4-5)</option>
|
||||||
|
<option value="low">Low (1-3)</option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column class="w-10">
|
||||||
|
<flux:checkbox wire:model.live="selectAll" wire:click="toggleSelectAll" />
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir" wire:click="sortBy('title')">
|
||||||
|
Todo
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column>Vendor</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'type'" :direction="$sortDir" wire:click="sortBy('type')">
|
||||||
|
Type
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'priority'" :direction="$sortDir" wire:click="sortBy('priority')" align="center">
|
||||||
|
Priority
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'effort'" :direction="$sortDir" wire:click="sortBy('effort')" align="center">
|
||||||
|
Effort
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column align="center">Status</flux:table.column>
|
||||||
|
<flux:table.column align="end">Actions</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->todos as $todo)
|
||||||
|
<flux:table.row wire:key="todo-{{ $todo->id }}" class="{{ $todo->has_conflicts ? 'bg-red-50 dark:bg-red-900/10' : '' }}">
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:checkbox wire:model.live="selectedTodos" value="{{ $todo->id }}" />
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div class="font-medium text-zinc-900 dark:text-zinc-100 truncate flex items-center gap-2">
|
||||||
|
{{ $todo->title }}
|
||||||
|
@if($todo->isQuickWin())
|
||||||
|
<flux:badge color="emerald" size="sm">Quick Win</flux:badge>
|
||||||
|
@endif
|
||||||
|
@if($todo->has_conflicts)
|
||||||
|
<flux:icon name="exclamation-triangle" class="size-4 text-red-500" title="Has conflicts" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if($todo->description)
|
||||||
|
<div class="text-sm text-zinc-500 truncate">{{ Str::limit($todo->description, 80) }}</div>
|
||||||
|
@endif
|
||||||
|
@if($todo->files && count($todo->files) > 0)
|
||||||
|
<div class="text-xs text-zinc-400 mt-1">
|
||||||
|
{{ count($todo->files) }} file(s)
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500">
|
||||||
|
{{ $todo->vendor->name }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span>{{ $todo->getTypeIcon() }}</span>
|
||||||
|
<span class="text-sm">{{ ucfirst($todo->type) }}</span>
|
||||||
|
</span>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : ($todo->priority >= 4 ? 'yellow' : 'zinc')) }}" size="sm">
|
||||||
|
{{ $todo->getPriorityLabel() }} ({{ $todo->priority }})
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<flux:badge color="{{ $todo->effort === 'low' ? 'green' : ($todo->effort === 'medium' ? 'yellow' : 'red') }}" size="sm">
|
||||||
|
{{ $todo->getEffortLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
<flux:badge class="{{ $todo->getStatusBadgeClass() }}" size="sm">
|
||||||
|
{{ ucfirst(str_replace('_', ' ', $todo->status)) }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end">
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||||
|
<flux:menu>
|
||||||
|
@if($todo->isPending())
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'in_progress')" icon="play">
|
||||||
|
Start Progress
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'ported')" icon="check">
|
||||||
|
Mark Ported
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'skipped')" icon="forward">
|
||||||
|
Skip
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'wont_port')" icon="x-mark" class="text-red-600">
|
||||||
|
Won't Port
|
||||||
|
</flux:menu.item>
|
||||||
|
@elseif($todo->status === 'in_progress')
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'ported')" icon="check">
|
||||||
|
Mark Ported
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="markStatus({{ $todo->id }}, 'skipped')" icon="forward">
|
||||||
|
Skip
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
@if($todo->github_issue_number)
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item icon="arrow-top-right-on-square">
|
||||||
|
View GitHub Issue
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="8" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="clipboard-document-list" class="size-12 opacity-50" />
|
||||||
|
<span class="text-lg">No todos found</span>
|
||||||
|
<span class="text-sm">Try adjusting your filters</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->todos->hasPages())
|
||||||
|
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
{{ $this->todos->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
</admin:module>
|
||||||
232
View/Blade/admin/vendor-manager.blade.php
Normal file
232
View/Blade/admin/vendor-manager.blade.php
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<admin:module title="Vendor Manager" subtitle="Track and manage upstream software vendors">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search vendors..." icon="magnifying-glass" size="sm" class="w-64" />
|
||||||
|
<flux:select wire:model.live="sourceType" size="sm">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="licensed">Licensed</option>
|
||||||
|
<option value="oss">Open Source</option>
|
||||||
|
<option value="plugin">Plugin</option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||||
|
Back
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDir" wire:click="sortBy('name')">
|
||||||
|
Vendor
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'source_type'" :direction="$sortDir" wire:click="sortBy('source_type')">
|
||||||
|
Type
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'current_version'" :direction="$sortDir" wire:click="sortBy('current_version')">
|
||||||
|
Current Version
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column align="center">Pending Todos</flux:table.column>
|
||||||
|
<flux:table.column align="center">Quick Wins</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'last_checked_at'" :direction="$sortDir" wire:click="sortBy('last_checked_at')">
|
||||||
|
Last Checked
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'is_active'" :direction="$sortDir" wire:click="sortBy('is_active')">
|
||||||
|
Status
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column align="end">Actions</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->vendors as $vendor)
|
||||||
|
<flux:table.row wire:key="vendor-{{ $vendor->id }}">
|
||||||
|
<flux:table.cell variant="strong">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if($vendor->source_type === 'licensed')
|
||||||
|
<flux:icon name="lock-closed" class="size-4 text-amber-500" />
|
||||||
|
@elseif($vendor->source_type === 'oss')
|
||||||
|
<flux:icon name="globe-alt" class="size-4 text-green-500" />
|
||||||
|
@else
|
||||||
|
<flux:icon name="puzzle-piece" class="size-4 text-blue-500" />
|
||||||
|
@endif
|
||||||
|
<div>
|
||||||
|
<div>{{ $vendor->name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ $vendor->slug }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge color="{{ $vendor->source_type === 'licensed' ? 'amber' : ($vendor->source_type === 'oss' ? 'green' : 'blue') }}" size="sm">
|
||||||
|
{{ $vendor->getSourceTypeLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="font-mono text-sm">
|
||||||
|
{{ $vendor->current_version ?? 'N/A' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($vendor->pending_todos_count > 0)
|
||||||
|
<flux:badge color="{{ $vendor->pending_todos_count > 10 ? 'red' : ($vendor->pending_todos_count > 5 ? 'yellow' : 'blue') }}" size="sm">
|
||||||
|
{{ $vendor->pending_todos_count }}
|
||||||
|
</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="green" size="sm">0</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($vendor->quick_wins_count > 0)
|
||||||
|
<flux:badge color="emerald" size="sm">{{ $vendor->quick_wins_count }}</flux:badge>
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500 text-sm">
|
||||||
|
{{ $vendor->last_checked_at?->diffForHumans() ?? 'Never' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($vendor->is_active)
|
||||||
|
<flux:badge color="green" size="sm">Active</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="zinc" size="sm">Inactive</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<flux:button wire:click="selectVendor({{ $vendor->id }})" variant="ghost" size="sm" icon="eye" />
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||||
|
<flux:menu>
|
||||||
|
<flux:menu.item wire:click="selectVendor({{ $vendor->id }})" icon="eye">
|
||||||
|
View Details
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.todos') }}?vendorId={{ $vendor->id }}" wire:navigate icon="clipboard-document-list">
|
||||||
|
View Todos
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item href="{{ route('hub.admin.uptelligence.diffs') }}?vendorId={{ $vendor->id }}" wire:navigate icon="document-magnifying-glass">
|
||||||
|
View Diffs
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="toggleActive({{ $vendor->id }})" icon="{{ $vendor->is_active ? 'pause' : 'play' }}">
|
||||||
|
{{ $vendor->is_active ? 'Deactivate' : 'Activate' }}
|
||||||
|
</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="8" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="building-office" class="size-12 opacity-50" />
|
||||||
|
<span class="text-lg">No vendors found</span>
|
||||||
|
<span class="text-sm">Try adjusting your search or filters</span>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->vendors->hasPages())
|
||||||
|
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
{{ $this->vendors->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Vendor Detail Modal --}}
|
||||||
|
<flux:modal wire:model="showVendorModal" name="vendor-detail" class="max-w-3xl">
|
||||||
|
@if($this->selectedVendor)
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="xl">{{ $this->selectedVendor->name }}</flux:heading>
|
||||||
|
<flux:subheading>{{ $this->selectedVendor->vendor_name ?? $this->selectedVendor->slug }}</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:badge color="{{ $this->selectedVendor->source_type === 'licensed' ? 'amber' : ($this->selectedVendor->source_type === 'oss' ? 'green' : 'blue') }}">
|
||||||
|
{{ $this->selectedVendor->getSourceTypeLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Current Version</div>
|
||||||
|
<div class="text-lg font-mono">{{ $this->selectedVendor->current_version ?? 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Previous Version</div>
|
||||||
|
<div class="text-lg font-mono">{{ $this->selectedVendor->previous_version ?? 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Pending Todos</div>
|
||||||
|
<div class="text-lg font-semibold">{{ $this->selectedVendor->pending_todos_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Last Checked</div>
|
||||||
|
<div class="text-lg">{{ $this->selectedVendor->last_checked_at?->format('d M Y H:i') ?? 'Never' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->selectedVendor->git_repo_url)
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500 mb-1">Git Repository</div>
|
||||||
|
<a href="{{ $this->selectedVendor->git_repo_url }}" target="_blank" class="text-blue-600 hover:underline flex items-center gap-1">
|
||||||
|
{{ $this->selectedVendor->git_repo_url }}
|
||||||
|
<flux:icon name="arrow-top-right-on-square" class="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($this->selectedVendor->todos->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<flux:heading size="sm" class="mb-3">Recent Todos</flux:heading>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($this->selectedVendor->todos as $todo)
|
||||||
|
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ $todo->getTypeIcon() }}</span>
|
||||||
|
<span class="font-medium truncate max-w-sm">{{ $todo->title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:badge color="{{ $todo->priority >= 8 ? 'red' : ($todo->priority >= 6 ? 'orange' : 'zinc') }}" size="sm">
|
||||||
|
P{{ $todo->priority }}
|
||||||
|
</flux:badge>
|
||||||
|
<flux:badge color="{{ $todo->effort === 'low' ? 'green' : ($todo->effort === 'medium' ? 'yellow' : 'red') }}" size="sm">
|
||||||
|
{{ ucfirst($todo->effort) }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($this->selectedVendorReleases->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<flux:heading size="sm" class="mb-3">Recent Releases</flux:heading>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($this->selectedVendorReleases->take(5) as $release)
|
||||||
|
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg flex items-center justify-between">
|
||||||
|
<div class="font-mono text-sm">{{ $release->getVersionCompare() }}</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-green-600">+{{ $release->files_added }}</span>
|
||||||
|
<span class="text-blue-600">~{{ $release->files_modified }}</span>
|
||||||
|
<span class="text-red-600">-{{ $release->files_removed }}</span>
|
||||||
|
<span class="text-zinc-500">{{ $release->analyzed_at?->diffForHumans() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="closeVendorModal" variant="ghost">Close</flux:button>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence.todos') }}?vendorId={{ $this->selectedVendor->id }}" wire:navigate variant="primary">
|
||||||
|
View All Todos
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:modal>
|
||||||
|
</admin:module>
|
||||||
391
View/Blade/admin/webhook-manager.blade.php
Normal file
391
View/Blade/admin/webhook-manager.blade.php
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
<admin:module title="Webhook Manager" subtitle="Receive vendor release notifications via webhooks">
|
||||||
|
<x-slot:actions>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:select wire:model.live="vendorId" size="sm" placeholder="All Vendors">
|
||||||
|
<option value="">All Vendors</option>
|
||||||
|
@foreach ($this->vendors as $vendor)
|
||||||
|
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="provider" size="sm" placeholder="All Providers">
|
||||||
|
<option value="">All Providers</option>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitlab">GitLab</option>
|
||||||
|
<option value="npm">npm</option>
|
||||||
|
<option value="packagist">Packagist</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="status" size="sm" placeholder="All Status">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="disabled">Disabled</option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:button wire:click="openCreateModal" variant="primary" size="sm" icon="plus">
|
||||||
|
New Webhook
|
||||||
|
</flux:button>
|
||||||
|
<flux:button href="{{ route('hub.admin.uptelligence') }}" wire:navigate variant="ghost" size="sm" icon="arrow-left">
|
||||||
|
Back
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<flux:card class="p-0 overflow-hidden">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>Vendor</flux:table.column>
|
||||||
|
<flux:table.column>Provider</flux:table.column>
|
||||||
|
<flux:table.column>Endpoint URL</flux:table.column>
|
||||||
|
<flux:table.column align="center">Deliveries (24h)</flux:table.column>
|
||||||
|
<flux:table.column>Last Received</flux:table.column>
|
||||||
|
<flux:table.column>Status</flux:table.column>
|
||||||
|
<flux:table.column align="end">Actions</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($this->webhooks as $webhook)
|
||||||
|
<flux:table.row wire:key="webhook-{{ $webhook->id }}">
|
||||||
|
<flux:table.cell variant="strong">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:icon name="{{ $webhook->getProviderIcon() }}" class="size-4 text-zinc-500" />
|
||||||
|
<div>
|
||||||
|
<div>{{ $webhook->vendor->name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ $webhook->vendor->slug }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge color="zinc" size="sm">
|
||||||
|
{{ $webhook->getProviderLabel() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="font-mono text-xs text-zinc-500 max-w-xs truncate">
|
||||||
|
{{ $webhook->getEndpointUrl() }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="center">
|
||||||
|
@if($webhook->recent_deliveries_count > 0)
|
||||||
|
<flux:badge color="blue" size="sm">{{ $webhook->recent_deliveries_count }}</flux:badge>
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400">-</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="text-zinc-500 text-sm">
|
||||||
|
{{ $webhook->last_received_at?->diffForHumans() ?? 'Never' }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge color="{{ $webhook->status_color }}" size="sm">
|
||||||
|
{{ $webhook->status_label }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell align="end">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<flux:button wire:click="selectWebhook({{ $webhook->id }})" variant="ghost" size="sm" icon="eye" />
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||||
|
<flux:menu>
|
||||||
|
<flux:menu.item wire:click="selectWebhook({{ $webhook->id }})" icon="eye">
|
||||||
|
View Details
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="viewDeliveries({{ $webhook->id }})" icon="inbox-stack">
|
||||||
|
View Deliveries
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="regenerateSecret({{ $webhook->id }})" icon="key">
|
||||||
|
Rotate Secret
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.item wire:click="toggleActive({{ $webhook->id }})" icon="{{ $webhook->is_active ? 'pause' : 'play' }}">
|
||||||
|
{{ $webhook->is_active ? 'Disable' : 'Enable' }}
|
||||||
|
</flux:menu.item>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item wire:click="deleteWebhook({{ $webhook->id }})" wire:confirm="Are you sure you want to delete this webhook?" icon="trash" variant="danger">
|
||||||
|
Delete
|
||||||
|
</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="7" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-zinc-500">
|
||||||
|
<flux:icon name="globe-alt" class="size-12 opacity-50" />
|
||||||
|
<span class="text-lg">No webhooks configured</span>
|
||||||
|
<span class="text-sm">Create a webhook to receive vendor release notifications</span>
|
||||||
|
<flux:button wire:click="openCreateModal" variant="primary" size="sm" icon="plus" class="mt-2">
|
||||||
|
Create Webhook
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if($this->webhooks->hasPages())
|
||||||
|
<div class="p-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
{{ $this->webhooks->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
{{-- Webhook Detail Modal --}}
|
||||||
|
<flux:modal wire:model="showWebhookModal" name="webhook-detail" class="max-w-2xl">
|
||||||
|
@if($this->selectedWebhook)
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="xl">{{ $this->selectedWebhook->vendor->name }}</flux:heading>
|
||||||
|
<flux:subheading>{{ $this->selectedWebhook->getProviderLabel() }} Webhook</flux:subheading>
|
||||||
|
</div>
|
||||||
|
<flux:badge color="{{ $this->selectedWebhook->status_color }}">
|
||||||
|
{{ $this->selectedWebhook->status_label }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500 mb-1">Webhook Endpoint URL</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-sm bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded flex-1 overflow-x-auto">
|
||||||
|
{{ $this->selectedWebhook->getEndpointUrl() }}
|
||||||
|
</code>
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="clipboard-document"
|
||||||
|
x-on:click="navigator.clipboard.writeText('{{ $this->selectedWebhook->getEndpointUrl() }}')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Total Deliveries</div>
|
||||||
|
<div class="text-lg font-semibold">{{ $this->selectedWebhook->deliveries_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Last 24 Hours</div>
|
||||||
|
<div class="text-lg font-semibold">{{ $this->selectedWebhook->recent_deliveries_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Last Received</div>
|
||||||
|
<div class="text-lg">{{ $this->selectedWebhook->last_received_at?->format('d M Y H:i') ?? 'Never' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500">Failure Count</div>
|
||||||
|
<div class="text-lg font-semibold {{ $this->selectedWebhook->failure_count > 0 ? 'text-red-600' : 'text-green-600' }}">
|
||||||
|
{{ $this->selectedWebhook->failure_count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($this->selectedWebhook->isInGracePeriod())
|
||||||
|
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
|
||||||
|
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||||
|
<span class="font-medium">Secret rotation in progress</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
Both old and new secrets are accepted until {{ $this->selectedWebhook->grace_ends_at->format('d M Y H:i') }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500 mb-2">Setup Instructions</div>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
@if($this->selectedWebhook->provider === 'github')
|
||||||
|
<p>1. Go to your GitHub repository Settings > Webhooks</p>
|
||||||
|
<p>2. Click "Add webhook"</p>
|
||||||
|
<p>3. Paste the endpoint URL above</p>
|
||||||
|
<p>4. Set Content type to <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">application/json</code></p>
|
||||||
|
<p>5. Enter your webhook secret</p>
|
||||||
|
<p>6. Select "Let me select individual events" and choose "Releases"</p>
|
||||||
|
@elseif($this->selectedWebhook->provider === 'gitlab')
|
||||||
|
<p>1. Go to your GitLab project Settings > Webhooks</p>
|
||||||
|
<p>2. Enter the endpoint URL</p>
|
||||||
|
<p>3. Add your secret token</p>
|
||||||
|
<p>4. Select "Releases events" trigger</p>
|
||||||
|
@elseif($this->selectedWebhook->provider === 'npm')
|
||||||
|
<p>1. Configure your npm package hooks using <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">npm hook add</code></p>
|
||||||
|
<p>2. Use the endpoint URL and your webhook secret</p>
|
||||||
|
@elseif($this->selectedWebhook->provider === 'packagist')
|
||||||
|
<p>1. Go to your Packagist package page</p>
|
||||||
|
<p>2. Edit the package settings</p>
|
||||||
|
<p>3. Add a webhook URL pointing to this endpoint</p>
|
||||||
|
@else
|
||||||
|
<p>Configure your system to POST JSON payloads to the endpoint URL.</p>
|
||||||
|
<p>Include the version in your payload as <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">version</code>, <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">tag</code>, or <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">tag_name</code>.</p>
|
||||||
|
<p>Sign payloads with HMAC-SHA256 using your secret and include in <code class="bg-zinc-100 dark:bg-zinc-800 px-1 rounded">X-Signature</code> header.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="deleteWebhook({{ $this->selectedWebhook->id }})" wire:confirm="Are you sure?" variant="danger" icon="trash">
|
||||||
|
Delete
|
||||||
|
</flux:button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:button wire:click="regenerateSecret({{ $this->selectedWebhook->id }})" variant="ghost" icon="key">
|
||||||
|
Rotate Secret
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="viewDeliveries({{ $this->selectedWebhook->id }})" variant="ghost" icon="inbox-stack">
|
||||||
|
View Deliveries
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="closeWebhookModal" variant="primary">Close</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
{{-- Create Webhook Modal --}}
|
||||||
|
<flux:modal wire:model="showCreateModal" name="create-webhook" class="max-w-md">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Create Webhook</flux:heading>
|
||||||
|
<flux:subheading>Configure a new vendor release webhook</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<flux:select wire:model="createVendorId" label="Vendor" :error="$errors->first('createVendorId')">
|
||||||
|
<option value="">Select a vendor...</option>
|
||||||
|
@foreach ($this->vendors as $vendor)
|
||||||
|
<option value="{{ $vendor->id }}">{{ $vendor->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model="createProvider" label="Provider" :error="$errors->first('createProvider')">
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitlab">GitLab</option>
|
||||||
|
<option value="npm">npm</option>
|
||||||
|
<option value="packagist">Packagist</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="closeCreateModal" variant="ghost">Cancel</flux:button>
|
||||||
|
<flux:button wire:click="createWebhook" variant="primary">Create Webhook</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
{{-- Secret Display Modal --}}
|
||||||
|
<flux:modal wire:model="showSecretModal" name="secret-display" class="max-w-lg">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Webhook Secret</flux:heading>
|
||||||
|
<flux:subheading>Copy this secret now - it will not be shown again</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-amber-800 dark:text-amber-200 mb-2">
|
||||||
|
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||||
|
<span class="font-medium">Important</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
This is the only time you will see this secret. Copy it now and store it securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($displaySecret)
|
||||||
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
<div class="text-sm text-zinc-500 mb-2">Webhook Secret</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-sm bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded flex-1 font-mono break-all">
|
||||||
|
{{ $displaySecret }}
|
||||||
|
</code>
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="clipboard-document"
|
||||||
|
x-on:click="navigator.clipboard.writeText('{{ $displaySecret }}')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="closeSecretModal" variant="primary">I have copied the secret</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
{{-- Deliveries Modal --}}
|
||||||
|
<flux:modal wire:model="showDeliveriesModal" name="webhook-deliveries" class="max-w-4xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Webhook Deliveries</flux:heading>
|
||||||
|
<flux:subheading>Recent webhook delivery history</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Time</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Event</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Version</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Status</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-zinc-500 uppercase">Signature</th>
|
||||||
|
<th class="px-3 py-2 text-right text-xs font-medium text-zinc-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||||
|
@forelse ($this->selectedWebhookDeliveries as $delivery)
|
||||||
|
<tr wire:key="delivery-{{ $delivery->id }}">
|
||||||
|
<td class="px-3 py-2 text-sm text-zinc-500">
|
||||||
|
{{ $delivery->created_at->format('d M H:i:s') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<flux:badge color="{{ $delivery->event_color }}" size="sm">
|
||||||
|
{{ $delivery->event_type }}
|
||||||
|
</flux:badge>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-sm">
|
||||||
|
{{ $delivery->version ?? '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<flux:badge color="{{ $delivery->status_color }}" size="sm">
|
||||||
|
{{ ucfirst($delivery->status) }}
|
||||||
|
</flux:badge>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<flux:badge color="{{ $delivery->signature_color }}" size="sm">
|
||||||
|
{{ ucfirst($delivery->signature_status ?? 'unknown') }}
|
||||||
|
</flux:badge>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
@if($delivery->canRetry())
|
||||||
|
<flux:button wire:click="retryDelivery({{ $delivery->id }})" variant="ghost" size="sm" icon="arrow-path">
|
||||||
|
Retry
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if($delivery->error_message)
|
||||||
|
<tr wire:key="delivery-error-{{ $delivery->id }}">
|
||||||
|
<td colspan="6" class="px-3 py-2 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<div class="text-sm text-red-700 dark:text-red-300">
|
||||||
|
<strong>Error:</strong> {{ $delivery->error_message }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-8 text-center text-zinc-500">
|
||||||
|
No deliveries recorded yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<flux:button wire:click="closeDeliveriesModal" variant="primary">Close</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</admin:module>
|
||||||
129
View/Modal/Admin/AssetManager.php
Normal file
129
View/Modal/Admin/AssetManager.php
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Core\Uptelligence\Models\Asset;
|
||||||
|
|
||||||
|
#[Title('Asset Manager')]
|
||||||
|
class AssetManager extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $type = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $licenceType = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public bool $needsUpdate = false;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortBy = 'name';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortDir = 'asc';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function assets(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return Asset::query()
|
||||||
|
->when($this->search, fn ($q) => $q->where(function ($sq) {
|
||||||
|
$sq->where('name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('slug', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('package_name', 'like', "%{$this->search}%");
|
||||||
|
}))
|
||||||
|
->when($this->type, fn ($q) => $q->where('type', $this->type))
|
||||||
|
->when($this->licenceType, fn ($q) => $q->where('licence_type', $this->licenceType))
|
||||||
|
->when($this->needsUpdate, fn ($q) => $q->needsUpdate())
|
||||||
|
->orderBy($this->sortBy, $this->sortDir)
|
||||||
|
->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function assetStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total' => Asset::active()->count(),
|
||||||
|
'needs_update' => Asset::needsUpdate()->count(),
|
||||||
|
'composer' => Asset::composer()->count(),
|
||||||
|
'npm' => Asset::npm()->count(),
|
||||||
|
'expiring_soon' => Asset::active()->get()->filter->isLicenceExpiringSoon()->count(),
|
||||||
|
'expired' => Asset::active()->get()->filter->isLicenceExpired()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDir = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleActive(int $assetId): void
|
||||||
|
{
|
||||||
|
$asset = Asset::findOrFail($assetId);
|
||||||
|
$asset->update(['is_active' => ! $asset->is_active]);
|
||||||
|
unset($this->assets, $this->assetStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedLicenceType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedNeedsUpdate(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->reset(['search', 'type', 'licenceType', 'needsUpdate']);
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.asset-manager')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Asset Manager']);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
View/Modal/Admin/Dashboard.php
Normal file
134
View/Modal/Admin/Dashboard.php
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Core\Uptelligence\Models\Asset;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
#[Title('Uptelligence Dashboard')]
|
||||||
|
class Dashboard extends Component
|
||||||
|
{
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vendors_tracked' => Vendor::active()->count(),
|
||||||
|
'pending_todos' => UpstreamTodo::pending()->count(),
|
||||||
|
'quick_wins' => UpstreamTodo::quickWins()->count(),
|
||||||
|
'security_updates' => UpstreamTodo::securityRelated()->pending()->count(),
|
||||||
|
'in_progress' => UpstreamTodo::inProgress()->count(),
|
||||||
|
'assets_tracked' => Asset::active()->count(),
|
||||||
|
'assets_need_update' => Asset::needsUpdate()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function statCards(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['value' => $this->stats['vendors_tracked'], 'label' => 'Vendors Tracked', 'icon' => 'building-office', 'color' => 'blue'],
|
||||||
|
['value' => $this->stats['pending_todos'], 'label' => 'Pending Todos', 'icon' => 'clipboard-document-list', 'color' => 'yellow'],
|
||||||
|
['value' => $this->stats['quick_wins'], 'label' => 'Quick Wins', 'icon' => 'bolt', 'color' => 'green'],
|
||||||
|
['value' => $this->stats['security_updates'], 'label' => 'Security Updates', 'icon' => 'shield-exclamation', 'color' => 'red'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function recentReleases(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return VersionRelease::with('vendor')
|
||||||
|
->analyzed()
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function recentTodos(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return UpstreamTodo::with('vendor')
|
||||||
|
->pending()
|
||||||
|
->orderByDesc('priority')
|
||||||
|
->take(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendorSummary(): array
|
||||||
|
{
|
||||||
|
return Vendor::active()
|
||||||
|
->withCount(['todos as pending_todos_count' => fn ($q) => $q->pending()])
|
||||||
|
->orderByDesc('pending_todos_count')
|
||||||
|
->take(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($v) => [
|
||||||
|
'id' => $v->id,
|
||||||
|
'name' => $v->name,
|
||||||
|
'slug' => $v->slug,
|
||||||
|
'source_type' => $v->source_type,
|
||||||
|
'current_version' => $v->current_version,
|
||||||
|
'pending_todos' => $v->pending_todos_count,
|
||||||
|
'last_checked' => $v->last_checked_at?->diffForHumans() ?? 'Never',
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function todosByType(): array
|
||||||
|
{
|
||||||
|
return UpstreamTodo::pending()
|
||||||
|
->selectRaw('type, COUNT(*) as count')
|
||||||
|
->groupBy('type')
|
||||||
|
->pluck('count', 'type')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function todosByEffort(): array
|
||||||
|
{
|
||||||
|
return UpstreamTodo::pending()
|
||||||
|
->selectRaw('effort, COUNT(*) as count')
|
||||||
|
->groupBy('effort')
|
||||||
|
->pluck('count', 'effort')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
unset(
|
||||||
|
$this->stats,
|
||||||
|
$this->statCards,
|
||||||
|
$this->recentReleases,
|
||||||
|
$this->recentTodos,
|
||||||
|
$this->vendorSummary,
|
||||||
|
$this->todosByType,
|
||||||
|
$this->todosByEffort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.dashboard')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Uptelligence Dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
View/Modal/Admin/DiffViewer.php
Normal file
174
View/Modal/Admin/DiffViewer.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Core\Uptelligence\Models\DiffCache;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
#[Title('Diff Viewer')]
|
||||||
|
class DiffViewer extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $vendorId = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $releaseId = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $category = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $changeType = '';
|
||||||
|
|
||||||
|
public ?int $selectedDiffId = null;
|
||||||
|
|
||||||
|
public bool $showDiffModal = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendors(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Vendor::active()
|
||||||
|
->whereHas('releases')
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function releases(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
if (! $this->vendorId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return VersionRelease::where('vendor_id', $this->vendorId)
|
||||||
|
->analyzed()
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedRelease(): ?VersionRelease
|
||||||
|
{
|
||||||
|
if (! $this->releaseId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VersionRelease::with('vendor')->find($this->releaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function diffs(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
if (! $this->releaseId) {
|
||||||
|
return DiffCache::whereNull('id')->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiffCache::where('version_release_id', $this->releaseId)
|
||||||
|
->when($this->category, fn ($q) => $q->where('category', $this->category))
|
||||||
|
->when($this->changeType, fn ($q) => $q->where('change_type', $this->changeType))
|
||||||
|
->orderByRaw("FIELD(change_type, 'added', 'modified', 'removed')")
|
||||||
|
->orderBy('file_path')
|
||||||
|
->paginate(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function diffStats(): array
|
||||||
|
{
|
||||||
|
if (! $this->releaseId) {
|
||||||
|
return [
|
||||||
|
'total' => 0,
|
||||||
|
'added' => 0,
|
||||||
|
'modified' => 0,
|
||||||
|
'removed' => 0,
|
||||||
|
'by_category' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$diffs = DiffCache::where('version_release_id', $this->releaseId)->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $diffs->count(),
|
||||||
|
'added' => $diffs->where('change_type', DiffCache::CHANGE_ADDED)->count(),
|
||||||
|
'modified' => $diffs->where('change_type', DiffCache::CHANGE_MODIFIED)->count(),
|
||||||
|
'removed' => $diffs->where('change_type', DiffCache::CHANGE_REMOVED)->count(),
|
||||||
|
'by_category' => $diffs->groupBy('category')->map->count()->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedDiff(): ?DiffCache
|
||||||
|
{
|
||||||
|
if (! $this->selectedDiffId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiffCache::find($this->selectedDiffId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectVendor(int $vendorId): void
|
||||||
|
{
|
||||||
|
$this->vendorId = $vendorId;
|
||||||
|
$this->releaseId = null;
|
||||||
|
$this->resetPage();
|
||||||
|
unset($this->releases, $this->selectedRelease, $this->diffs, $this->diffStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectRelease(int $releaseId): void
|
||||||
|
{
|
||||||
|
$this->releaseId = $releaseId;
|
||||||
|
$this->resetPage();
|
||||||
|
unset($this->selectedRelease, $this->diffs, $this->diffStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewDiff(int $diffId): void
|
||||||
|
{
|
||||||
|
$this->selectedDiffId = $diffId;
|
||||||
|
$this->showDiffModal = true;
|
||||||
|
unset($this->selectedDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeDiffModal(): void
|
||||||
|
{
|
||||||
|
$this->showDiffModal = false;
|
||||||
|
$this->selectedDiffId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedCategory(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedChangeType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.diff-viewer')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Diff Viewer']);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
View/Modal/Admin/DigestPreferences.php
Normal file
237
View/Modal/Admin/DigestPreferences.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceDigest;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Services\UptelligenceDigestService;
|
||||||
|
|
||||||
|
#[Title('Digest Preferences')]
|
||||||
|
class DigestPreferences extends Component
|
||||||
|
{
|
||||||
|
// Form state
|
||||||
|
public bool $isEnabled = false;
|
||||||
|
|
||||||
|
public string $frequency = 'weekly';
|
||||||
|
|
||||||
|
public array $selectedVendorIds = [];
|
||||||
|
|
||||||
|
public array $selectedTypes = ['releases', 'todos', 'security'];
|
||||||
|
|
||||||
|
public ?int $minPriority = null;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
public bool $showPreview = false;
|
||||||
|
|
||||||
|
protected UptelligenceDigestService $digestService;
|
||||||
|
|
||||||
|
public function boot(UptelligenceDigestService $digestService): void
|
||||||
|
{
|
||||||
|
$this->digestService = $digestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
$this->loadPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing preferences from database.
|
||||||
|
*/
|
||||||
|
protected function loadPreferences(): void
|
||||||
|
{
|
||||||
|
$digest = $this->getDigest();
|
||||||
|
|
||||||
|
$this->isEnabled = $digest->is_enabled;
|
||||||
|
$this->frequency = $digest->frequency;
|
||||||
|
$this->selectedVendorIds = $digest->getVendorIds() ?? [];
|
||||||
|
$this->selectedTypes = $digest->getIncludedTypes();
|
||||||
|
$this->minPriority = $digest->getMinPriority();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the digest record for the current user.
|
||||||
|
*/
|
||||||
|
protected function getDigest(): UptelligenceDigest
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspaceId = $user->defaultHostWorkspace()?->id;
|
||||||
|
|
||||||
|
if (! $workspaceId) {
|
||||||
|
abort(403, 'No workspace context');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->digestService->getOrCreateDigest($user->id, $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendors(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Vendor::active()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'slug', 'source_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function digest(): UptelligenceDigest
|
||||||
|
{
|
||||||
|
return $this->getDigest();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function preview(): array
|
||||||
|
{
|
||||||
|
$digest = $this->getDigest();
|
||||||
|
|
||||||
|
// Apply current form values to preview
|
||||||
|
$digest->is_enabled = true; // Preview as if enabled
|
||||||
|
$digest->frequency = $this->frequency;
|
||||||
|
$digest->preferences = [
|
||||||
|
'vendor_ids' => empty($this->selectedVendorIds) ? null : $this->selectedVendorIds,
|
||||||
|
'include_types' => $this->selectedTypes,
|
||||||
|
'min_priority' => $this->minPriority,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->digestService->getDigestPreview($digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save preferences to database.
|
||||||
|
*/
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$digest = $this->getDigest();
|
||||||
|
|
||||||
|
$digest->update([
|
||||||
|
'is_enabled' => $this->isEnabled,
|
||||||
|
'frequency' => $this->frequency,
|
||||||
|
'preferences' => [
|
||||||
|
'vendor_ids' => empty($this->selectedVendorIds) ? null : $this->selectedVendorIds,
|
||||||
|
'include_types' => $this->selectedTypes,
|
||||||
|
'min_priority' => $this->minPriority,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->dispatch('toast', message: 'Digest preferences saved successfully.', type: 'success');
|
||||||
|
|
||||||
|
// Refresh computed
|
||||||
|
unset($this->digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle digest enabled state.
|
||||||
|
*/
|
||||||
|
public function toggleEnabled(): void
|
||||||
|
{
|
||||||
|
$this->isEnabled = ! $this->isEnabled;
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle vendor selection.
|
||||||
|
*/
|
||||||
|
public function toggleVendor(int $vendorId): void
|
||||||
|
{
|
||||||
|
if (in_array($vendorId, $this->selectedVendorIds)) {
|
||||||
|
$this->selectedVendorIds = array_values(
|
||||||
|
array_diff($this->selectedVendorIds, [$vendorId])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->selectedVendorIds[] = $vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear preview cache
|
||||||
|
unset($this->preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select all vendors.
|
||||||
|
*/
|
||||||
|
public function selectAllVendors(): void
|
||||||
|
{
|
||||||
|
$this->selectedVendorIds = [];
|
||||||
|
unset($this->preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle type selection.
|
||||||
|
*/
|
||||||
|
public function toggleType(string $type): void
|
||||||
|
{
|
||||||
|
if (in_array($type, $this->selectedTypes)) {
|
||||||
|
$this->selectedTypes = array_values(
|
||||||
|
array_diff($this->selectedTypes, [$type])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->selectedTypes[] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the preview panel.
|
||||||
|
*/
|
||||||
|
public function showPreview(): void
|
||||||
|
{
|
||||||
|
$this->showPreview = true;
|
||||||
|
unset($this->preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the preview panel.
|
||||||
|
*/
|
||||||
|
public function hidePreview(): void
|
||||||
|
{
|
||||||
|
$this->showPreview = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test digest immediately.
|
||||||
|
*/
|
||||||
|
public function sendTestDigest(): void
|
||||||
|
{
|
||||||
|
$digest = $this->getDigest();
|
||||||
|
|
||||||
|
// Temporarily enable for test
|
||||||
|
$wasEnabled = $digest->is_enabled;
|
||||||
|
$digest->is_enabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sent = $this->digestService->sendDigest($digest);
|
||||||
|
|
||||||
|
if ($sent) {
|
||||||
|
$this->dispatch('toast', message: 'Test digest sent to your email.', type: 'success');
|
||||||
|
} else {
|
||||||
|
$this->dispatch('toast', message: 'No content to include in digest.', type: 'info');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->dispatch('toast', message: 'Failed to send test digest: '.$e->getMessage(), type: 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original state
|
||||||
|
if (! $wasEnabled) {
|
||||||
|
$digest->update(['is_enabled' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.digest-preferences')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Digest Preferences']);
|
||||||
|
}
|
||||||
|
}
|
||||||
213
View/Modal/Admin/TodoList.php
Normal file
213
View/Modal/Admin/TodoList.php
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Core\Uptelligence\Models\UpstreamTodo;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
#[Title('Upstream Todos')]
|
||||||
|
class TodoList extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $vendorId = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $status = 'pending';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $type = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $effort = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $priority = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortBy = 'priority';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortDir = 'desc';
|
||||||
|
|
||||||
|
public array $selectedTodos = [];
|
||||||
|
|
||||||
|
public bool $selectAll = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendors(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Vendor::active()->orderBy('name')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function todos(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return UpstreamTodo::with('vendor')
|
||||||
|
->when($this->search, fn ($q) => $q->where(function ($sq) {
|
||||||
|
$sq->where('title', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('description', 'like', "%{$this->search}%");
|
||||||
|
}))
|
||||||
|
->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId))
|
||||||
|
->when($this->status, fn ($q) => match ($this->status) {
|
||||||
|
'pending' => $q->pending(),
|
||||||
|
'in_progress' => $q->inProgress(),
|
||||||
|
'completed' => $q->completed(),
|
||||||
|
'quick_wins' => $q->quickWins(),
|
||||||
|
default => $q,
|
||||||
|
})
|
||||||
|
->when($this->type, fn ($q) => $q->where('type', $this->type))
|
||||||
|
->when($this->effort, fn ($q) => $q->where('effort', $this->effort))
|
||||||
|
->when($this->priority, fn ($q) => match ($this->priority) {
|
||||||
|
'critical' => $q->where('priority', '>=', 8),
|
||||||
|
'high' => $q->whereBetween('priority', [6, 7]),
|
||||||
|
'medium' => $q->whereBetween('priority', [4, 5]),
|
||||||
|
'low' => $q->where('priority', '<', 4),
|
||||||
|
default => $q,
|
||||||
|
})
|
||||||
|
->orderBy($this->sortBy, $this->sortDir)
|
||||||
|
->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function todoStats(): array
|
||||||
|
{
|
||||||
|
$baseQuery = UpstreamTodo::query()
|
||||||
|
->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => (clone $baseQuery)->pending()->count(),
|
||||||
|
'in_progress' => (clone $baseQuery)->inProgress()->count(),
|
||||||
|
'quick_wins' => (clone $baseQuery)->quickWins()->count(),
|
||||||
|
'completed' => (clone $baseQuery)->completed()->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDir = $column === 'priority' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markStatus(int $todoId, string $status): void
|
||||||
|
{
|
||||||
|
$todo = UpstreamTodo::findOrFail($todoId);
|
||||||
|
|
||||||
|
match ($status) {
|
||||||
|
'in_progress' => $todo->markInProgress(),
|
||||||
|
'ported' => $todo->markPorted(),
|
||||||
|
'skipped' => $todo->markSkipped(),
|
||||||
|
'wont_port' => $todo->markWontPort(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
unset($this->todos, $this->todoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkMarkStatus(string $status): void
|
||||||
|
{
|
||||||
|
if (empty($this->selectedTodos)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todos = UpstreamTodo::whereIn('id', $this->selectedTodos)->get();
|
||||||
|
|
||||||
|
foreach ($todos as $todo) {
|
||||||
|
match ($status) {
|
||||||
|
'in_progress' => $todo->markInProgress(),
|
||||||
|
'ported' => $todo->markPorted(),
|
||||||
|
'skipped' => $todo->markSkipped(),
|
||||||
|
'wont_port' => $todo->markWontPort(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->selectedTodos = [];
|
||||||
|
$this->selectAll = false;
|
||||||
|
unset($this->todos, $this->todoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleSelectAll(): void
|
||||||
|
{
|
||||||
|
if ($this->selectAll) {
|
||||||
|
$this->selectedTodos = $this->todos->pluck('id')->toArray();
|
||||||
|
} else {
|
||||||
|
$this->selectedTodos = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedVendorId(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
unset($this->todoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatus(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedEffort(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedPriority(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->reset(['search', 'vendorId', 'status', 'type', 'effort', 'priority']);
|
||||||
|
$this->status = 'pending';
|
||||||
|
$this->resetPage();
|
||||||
|
unset($this->todoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.todo-list')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Upstream Todos']);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
View/Modal/Admin/VendorManager.php
Normal file
140
View/Modal/Admin/VendorManager.php
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
use Core\Uptelligence\Models\VersionRelease;
|
||||||
|
|
||||||
|
#[Title('Vendor Manager')]
|
||||||
|
class VendorManager extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sourceType = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortBy = 'name';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sortDir = 'asc';
|
||||||
|
|
||||||
|
public ?int $selectedVendorId = null;
|
||||||
|
|
||||||
|
public bool $showVendorModal = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendors(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return Vendor::query()
|
||||||
|
->withCount([
|
||||||
|
'todos as pending_todos_count' => fn ($q) => $q->pending(),
|
||||||
|
'todos as quick_wins_count' => fn ($q) => $q->quickWins(),
|
||||||
|
])
|
||||||
|
->when($this->search, fn ($q) => $q->where(function ($sq) {
|
||||||
|
$sq->where('name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('slug', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('vendor_name', 'like', "%{$this->search}%");
|
||||||
|
}))
|
||||||
|
->when($this->sourceType, fn ($q) => $q->where('source_type', $this->sourceType))
|
||||||
|
->orderBy($this->sortBy, $this->sortDir)
|
||||||
|
->paginate(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedVendor(): ?Vendor
|
||||||
|
{
|
||||||
|
if (! $this->selectedVendorId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Vendor::with(['todos' => fn ($q) => $q->pending()->orderByDesc('priority')->take(5)])
|
||||||
|
->withCount(['todos as pending_todos_count' => fn ($q) => $q->pending()])
|
||||||
|
->find($this->selectedVendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedVendorReleases(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
if (! $this->selectedVendorId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return VersionRelease::where('vendor_id', $this->selectedVendorId)
|
||||||
|
->analyzed()
|
||||||
|
->latest()
|
||||||
|
->take(10)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectVendor(int $vendorId): void
|
||||||
|
{
|
||||||
|
$this->selectedVendorId = $vendorId;
|
||||||
|
$this->showVendorModal = true;
|
||||||
|
unset($this->selectedVendor, $this->selectedVendorReleases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeVendorModal(): void
|
||||||
|
{
|
||||||
|
$this->showVendorModal = false;
|
||||||
|
$this->selectedVendorId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDir = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleActive(int $vendorId): void
|
||||||
|
{
|
||||||
|
$vendor = Vendor::findOrFail($vendorId);
|
||||||
|
$vendor->update(['is_active' => ! $vendor->is_active]);
|
||||||
|
unset($this->vendors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSourceType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.vendor-manager')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Vendor Manager']);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
View/Modal/Admin/WebhookManager.php
Normal file
253
View/Modal/Admin/WebhookManager.php
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Uptelligence\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhook;
|
||||||
|
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
||||||
|
use Core\Uptelligence\Models\Vendor;
|
||||||
|
|
||||||
|
#[Title('Webhook Manager')]
|
||||||
|
class WebhookManager extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $vendorId = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $provider = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
public ?int $selectedWebhookId = null;
|
||||||
|
|
||||||
|
public bool $showWebhookModal = false;
|
||||||
|
|
||||||
|
public bool $showCreateModal = false;
|
||||||
|
|
||||||
|
public bool $showDeliveriesModal = false;
|
||||||
|
|
||||||
|
public bool $showSecretModal = false;
|
||||||
|
|
||||||
|
// Create form fields
|
||||||
|
public ?int $createVendorId = null;
|
||||||
|
|
||||||
|
public string $createProvider = UptelligenceWebhook::PROVIDER_GITHUB;
|
||||||
|
|
||||||
|
// Displayed secret after creation/regeneration
|
||||||
|
public ?string $displaySecret = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->checkHadesAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function webhooks(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return UptelligenceWebhook::query()
|
||||||
|
->with('vendor')
|
||||||
|
->withCount(['deliveries', 'deliveries as recent_deliveries_count' => fn ($q) => $q->recent(24)])
|
||||||
|
->when($this->vendorId, fn ($q) => $q->where('vendor_id', $this->vendorId))
|
||||||
|
->when($this->provider, fn ($q) => $q->where('provider', $this->provider))
|
||||||
|
->when($this->status === 'active', fn ($q) => $q->where('is_active', true))
|
||||||
|
->when($this->status === 'disabled', fn ($q) => $q->where('is_active', false))
|
||||||
|
->latest()
|
||||||
|
->paginate(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function vendors(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Vendor::orderBy('name')->get(['id', 'name', 'slug']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedWebhook(): ?UptelligenceWebhook
|
||||||
|
{
|
||||||
|
if (! $this->selectedWebhookId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UptelligenceWebhook::with('vendor')
|
||||||
|
->withCount(['deliveries', 'deliveries as recent_deliveries_count' => fn ($q) => $q->recent(24)])
|
||||||
|
->find($this->selectedWebhookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedWebhookDeliveries(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
if (! $this->selectedWebhookId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UptelligenceWebhookDelivery::where('webhook_id', $this->selectedWebhookId)
|
||||||
|
->latest()
|
||||||
|
->take(20)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function selectWebhook(int $webhookId): void
|
||||||
|
{
|
||||||
|
$this->selectedWebhookId = $webhookId;
|
||||||
|
$this->showWebhookModal = true;
|
||||||
|
$this->displaySecret = null;
|
||||||
|
unset($this->selectedWebhook, $this->selectedWebhookDeliveries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeWebhookModal(): void
|
||||||
|
{
|
||||||
|
$this->showWebhookModal = false;
|
||||||
|
$this->selectedWebhookId = null;
|
||||||
|
$this->displaySecret = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->createVendorId = $this->vendorId;
|
||||||
|
$this->createProvider = UptelligenceWebhook::PROVIDER_GITHUB;
|
||||||
|
$this->showCreateModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->createVendorId = null;
|
||||||
|
$this->displaySecret = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createWebhook(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'createVendorId' => 'required|exists:vendors,id',
|
||||||
|
'createProvider' => 'required|in:'.implode(',', UptelligenceWebhook::PROVIDERS),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if webhook already exists for this vendor/provider
|
||||||
|
$existing = UptelligenceWebhook::where('vendor_id', $this->createVendorId)
|
||||||
|
->where('provider', $this->createProvider)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$this->addError('createVendorId', 'A webhook for this vendor and provider already exists.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook = UptelligenceWebhook::create([
|
||||||
|
'vendor_id' => $this->createVendorId,
|
||||||
|
'provider' => $this->createProvider,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Show the secret to the user
|
||||||
|
$this->displaySecret = $webhook->secret;
|
||||||
|
$this->showSecretModal = true;
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
|
||||||
|
// Select the new webhook
|
||||||
|
$this->selectedWebhookId = $webhook->id;
|
||||||
|
|
||||||
|
unset($this->webhooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleActive(int $webhookId): void
|
||||||
|
{
|
||||||
|
$webhook = UptelligenceWebhook::findOrFail($webhookId);
|
||||||
|
$webhook->update(['is_active' => ! $webhook->is_active]);
|
||||||
|
|
||||||
|
// Reset failure count when re-enabling
|
||||||
|
if ($webhook->is_active) {
|
||||||
|
$webhook->resetFailureCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->webhooks, $this->selectedWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateSecret(int $webhookId): void
|
||||||
|
{
|
||||||
|
$webhook = UptelligenceWebhook::findOrFail($webhookId);
|
||||||
|
$this->displaySecret = $webhook->rotateSecret();
|
||||||
|
$this->showSecretModal = true;
|
||||||
|
unset($this->selectedWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeSecretModal(): void
|
||||||
|
{
|
||||||
|
$this->showSecretModal = false;
|
||||||
|
$this->displaySecret = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewDeliveries(int $webhookId): void
|
||||||
|
{
|
||||||
|
$this->selectedWebhookId = $webhookId;
|
||||||
|
$this->showDeliveriesModal = true;
|
||||||
|
unset($this->selectedWebhookDeliveries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeDeliveriesModal(): void
|
||||||
|
{
|
||||||
|
$this->showDeliveriesModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryDelivery(int $deliveryId): void
|
||||||
|
{
|
||||||
|
$delivery = UptelligenceWebhookDelivery::findOrFail($deliveryId);
|
||||||
|
|
||||||
|
if ($delivery->canRetry()) {
|
||||||
|
$delivery->scheduleRetry();
|
||||||
|
\Core\Uptelligence\Jobs\ProcessUptelligenceWebhook::dispatch($delivery);
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->selectedWebhookDeliveries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteWebhook(int $webhookId): void
|
||||||
|
{
|
||||||
|
$webhook = UptelligenceWebhook::findOrFail($webhookId);
|
||||||
|
$webhook->delete();
|
||||||
|
|
||||||
|
$this->closeWebhookModal();
|
||||||
|
unset($this->webhooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedVendorId(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedProvider(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatus(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkHadesAccess(): void
|
||||||
|
{
|
||||||
|
if (! auth()->user()?->isHades()) {
|
||||||
|
abort(403, 'Hades access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('uptelligence::admin.webhook-manager')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Webhook Manager']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
artisan
15
artisan
|
|
@ -1,15 +0,0 @@
|
||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the command...
|
|
||||||
$status = (require_once __DIR__.'/bootstrap/app.php')
|
|
||||||
->handleCommand(new ArgvInput);
|
|
||||||
|
|
||||||
exit($status);
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withProviders([
|
|
||||||
// Core PHP Framework
|
|
||||||
\Core\LifecycleEventProvider::class,
|
|
||||||
\Core\Website\Boot::class,
|
|
||||||
\Core\Front\Boot::class,
|
|
||||||
\Core\Mod\Boot::class,
|
|
||||||
])
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
api: __DIR__.'/../routes/api.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
|
||||||
\Core\Front\Boot::middleware($middleware);
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
|
||||||
//
|
|
||||||
})->create();
|
|
||||||
2
bootstrap/cache/.gitignore
vendored
2
bootstrap/cache/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
App\Providers\AppServiceProvider::class,
|
|
||||||
];
|
|
||||||
|
|
@ -1,78 +1,44 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core-template",
|
"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
300
config.php
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Vendor Storage Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Supports local and S3 cold storage. When using S3, versions are archived
|
||||||
|
| after import and downloaded on-demand for analysis.
|
||||||
|
*/
|
||||||
|
'storage' => [
|
||||||
|
// Primary storage disk: 'local' or 's3'
|
||||||
|
'disk' => env('UPSTREAM_STORAGE_DISK', 'local'),
|
||||||
|
|
||||||
|
// Local paths (always used for active/temp files)
|
||||||
|
'base_path' => storage_path('app/vendors'),
|
||||||
|
'licensed' => storage_path('app/vendors/licensed'),
|
||||||
|
'oss' => storage_path('app/vendors/oss'),
|
||||||
|
'plugins' => storage_path('app/vendors/plugins'),
|
||||||
|
'temp_path' => storage_path('app/temp/upstream'),
|
||||||
|
|
||||||
|
// S3 cold storage settings (Hetzner Object Store compatible)
|
||||||
|
's3' => [
|
||||||
|
// Private bucket for vendor archives (not publicly accessible)
|
||||||
|
'bucket' => env('UPSTREAM_S3_BUCKET', 'hostuk'),
|
||||||
|
'prefix' => env('UPSTREAM_S3_PREFIX', 'upstream/vendors/'),
|
||||||
|
'region' => env('UPSTREAM_S3_REGION', env('AWS_DEFAULT_REGION', 'eu-west-2')),
|
||||||
|
|
||||||
|
// Dual endpoint support for Hetzner Object Store
|
||||||
|
// Private: Internal access only (hostuk)
|
||||||
|
// Public: CDN/public access (host-uk) - NOT used for vendor archives
|
||||||
|
'private_endpoint' => env('S3_PRIVATE_ENDPOINT', env('AWS_ENDPOINT')),
|
||||||
|
'public_endpoint' => env('S3_PUBLIC_ENDPOINT'),
|
||||||
|
|
||||||
|
// Disk name in config/filesystems.php
|
||||||
|
// Defaults to private storage for vendor archives
|
||||||
|
'disk' => env('UPSTREAM_S3_DISK', 's3-private'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Archive behavior
|
||||||
|
'archive' => [
|
||||||
|
// Auto-archive to S3 after import (if disk is 's3')
|
||||||
|
'auto_archive' => env('UPSTREAM_AUTO_ARCHIVE', true),
|
||||||
|
// Delete local files after successful S3 upload
|
||||||
|
'delete_local_after_archive' => env('UPSTREAM_DELETE_LOCAL', true),
|
||||||
|
// Keep local copies for N most recent versions per vendor
|
||||||
|
'keep_local_versions' => env('UPSTREAM_KEEP_LOCAL', 2),
|
||||||
|
// Cleanup temp files older than N hours
|
||||||
|
'cleanup_after_hours' => env('UPSTREAM_CLEANUP_HOURS', 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Download behavior
|
||||||
|
'download' => [
|
||||||
|
// Max concurrent downloads
|
||||||
|
'max_concurrent' => 3,
|
||||||
|
// Download timeout in seconds
|
||||||
|
'timeout' => 300,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Vendor Source Types
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| - licensed: Paid software (manual upload/extract)
|
||||||
|
| - oss: Open source (git submodule capable)
|
||||||
|
| - plugin: Plugin packages (Altum, WordPress, etc.)
|
||||||
|
*/
|
||||||
|
'source_types' => [
|
||||||
|
'licensed' => [
|
||||||
|
'label' => 'Licensed Software',
|
||||||
|
'description' => 'Paid/proprietary software requiring manual version uploads',
|
||||||
|
'can_git_sync' => false,
|
||||||
|
'requires_upload' => true,
|
||||||
|
],
|
||||||
|
'oss' => [
|
||||||
|
'label' => 'Open Source',
|
||||||
|
'description' => 'Open source projects that can be git submoduled',
|
||||||
|
'can_git_sync' => true,
|
||||||
|
'requires_upload' => false,
|
||||||
|
],
|
||||||
|
'plugin' => [
|
||||||
|
'label' => 'Plugin/Extension',
|
||||||
|
'description' => 'Plugins for various platforms (Altum, WordPress, etc.)',
|
||||||
|
'can_git_sync' => false,
|
||||||
|
'requires_upload' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Plugin Platforms
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'plugin_platforms' => [
|
||||||
|
'altum' => 'Altum/phpBioLinks',
|
||||||
|
'wordpress' => 'WordPress',
|
||||||
|
'laravel' => 'Laravel Package',
|
||||||
|
'other' => 'Other',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Auto-Detection Patterns
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| File patterns to auto-detect change categories
|
||||||
|
*/
|
||||||
|
'detection_patterns' => [
|
||||||
|
'security' => [
|
||||||
|
'*/security/*',
|
||||||
|
'*/auth/*',
|
||||||
|
'*password*',
|
||||||
|
'*permission*',
|
||||||
|
'*/middleware/*',
|
||||||
|
'*csrf*',
|
||||||
|
'*xss*',
|
||||||
|
],
|
||||||
|
'controller' => [
|
||||||
|
'*/controllers/*',
|
||||||
|
'*Controller.php',
|
||||||
|
],
|
||||||
|
'model' => [
|
||||||
|
'*/models/*',
|
||||||
|
'*Model.php',
|
||||||
|
'*/Entities/*',
|
||||||
|
],
|
||||||
|
'view' => [
|
||||||
|
'*/views/*',
|
||||||
|
'*/themes/*',
|
||||||
|
'*.blade.php',
|
||||||
|
'*/templates/*',
|
||||||
|
],
|
||||||
|
'migration' => [
|
||||||
|
'*/migrations/*',
|
||||||
|
'*/database/*',
|
||||||
|
'*schema*',
|
||||||
|
],
|
||||||
|
'api' => [
|
||||||
|
'*/api/*',
|
||||||
|
'*api.php',
|
||||||
|
'*/Api/*',
|
||||||
|
],
|
||||||
|
'block' => [
|
||||||
|
'*/blocks/*',
|
||||||
|
'*biolink*',
|
||||||
|
'*Block.php',
|
||||||
|
],
|
||||||
|
'plugin' => [
|
||||||
|
'*/plugins/*',
|
||||||
|
'*Plugin.php',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| AI Analysis Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'ai' => [
|
||||||
|
'provider' => env('UPSTREAM_AI_PROVIDER', 'anthropic'),
|
||||||
|
'model' => env('UPSTREAM_AI_MODEL', 'claude-sonnet-4-20250514'),
|
||||||
|
'max_tokens' => 4096,
|
||||||
|
'temperature' => 0.3,
|
||||||
|
|
||||||
|
// Rate limiting: max AI API calls per minute
|
||||||
|
'rate_limit' => env('UPSTREAM_AI_RATE_LIMIT', 10),
|
||||||
|
|
||||||
|
// Prompt templates
|
||||||
|
'prompts' => [
|
||||||
|
'categorize' => 'Analyse this code diff and categorise the change type (feature, bugfix, security, ui, refactor, etc). Also estimate the effort level (low, medium, high) and priority (1-10) for porting.',
|
||||||
|
'summarize' => 'Summarise the key changes in this version update in bullet points. Focus on user-facing features, security updates, and breaking changes.',
|
||||||
|
'dependencies' => 'Identify any dependencies this change has on other files or features that would need to be ported first.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| GitHub Integration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'github' => [
|
||||||
|
'enabled' => env('UPSTREAM_GITHUB_ENABLED', true),
|
||||||
|
'token' => env('GITHUB_TOKEN'),
|
||||||
|
'default_labels' => ['upstream', 'auto-generated'],
|
||||||
|
'assignees' => explode(',', env('UPSTREAM_GITHUB_ASSIGNEES', '')),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Gitea Integration (Internal)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'gitea' => [
|
||||||
|
'enabled' => env('UPSTREAM_GITEA_ENABLED', true),
|
||||||
|
'url' => env('GITEA_URL', 'https://git.host.uk'),
|
||||||
|
'token' => env('GITEA_TOKEN'),
|
||||||
|
'org' => env('GITEA_ORG', 'host-uk'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Update Checker Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'update_checker' => [
|
||||||
|
// Auto-create todos when updates are detected
|
||||||
|
'create_todos' => env('UPSTREAM_CREATE_TODOS', true),
|
||||||
|
|
||||||
|
// Default priority for auto-created update todos (1-10)
|
||||||
|
'default_priority' => 5,
|
||||||
|
|
||||||
|
// Skip checking vendors that haven't been updated in N days
|
||||||
|
// Set to 0 to always check all vendors
|
||||||
|
'skip_recently_checked_days' => 0,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Notifications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'notifications' => [
|
||||||
|
'slack_webhook' => env('UPSTREAM_SLACK_WEBHOOK'),
|
||||||
|
'discord_webhook' => env('UPSTREAM_DISCORD_WEBHOOK'),
|
||||||
|
'email_recipients' => explode(',', env('UPSTREAM_EMAIL_RECIPIENTS', '')),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Vendor Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pre-configured vendors to seed the database with
|
||||||
|
*/
|
||||||
|
'default_vendors' => [
|
||||||
|
[
|
||||||
|
'slug' => '66biolinks',
|
||||||
|
'name' => '66biolinks',
|
||||||
|
'vendor_name' => 'AltumCode',
|
||||||
|
'source_type' => 'licensed',
|
||||||
|
'path_mapping' => [
|
||||||
|
'app/' => 'product/app/',
|
||||||
|
'themes/' => 'product/themes/',
|
||||||
|
'plugins/' => 'product/plugins/',
|
||||||
|
],
|
||||||
|
'ignored_paths' => [
|
||||||
|
'vendor/*',
|
||||||
|
'node_modules/*',
|
||||||
|
'storage/*',
|
||||||
|
'.git/*',
|
||||||
|
'*.log',
|
||||||
|
],
|
||||||
|
'priority_paths' => [
|
||||||
|
'app/controllers/*',
|
||||||
|
'app/models/*',
|
||||||
|
'plugins/*/init.php',
|
||||||
|
'themes/altum/views/l/*',
|
||||||
|
],
|
||||||
|
'target_repo' => 'host-uk/bio.host.uk.com',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'mixpost-pro',
|
||||||
|
'name' => 'Mixpost Pro',
|
||||||
|
'vendor_name' => 'Inovector',
|
||||||
|
'source_type' => 'licensed',
|
||||||
|
'path_mapping' => [
|
||||||
|
'src/' => 'packages/mixpost-pro/src/',
|
||||||
|
],
|
||||||
|
'ignored_paths' => [
|
||||||
|
'vendor/*',
|
||||||
|
'node_modules/*',
|
||||||
|
'tests/*',
|
||||||
|
],
|
||||||
|
'priority_paths' => [
|
||||||
|
'src/Http/Controllers/*',
|
||||||
|
'src/Models/*',
|
||||||
|
'src/Services/*',
|
||||||
|
],
|
||||||
|
'target_repo' => 'host-uk/host.uk.com',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'mixpost-enterprise',
|
||||||
|
'name' => 'Mixpost Enterprise',
|
||||||
|
'vendor_name' => 'Inovector',
|
||||||
|
'source_type' => 'licensed',
|
||||||
|
'path_mapping' => [
|
||||||
|
'src/' => 'packages/mixpost-enterprise/src/',
|
||||||
|
],
|
||||||
|
'ignored_paths' => [
|
||||||
|
'vendor/*',
|
||||||
|
'node_modules/*',
|
||||||
|
'tests/*',
|
||||||
|
],
|
||||||
|
'priority_paths' => [
|
||||||
|
'src/Billing/*',
|
||||||
|
'src/Features/*',
|
||||||
|
],
|
||||||
|
'target_repo' => 'host-uk/host.uk.com',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Core PHP Framework Configuration
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
'module_paths' => [
|
|
||||||
app_path('Core'),
|
|
||||||
app_path('Mod'),
|
|
||||||
app_path('Website'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'services' => [
|
|
||||||
'cache_discovery' => env('CORE_CACHE_DISCOVERY', true),
|
|
||||||
],
|
|
||||||
|
|
||||||
'cdn' => [
|
|
||||||
'enabled' => env('CDN_ENABLED', false),
|
|
||||||
'driver' => env('CDN_DRIVER', 'bunny'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Uptelligence module tables - uptime monitoring.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
// 1. Monitors
|
||||||
|
Schema::create('uptelligence_monitors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('type', 32)->default('http');
|
||||||
|
$table->string('url', 2048);
|
||||||
|
$table->string('method', 10)->default('GET');
|
||||||
|
$table->json('headers')->nullable();
|
||||||
|
$table->text('body')->nullable();
|
||||||
|
$table->json('expected_response')->nullable();
|
||||||
|
$table->unsignedSmallInteger('interval_seconds')->default(300);
|
||||||
|
$table->unsignedSmallInteger('timeout_seconds')->default(30);
|
||||||
|
$table->unsignedTinyInteger('retries')->default(3);
|
||||||
|
$table->string('status', 32)->default('active');
|
||||||
|
$table->string('current_status', 32)->default('unknown');
|
||||||
|
$table->decimal('uptime_percentage', 5, 2)->default(100);
|
||||||
|
$table->unsignedInteger('avg_response_ms')->nullable();
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->timestamp('last_up_at')->nullable();
|
||||||
|
$table->timestamp('last_down_at')->nullable();
|
||||||
|
$table->json('notification_channels')->nullable();
|
||||||
|
$table->json('settings')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'status']);
|
||||||
|
$table->index(['status', 'current_status']);
|
||||||
|
$table->index('last_checked_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Monitor Checks
|
||||||
|
Schema::create('uptelligence_checks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete();
|
||||||
|
$table->string('status', 32);
|
||||||
|
$table->unsignedInteger('response_time_ms')->nullable();
|
||||||
|
$table->unsignedSmallInteger('status_code')->nullable();
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->json('response_headers')->nullable();
|
||||||
|
$table->text('response_body')->nullable();
|
||||||
|
$table->string('checked_from', 64)->nullable();
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
|
||||||
|
$table->index(['monitor_id', 'created_at']);
|
||||||
|
$table->index(['monitor_id', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Monitor Incidents
|
||||||
|
Schema::create('uptelligence_incidents', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete();
|
||||||
|
$table->string('status', 32)->default('ongoing');
|
||||||
|
$table->text('cause')->nullable();
|
||||||
|
$table->unsignedInteger('duration_seconds')->nullable();
|
||||||
|
$table->unsignedInteger('checks_failed')->default(1);
|
||||||
|
$table->timestamp('started_at');
|
||||||
|
$table->timestamp('resolved_at')->nullable();
|
||||||
|
$table->timestamp('acknowledged_at')->nullable();
|
||||||
|
$table->foreignId('acknowledged_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['monitor_id', 'status']);
|
||||||
|
$table->index(['status', 'started_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Monitor Daily Stats
|
||||||
|
Schema::create('uptelligence_daily_stats', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('monitor_id')->constrained('uptelligence_monitors')->cascadeOnDelete();
|
||||||
|
$table->date('date');
|
||||||
|
$table->unsignedInteger('checks_total')->default(0);
|
||||||
|
$table->unsignedInteger('checks_up')->default(0);
|
||||||
|
$table->unsignedInteger('checks_down')->default(0);
|
||||||
|
$table->decimal('uptime_percentage', 5, 2)->default(100);
|
||||||
|
$table->unsignedInteger('avg_response_ms')->nullable();
|
||||||
|
$table->unsignedInteger('min_response_ms')->nullable();
|
||||||
|
$table->unsignedInteger('max_response_ms')->nullable();
|
||||||
|
$table->unsignedInteger('incidents_count')->default(0);
|
||||||
|
$table->unsignedInteger('total_downtime_seconds')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['monitor_id', 'date']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
Schema::dropIfExists('uptelligence_daily_stats');
|
||||||
|
Schema::dropIfExists('uptelligence_incidents');
|
||||||
|
Schema::dropIfExists('uptelligence_checks');
|
||||||
|
Schema::dropIfExists('uptelligence_monitors');
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create the uptelligence_digests table for email digest preferences.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('uptelligence_digests', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->string('frequency', 16)->default('weekly'); // daily, weekly, monthly
|
||||||
|
$table->timestamp('last_sent_at')->nullable();
|
||||||
|
$table->json('preferences')->nullable(); // vendor filters, update types, etc.
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Each user can only have one digest preference per workspace
|
||||||
|
$table->unique(['user_id', 'workspace_id']);
|
||||||
|
|
||||||
|
// Index for finding users due for digest
|
||||||
|
$table->index(['is_enabled', 'frequency', 'last_sent_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('uptelligence_digests');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Uptelligence webhooks tables - receive vendor release notifications.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
// 1. Webhook endpoints per vendor
|
||||||
|
Schema::create('uptelligence_webhooks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->foreignId('vendor_id')->constrained('vendors')->cascadeOnDelete();
|
||||||
|
$table->string('provider', 32); // github, gitlab, npm, packagist, custom
|
||||||
|
$table->text('secret')->nullable(); // encrypted, for signature verification
|
||||||
|
$table->text('previous_secret')->nullable(); // encrypted, for grace period
|
||||||
|
$table->timestamp('secret_rotated_at')->nullable();
|
||||||
|
$table->unsignedInteger('grace_period_seconds')->default(86400); // 24 hours
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedInteger('failure_count')->default(0);
|
||||||
|
$table->timestamp('last_received_at')->nullable();
|
||||||
|
$table->json('settings')->nullable(); // provider-specific settings
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['vendor_id', 'is_active']);
|
||||||
|
$table->index('provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Webhook delivery logs
|
||||||
|
Schema::create('uptelligence_webhook_deliveries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('webhook_id')->constrained('uptelligence_webhooks')->cascadeOnDelete();
|
||||||
|
$table->foreignId('vendor_id')->constrained('vendors')->cascadeOnDelete();
|
||||||
|
$table->string('event_type', 64); // release.published, package.updated, etc.
|
||||||
|
$table->string('provider', 32);
|
||||||
|
$table->string('version')->nullable(); // extracted version
|
||||||
|
$table->string('tag_name')->nullable(); // original tag name
|
||||||
|
$table->json('payload'); // raw payload
|
||||||
|
$table->json('parsed_data')->nullable(); // normalised release data
|
||||||
|
$table->string('status', 32)->default('pending'); // pending, processing, completed, failed
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->string('source_ip', 45)->nullable();
|
||||||
|
$table->string('signature_status', 16)->nullable(); // valid, invalid, missing
|
||||||
|
$table->timestamp('processed_at')->nullable();
|
||||||
|
$table->unsignedTinyInteger('retry_count')->default(0);
|
||||||
|
$table->unsignedTinyInteger('max_retries')->default(3);
|
||||||
|
$table->timestamp('next_retry_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['webhook_id', 'status']);
|
||||||
|
$table->index(['vendor_id', 'created_at']);
|
||||||
|
$table->index(['status', 'next_retry_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
Schema::dropIfExists('uptelligence_webhook_deliveries');
|
||||||
|
Schema::dropIfExists('uptelligence_webhooks');
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Seed the application's database.
|
|
||||||
*/
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
// Core modules handle their own seeding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
package.json
16
package.json
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"axios": "^1.7.4",
|
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"vite": "^7.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
phpunit.xml
33
phpunit.xml
|
|
@ -1,33 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="vendor/autoload.php"
|
|
||||||
colors="true"
|
|
||||||
>
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="Unit">
|
|
||||||
<directory>tests/Unit</directory>
|
|
||||||
</testsuite>
|
|
||||||
<testsuite name="Feature">
|
|
||||||
<directory>tests/Feature</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
<source>
|
|
||||||
<include>
|
|
||||||
<directory>app</directory>
|
|
||||||
</include>
|
|
||||||
</source>
|
|
||||||
<php>
|
|
||||||
<env name="APP_ENV" value="testing"/>
|
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
|
||||||
<env name="CACHE_STORE" value="array"/>
|
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
|
||||||
</php>
|
|
||||||
</phpunit>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<IfModule mod_rewrite.c>
|
|
||||||
<IfModule mod_negotiation.c>
|
|
||||||
Options -MultiViews -Indexes
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
RewriteEngine On
|
|
||||||
|
|
||||||
# Handle Authorization Header
|
|
||||||
RewriteCond %{HTTP:Authorization} .
|
|
||||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
|
||||||
|
|
||||||
# Redirect Trailing Slashes If Not A Folder...
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
|
||||||
RewriteCond %{REQUEST_URI} (.+)/$
|
|
||||||
RewriteRule ^ %1 [L,R=301]
|
|
||||||
|
|
||||||
# Send Requests To Front Controller...
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
|
||||||
RewriteRule ^ index.php [L]
|
|
||||||
</IfModule>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
|
||||||
|
|
||||||
// Determine if the application is in maintenance mode...
|
|
||||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
|
||||||
require $maintenance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/../vendor/autoload.php';
|
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the request...
|
|
||||||
(require_once __DIR__.'/../bootstrap/app.php')
|
|
||||||
->handleRequest(Request::capture());
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
import './bootstrap';
|
|
||||||
3
resources/js/bootstrap.js
vendored
3
resources/js/bootstrap.js
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
window.axios = axios;
|
|
||||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Core PHP Framework</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
.version {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.links {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border: 1px solid #667eea;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
background: #667eea;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Core PHP Framework</h1>
|
|
||||||
<p class="version">Laravel {{ Illuminate\Foundation\Application::VERSION }} | PHP {{ PHP_VERSION }}</p>
|
|
||||||
<div class="links">
|
|
||||||
<a href="https://github.com/host-uk/core-php">Documentation</a>
|
|
||||||
<a href="/admin">Admin Panel</a>
|
|
||||||
<a href="/api/docs">API Docs</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
30
routes/admin.php
Normal file
30
routes/admin.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\AssetManager;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\Dashboard;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\DiffViewer;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\DigestPreferences;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\TodoList;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\VendorManager;
|
||||||
|
use Core\Uptelligence\View\Modal\Admin\WebhookManager;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Uptelligence Admin Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Routes for the Uptelligence admin panel. All routes are prefixed with
|
||||||
|
| /hub/admin/uptelligence and require Hades access.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::prefix('hub/admin/uptelligence')->middleware(['web', 'auth'])->group(function () {
|
||||||
|
Route::get('/', Dashboard::class)->name('hub.admin.uptelligence');
|
||||||
|
Route::get('/vendors', VendorManager::class)->name('hub.admin.uptelligence.vendors');
|
||||||
|
Route::get('/todos', TodoList::class)->name('hub.admin.uptelligence.todos');
|
||||||
|
Route::get('/diffs', DiffViewer::class)->name('hub.admin.uptelligence.diffs');
|
||||||
|
Route::get('/assets', AssetManager::class)->name('hub.admin.uptelligence.assets');
|
||||||
|
Route::get('/digests', DigestPreferences::class)->name('hub.admin.uptelligence.digests');
|
||||||
|
Route::get('/webhooks', WebhookManager::class)->name('hub.admin.uptelligence.webhooks');
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,34 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
declare(strict_types=1);
|
||||||
|
|
||||||
// API routes are registered via Core modules
|
/**
|
||||||
|
* Uptelligence Module API Routes
|
||||||
|
*
|
||||||
|
* Webhook endpoints for receiving vendor release notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Core\Uptelligence\Controllers\Api\WebhookController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Uptelligence Webhooks (Public - No Auth Required)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| External webhook endpoints for receiving release notifications from
|
||||||
|
| GitHub, GitLab, npm, Packagist, and other vendor systems.
|
||||||
|
| Authentication is handled via signature verification using the
|
||||||
|
| webhook's secret key.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::prefix('uptelligence/webhook')->name('api.uptelligence.webhooks.')->group(function () {
|
||||||
|
Route::post('/{webhook}', [WebhookController::class, 'receive'])
|
||||||
|
->name('receive')
|
||||||
|
->middleware('throttle:uptelligence-webhooks');
|
||||||
|
|
||||||
|
Route::post('/{webhook}/test', [WebhookController::class, 'test'])
|
||||||
|
->name('test')
|
||||||
|
->middleware('throttle:uptelligence-webhooks');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
// Console commands are registered via Core modules
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::get('/', function () {
|
|
||||||
return view('welcome');
|
|
||||||
});
|
|
||||||
3
storage/app/.gitignore
vendored
3
storage/app/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
*
|
|
||||||
!public/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/app/public/.gitignore
vendored
2
storage/app/public/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
9
storage/framework/.gitignore
vendored
9
storage/framework/.gitignore
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
compiled.php
|
|
||||||
config.php
|
|
||||||
down
|
|
||||||
events.scanned.php
|
|
||||||
maintenance.php
|
|
||||||
routes.php
|
|
||||||
routes.scanned.php
|
|
||||||
schedule-*
|
|
||||||
services.json
|
|
||||||
3
storage/framework/cache/.gitignore
vendored
3
storage/framework/cache/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
*
|
|
||||||
!data/
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/cache/data/.gitignore
vendored
2
storage/framework/cache/data/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/sessions/.gitignore
vendored
2
storage/framework/sessions/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/testing/.gitignore
vendored
2
storage/framework/testing/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/framework/views/.gitignore
vendored
2
storage/framework/views/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
storage/logs/.gitignore
vendored
2
storage/logs/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./resources/**/*.blade.php",
|
|
||||||
"./resources/**/*.js",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import laravel from 'laravel-vite-plugin';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
laravel({
|
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
|
||||||
refresh: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue