Merge pull request 'Add template context to API and MCP project payloads' (#100) from koda/kan-8-add-project-template-to-mcp-api into epic/kan-3
linter / quality (pull_request) Successful in 1m24s
tests / ci (8.4) (pull_request) Successful in 1m46s

Reviewed-on: OpenSource/KanbanGit#100
This commit was merged in pull request #100.
This commit is contained in:
2026-06-23 22:40:29 +00:00
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 [
'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,
+23
View File
@@ -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(),
@@ -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'),
];
+70
View File
@@ -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();
+63
View File
@@ -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();