Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ SLACK_DEFAULT_CHANNEL=

# GitHub
GITHUB_TOKEN=
GITHUB_ORG=conduit-ui
GITHUB_REPO=jordanpartridge/your-repo

# Bifrost
BIFROST_URL=https://bifrost.jordanpartridge.us
BIFROST_AGENT_SECRET=
BIFROST_TOKEN=

# Qdrant — shared Odin vector store (knowledge-qdrant container)
QDRANT_URL=http://127.0.0.1:6333
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ jobs:
with:
check: certify
coverage-threshold: 100
auto-merge: false
auto-merge: true
github-token: ${{ secrets.GITHUB_TOKEN }}
23 changes: 23 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/node_modules
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
/bootstrap/cache/*.php
*.sqlite
*.sqlite-journal
coverage.xml
coverage-report/
.blueprint
36 changes: 36 additions & 0 deletions app/Commands/ListenCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Commands;

use App\Data\ManifestData;
use App\Services\ScaffoldPipeline;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;

class ListenCommand extends Command
{
protected $signature = 'foundry:listen';

protected $description = 'Subscribe to bifrost.foundry Redis channel and route scaffold events';

public function handle(ScaffoldPipeline $pipeline): void
{
$this->info('Listening on bifrost.foundry...');

Redis::subscribe(['bifrost.foundry'], function (string $message) use ($pipeline) {
$data = json_decode($message, true);
$event = $data['event'] ?? null;

if ($event !== 'foundry.scaffold.requested') {
$this->line("Skipping unknown event: {$event}");

return;
}

$manifest = ManifestData::from($data['payload']);
$run = $pipeline->dispatch($manifest);

$this->info("Scaffold queued: {$run->id}");
});
}
}
11 changes: 11 additions & 0 deletions app/Enums/ScaffoldStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Enums;

enum ScaffoldStatus: string
{
case Queued = 'queued';
case Running = 'running';
case Complete = 'complete';
case Failed = 'failed';
}
59 changes: 59 additions & 0 deletions app/Http/Controllers/ScaffoldController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Http\Controllers;

use App\Data\ManifestData;
use App\Models\ScaffoldRun;
use App\Services\ScaffoldPipeline;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;

class ScaffoldController extends Controller
{
public function store(Request $request, ScaffoldPipeline $pipeline): JsonResponse
{
$request->validate([
'name' => ['required', 'string'],
'description' => ['required', 'string'],
'stack' => ['required', 'array'],
'stack.framework' => ['required', 'string'],
'stack.type' => ['required', 'string'],
'stack.php' => ['required', 'string'],
'stack.auth' => ['required', 'string'],
'stack.queue' => ['required', 'string'],
'stack.db' => ['required', 'string'],
'models' => ['present', 'array'],
'bifrost' => ['required', 'array'],
'bifrost.source' => ['required', 'string'],
'bifrost.events' => ['present', 'array'],
'quality' => ['required', 'array'],
'quality.runner' => ['required', 'boolean'],
'quality.coverage' => ['required', 'numeric'],
'quality.pint' => ['required', 'boolean'],
'quality.phpstan' => ['required', 'boolean'],
'deploy' => ['required', 'array'],
'deploy.host' => ['required', 'string'],
'deploy.quadlet' => ['required', 'boolean'],
]);

$manifest = ManifestData::from($request->all());
$run = $pipeline->dispatch($manifest);

return response()->json([
'id' => $run->id,
'status' => $run->status->value,
'status_url' => route('scaffold.show', $run->id),
], 201);
}

public function show(string $id): JsonResponse
{
$run = ScaffoldRun::findOrFail($id);

return response()->json([
'id' => $run->id,
'status' => $run->status->value,
]);
}
}
74 changes: 74 additions & 0 deletions app/Jobs/CodeGeneratorJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace App\Jobs;

use App\Data\ManifestData;
use App\Enums\ScaffoldStatus;
use App\Models\ScaffoldRun;
use App\Services\BlueprintService;
use App\Services\OllamaService;
use App\Services\QuadletService;
use App\Services\SkeletonService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CodeGeneratorJob implements ShouldQueue
{
use Queueable;

public int $tries = 1;

public int $timeout = 600;

public function __construct(
public readonly ScaffoldRun $run,
public readonly ManifestData $manifest,
) {}

public function handle(
SkeletonService $skeleton,
BlueprintService $blueprint,
OllamaService $ollama,
QuadletService $quadlet,
): void {
$run = $this->run->fresh();
$run->update(['status' => ScaffoldStatus::Running]);

$tmpPath = sys_get_temp_dir().'/'.$this->manifest->name.'-'.uniqid();

try {
$skeleton->clone($tmpPath);
$blueprint->build($this->manifest, $tmpPath);

try {
$customization = $ollama->generate(
"Given this Laravel app manifest: {$this->manifest->name} — {$this->manifest->description}. ".
'Suggest any additional scopes, accessors, or business logic methods worth adding to the generated models. Be concise.'
);
} catch (\Throwable) {
$customization = '';
}

$skeleton->push(
$tmpPath,
'git@github.com:'.config('services.github.org').'/'.$this->manifest->name.'.git'
);

$quadlet->deploy($this->manifest, $tmpPath);

$run->update([
'status' => ScaffoldStatus::Complete,
'output' => $customization,
'completed_at' => now(),
]);
} catch (\Throwable $e) {
$run->update([
'status' => ScaffoldStatus::Failed,
'output' => $e->getMessage(),
'completed_at' => now(),
]);

throw $e;
}
}
}
78 changes: 78 additions & 0 deletions app/Jobs/RepoGeneratorJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace App\Jobs;

use App\Data\ManifestData;
use App\Data\ModelData;
use App\Services\BifrostService;
use App\Services\GitHubService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class RepoGeneratorJob implements ShouldQueue
{
use Queueable;

public function __construct(public readonly ManifestData $manifest) {}

public function handle(GitHubService $github, BifrostService $bifrost): void
{
$github->createRepo($this->manifest);

$github->openIssue(
$this->manifest->name,
'Setup: initial project bootstrap',
$this->setupIssueBody(),
);

foreach ($this->manifest->models as $model) {
/** @var ModelData $model */
$github->openIssue(
$this->manifest->name,
"Model: {$model->name}",
$this->modelIssueBody($model),
);
}

try {
$bifrost->registerSource($this->manifest);
} catch (\Throwable) {
// Bifrost source registration is best-effort; source can be created via bifrost:source:add
}
}

private function setupIssueBody(): string
{
$stack = $this->manifest->stack;

return implode("\n", [
'## Setup Checklist',
'',
"- [ ] Framework: {$stack->framework} {$stack->php}",
"- [ ] Database: {$stack->db->value}",
"- [ ] Auth: {$stack->auth->value}",
"- [ ] Queue: {$stack->queue}",
'- [ ] Install dependencies',
'- [ ] Configure environment',
'- [ ] Run migrations',
]);
}

private function modelIssueBody(ModelData $model): string
{
$attributes = implode("\n", array_map(
fn (string $attr) => "- [ ] `{$attr}`",
$model->attributes,
));

$attributeSection = $attributes !== '' ? "\n## Attributes\n{$attributes}" : '';

$relationships = $model->relationships->toCollection()
->map(fn ($rel) => "- [ ] {$rel->type}: {$rel->model}")
->implode("\n");

$relationshipSection = $relationships !== '' ? "\n## Relationships\n{$relationships}" : '';

return "## {$model->name}{$attributeSection}{$relationshipSection}";
}
}
25 changes: 25 additions & 0 deletions app/Models/ScaffoldRun.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Models;

use App\Enums\ScaffoldStatus;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class ScaffoldRun extends Model
{
use HasUuids;

protected $fillable = [
'manifest',
'status',
'output',
'completed_at',
];

protected $casts = [
'manifest' => 'array',
'status' => ScaffoldStatus::class,
'completed_at' => 'datetime',
];
}
44 changes: 44 additions & 0 deletions app/Services/BifrostService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Services;

use App\Data\ManifestData;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class BifrostService
{
private string $url;

private string $token;

public function __construct()
{
$this->url = rtrim((string) config('services.bifrost.url'), '/');
$this->token = (string) config('services.bifrost.token');
}

public function registerSource(ManifestData $manifest): array
{
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->token}",
'Accept' => 'application/json',
])->post("{$this->url}/api/sources", [
'name' => $manifest->bifrost->source,
'signing_secret' => Str::random(40),
'is_active' => true,
]);

$this->ensureSuccess($response, 'Failed to register Bifrost source');

return $response->json();
}

private function ensureSuccess(Response $response, string $message): void
{
if ($response->failed()) {
throw new \RuntimeException("{$message}: HTTP {$response->status()}");
}
}
}
Loading