From b32d339a53e5ad3cb357bfedf2758bc0722035f6 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 14:43:22 +0000 Subject: [PATCH] feat: add agentic:scan, agentic:dispatch, agentic:pr-manage commands Co-Authored-By: Virgil --- Boot.php | 23 ++++++- Console/Commands/DispatchCommand.php | 80 +++++++++++++++++++++++ Console/Commands/PrManageCommand.php | 94 ++++++++++++++++++++++++++ Console/Commands/ScanCommand.php | 98 ++++++++++++++++++++++++++++ agentic.php | 16 +++++ 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 Console/Commands/DispatchCommand.php create mode 100644 Console/Commands/PrManageCommand.php create mode 100644 Console/Commands/ScanCommand.php diff --git a/Boot.php b/Boot.php index 21f5b39..df6b218 100644 --- a/Boot.php +++ b/Boot.php @@ -35,17 +35,24 @@ class Boot extends ServiceProvider $this->loadMigrationsFrom(__DIR__.'/Migrations'); $this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic'); $this->configureRateLimiting(); - $this->scheduleRetentionCleanup(); + $this->scheduleCommands(); } /** - * Register the daily retention cleanup schedule. + * Register all scheduled commands. */ - protected function scheduleRetentionCleanup(): void + protected function scheduleCommands(): void { $this->app->booted(function (): void { $schedule = $this->app->make(Schedule::class); $schedule->command('agentic:plan-cleanup')->daily(); + + // Forgejo pipeline — only active when a token is configured + if (config('agentic.forge_token') !== '') { + $schedule->command('agentic:scan')->everyFiveMinutes(); + $schedule->command('agentic:dispatch')->everyTwoMinutes(); + $schedule->command('agentic:pr-manage')->everyFiveMinutes(); + } }); } @@ -84,6 +91,13 @@ class Boot extends ServiceProvider $this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class); $this->app->singleton(\Core\Mod\Agentic\Services\AgentToolRegistry::class); + $this->app->singleton(\Core\Mod\Agentic\Services\ForgejoService::class, function ($app) { + return new \Core\Mod\Agentic\Services\ForgejoService( + baseUrl: (string) config('agentic.forge_url', 'https://forge.lthn.ai'), + token: (string) config('agentic.forge_token', ''), + ); + }); + $this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) { $ollamaUrl = config('mcp.brain.ollama_url', 'http://localhost:11434'); $qdrantUrl = config('mcp.brain.qdrant_url', 'http://localhost:6334'); @@ -155,6 +169,9 @@ class Boot extends ServiceProvider $event->command(Console\Commands\PlanRetentionCommand::class); $event->command(Console\Commands\BrainSeedMemoryCommand::class); $event->command(Console\Commands\BrainIngestCommand::class); + $event->command(Console\Commands\ScanCommand::class); + $event->command(Console\Commands\DispatchCommand::class); + $event->command(Console\Commands\PrManageCommand::class); } /** diff --git a/Console/Commands/DispatchCommand.php b/Console/Commands/DispatchCommand.php new file mode 100644 index 0000000..42d09f2 --- /dev/null +++ b/Console/Commands/DispatchCommand.php @@ -0,0 +1,80 @@ +option('workspace'); + $defaultAgentType = (string) $this->option('agent-type'); + $isDryRun = (bool) $this->option('dry-run'); + + $plans = AgentPlan::where('status', AgentPlan::STATUS_DRAFT) + ->whereJsonContains('metadata->source', 'forgejo') + ->whereDoesntHave('sessions') + ->get(); + + if ($plans->isEmpty()) { + $this->info('No draft Forgejo plans awaiting dispatch.'); + + return self::SUCCESS; + } + + $dispatched = 0; + + foreach ($plans as $plan) { + $assignee = $plan->metadata['assignee'] ?? $defaultAgentType; + $issueNumber = $plan->metadata['issue_number'] ?? null; + $owner = $plan->metadata['repo_owner'] ?? null; + $repo = $plan->metadata['repo_name'] ?? null; + + if ($isDryRun) { + $this->line("DRY RUN: Would dispatch '{$assignee}' to plan #{$plan->id} — {$plan->title}"); + $dispatched++; + + continue; + } + + $session = AssignAgent::run($plan, $assignee, $workspaceId); + + if ($issueNumber !== null && $owner !== null && $repo !== null) { + ReportToIssue::run( + (string) $owner, + (string) $repo, + (int) $issueNumber, + "Agent **{$assignee}** dispatched. Session: #{$session->id}" + ); + } + + $this->line("Dispatched '{$assignee}' to plan #{$plan->id}: {$plan->title} (session #{$session->id})"); + $dispatched++; + } + + $action = $isDryRun ? 'would be dispatched' : 'dispatched'; + $this->info("{$dispatched} plan(s) {$action}."); + + return self::SUCCESS; + } +} diff --git a/Console/Commands/PrManageCommand.php b/Console/Commands/PrManageCommand.php new file mode 100644 index 0000000..63d60be --- /dev/null +++ b/Console/Commands/PrManageCommand.php @@ -0,0 +1,94 @@ +option('repos'); + + if (empty($repos)) { + $repos = config('agentic.scan_repos', []); + } + + $repos = array_filter($repos); + + if (empty($repos)) { + $this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.'); + + return self::SUCCESS; + } + + $isDryRun = (bool) $this->option('dry-run'); + $forge = app(ForgejoService::class); + $totalProcessed = 0; + + foreach ($repos as $repoSpec) { + $parts = explode('/', $repoSpec, 2); + + if (count($parts) !== 2) { + $this->error("Invalid repo format: {$repoSpec} (expected owner/name)"); + + continue; + } + + [$owner, $repo] = $parts; + + $this->info("Checking PRs for {$owner}/{$repo}..."); + + $pullRequests = $forge->listPullRequests($owner, $repo, 'open'); + + if (empty($pullRequests)) { + $this->line(" No open PRs."); + + continue; + } + + foreach ($pullRequests as $pr) { + $prNumber = (int) $pr['number']; + $prTitle = (string) ($pr['title'] ?? ''); + $totalProcessed++; + + if ($isDryRun) { + $this->line(" DRY RUN: Would evaluate PR #{$prNumber} — {$prTitle}"); + + continue; + } + + $result = ManagePullRequest::run($owner, $repo, $prNumber); + + if ($result['merged']) { + $this->line(" Merged PR #{$prNumber}: {$prTitle}"); + } else { + $reason = $result['reason'] ?? 'unknown'; + $this->line(" Skipped PR #{$prNumber}: {$prTitle} ({$reason})"); + } + } + } + + $action = $isDryRun ? 'found' : 'processed'; + $this->info("PR management complete: {$totalProcessed} PR(s) {$action}."); + + return self::SUCCESS; + } +} diff --git a/Console/Commands/ScanCommand.php b/Console/Commands/ScanCommand.php new file mode 100644 index 0000000..2c47685 --- /dev/null +++ b/Console/Commands/ScanCommand.php @@ -0,0 +1,98 @@ +option('workspace'); + $repos = $this->option('repos'); + + if (empty($repos)) { + $repos = config('agentic.scan_repos', []); + } + + $repos = array_filter($repos); + + if (empty($repos)) { + $this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.'); + + return self::SUCCESS; + } + + $isDryRun = (bool) $this->option('dry-run'); + $totalItems = 0; + + foreach ($repos as $repoSpec) { + $parts = explode('/', $repoSpec, 2); + + if (count($parts) !== 2) { + $this->error("Invalid repo format: {$repoSpec} (expected owner/name)"); + + continue; + } + + [$owner, $repo] = $parts; + + $this->info("Scanning {$owner}/{$repo}..."); + + $workItems = ScanForWork::run($owner, $repo); + + if (empty($workItems)) { + $this->line(" No actionable work found."); + + continue; + } + + foreach ($workItems as $item) { + $totalItems++; + $issueNumber = $item['issue_number']; + $title = $item['issue_title']; + + if ($isDryRun) { + $this->line(" DRY RUN: Would create plan for #{$issueNumber} — {$title}"); + + continue; + } + + $plan = CreatePlanFromIssue::run($item, $workspaceId); + + ReportToIssue::run( + $owner, + $repo, + $issueNumber, + "Plan created: **{$plan->title}** (#{$plan->id})" + ); + + $this->line(" Created plan #{$plan->id} for issue #{$issueNumber}: {$title}"); + } + } + + $action = $isDryRun ? 'found' : 'processed'; + $this->info("Scan complete: {$totalItems} work item(s) {$action}."); + + return self::SUCCESS; + } +} diff --git a/agentic.php b/agentic.php index 689c61c..dc44e25 100644 --- a/agentic.php +++ b/agentic.php @@ -18,4 +18,20 @@ return [ 'plan_retention_days' => env('AGENTIC_PLAN_RETENTION_DAYS', 90), + /* + |-------------------------------------------------------------------------- + | Forgejo Integration + |-------------------------------------------------------------------------- + | + | Configuration for the Forgejo-based scan/dispatch/PR pipeline. + | AGENTIC_SCAN_REPOS is a comma-separated list of owner/name repos. + | + */ + + 'scan_repos' => array_filter(explode(',', env('AGENTIC_SCAN_REPOS', ''))), + + 'forge_url' => env('FORGE_URL', 'https://forge.lthn.ai'), + + 'forge_token' => env('FORGE_TOKEN', ''), + ];