Add template context to API and MCP project payloads

This commit is contained in:
2026-09-08 10:25:52 +00:00
committed by Twyco
parent 831014f7fe
commit b8a1d65122
5 changed files with 165 additions and 1 deletions
@@ -62,11 +62,17 @@ class ResolveProjectTemplateState
]; ];
} }
$overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value'));
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [ return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, array_flip(array_column($overriddenAreas, 'value')))), 'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'inheritedAreas' => $inheritedAreas, 'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => true, 'inheritsFromTemplate' => true,
'overriddenAreas' => $overriddenAreas, 'overriddenAreas' => $overriddenAreas,
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
'templateConfiguration' => $templateConfiguration,
'templateId' => $projectTemplate->id, 'templateId' => $projectTemplate->id,
'templateName' => $projectTemplate->name, 'templateName' => $projectTemplate->name,
'templateSlug' => $projectTemplate->slug, 'templateSlug' => $projectTemplate->slug,
+23
View File
@@ -2,8 +2,10 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Actions\Projects\ResolveProjectTemplateState;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class ProjectResource extends JsonResource class ProjectResource extends JsonResource
{ {
@@ -14,11 +16,32 @@ class ProjectResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$this->resource->loadMissing('projectTemplate');
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
return [ return [
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
'key' => $this->key, 'key' => $this->key,
'project_template_id' => $this->project_template_id, 'project_template_id' => $this->project_template_id,
'project_template' => $this->projectTemplate === null
? null
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
'template_state' => $templateState === null
? null
: [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
'template_id' => Arr::get($templateState, 'templateId'),
'template_name' => Arr::get($templateState, 'templateName'),
'template_slug' => Arr::get($templateState, 'templateSlug'),
],
'show_author' => $this->show_author, 'show_author' => $this->show_author,
'show_story_points' => $this->show_story_points, 'show_story_points' => $this->show_story_points,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
@@ -28,6 +28,7 @@ class ProjectTemplateResource extends JsonResource
'slug' => $this->slug, 'slug' => $this->slug,
'is_published' => $this->is_published, 'is_published' => $this->is_published,
'copy_areas' => $this->copy_areas ?? [], 'copy_areas' => $this->copy_areas ?? [],
'configuration_snapshot' => $this->configuration_snapshot ?? [],
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
]; ];
@@ -42,6 +43,7 @@ class ProjectTemplateResource extends JsonResource
'slug' => Arr::get($this->resource, 'slug'), 'slug' => Arr::get($this->resource, 'slug'),
'is_published' => Arr::get($this->resource, 'is_published', true), 'is_published' => Arr::get($this->resource, 'is_published', true),
'copy_areas' => Arr::get($this->resource, 'copy_areas', []), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
'created_at' => Arr::get($this->resource, 'created_at'), 'created_at' => Arr::get($this->resource, 'created_at'),
'updated_at' => Arr::get($this->resource, 'updated_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'),
]; ];
+70
View File
@@ -108,6 +108,76 @@ class ProjectApiTest extends TestCase
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
} }
public function test_project_api_exposes_template_context_and_overrides(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'TMP1',
'name' => 'Template Driven Project',
'show_author' => false,
'show_story_points' => false,
]);
$template = ProjectTemplate::factory()->create([
'name' => 'Workflow Template',
'slug' => 'workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
[
'name' => 'Feature',
'position' => 2,
'is_parent' => false,
],
],
],
]);
$project->update([
'project_template_id' => $template->id,
]);
$project->ticketTypes()->delete();
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Feature',
'position' => 2,
'is_parent' => false,
]);
$this->syncProjectPermissions($project, $user, ['view']);
$this->withApplicationKey($user)
->getJson(route('api.projects.show', $project))
->assertOk()
->assertJsonPath('data.project_template.id', $template->id)
->assertJsonPath('data.project_template.slug', 'workflow-template')
->assertJsonPath('data.project_template.configuration_snapshot.project_settings.show_author', true)
->assertJsonPath('data.template_state.inherits_from_template', true)
->assertJsonPath('data.template_state.template_id', $template->id)
->assertJsonPath('data.template_state.template_configuration.project_settings.show_author', true)
->assertJsonPath('data.template_state.effective_configuration.ticket_types.0.name', 'Bug')
->assertJsonPath('data.template_state.inherited_configuration.ticket_types.1.name', 'Feature')
->assertJsonPath('data.template_state.overridden_configuration.project_settings.show_author', false)
->assertJsonPath('data.template_state.overridden_areas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS);
}
public function test_users_can_duplicate_projects_via_api(): void public function test_users_can_duplicate_projects_via_api(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
+63
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Mcp;
use App\Actions\Notifications\DeliverNotificationAction; use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Enums\NotificationDeliveryStatus; use App\Enums\NotificationDeliveryStatus;
use App\Mcp\Servers\KanbanGitServer; use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
@@ -15,8 +16,10 @@ use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\BoardColumn; use App\Models\BoardColumn;
use App\Models\NotificationAction; use App\Models\NotificationAction;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketStatus; use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@@ -207,6 +210,66 @@ class KanbanGitMcpTest extends TestCase
->etc()); ->etc());
} }
public function test_get_ticket_exposes_project_template_context_and_overrides(): void
{
$user = User::factory()->create();
$project = $this->project('CORE', 'Core project');
$template = ProjectTemplate::factory()->create([
'name' => 'Agent Workflow Template',
'slug' => 'agent-workflow-template',
'copy_areas' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
[
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
],
],
],
]);
$project->update([
'project_template_id' => $template->id,
'show_author' => false,
'show_story_points' => false,
]);
$project->ticketTypes()->delete();
TicketType::factory()->create([
'project_id' => $project->id,
'name' => 'Bug',
'position' => 1,
'is_parent' => false,
]);
$status = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $status, [
'ticket_key' => 'CORE-1',
'title' => 'Template aware ticket',
]);
$this->syncProjectPermissions($project, $user, ['view']);
KanbanGitServer::actingAs($user)
->tool(GetTicket::class, [
'ticket_id' => $ticket->id,
])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.project.project_template.id', $template->id)
->where('data.project.project_template.slug', 'agent-workflow-template')
->where('data.project.template_state.inherits_from_template', true)
->where('data.project.template_state.template_configuration.project_settings.show_author', true)
->where('data.project.template_state.overridden_configuration.project_settings.show_author', false)
->where('data.project.template_state.effective_configuration.ticket_types.0.name', 'Bug')
->etc());
}
public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();