diff --git a/app/Actions/Projects/ResolveProjectTemplateState.php b/app/Actions/Projects/ResolveProjectTemplateState.php index 8c69824..40e8b0c 100644 --- a/app/Actions/Projects/ResolveProjectTemplateState.php +++ b/app/Actions/Projects/ResolveProjectTemplateState.php @@ -62,11 +62,17 @@ class ResolveProjectTemplateState ]; } + $overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value')); + $inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value')); + 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, + 'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues), 'inheritsFromTemplate' => true, 'overriddenAreas' => $overriddenAreas, + 'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues), + 'templateConfiguration' => $templateConfiguration, 'templateId' => $projectTemplate->id, 'templateName' => $projectTemplate->name, 'templateSlug' => $projectTemplate->slug, diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index a30fba5..a4bafea 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -2,8 +2,10 @@ namespace App\Http\Resources; +use App\Actions\Projects\ResolveProjectTemplateState; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Arr; class ProjectResource extends JsonResource { @@ -14,11 +16,32 @@ class ProjectResource extends JsonResource */ public function toArray(Request $request): array { + $this->resource->loadMissing('projectTemplate'); + + $templateState = app(ResolveProjectTemplateState::class)->handle($this->resource); + return [ 'id' => $this->id, 'name' => $this->name, 'key' => $this->key, '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_story_points' => $this->show_story_points, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/ProjectTemplateResource.php b/app/Http/Resources/ProjectTemplateResource.php index bf49dc9..3bcdcc9 100644 --- a/app/Http/Resources/ProjectTemplateResource.php +++ b/app/Http/Resources/ProjectTemplateResource.php @@ -28,6 +28,7 @@ class ProjectTemplateResource extends JsonResource 'slug' => $this->slug, 'is_published' => $this->is_published, 'copy_areas' => $this->copy_areas ?? [], + 'configuration_snapshot' => $this->configuration_snapshot ?? [], 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; @@ -42,6 +43,7 @@ class ProjectTemplateResource extends JsonResource 'slug' => Arr::get($this->resource, 'slug'), 'is_published' => Arr::get($this->resource, 'is_published', true), 'copy_areas' => Arr::get($this->resource, 'copy_areas', []), + 'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []), 'created_at' => Arr::get($this->resource, 'created_at'), 'updated_at' => Arr::get($this->resource, 'updated_at'), ]; diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 4d2ec2e..ff069f8 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -108,6 +108,76 @@ class ProjectApiTest extends TestCase $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 { $user = User::factory()->create(); diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php index 59adf59..1a2b313 100644 --- a/tests/Feature/Mcp/KanbanGitMcpTest.php +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Mcp; use App\Actions\Notifications\DeliverNotificationAction; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Enums\NotificationDeliveryStatus; use App\Mcp\Servers\KanbanGitServer; use App\Mcp\Tools\GetTicket; @@ -15,8 +16,10 @@ use App\Mcp\Tools\UpdateTicketDescription; use App\Models\BoardColumn; use App\Models\NotificationAction; use App\Models\Project; +use App\Models\ProjectTemplate; use App\Models\Ticket; use App\Models\TicketStatus; +use App\Models\TicketType; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; @@ -207,6 +210,66 @@ class KanbanGitMcpTest extends TestCase ->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 { $user = User::factory()->create();