php-uptelligence/Services/UpstreamPlanGeneratorService.php
Snider e0d2325a20 refactor: move namespace from Core\Uptelligence to Core\Mod\Uptelligence
Aligns module namespace with Core PHP Framework conventions where
modules live under the Core\Mod\ namespace hierarchy. This follows
the monorepo separation work started in 40d893a.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:32:55 +00:00

433 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Services;
use Illuminate\Support\Collection;
use Core\Mod\Uptelligence\Models\UpstreamTodo;
use Core\Mod\Uptelligence\Models\Vendor;
use Core\Mod\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;
}
}