feat: add agentic:scan, agentic:dispatch, agentic:pr-manage commands
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 14:43:22 +00:00
parent 6ac515d80e
commit b32d339a53
5 changed files with 308 additions and 3 deletions

View file

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

View file

@ -0,0 +1,80 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\AssignAgent;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class DispatchCommand extends Command
{
protected $signature = 'agentic:dispatch
{--workspace=1 : Workspace ID}
{--agent-type=opus : Default agent type}
{--dry-run : Show what would be dispatched}';
protected $description = 'Dispatch agents to draft plans sourced from Forgejo';
public function handle(): int
{
$workspaceId = (int) $this->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;
}
}

View file

@ -0,0 +1,94 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\ManagePullRequest;
use Core\Mod\Agentic\Services\ForgejoService;
use Illuminate\Console\Command;
class PrManageCommand extends Command
{
protected $signature = 'agentic:pr-manage
{--repos=* : Repos to manage (owner/name format)}
{--dry-run : Show what would be merged}';
protected $description = 'Review and merge ready pull requests on Forgejo repositories';
public function handle(): int
{
$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');
$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;
}
}

View file

@ -0,0 +1,98 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\CreatePlanFromIssue;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Actions\Forge\ScanForWork;
use Illuminate\Console\Command;
class ScanCommand extends Command
{
protected $signature = 'agentic:scan
{--workspace=1 : Workspace ID}
{--repos=* : Repos to scan (owner/name format)}
{--dry-run : Show what would be created without acting}';
protected $description = 'Scan Forgejo repositories for actionable work from epic issues';
public function handle(): int
{
$workspaceId = (int) $this->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;
}
}

View file

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