From 70faf4f5e5fb7a6a344fe4222aeb477f63a3bc08 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 8 Jun 2026 19:34:47 -0700 Subject: [PATCH] feat: implement RepoGeneratorJob, GitHubService, and BifrostService (issue #2) Adds queued job that creates a GitHub repo, opens starter issues (one Setup issue + one per model with attributes and relationships), and registers a Bifrost webhook source. Uses raw Http facade since conduit-ui/connector is not yet available. Config wired through services.php. Tests cover all success and failure paths at 100% coverage using Http::fake() and Mockery. --- .env.example | 2 + app/Jobs/RepoGeneratorJob.php | 74 ++++++++ app/Services/BifrostService.php | 44 +++++ app/Services/GitHubService.php | 65 +++++++ config/services.php | 15 ++ tests/Unit/Jobs/RepoGeneratorJobTest.php | 200 +++++++++++++++++++++ tests/Unit/Services/BifrostServiceTest.php | 91 ++++++++++ tests/Unit/Services/GitHubServiceTest.php | 105 +++++++++++ 8 files changed, 596 insertions(+) create mode 100644 app/Jobs/RepoGeneratorJob.php create mode 100644 app/Services/BifrostService.php create mode 100644 app/Services/GitHubService.php create mode 100644 config/services.php create mode 100644 tests/Unit/Jobs/RepoGeneratorJobTest.php create mode 100644 tests/Unit/Services/BifrostServiceTest.php create mode 100644 tests/Unit/Services/GitHubServiceTest.php 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/app/Jobs/RepoGeneratorJob.php b/app/Jobs/RepoGeneratorJob.php new file mode 100644 index 0000000..62bd5cf --- /dev/null +++ b/app/Jobs/RepoGeneratorJob.php @@ -0,0 +1,74 @@ +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), + ); + } + + $bifrost->registerSource($this->manifest); + } + + 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/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/GitHubService.php b/app/Services/GitHubService.php new file mode 100644 index 0000000..b5779b7 --- /dev/null +++ b/app/Services/GitHubService.php @@ -0,0 +1,65 @@ +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, + 'auto_init' => 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/config/services.php b/config/services.php new file mode 100644 index 0000000..db6c370 --- /dev/null +++ b/config/services.php @@ -0,0 +1,15 @@ + [ + 'token' => env('GITHUB_TOKEN'), + 'org' => env('GITHUB_ORG', 'conduit-ui'), + ], + + 'bifrost' => [ + 'url' => env('BIFROST_URL'), + 'token' => env('BIFROST_TOKEN'), + ], + +]; diff --git a/tests/Unit/Jobs/RepoGeneratorJobTest.php b/tests/Unit/Jobs/RepoGeneratorJobTest.php new file mode 100644 index 0000000..9b560ef --- /dev/null +++ b/tests/Unit/Jobs/RepoGeneratorJobTest.php @@ -0,0 +1,200 @@ + '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('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/GitHubServiceTest.php b/tests/Unit/Services/GitHubServiceTest.php new file mode 100644 index 0000000..16257c9 --- /dev/null +++ b/tests/Unit/Services/GitHubServiceTest.php @@ -0,0 +1,105 @@ + $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 + && $request['auto_init'] === 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'); + }); + }); +});