diff --git a/.env.example b/.env.example index 3795270..068c033 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 3a1f141..ecd6f17 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -21,5 +21,5 @@ jobs: with: check: certify coverage-threshold: 100 - auto-merge: false + auto-merge: true github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43444ed --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/Commands/ListenCommand.php b/app/Commands/ListenCommand.php new file mode 100644 index 0000000..3d0ff2e --- /dev/null +++ b/app/Commands/ListenCommand.php @@ -0,0 +1,36 @@ +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}"); + }); + } +} diff --git a/app/Enums/ScaffoldStatus.php b/app/Enums/ScaffoldStatus.php new file mode 100644 index 0000000..cd56875 --- /dev/null +++ b/app/Enums/ScaffoldStatus.php @@ -0,0 +1,11 @@ +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, + ]); + } +} diff --git a/app/Jobs/CodeGeneratorJob.php b/app/Jobs/CodeGeneratorJob.php new file mode 100644 index 0000000..e231d36 --- /dev/null +++ b/app/Jobs/CodeGeneratorJob.php @@ -0,0 +1,74 @@ +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; + } + } +} diff --git a/app/Jobs/RepoGeneratorJob.php b/app/Jobs/RepoGeneratorJob.php new file mode 100644 index 0000000..00a14dc --- /dev/null +++ b/app/Jobs/RepoGeneratorJob.php @@ -0,0 +1,78 @@ +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}"; + } +} diff --git a/app/Models/ScaffoldRun.php b/app/Models/ScaffoldRun.php new file mode 100644 index 0000000..4af0346 --- /dev/null +++ b/app/Models/ScaffoldRun.php @@ -0,0 +1,25 @@ + 'array', + 'status' => ScaffoldStatus::class, + 'completed_at' => 'datetime', + ]; +} diff --git a/app/Services/BifrostService.php b/app/Services/BifrostService.php new file mode 100644 index 0000000..530e878 --- /dev/null +++ b/app/Services/BifrostService.php @@ -0,0 +1,44 @@ +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()}"); + } + } +} diff --git a/app/Services/BlueprintService.php b/app/Services/BlueprintService.php new file mode 100644 index 0000000..6a69c6a --- /dev/null +++ b/app/Services/BlueprintService.php @@ -0,0 +1,71 @@ +models as $model) { + $yaml .= " {$model->name}:\n"; + + foreach ($model->attributes as $attribute) { + [$name, $type] = str_contains($attribute, ':') + ? explode(':', $attribute, 2) + : [$attribute, 'string']; + + $yaml .= " {$name}: {$type}\n"; + } + + if ($model->relationships->count() > 0) { + $yaml .= " relationships:\n"; + + foreach ($model->relationships as $relationship) { + $yaml .= " {$relationship->type}: {$relationship->model}\n"; + } + } + } + + return $yaml; + } + + public function build(ManifestData $manifest, string $repoPath): void + { + // Write draft to the target repo (for reference) + file_put_contents($repoPath.'/draft.yaml', $this->generateDraft($manifest)); + + // Run blueprint from foundry's own artisan (blueprint is installed here) + // and capture which files it generates + $draftPath = base_path('draft-'.uniqid().'.yaml'); + file_put_contents($draftPath, $this->generateDraft($manifest)); + + $result = Process::run('php '.base_path('artisan')." blueprint:build {$draftPath}"); + + @unlink($draftPath); + + // Parse generated file paths from blueprint output and copy to target repo + preg_match_all('/\[([^\]]+)\] built successfully/', $result->output(), $matches); + + foreach ($matches[1] as $generatedPath) { + $srcPath = base_path($generatedPath); + $destPath = $repoPath.'/'.$generatedPath; + + if (file_exists($srcPath)) { + @mkdir(dirname($destPath), 0755, true); + copy($srcPath, $destPath); + @unlink($srcPath); + } + } + + Process::path($repoPath)->run('git add -A'); + Process::path($repoPath)->run( + 'git -c user.email="foundry@conduit-ui.com" -c user.name="Foundry" '. + 'commit -m "feat: scaffold '.addslashes($manifest->name).'"' + ); + } +} diff --git a/app/Services/GitHubService.php b/app/Services/GitHubService.php new file mode 100644 index 0000000..32eb60c --- /dev/null +++ b/app/Services/GitHubService.php @@ -0,0 +1,64 @@ +token = (string) config('services.github.token'); + $this->org = (string) config('services.github.org', 'conduit-ui'); + } + + public function createRepo(ManifestData $manifest): array + { + $response = $this->client() + ->post("https://api.github.com/orgs/{$this->org}/repos", [ + 'name' => $manifest->name, + 'description' => $manifest->description, + 'private' => true, + ]); + + $this->ensureSuccess($response, 'Failed to create GitHub repository'); + + return $response->json(); + } + + public function openIssue(string $repo, string $title, string $body): array + { + $response = $this->client() + ->post("https://api.github.com/repos/{$this->org}/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + ]); + + $this->ensureSuccess($response, "Failed to open issue: {$title}"); + + return $response->json(); + } + + private function client(): PendingRequest + { + return Http::withHeaders([ + 'Authorization' => "Bearer {$this->token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ]); + } + + private function ensureSuccess(Response $response, string $message): void + { + if ($response->failed()) { + throw new \RuntimeException("{$message}: HTTP {$response->status()}"); + } + } +} diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php new file mode 100644 index 0000000..feb99a8 --- /dev/null +++ b/app/Services/OllamaService.php @@ -0,0 +1,19 @@ + $model, + 'prompt' => $prompt, + 'stream' => false, + ]); + + return $response->json('response', ''); + } +} diff --git a/app/Services/QuadletService.php b/app/Services/QuadletService.php new file mode 100644 index 0000000..53cad19 --- /dev/null +++ b/app/Services/QuadletService.php @@ -0,0 +1,45 @@ +name; + $host = config('services.deploy.host'); + + return <<name; + $host = config('services.deploy.host'); + $user = config('services.deploy.user'); + $quadlet = $this->generate($manifest); + + $escapedQuadlet = escapeshellarg($quadlet); + $quadletPath = "/home/{$user}/.config/containers/systemd/{$name}.container"; + + Process::run("ssh {$user}@{$host} \"echo {$escapedQuadlet} > {$quadletPath} && systemctl --user daemon-reload && systemctl --user enable --now {$name}\""); + } +} diff --git a/app/Services/ScaffoldPipeline.php b/app/Services/ScaffoldPipeline.php new file mode 100644 index 0000000..7bf55b9 --- /dev/null +++ b/app/Services/ScaffoldPipeline.php @@ -0,0 +1,25 @@ + $manifest->toArray(), + 'status' => ScaffoldStatus::Queued, + ]); + + RepoGeneratorJob::dispatch($manifest); + CodeGeneratorJob::dispatch($run, $manifest); + + return $run; + } +} diff --git a/app/Services/SkeletonService.php b/app/Services/SkeletonService.php new file mode 100644 index 0000000..141267e --- /dev/null +++ b/app/Services/SkeletonService.php @@ -0,0 +1,24 @@ +run("git remote set-url origin {$repoUrl}"); + + $result = Process::path($repoPath)->run('git push -u origin main'); + + if (! $result->successful()) { + throw new \RuntimeException("Failed to push to {$repoUrl}: {$result->errorOutput()}"); + } + } +} diff --git a/composer.json b/composer.json index c5aa8bc..61a3589 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ }, "require-dev": { "larastan/larastan": "^3.0", + "laravel-shift/blueprint": "^2.13", "laravel/pint": "^1.0", "mockery/mockery": "^1.6", "pestphp/pest": "^3.8.4|^4.1.2", diff --git a/composer.lock b/composer.lock index 976e705..7a70b21 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4b64bb148a08f32b5de734ef1558ce11", + "content-hash": "36bacf6895b219fe9980859558f87d1b", "packages": [ { "name": "brick/math", @@ -8070,6 +8070,107 @@ ], "time": "2026-05-28T08:00:58+00:00" }, + { + "name": "laravel-shift/blueprint", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-shift/blueprint.git", + "reference": "a7c74809d2b769d62a549104d96d02b524173fe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-shift/blueprint/zipball/a7c74809d2b769d62a549104d96d02b524173fe5", + "reference": "a7c74809d2b769d62a549104d96d02b524173fe5", + "shasum": "" + }, + "require": { + "illuminate/console": ">=12.0", + "illuminate/database": ">=12.0", + "illuminate/filesystem": ">=12.0", + "illuminate/support": ">=12.0", + "laravel-shift/faker-registry": "^0.3.0", + "nunomaduro/termwind": "^2.3", + "symfony/yaml": ">=7.0" + }, + "require-dev": { + "laravel/pint": "~1.25.0", + "mockery/mockery": "^1.4.4", + "orchestra/testbench": ">=9.0", + "phpunit/phpunit": "^11.5.3" + }, + "suggest": { + "jasonmccreary/laravel-test-assertions": "Required to use additional assertions in generated tests (^2.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Blueprint\\BlueprintServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Blueprint\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "An expressive, human readable code generation tool.", + "keywords": [ + "code generation", + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel-shift/blueprint/issues", + "source": "https://github.com/laravel-shift/blueprint/tree/v2.13.0" + }, + "time": "2025-11-13T20:57:52+00:00" + }, + { + "name": "laravel-shift/faker-registry", + "version": "v0.3.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-shift/faker-registry.git", + "reference": "968ab023b6c76c2f67cc474ba6d81fce8ff869a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-shift/faker-registry/zipball/968ab023b6c76c2f67cc474ba6d81fce8ff869a9", + "reference": "968ab023b6c76c2f67cc474ba6d81fce8ff869a9", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Shift\\Faker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jason McCreary", + "email": "jason@pureconcepts.net" + } + ], + "description": "A registry for generating Faker data from a name or data type.", + "support": { + "issues": "https://github.com/laravel-shift/faker-registry/issues", + "source": "https://github.com/laravel-shift/faker-registry/tree/v0.3.0" + }, + "time": "2023-11-24T15:54:02+00:00" + }, { "name": "laravel/pint", "version": "v1.29.1", @@ -10538,6 +10639,82 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/yaml", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.7", diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..896d119 --- /dev/null +++ b/config/services.php @@ -0,0 +1,24 @@ + [ + 'token' => env('GITHUB_TOKEN'), + 'org' => env('GITHUB_ORG', 'conduit-ui'), + ], + + 'bifrost' => [ + 'url' => env('BIFROST_URL'), + 'token' => env('BIFROST_TOKEN'), + ], + + 'ollama' => [ + 'url' => env('OLLAMA_URL', 'http://loki:11434'), + ], + + 'deploy' => [ + 'host' => env('DEPLOY_SSH_HOST', 'odin'), + 'user' => env('DEPLOY_SSH_USER', 'jordan'), + ], + +]; diff --git a/database/migrations/2026_05_03_000001_create_scaffold_runs_table.php b/database/migrations/2026_05_03_000001_create_scaffold_runs_table.php new file mode 100644 index 0000000..08cbe91 --- /dev/null +++ b/database/migrations/2026_05_03_000001_create_scaffold_runs_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->json('manifest'); + $table->string('status')->default('queued'); + $table->text('output')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('scaffold_runs'); + } +}; diff --git a/database/migrations/2026_06_16_024907_create_failed_jobs_table.php b/database/migrations/2026_06_16_024907_create_failed_jobs_table.php new file mode 100644 index 0000000..e4cb67c --- /dev/null +++ b/database/migrations/2026_06_16_024907_create_failed_jobs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9971c35 --- /dev/null +++ b/public/index.php @@ -0,0 +1,11 @@ +handleRequest(Request::capture()); diff --git a/routes/api.php b/routes/api.php index c75ff67..58073c8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,9 @@ response()->json(['status' => 'ok'])); + +Route::post('/scaffold', [ScaffoldController::class, 'store'])->name('scaffold.store'); +Route::get('/scaffold/{id}', [ScaffoldController::class, 'show'])->name('scaffold.show'); diff --git a/tests/Feature/Jobs/CodeGeneratorJobTest.php b/tests/Feature/Jobs/CodeGeneratorJobTest.php new file mode 100644 index 0000000..67bddc0 --- /dev/null +++ b/tests/Feature/Jobs/CodeGeneratorJobTest.php @@ -0,0 +1,126 @@ + [], + 'status' => ScaffoldStatus::Queued, + ]); +} + +describe('CodeGeneratorJob', function () { + it('implements ShouldQueue', function () { + expect(CodeGeneratorJob::class)->toImplement(ShouldQueue::class); + }); + + it('holds run and manifest as public properties', function () { + $run = makeRun(); + $manifest = makeCodeGenManifest(); + $job = new CodeGeneratorJob($run, $manifest); + + expect($job->run->id)->toBe($run->id) + ->and($job->manifest->name)->toBe('billing-service'); + }); + + it('completes successfully and updates run status', function () { + $run = makeRun(); + $manifest = makeCodeGenManifest(); + + $skeleton = Mockery::mock(SkeletonService::class); + $skeleton->shouldReceive('clone')->once(); + $skeleton->shouldReceive('push')->once(); + + $blueprint = Mockery::mock(BlueprintService::class); + $blueprint->shouldReceive('build')->once(); + + $ollama = Mockery::mock(OllamaService::class); + $ollama->shouldReceive('generate')->once()->andReturn('Add a scope for active subscriptions.'); + + $quadlet = Mockery::mock(QuadletService::class); + $quadlet->shouldReceive('deploy')->once(); + + $job = new CodeGeneratorJob($run, $manifest); + $job->handle($skeleton, $blueprint, $ollama, $quadlet); + + $run->refresh(); + expect($run->status)->toBe(ScaffoldStatus::Complete) + ->and($run->completed_at)->not->toBeNull(); + }); + + it('completes successfully when ollama throws, storing empty output', function () { + $run = makeRun(); + $manifest = makeCodeGenManifest(); + + $skeleton = Mockery::mock(SkeletonService::class); + $skeleton->shouldReceive('clone')->once(); + $skeleton->shouldReceive('push')->once(); + + $blueprint = Mockery::mock(BlueprintService::class); + $blueprint->shouldReceive('build')->once(); + + $ollama = Mockery::mock(OllamaService::class); + $ollama->shouldReceive('generate')->once()->andThrow(new \RuntimeException('Connection refused')); + + $quadlet = Mockery::mock(QuadletService::class); + $quadlet->shouldReceive('deploy')->once(); + + $job = new CodeGeneratorJob($run, $manifest); + $job->handle($skeleton, $blueprint, $ollama, $quadlet); + + $run->refresh(); + expect($run->status)->toBe(ScaffoldStatus::Complete) + ->and($run->output)->toBe('') + ->and($run->completed_at)->not->toBeNull(); + }); + + it('marks run as failed and re-throws when a service throws', function () { + $run = makeRun(); + $manifest = makeCodeGenManifest(); + + $skeleton = Mockery::mock(SkeletonService::class); + $skeleton->shouldReceive('clone')->once()->andThrow(new \RuntimeException('Clone failed')); + + $blueprint = Mockery::mock(BlueprintService::class); + $ollama = Mockery::mock(OllamaService::class); + $quadlet = Mockery::mock(QuadletService::class); + + $job = new CodeGeneratorJob($run, $manifest); + + expect(fn () => $job->handle($skeleton, $blueprint, $ollama, $quadlet)) + ->toThrow(\RuntimeException::class, 'Clone failed'); + + $run->refresh(); + expect($run->status)->toBe(ScaffoldStatus::Failed) + ->and($run->output)->toBe('Clone failed'); + }); +}); diff --git a/tests/Feature/Models/ScaffoldRunTest.php b/tests/Feature/Models/ScaffoldRunTest.php new file mode 100644 index 0000000..4342da3 --- /dev/null +++ b/tests/Feature/Models/ScaffoldRunTest.php @@ -0,0 +1,76 @@ + ['name' => 'my-service'], + 'status' => ScaffoldStatus::Queued, + ]); + + expect($run->id)->toBeString() + ->and(strlen($run->id))->toBe(36); + }); + + it('casts manifest to array', function () { + $run = ScaffoldRun::create([ + 'manifest' => ['name' => 'my-service', 'version' => 1], + 'status' => ScaffoldStatus::Queued, + ]); + + $fresh = ScaffoldRun::find($run->id); + + expect($fresh->manifest)->toBeArray() + ->and($fresh->manifest['name'])->toBe('my-service'); + }); + + it('casts status to ScaffoldStatus enum', function () { + $run = ScaffoldRun::create([ + 'manifest' => [], + 'status' => ScaffoldStatus::Running, + ]); + + $fresh = ScaffoldRun::find($run->id); + + expect($fresh->status)->toBe(ScaffoldStatus::Running); + }); + + it('stores nullable output', function () { + $run = ScaffoldRun::create([ + 'manifest' => [], + 'status' => ScaffoldStatus::Complete, + 'output' => 'Generated successfully', + ]); + + $fresh = ScaffoldRun::find($run->id); + + expect($fresh->output)->toBe('Generated successfully'); + }); + + it('stores nullable completed_at as datetime', function () { + $now = now(); + + $run = ScaffoldRun::create([ + 'manifest' => [], + 'status' => ScaffoldStatus::Complete, + 'completed_at' => $now, + ]); + + $fresh = ScaffoldRun::find($run->id); + + expect($fresh->completed_at)->not->toBeNull() + ->and($fresh->completed_at->timestamp)->toBe($now->timestamp); + }); + + it('has null output and completed_at by default', function () { + $run = ScaffoldRun::create([ + 'manifest' => [], + 'status' => ScaffoldStatus::Queued, + ]); + + expect($run->output)->toBeNull() + ->and($run->completed_at)->toBeNull(); + }); +}); diff --git a/tests/Feature/ScaffoldControllerTest.php b/tests/Feature/ScaffoldControllerTest.php new file mode 100644 index 0000000..75805a8 --- /dev/null +++ b/tests/Feature/ScaffoldControllerTest.php @@ -0,0 +1,95 @@ + 'billing-service', + 'description' => 'Handles billing', + 'stack' => [ + 'framework' => 'laravel', + 'type' => 'microservice', + 'php' => '8.2', + 'auth' => 'none', + 'queue' => 'redis', + 'db' => 'sqlite', + ], + 'models' => [], + 'bifrost' => ['source' => 'billing-service', 'events' => []], + 'quality' => ['runner' => true, 'coverage' => 100, 'pint' => true, 'phpstan' => true], + 'deploy' => ['host' => 'odin', 'quadlet' => true], + ]; + + describe('POST /api/scaffold', function () use (&$validManifest) { + it('creates a scaffold run and dispatches both jobs', function () use (&$validManifest) { + Queue::fake(); + + $response = $this->postJson('/api/scaffold', $validManifest); + + $response->assertStatus(201) + ->assertJsonStructure(['id', 'status', 'status_url']) + ->assertJsonPath('status', 'queued'); + + Queue::assertPushed(RepoGeneratorJob::class); + Queue::assertPushed(CodeGeneratorJob::class); + + $this->assertDatabaseHas('scaffold_runs', [ + 'status' => 'queued', + ]); + }); + + it('returns 422 for invalid manifest missing required fields', function () { + Queue::fake(); + + $response = $this->postJson('/api/scaffold', ['name' => 'only-name']); + + $response->assertStatus(422); + + Queue::assertNothingPushed(); + }); + + it('returns 422 for completely empty body', function () { + Queue::fake(); + + $response = $this->postJson('/api/scaffold', []); + + $response->assertStatus(422); + + Queue::assertNothingPushed(); + }); + + it('returns a status_url pointing to the show route', function () use (&$validManifest) { + Queue::fake(); + + $response = $this->postJson('/api/scaffold', $validManifest); + + $id = $response->json('id'); + $response->assertJsonPath('status_url', route('scaffold.show', $id)); + }); + }); + + describe('GET /api/scaffold/{id}', function () { + it('returns current status of an existing run', function () { + $run = ScaffoldRun::create([ + 'manifest' => ['name' => 'test'], + 'status' => ScaffoldStatus::Queued, + ]); + + $response = $this->getJson("/api/scaffold/{$run->id}"); + + $response->assertOk() + ->assertJsonPath('id', $run->id) + ->assertJsonPath('status', 'queued'); + }); + + it('returns 404 for unknown id', function () { + $response = $this->getJson('/api/scaffold/non-existent-id'); + + $response->assertNotFound(); + }); + }); +}); diff --git a/tests/Unit/Commands/ListenCommandTest.php b/tests/Unit/Commands/ListenCommandTest.php new file mode 100644 index 0000000..771c263 --- /dev/null +++ b/tests/Unit/Commands/ListenCommandTest.php @@ -0,0 +1,124 @@ + 'foundry.scaffold.requested', + 'payload' => $manifest->toArray(), + ]); + + $capturedCallback = null; + + Redis::shouldReceive('subscribe') + ->once() + ->with(['bifrost.foundry'], Mockery::capture($capturedCallback)); + + $pipeline = Mockery::mock(ScaffoldPipeline::class); + $pipeline->shouldReceive('dispatch') + ->once() + ->with(Mockery::type(ManifestData::class)) + ->andReturn(new ScaffoldRun); + + app()->instance(ScaffoldPipeline::class, $pipeline); + + $command = new ListenCommand; + $command->setLaravel(app()); + + $output = new \Symfony\Component\Console\Output\BufferedOutput; + $command->setOutput(new \Illuminate\Console\OutputStyle( + new \Symfony\Component\Console\Input\ArgvInput, + $output, + )); + + $command->handle($pipeline); + + $capturedCallback($message); + }); + + it('skips unknown events without dispatching', function () { + $message = json_encode(['event' => 'some.other.event', 'payload' => []]); + + $capturedCallback = null; + + Redis::shouldReceive('subscribe') + ->once() + ->with(['bifrost.foundry'], Mockery::capture($capturedCallback)); + + $pipeline = Mockery::mock(ScaffoldPipeline::class); + $pipeline->shouldNotReceive('dispatch'); + + app()->instance(ScaffoldPipeline::class, $pipeline); + + $command = new ListenCommand; + $command->setLaravel(app()); + + $output = new \Symfony\Component\Console\Output\BufferedOutput; + $command->setOutput(new \Illuminate\Console\OutputStyle( + new \Symfony\Component\Console\Input\ArgvInput, + $output, + )); + + $command->handle($pipeline); + + $capturedCallback($message); + }); + + it('handles missing event key without dispatching', function () { + $message = json_encode(['payload' => []]); + + $capturedCallback = null; + + Redis::shouldReceive('subscribe') + ->once() + ->with(['bifrost.foundry'], Mockery::capture($capturedCallback)); + + $pipeline = Mockery::mock(ScaffoldPipeline::class); + $pipeline->shouldNotReceive('dispatch'); + + app()->instance(ScaffoldPipeline::class, $pipeline); + + $command = new ListenCommand; + $command->setLaravel(app()); + + $output = new \Symfony\Component\Console\Output\BufferedOutput; + $command->setOutput(new \Illuminate\Console\OutputStyle( + new \Symfony\Component\Console\Input\ArgvInput, + $output, + )); + + $command->handle($pipeline); + + $capturedCallback($message); + }); +}); diff --git a/tests/Unit/Enums/ScaffoldStatusTest.php b/tests/Unit/Enums/ScaffoldStatusTest.php new file mode 100644 index 0000000..1cd9c50 --- /dev/null +++ b/tests/Unit/Enums/ScaffoldStatusTest.php @@ -0,0 +1,32 @@ +toHaveCount(4); + }); + + it('has queued case with correct value', function () { + expect(ScaffoldStatus::Queued->value)->toBe('queued'); + }); + + it('has running case with correct value', function () { + expect(ScaffoldStatus::Running->value)->toBe('running'); + }); + + it('has complete case with correct value', function () { + expect(ScaffoldStatus::Complete->value)->toBe('complete'); + }); + + it('has failed case with correct value', function () { + expect(ScaffoldStatus::Failed->value)->toBe('failed'); + }); + + it('can be created from a string value', function () { + expect(ScaffoldStatus::from('queued'))->toBe(ScaffoldStatus::Queued) + ->and(ScaffoldStatus::from('running'))->toBe(ScaffoldStatus::Running) + ->and(ScaffoldStatus::from('complete'))->toBe(ScaffoldStatus::Complete) + ->and(ScaffoldStatus::from('failed'))->toBe(ScaffoldStatus::Failed); + }); +}); diff --git a/tests/Unit/Jobs/RepoGeneratorJobTest.php b/tests/Unit/Jobs/RepoGeneratorJobTest.php new file mode 100644 index 0000000..0e68250 --- /dev/null +++ b/tests/Unit/Jobs/RepoGeneratorJobTest.php @@ -0,0 +1,218 @@ + 'billing-service', + 'description' => 'Handles billing', + 'stack' => [ + 'framework' => 'laravel', + 'type' => 'microservice', + 'php' => '8.2', + 'auth' => 'github-socialite', + 'queue' => 'redis', + 'db' => 'sqlite', + ], + 'models' => $models, + 'bifrost' => ['source' => 'billing-service', 'events' => ['subscription.created']], + 'quality' => ['runner' => true, 'coverage' => 100, 'pint' => true, 'phpstan' => true], + 'deploy' => ['host' => 'odin', 'quadlet' => true], + ]); +} + +describe('RepoGeneratorJob', function () { + it('is queueable', function () { + expect(RepoGeneratorJob::class)->toImplement(ShouldQueue::class); + }); + + it('stores the manifest as a public property', function () { + $manifest = makeJobManifest(); + $job = new RepoGeneratorJob($manifest); + + expect($job->manifest)->toBe($manifest); + }); + + it('creates a repo, opens a setup issue, and registers the bifrost source when there are no models', function () { + $manifest = makeJobManifest(); + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('createRepo')->once()->with($manifest)->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Setup: initial project bootstrap', Mockery::type('string')) + ->andReturn(['number' => 1]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('registerSource')->once()->with($manifest)->andReturn(['id' => 10]); + }); + + $job = new RepoGeneratorJob($manifest); + $job->handle($github, $bifrost); + }); + + it('opens one issue per model in addition to the setup issue', function () { + $manifest = makeJobManifest([ + ['name' => 'Plan', 'attributes' => ['name:string', 'price_cents:integer'], 'relationships' => [ + ['type' => 'hasMany', 'model' => 'Subscription'], + ]], + ['name' => 'Subscription', 'attributes' => [], 'relationships' => []], + ]); + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('createRepo')->once()->with($manifest)->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Setup: initial project bootstrap', Mockery::type('string')) + ->andReturn(['number' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Model: Plan', Mockery::type('string')) + ->andReturn(['number' => 2]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Model: Subscription', Mockery::type('string')) + ->andReturn(['number' => 3]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('registerSource')->once()->with($manifest)->andReturn(['id' => 10]); + }); + + $job = new RepoGeneratorJob($manifest); + $job->handle($github, $bifrost); + }); + + it('includes stack details in the setup issue body', function () { + $manifest = makeJobManifest(); + + $setupBody = null; + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use (&$setupBody) { + $mock->shouldReceive('createRepo')->once()->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->withArgs(function (string $repo, string $title, string $body) use (&$setupBody) { + $setupBody = $body; + + return true; + }) + ->andReturn(['number' => 1]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) { + $mock->shouldReceive('registerSource')->once()->andReturn(['id' => 10]); + }); + + $job = new RepoGeneratorJob($manifest); + $job->handle($github, $bifrost); + + expect($setupBody) + ->toContain('laravel') + ->toContain('sqlite') + ->toContain('github-socialite') + ->toContain('redis'); + }); + + it('includes attributes and relationships in model issue bodies', function () { + $manifest = makeJobManifest([ + ['name' => 'Plan', 'attributes' => ['name:string', 'price_cents:integer'], 'relationships' => [ + ['type' => 'hasMany', 'model' => 'Subscription'], + ]], + ]); + + $modelBody = null; + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use (&$modelBody) { + $mock->shouldReceive('createRepo')->once()->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Setup: initial project bootstrap', Mockery::type('string')) + ->andReturn(['number' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Model: Plan', Mockery::type('string')) + ->withArgs(function (string $repo, string $title, string $body) use (&$modelBody) { + $modelBody = $body; + + return true; + }) + ->andReturn(['number' => 2]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) { + $mock->shouldReceive('registerSource')->once()->andReturn(['id' => 10]); + }); + + $job = new RepoGeneratorJob($manifest); + $job->handle($github, $bifrost); + + expect($modelBody) + ->toContain('name:string') + ->toContain('price_cents:integer') + ->toContain('hasMany') + ->toContain('Subscription'); + }); + + it('completes without throwing when bifrost registration fails', function () { + $manifest = makeJobManifest(); + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('createRepo')->once()->with($manifest)->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue')->once()->andReturn(['number' => 1]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) use ($manifest) { + $mock->shouldReceive('registerSource')->once()->with($manifest) + ->andThrow(new \RuntimeException('Failed to register Bifrost source: HTTP 404')); + }); + + $job = new RepoGeneratorJob($manifest); + + expect(fn () => $job->handle($github, $bifrost))->not->toThrow(\Throwable::class); + }); + + it('produces a model issue body without attribute or relationship sections when both are empty', function () { + $manifest = makeJobManifest([ + ['name' => 'Tag', 'attributes' => [], 'relationships' => []], + ]); + + $modelBody = null; + + $github = Mockery::mock(GitHubService::class, function (MockInterface $mock) use (&$modelBody) { + $mock->shouldReceive('createRepo')->once()->andReturn(['id' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Setup: initial project bootstrap', Mockery::type('string')) + ->andReturn(['number' => 1]); + $mock->shouldReceive('openIssue') + ->once() + ->with('billing-service', 'Model: Tag', Mockery::type('string')) + ->withArgs(function (string $repo, string $title, string $body) use (&$modelBody) { + $modelBody = $body; + + return true; + }) + ->andReturn(['number' => 2]); + }); + + $bifrost = Mockery::mock(BifrostService::class, function (MockInterface $mock) { + $mock->shouldReceive('registerSource')->once()->andReturn(['id' => 10]); + }); + + $job = new RepoGeneratorJob($manifest); + $job->handle($github, $bifrost); + + expect($modelBody) + ->toBe('## Tag') + ->not->toContain('Attributes') + ->not->toContain('Relationships'); + }); +}); diff --git a/tests/Unit/Services/BifrostServiceTest.php b/tests/Unit/Services/BifrostServiceTest.php new file mode 100644 index 0000000..02de43f --- /dev/null +++ b/tests/Unit/Services/BifrostServiceTest.php @@ -0,0 +1,91 @@ + $source, + 'description' => 'A test service', + 'stack' => [ + 'framework' => 'laravel', + 'type' => 'microservice', + 'php' => '8.2', + 'auth' => 'none', + 'queue' => 'redis', + 'db' => 'sqlite', + ], + 'models' => [], + 'bifrost' => ['source' => $source, 'events' => []], + 'quality' => ['runner' => true, 'coverage' => 100, 'pint' => true, 'phpstan' => true], + 'deploy' => ['host' => 'odin', 'quadlet' => true], + ]); +} + +describe('BifrostService', function () { + beforeEach(function () { + config([ + 'services.bifrost.url' => 'https://bifrost.example.com', + 'services.bifrost.token' => 'bifrost-token', + ]); + }); + + describe('registerSource', function () { + it('posts source registration to Bifrost and returns json', function () { + Http::fake([ + 'https://bifrost.example.com/api/sources' => Http::response(['id' => 42, 'name' => 'my-service'], 201), + ]); + + $service = new BifrostService; + $result = $service->registerSource(makeBifrostManifest()); + + expect($result)->toBe(['id' => 42, 'name' => 'my-service']); + + Http::assertSent(fn ($request) => $request->url() === 'https://bifrost.example.com/api/sources' + && $request->method() === 'POST' + && $request['name'] === 'my-service' + && $request['is_active'] === true + && isset($request['signing_secret']) + ); + }); + + it('sends bearer token and accept headers', function () { + Http::fake([ + 'https://bifrost.example.com/api/sources' => Http::response(['id' => 1], 201), + ]); + + $service = new BifrostService; + $service->registerSource(makeBifrostManifest()); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer bifrost-token') + && $request->hasHeader('Accept', 'application/json') + ); + }); + + it('strips trailing slash from configured url', function () { + config(['services.bifrost.url' => 'https://bifrost.example.com/']); + + Http::fake([ + 'https://bifrost.example.com/api/sources' => Http::response(['id' => 1], 201), + ]); + + $service = new BifrostService; + $service->registerSource(makeBifrostManifest()); + + Http::assertSent(fn ($request) => $request->url() === 'https://bifrost.example.com/api/sources'); + }); + + it('throws a RuntimeException when the request fails', function () { + Http::fake([ + 'https://bifrost.example.com/api/sources' => Http::response(['error' => 'conflict'], 409), + ]); + + $service = new BifrostService; + + expect(fn () => $service->registerSource(makeBifrostManifest())) + ->toThrow(RuntimeException::class, 'Failed to register Bifrost source: HTTP 409'); + }); + }); +}); diff --git a/tests/Unit/Services/BlueprintServiceTest.php b/tests/Unit/Services/BlueprintServiceTest.php new file mode 100644 index 0000000..b3d48fa --- /dev/null +++ b/tests/Unit/Services/BlueprintServiceTest.php @@ -0,0 +1,107 @@ +generateDraft(blueprintManifest()); + + expect($yaml)->toContain('models:'); + }); + + it('generates draft YAML with typed attributes', function () { + $model = new ModelData( + name: 'Plan', + attributes: ['name:string', 'price_cents:integer'], + relationships: new DataCollection(RelationshipData::class, []), + ); + + $service = new BlueprintService; + $yaml = $service->generateDraft(blueprintManifest([$model])); + + expect($yaml) + ->toContain('Plan:') + ->toContain('name: string') + ->toContain('price_cents: integer'); + }); + + it('defaults attribute type to string when no type given', function () { + $model = new ModelData( + name: 'Plan', + attributes: ['title'], + relationships: new DataCollection(RelationshipData::class, []), + ); + + $service = new BlueprintService; + $yaml = $service->generateDraft(blueprintManifest([$model])); + + expect($yaml)->toContain('title: string'); + }); + + it('includes relationships in YAML', function () { + $model = new ModelData( + name: 'Plan', + attributes: ['name:string'], + relationships: new DataCollection(RelationshipData::class, [ + new RelationshipData('hasMany', 'Subscription'), + ]), + ); + + $service = new BlueprintService; + $yaml = $service->generateDraft(blueprintManifest([$model])); + + expect($yaml) + ->toContain('relationships:') + ->toContain('hasMany: Subscription'); + }); + + it('writes draft.yaml, runs blueprint:build, and commits generated files to target repo', function () { + Process::fake([ + '*blueprint:build*' => Process::result( + " CREATED Factory [database/factories/TestFactory.php] built successfully.\n". + " CREATED Model [app/Models/Test.php] built successfully.\n" + ), + '*git add*' => Process::result(''), + '*git*commit*' => Process::result(''), + ]); + + $tmpPath = sys_get_temp_dir() . '/blueprint-test-' . uniqid(); + mkdir($tmpPath); + + $service = new BlueprintService; + $service->build(blueprintManifest(), $tmpPath); + + expect(file_exists($tmpPath . '/draft.yaml'))->toBeTrue(); + Process::assertRan(fn ($p) => str_contains($p->command, 'blueprint:build')); + Process::assertRan(fn ($p) => str_contains($p->command, 'git add')); + Process::assertRan(fn ($p) => str_contains($p->command, 'commit')); + + @unlink($tmpPath . '/draft.yaml'); + rmdir($tmpPath); + }); +}); diff --git a/tests/Unit/Services/GitHubServiceTest.php b/tests/Unit/Services/GitHubServiceTest.php new file mode 100644 index 0000000..28c5186 --- /dev/null +++ b/tests/Unit/Services/GitHubServiceTest.php @@ -0,0 +1,104 @@ + $name, + 'description' => 'A test service', + 'stack' => [ + 'framework' => 'laravel', + 'type' => 'microservice', + 'php' => '8.2', + 'auth' => 'none', + 'queue' => 'redis', + 'db' => 'sqlite', + ], + 'models' => [], + 'bifrost' => ['source' => $name, 'events' => []], + 'quality' => ['runner' => true, 'coverage' => 100, 'pint' => true, 'phpstan' => true], + 'deploy' => ['host' => 'odin', 'quadlet' => true], + ]); +} + +describe('GitHubService', function () { + beforeEach(function () { + config(['services.github.token' => 'test-token', 'services.github.org' => 'test-org']); + }); + + describe('createRepo', function () { + it('posts to the GitHub orgs repos endpoint and returns json', function () { + Http::fake([ + 'https://api.github.com/orgs/test-org/repos' => Http::response(['id' => 1, 'name' => 'my-service'], 201), + ]); + + $service = new GitHubService; + $result = $service->createRepo(makeManifest()); + + expect($result)->toBe(['id' => 1, 'name' => 'my-service']); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.github.com/orgs/test-org/repos' + && $request->method() === 'POST' + && $request['name'] === 'my-service' + && $request['description'] === 'A test service' + && $request['private'] === true + ); + }); + + it('sends bearer token and accept headers', function () { + Http::fake([ + 'https://api.github.com/orgs/test-org/repos' => Http::response(['id' => 1], 201), + ]); + + $service = new GitHubService; + $service->createRepo(makeManifest()); + + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer test-token') + && $request->hasHeader('Accept', 'application/vnd.github+json') + ); + }); + + it('throws a RuntimeException when the request fails', function () { + Http::fake([ + 'https://api.github.com/orgs/test-org/repos' => Http::response(['message' => 'Unprocessable Entity'], 422), + ]); + + $service = new GitHubService; + + expect(fn () => $service->createRepo(makeManifest())) + ->toThrow(RuntimeException::class, 'Failed to create GitHub repository: HTTP 422'); + }); + }); + + describe('openIssue', function () { + it('posts to the repos issues endpoint and returns json', function () { + Http::fake([ + 'https://api.github.com/repos/test-org/my-service/issues' => Http::response(['number' => 7], 201), + ]); + + $service = new GitHubService; + $result = $service->openIssue('my-service', 'Setup: bootstrap', 'Do the thing'); + + expect($result)->toBe(['number' => 7]); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.github.com/repos/test-org/my-service/issues' + && $request['title'] === 'Setup: bootstrap' + && $request['body'] === 'Do the thing' + ); + }); + + it('throws a RuntimeException when the request fails', function () { + Http::fake([ + 'https://api.github.com/repos/test-org/my-service/issues' => Http::response([], 500), + ]); + + $service = new GitHubService; + + expect(fn () => $service->openIssue('my-service', 'Title', 'Body')) + ->toThrow(RuntimeException::class, 'Failed to open issue: Title: HTTP 500'); + }); + }); +}); diff --git a/tests/Unit/Services/OllamaServiceTest.php b/tests/Unit/Services/OllamaServiceTest.php new file mode 100644 index 0000000..4548577 --- /dev/null +++ b/tests/Unit/Services/OllamaServiceTest.php @@ -0,0 +1,43 @@ + Http::response(['response' => 'Add an active scope.'], 200), + ]); + + $service = new OllamaService; + $result = $service->generate('Suggest scopes for a Plan model.'); + + expect($result)->toBe('Add an active scope.'); + }); + + it('sends the correct model and prompt in the request body', function () { + Http::fake([ + '*/api/generate' => Http::response(['response' => 'ok'], 200), + ]); + + $service = new OllamaService; + $service->generate('test prompt', 'qwen2.5-coder:7b'); + + Http::assertSent(function ($request) { + return $request->data()['model'] === 'qwen2.5-coder:7b' + && $request->data()['prompt'] === 'test prompt' + && $request->data()['stream'] === false; + }); + }); + + it('returns empty string when response key is missing', function () { + Http::fake([ + '*/api/generate' => Http::response([], 200), + ]); + + $service = new OllamaService; + $result = $service->generate('prompt'); + + expect($result)->toBe(''); + }); +}); diff --git a/tests/Unit/Services/QuadletServiceTest.php b/tests/Unit/Services/QuadletServiceTest.php new file mode 100644 index 0000000..939b051 --- /dev/null +++ b/tests/Unit/Services/QuadletServiceTest.php @@ -0,0 +1,47 @@ +generate(quadletManifest()); + + expect($quadlet) + ->toContain('billing-service') + ->toContain('[Container]') + ->toContain('[Install]'); + }); + + it('deploys by SSHing to the deploy host and enabling the service', function () { + Process::fake(); + + $service = new QuadletService; + $service->deploy(quadletManifest(), '/tmp/billing-service'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh') && str_contains($process->command, 'systemctl --user daemon-reload')); + }); +}); diff --git a/tests/Unit/Services/SkeletonServiceTest.php b/tests/Unit/Services/SkeletonServiceTest.php new file mode 100644 index 0000000..b34fc99 --- /dev/null +++ b/tests/Unit/Services/SkeletonServiceTest.php @@ -0,0 +1,37 @@ +clone('/tmp/my-app'); + + Process::assertRan('git clone git@github.com:the-shit/agent-skeleton.git /tmp/my-app'); + }); + + it('sets remote origin and pushes to the given repo URL', function () { + Process::fake(); + + $service = new SkeletonService; + $service->push('/tmp/my-app', 'git@github.com:conduit-ui/my-app.git'); + + Process::assertRan('git remote set-url origin git@github.com:conduit-ui/my-app.git'); + Process::assertRan('git push -u origin main'); + }); + + it('throws when git push fails', function () { + Process::fake([ + 'git remote set-url*' => Process::result('', '', 0), + 'git push*' => Process::result('', 'error: failed to push', 1), + ]); + + $service = new SkeletonService; + + expect(fn () => $service->push('/tmp/my-app', 'git@github.com:conduit-ui/my-app.git')) + ->toThrow(\RuntimeException::class, 'Failed to push to'); + }); +});