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
Reviewed-on: OpenSource/KanbanGit#100
This commit was merged in pull request #100.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user