Skip to content
Merged
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
30 changes: 0 additions & 30 deletions .github/workflows/growth-sync.yml

This file was deleted.

4 changes: 4 additions & 0 deletions app/Mcp/Servers/EvolveServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +37,8 @@ class EvolveServer extends Server
UpsertContentRow::class,
DeleteContentRow::class,
CreateContentModel::class,
SendFeedback::class,
TriageFeedback::class,
];

protected array $resources = [
Expand Down
57 changes: 57 additions & 0 deletions app/Mcp/Tools/SendFeedback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Mcp\Tools;

use App\Services\EvolveFeedbackRepository;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Record product, content, or implementation feedback for the Evolve workbench. Defaults to dry-run.')]
class SendFeedback extends Tool
{
public function handle(Request $request, EvolveFeedbackRepository $feedback): ResponseFactory
{
$data = $request->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(),
];
}
}
52 changes: 52 additions & 0 deletions app/Mcp/Tools/TriageFeedback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace App\Mcp\Tools;

use App\Services\EvolveFeedbackRepository;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Triage an existing Evolve feedback item. Defaults to dry-run.')]
class TriageFeedback extends Tool
{
public function handle(Request $request, EvolveFeedbackRepository $feedback): ResponseFactory
{
$data = $request->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(),
];
}
}
65 changes: 65 additions & 0 deletions app/Models/EvolveFeedback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class EvolveFeedback extends Model
{
protected $table = 'evolve_feedback';

public $incrementing = false;

protected $keyType = 'string';

protected $fillable = [
'id',
'type',
'message',
'source',
'author',
'url',
'artifact_kind',
'artifact_id',
'context',
'status',
'priority',
'labels',
'assignee',
'triage_notes',
'triaged_at',
];

protected static function booted(): void
{
static::creating(function (EvolveFeedback $feedback): void {
if (blank($feedback->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');
}
}
147 changes: 147 additions & 0 deletions app/Services/EvolveFeedbackRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

namespace App\Services;

use App\Models\EvolveFeedback;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

class EvolveFeedbackRepository
{
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function previewFeedback(array $data): array
{
return [
'feedback' => $this->feedbackPayload($data),
'would_write' => true,
];
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function sendFeedback(array $data): array
{
$feedback = EvolveFeedback::query()->create($this->feedbackPayload($data));

return [
'feedback' => $this->serialize($feedback),
'created' => true,
];
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<int, array<string, mixed>>
*/
public function all(): array
{
return EvolveFeedback::query()
->newest()
->get()
->map(fn (EvolveFeedback $feedback): array => $this->serialize($feedback))
->all();
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed>
*/
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(),
];
}
}
Loading