diff --git a/.github/workflows/growth-sync.yml b/.github/workflows/growth-sync.yml deleted file mode 100644 index eb21a00..0000000 --- a/.github/workflows/growth-sync.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Growth sync - -on: - pull_request: - types: [opened, synchronize, closed] - check_run: - types: [completed] - workflow_run: - workflows: ["tests.yml", "lint.yml"] - types: [completed] - deployment_status: - release: - types: [published] - -permissions: - contents: read - checks: write - actions: read - pull-requests: write - -jobs: - sync: - runs-on: ubuntu-latest - - steps: - - uses: datashaman/growth/actions/growth-sync@main - with: - growth-url: ${{ vars.GROWTH_URL }} - growth-token: ${{ secrets.GROWTH_MCP_TOKEN }} diff --git a/app/Mcp/Servers/EvolveServer.php b/app/Mcp/Servers/EvolveServer.php index bbda976..5948469 100644 --- a/app/Mcp/Servers/EvolveServer.php +++ b/app/Mcp/Servers/EvolveServer.php @@ -11,6 +11,8 @@ use App\Mcp\Tools\ReadArtifact; use App\Mcp\Tools\ReorderStyles; use App\Mcp\Tools\RestoreArtifact; +use App\Mcp\Tools\SendFeedback; +use App\Mcp\Tools\TriageFeedback; use App\Mcp\Tools\UpsertArtifact; use App\Mcp\Tools\UpsertContentRow; use Laravel\Mcp\Server; @@ -35,6 +37,8 @@ class EvolveServer extends Server UpsertContentRow::class, DeleteContentRow::class, CreateContentModel::class, + SendFeedback::class, + TriageFeedback::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/SendFeedback.php b/app/Mcp/Tools/SendFeedback.php new file mode 100644 index 0000000..d2b6e0b --- /dev/null +++ b/app/Mcp/Tools/SendFeedback.php @@ -0,0 +1,57 @@ +validate([ + 'id' => ['nullable', 'string', 'max:80', 'regex:/^[A-Za-z0-9_.:-]+$/'], + 'type' => ['nullable', 'string', 'in:bug,feature,content,design,question,other'], + 'message' => ['required', 'string'], + 'source' => ['nullable', 'string', 'max:80'], + 'author' => ['nullable', 'string', 'max:120'], + 'url' => ['nullable', 'string'], + 'artifact_kind' => ['nullable', 'string', 'in:style,component,form,layout,page,snippet,view'], + 'artifact_id' => ['nullable', 'string'], + 'context' => ['nullable', 'array'], + 'dry_run' => ['sometimes', 'boolean'], + ]); + + $dryRun = (bool) ($data['dry_run'] ?? true); + $result = $dryRun + ? $feedback->previewFeedback($data) + : $feedback->sendFeedback($data); + + return Response::structured([ + 'dry_run' => $dryRun, + ...$result, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->string()->description('Optional stable feedback id. Generated when omitted.')->nullable(), + 'type' => $schema->string()->enum(['bug', 'feature', 'content', 'design', 'question', 'other'])->description('Feedback category. Defaults to other.')->nullable(), + 'message' => $schema->string()->description('The feedback text.')->required(), + 'source' => $schema->string()->description('Where the feedback came from. Defaults to mcp.')->nullable(), + 'author' => $schema->string()->nullable(), + 'url' => $schema->string()->description('Optional page or preview URL related to the feedback.')->nullable(), + 'artifact_kind' => $schema->string()->enum(['style', 'component', 'form', 'layout', 'page', 'snippet', 'view'])->nullable(), + 'artifact_id' => $schema->string()->description('Optional artifact id related to the feedback.')->nullable(), + 'context' => $schema->object()->description('Optional structured context captured with the feedback.')->nullable(), + 'dry_run' => $schema->boolean()->description('Defaults to true. Set false to persist feedback.')->nullable(), + ]; + } +} diff --git a/app/Mcp/Tools/TriageFeedback.php b/app/Mcp/Tools/TriageFeedback.php new file mode 100644 index 0000000..f7d6113 --- /dev/null +++ b/app/Mcp/Tools/TriageFeedback.php @@ -0,0 +1,52 @@ +validate([ + 'id' => ['required', 'string'], + 'status' => ['nullable', 'string', 'in:new,triaged,accepted,rejected,planned,in_progress,done'], + 'priority' => ['nullable', 'string', 'in:low,medium,high,urgent'], + 'labels' => ['nullable', 'array'], + 'labels.*' => ['string'], + 'assignee' => ['nullable', 'string', 'max:120'], + 'notes' => ['nullable', 'string'], + 'dry_run' => ['sometimes', 'boolean'], + ]); + + $dryRun = (bool) ($data['dry_run'] ?? true); + $result = $dryRun + ? $feedback->previewTriage($data['id'], $data) + : $feedback->triageFeedback($data['id'], $data); + + return Response::structured([ + 'dry_run' => $dryRun, + ...$result, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->string()->description('Feedback id to triage.')->required(), + 'status' => $schema->string()->enum(['new', 'triaged', 'accepted', 'rejected', 'planned', 'in_progress', 'done'])->nullable(), + 'priority' => $schema->string()->enum(['low', 'medium', 'high', 'urgent'])->nullable(), + 'labels' => $schema->array()->items($schema->string())->description('Labels to replace on the feedback item.')->nullable(), + 'assignee' => $schema->string()->nullable(), + 'notes' => $schema->string()->description('Triage notes.')->nullable(), + 'dry_run' => $schema->boolean()->description('Defaults to true. Set false to persist triage.')->nullable(), + ]; + } +} diff --git a/app/Models/EvolveFeedback.php b/app/Models/EvolveFeedback.php new file mode 100644 index 0000000..4c04b1a --- /dev/null +++ b/app/Models/EvolveFeedback.php @@ -0,0 +1,65 @@ +id)) { + $feedback->id = 'fb_'.Str::lower(Str::random(12)); + } + }); + } + + protected function casts(): array + { + return [ + 'context' => 'array', + 'labels' => 'array', + 'triaged_at' => 'datetime', + ]; + } + + #[Scope] + protected function open(Builder $query): void + { + $query->whereNotIn('status', ['rejected', 'done']); + } + + #[Scope] + protected function newest(Builder $query): void + { + $query->orderByDesc('created_at'); + } +} diff --git a/app/Services/EvolveFeedbackRepository.php b/app/Services/EvolveFeedbackRepository.php new file mode 100644 index 0000000..6e071e5 --- /dev/null +++ b/app/Services/EvolveFeedbackRepository.php @@ -0,0 +1,147 @@ + $data + * @return array + */ + public function previewFeedback(array $data): array + { + return [ + 'feedback' => $this->feedbackPayload($data), + 'would_write' => true, + ]; + } + + /** + * @param array $data + * @return array + */ + public function sendFeedback(array $data): array + { + $feedback = EvolveFeedback::query()->create($this->feedbackPayload($data)); + + return [ + 'feedback' => $this->serialize($feedback), + 'created' => true, + ]; + } + + /** + * @param array $data + * @return array + */ + public function previewTriage(string $id, array $data): array + { + $feedback = EvolveFeedback::query()->findOrFail($id); + + return [ + 'feedback' => [ + ...$this->serialize($feedback), + ...$this->triagePayload($data), + ], + 'would_write' => true, + ]; + } + + /** + * @param array $data + * @return array + */ + public function triageFeedback(string $id, array $data): array + { + $feedback = EvolveFeedback::query()->findOrFail($id); + $feedback->fill($this->triagePayload($data)); + $feedback->save(); + + return [ + 'feedback' => $this->serialize($feedback), + 'updated' => true, + ]; + } + + /** + * @return array> + */ + public function all(): array + { + return EvolveFeedback::query() + ->newest() + ->get() + ->map(fn (EvolveFeedback $feedback): array => $this->serialize($feedback)) + ->all(); + } + + /** + * @param array $data + * @return array + */ + private function feedbackPayload(array $data): array + { + return [ + 'id' => $data['id'] ?? 'fb_'.Str::lower(Str::random(12)), + 'type' => $data['type'] ?? 'other', + 'message' => $data['message'], + 'source' => $data['source'] ?? 'mcp', + 'author' => $data['author'] ?? null, + 'url' => $data['url'] ?? null, + 'artifact_kind' => $data['artifact_kind'] ?? null, + 'artifact_id' => $data['artifact_id'] ?? null, + 'context' => $data['context'] ?? [], + 'status' => 'new', + 'priority' => null, + 'labels' => [], + 'triage_notes' => null, + ]; + } + + /** + * @param array $data + * @return array + */ + private function triagePayload(array $data): array + { + $payload = Arr::only($data, ['status', 'priority', 'labels', 'assignee']); + + if (array_key_exists('notes', $data)) { + $payload['triage_notes'] = $data['notes']; + } + + $payload['triaged_at'] = now(); + + return $payload; + } + + /** + * @return array + */ + private function serialize(EvolveFeedback $feedback): array + { + return [ + 'id' => $feedback->id, + 'type' => $feedback->type, + 'message' => $feedback->message, + 'source' => $feedback->source, + 'author' => $feedback->author, + 'url' => $feedback->url, + 'artifact_kind' => $feedback->artifact_kind, + 'artifact_id' => $feedback->artifact_id, + 'context' => $feedback->context ?? [], + 'status' => $feedback->status, + 'priority' => $feedback->priority, + 'labels' => $feedback->labels ?? [], + 'assignee' => $feedback->assignee, + 'triage_notes' => $feedback->triage_notes, + 'triaged_at' => $feedback->triaged_at?->toISOString(), + 'created_at' => $feedback->created_at?->toISOString(), + 'updated_at' => $feedback->updated_at?->toISOString(), + ]; + } +} diff --git a/database/migrations/2026_05_31_140000_create_evolve_feedback_table.php b/database/migrations/2026_05_31_140000_create_evolve_feedback_table.php new file mode 100644 index 0000000..6d5618a --- /dev/null +++ b/database/migrations/2026_05_31_140000_create_evolve_feedback_table.php @@ -0,0 +1,39 @@ +string('id')->primary(); + $table->string('type')->default('other'); + $table->text('message'); + $table->string('source')->default('mcp'); + $table->string('author')->nullable(); + $table->text('url')->nullable(); + $table->string('artifact_kind')->nullable(); + $table->string('artifact_id')->nullable(); + $table->json('context')->nullable(); + $table->string('status')->default('new')->index(); + $table->string('priority')->nullable()->index(); + $table->json('labels')->nullable(); + $table->string('assignee')->nullable()->index(); + $table->text('triage_notes')->nullable(); + $table->timestamp('triaged_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('evolve_feedback'); + } +}; diff --git a/tests/Feature/EvolveMcpServerTest.php b/tests/Feature/EvolveMcpServerTest.php index 5552f06..4941126 100644 --- a/tests/Feature/EvolveMcpServerTest.php +++ b/tests/Feature/EvolveMcpServerTest.php @@ -12,6 +12,8 @@ use App\Mcp\Tools\ReadArtifact; use App\Mcp\Tools\ReorderStyles; use App\Mcp\Tools\RestoreArtifact; +use App\Mcp\Tools\SendFeedback; +use App\Mcp\Tools\TriageFeedback; use App\Mcp\Tools\UpsertArtifact; use App\Mcp\Tools\UpsertContentRow; use Illuminate\Database\Eloquent\Attributes\Scope; @@ -400,6 +402,100 @@ public function test_content_model_creation_defaults_to_dry_run(): void $this->assertFalse(File::exists(app_path('Models/FieldNote.php'))); } + public function test_feedback_tools_send_and_triage_developer_agent_feedback(): void + { + EvolveServer::tool(SendFeedback::class, [ + 'id' => 'fb-test-1', + 'type' => 'bug', + 'message' => 'Preview feedback should not write.', + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', true) + ->where('feedback.id', 'fb-test-1') + ->where('feedback.status', 'new') + ->where('would_write', true) + ->etc() + ); + + $this->assertDatabaseMissing('evolve_feedback', ['id' => 'fb-test-1']); + + EvolveServer::tool(SendFeedback::class, [ + 'id' => 'fb-test-1', + 'type' => 'bug', + 'message' => 'Persist this for developer and agent triage.', + 'source' => 'agent', + 'artifact_kind' => 'page', + 'artifact_id' => 'home', + 'context' => ['route' => '/'], + 'dry_run' => false, + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', false) + ->where('created', true) + ->where('feedback.id', 'fb-test-1') + ->where('feedback.source', 'agent') + ->where('feedback.context.route', '/') + ->etc() + ); + + $this->assertDatabaseHas('evolve_feedback', [ + 'id' => 'fb-test-1', + 'status' => 'new', + 'source' => 'agent', + ]); + + EvolveServer::tool(TriageFeedback::class, [ + 'id' => 'fb-test-1', + 'status' => 'accepted', + 'priority' => 'high', + 'labels' => ['process', 'agent'], + 'assignee' => 'developer', + 'notes' => 'Handle in the local feedback channel.', + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', true) + ->where('feedback.status', 'accepted') + ->where('feedback.priority', 'high') + ->where('feedback.labels.0', 'process') + ->etc() + ); + + $this->assertDatabaseHas('evolve_feedback', [ + 'id' => 'fb-test-1', + 'status' => 'new', + 'priority' => null, + ]); + + EvolveServer::tool(TriageFeedback::class, [ + 'id' => 'fb-test-1', + 'status' => 'accepted', + 'priority' => 'high', + 'labels' => ['process', 'agent'], + 'assignee' => 'developer', + 'notes' => 'Handle in the local feedback channel.', + 'dry_run' => false, + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', false) + ->where('updated', true) + ->where('feedback.status', 'accepted') + ->where('feedback.assignee', 'developer') + ->etc() + ); + + $this->assertDatabaseHas('evolve_feedback', [ + 'id' => 'fb-test-1', + 'status' => 'accepted', + 'priority' => 'high', + 'assignee' => 'developer', + ]); + } + + public function test_growth_sync_workflow_has_been_removed(): void + { + $this->assertFalse(File::exists($this->originalBasePath.'/.github/workflows/growth-sync.yml')); + } + private function fixtureModelClass(): string { return McpFixture::class;