Compare commits

..
Author SHA1 Message Date
Twyco 80d911bcc1 feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m12s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 1m59s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:02:50 +02:00
35 changed files with 203 additions and 1389 deletions
+73 -13
View File
@@ -1,18 +1,78 @@
name: test_and_check_app
description: Build the test image and run all application checks
name: "Lint and Test APP"
description: "Run Pint and the Pest test suite"
runs:
using: composite
steps:
- name: Test and check app
shell: bash
run: |
set -Eeuo pipefail
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
- name: Verify Composer lockfile
run: |
if [ ! -f composer.lock ]; then
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
+1 -8
View File
@@ -3,7 +3,6 @@
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
class DeleteProject
{
@@ -11,13 +10,7 @@ class DeleteProject
{
$projectName = $project->name;
DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete();
$baseTemplate?->delete();
});
$project->delete();
return $projectName;
}
+12 -15
View File
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
) {}
/**
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
*/
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
{
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
$linkedTicket = Ticket::query()
->where('project_id', $project->id)
->findOrFail($linkedTicketId);
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id')
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
} else {
$ticketLink->update([
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
}
$ticketLink->load('ticketLinkType');
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$this->createTicketWorklogEntry->handle(
$ticket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
[
'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $ticketLabel,
'ticket_link_type' => $sourceLabel,
],
);
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
$linkedTicket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
"Linked {$ticket->ticket_key} as {$targetLabel}.",
[
'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $linkedTicketLabel,
'ticket_link_type' => $targetLabel,
],
);
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
/**
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
*/
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
public static function syncTicket(Ticket $ticket, array $items): void
{
$normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
@@ -73,8 +73,7 @@ class AcceptanceCriteriaChecklistMapper
: $existingCriteria->get($criterionId);
while (
$reuseUnidentifiedCriteria
&& $criterion === null
$criterion === null
&& $fallbackIndex < $fallbackCriteria->count()
) {
/** @var AcceptanceCriterion $candidate */
+3 -6
View File
@@ -51,7 +51,6 @@ class UpdateProjectTicket
Ticket $ticket,
User $actor,
array $validated,
bool $replaceCriteriaWithIds = false,
): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
@@ -59,7 +58,7 @@ class UpdateProjectTicket
);
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null;
}
@@ -92,9 +91,7 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = $replaceCriteriaWithIds
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = [];
$summaryLabels = [];
@@ -212,7 +209,7 @@ class UpdateProjectTicket
}
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
}
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
@@ -36,12 +36,11 @@ class ProjectTicketLinkController extends ApiController
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
->additional([
'meta' => [
'message' => "{$label} link was saved.",
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
],
])
->response()
@@ -34,10 +34,9 @@ class ProjectTicketLinkController extends Controller
);
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
return to_route('projects.tickets.show', [$project, $ticket])
->with('status', "{$label} link was saved.");
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
}
public function destroy(
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
return $this->user()?->can('access-admin') ?? false;
}
/**
@@ -42,7 +42,6 @@ class StoreTicketLinkRequest extends FormRequest
fn ($query) => $query->where('project_id', $project->id),
),
],
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
];
}
+2 -10
View File
@@ -3,15 +3,11 @@
namespace App\Mcp\Servers;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\SearchTickets;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription;
use Laravel\Mcp\Server;
@@ -20,8 +16,8 @@ use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')]
#[Version('0.2.0')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration for compact current project metadata. Get tickets by exact ticket_key or ticket_id, or search_tickets with pagination. Use update_ticket for partial edits; omitted fields are preserved, nullable scalar null clears, and supplied lists replace the entire list. Use move_ticket_status for allowed workflow transitions. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
#[Version('0.1.0')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server
{
protected array $tools = [
@@ -33,10 +29,6 @@ class KanbanGitServer extends Server
MoveTicketStatus::class,
CreateTicket::class,
ListTicketTypes::class,
ListProjects::class,
GetProjectConfiguration::class,
SearchTickets::class,
UpdateTicket::class,
];
protected array $resources = [
-24
View File
@@ -13,7 +13,6 @@ use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
@@ -79,17 +78,6 @@ class KanbanGitMcp
return $ticket;
}
public function ticketForKey(User $user, string $ticketKey): Ticket
{
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
}
$this->ensureCanRead($user, $ticket->project);
return $ticket;
}
/**
* @throws AuthorizationException
*/
@@ -213,18 +201,6 @@ class KanbanGitMcp
));
}
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
public function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
];
}
/**
* @return array<string, mixed>
*/
-108
View File
@@ -1,108 +0,0 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\User;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
class TicketUpdateInput
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
/** @return array<string, mixed> */
public function validate(Request $request, Ticket $ticket, User $user): array
{
$form = new UpdateProjectTicketRequest;
$route = new Route('PUT', '/', fn () => null);
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
$route->setParameter('project', $ticket->project);
$route->setParameter('ticket', $ticket);
$form->setRouteResolver(fn () => $route);
$form->setUserResolver(fn () => $user);
$rules = $form->rules();
foreach ($rules as $key => &$rule) {
if (! str_contains($key, '.')) {
$rule = ['sometimes', ...$rule];
}
}
unset($rule);
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
$rules['field_values'] = ['sometimes', 'array', 'list'];
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
$rules['ticket_status_id'] = ['prohibited'];
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
if (is_string($value) && trim($value) === '') {
$fail('Criterion text must not be blank.');
}
};
$input = $request->validate($rules);
$previousIndent = -1;
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
$indent = Arr::get($item, 'indent', 0);
if ($indent > $previousIndent + 1) {
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
}
$previousIndent = $indent;
}
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
$validator = Validator::make($merged, $form->rules());
$form->setValidator($validator);
$validator->validate();
foreach ($form->after() as $callback) {
$callback($validator);
}
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
foreach (['assignee_user_id', 'author_user_id'] as $field) {
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
$validator->errors()->add($field, 'The person must be able to view this project.');
}
}
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
$definition = $definitions->get($value['ticket_field_definition_id']);
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
if (Arr::get($value, $incompatible) !== null) {
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
}
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
}
}
if ($validator->errors()->isNotEmpty()) {
throw new ValidationException($validator);
}
if (Arr::exists($input, 'field_values')) {
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
'ticket_field_definition_id' => $definition->id,
'value_number' => null,
'value_user_id' => null,
])->all();
foreach ($input['field_values'] as $value) {
$replacement[$value['ticket_field_definition_id']] = $value;
}
$merged['field_values'] = array_values($replacement);
}
return $merged;
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_project_configuration')]
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
#[IsReadOnly]
class GetProjectConfiguration extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'project_id' => ['required', 'integer'],
'people_page' => ['sometimes', 'integer', 'min:1'],
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
$columnPositions = $columns->pluck('position', 'id');
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
)->values();
return Response::structured([
'data' => [
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
'permissions' => [
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
],
'board_columns' => $columns->toArray(),
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
'position' => $status->position, 'board_column_id' => $status->board_column_id,
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
])->all(),
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
],
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
];
}
}
+5 -8
View File
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_ticket')]
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly]
class GetTicket extends Tool
{
@@ -25,14 +25,11 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
'ticket_id' => ['required', 'integer'],
]);
$user = $this->kanbanGitMcp->user($request);
$ticket = isset($validated['ticket_id'])
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -46,8 +43,8 @@ class GetTicket extends Tool
{
return [
'ticket_id' => $schema->integer()
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
->description('Ticket ID.')
->required(),
];
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('list_projects')]
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
#[IsReadOnly]
class ListProjects extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
$user = $this->kanbanGitMcp->user($request);
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Ticket;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('search_tickets')]
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
#[IsReadOnly]
class SearchTickets extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
'query' => ['sometimes', 'string', 'max:255'],
'project_id' => ['sometimes', 'integer'],
'ticket_status_id' => ['sometimes', 'integer'],
'ticket_type_id' => ['sometimes', 'integer'],
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
if (Arr::exists($input, $filter)) {
$query->where($filter, $input[$filter]);
}
}
if (Arr::exists($input, 'query')) {
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
}
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
'id' => $ticket->id,
'ticket_key' => $ticket->ticket_key,
'title' => $ticket->title,
'project_id' => $ticket->project_id,
'ticket_status_id' => $ticket->ticket_status_id,
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
'project_id' => $schema->integer()->description('Visible project ID.'),
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
-71
View File
@@ -1,71 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\KanbanGitMcp;
use App\Mcp\Support\TicketUpdateInput;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Name('update_ticket')]
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
#[IsDestructive(false)]
class UpdateTicket extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly TicketUpdateInput $ticketUpdateInput,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
'meta' => ['updated' => $updated],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
'description' => $schema->string()->nullable()->description('Description; null clears it.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
'acceptance_criteria' => $schema->array()->items($schema->object([
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid.'),
'field_values' => $schema->array()->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
];
}
}
Generated
+22 -26
View File
@@ -1451,20 +1451,20 @@
},
{
"name": "laravel/framework",
"version": "v13.34.0",
"version": "v13.26.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
"shasum": ""
},
"require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
@@ -1576,20 +1576,18 @@
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1",
"orchestra/testbench-core": "^11.5.0",
"orchestra/testbench-core": "^11.0.0",
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "2.2.15",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
"predis/predis": "^2.3 || ^3.0",
"rector/rector": "2.6.3",
"rector/rector": "^2.3",
"resend/resend-php": "^1.0",
"symfony/cache": "^7.4.0 || ^8.0.0",
"symfony/http-client": "^7.4.0 || ^8.0.0",
"symfony/mercure": "^0.8.0",
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
"symfony/translation": "^7.4.0 || ^8.0.0",
"web-token/jwt-library": "^4.1"
"symfony/translation": "^7.4.0 || ^8.0.0"
},
"suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1625,10 +1623,8 @@
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
},
"type": "library",
"extra": {
@@ -1678,7 +1674,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-09-29T12:46:21+00:00"
"time": "2026-08-18T20:31:28+00:00"
},
{
"name": "laravel/mcp",
@@ -2007,16 +2003,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.3",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"shasum": ""
},
"require": {
@@ -2110,7 +2106,7 @@
"type": "tidelift"
}
],
"time": "2026-09-21T13:07:34+00:00"
"time": "2026-08-11T16:06:25+00:00"
},
{
"name": "league/config",
@@ -2196,16 +2192,16 @@
},
{
"name": "league/flysystem",
"version": "3.36.0",
"version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
"shasum": ""
},
"require": {
@@ -2273,9 +2269,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
},
"time": "2026-09-02T08:00:27+00:00"
"time": "2026-07-06T14:42:07+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
+1 -1
View File
@@ -15,7 +15,7 @@
"dev": "vite",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
"types:check": "vue-tsc --noEmit"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+17 -25
View File
@@ -203,14 +203,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
importers:
.:
@@ -1252,8 +1244,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1265,14 +1257,14 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -2904,7 +2896,7 @@ snapshots:
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
dependencies:
'@types/lodash-es': 4.17.12
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1
qs: 6.16.0
@@ -3692,7 +3684,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
form-data: 4.0.6
@@ -3708,16 +3700,16 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@1.1.21:
brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.7:
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.12:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -4460,7 +4452,7 @@ snapshots:
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies:
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1
transitivePeerDependencies:
- debug
@@ -4615,15 +4607,15 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.12
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.21
brace-expansion: 1.1.18
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.7
brace-expansion: 2.1.4
minimist@1.2.8: {}
-12
View File
@@ -1,16 +1,4 @@
minimumReleaseAge: 10080
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
minimumReleaseAgeExclude:
- brace-expansion@1.1.21
- brace-expansion@2.1.7
- brace-expansion@5.0.12
allowBuilds:
unrs-resolver: true
vue-demi: true
overrides:
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
@@ -12,12 +12,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketDetailRelationshipsProps,
TicketLinkFormData,
} from '@/types/ticket-detail';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
defineEmits<{ editParent: [] }>();
@@ -37,17 +33,17 @@ const canAddLink = computed(
props.ticketLinkTypes.length > 0 &&
props.ticketLinkOptions.length > 0,
);
const form = useForm<TicketLinkFormData>({
const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source',
linked_ticket_id: '',
});
const protection = useFormProtection(form, {
isDirty: () => props.canEditTicket && form.isDirty,
});
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
() => props.ticketLinkTypes,
form,
const selectedType = computed(() =>
props.ticketLinkTypes.find(
(type) => String(type.id) === String(form.ticket_link_type_id),
),
);
const selectedTicket = computed(() =>
props.ticketLinkOptions.find(
@@ -228,14 +224,16 @@ function submit(): void {
>
<TicketFormField
id="ticket_link_type_id"
v-model="selectedAction"
v-model="form.ticket_link_type_id"
kind="select"
:label="t('ticketDetail.linkAction')"
:options="actions"
:error="
form.errors.ticket_link_type_id ||
form.errors.link_direction
:label="t('ticketDetail.linkType')"
:options="
ticketLinkTypes.map((type) => ({
value: type.id,
label: type.label,
}))
"
:error="form.errors.ticket_link_type_id"
:disabled="form.processing"
required
/>
@@ -262,19 +260,22 @@ function submit(): void {
/>
</fieldset>
<p
v-if="selectedActionLabel && selectedTicket"
v-if="selectedType && selectedTicket"
aria-live="polite"
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-mono text-xs">{{
ticket.ticket_key
}}</span
><span>{{ selectedActionLabel }}</span
><span>{{ selectedType.label }}</span
><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span
>
</p>
<p class="text-muted-foreground text-xs">
{{ t('ticketRelationships.directionHelp') }}
</p>
<Button
type="submit"
:disabled="form.processing || !selectedTicket"
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { Unlink } from 'lucide-vue-next';
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
import { computed } from 'vue';
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import FormFeedback from '@/components/FormFeedback.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
@@ -18,6 +20,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{ removed: [] }>();
const { t } = useLocalization();
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
const form = useForm({});
const protection = useFormProtection(form);
function remove(): void {
@@ -44,6 +47,27 @@ function remove(): void {
:data-ticket-link="link.id"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge
variant="outline"
class="text-muted-foreground max-w-full whitespace-normal"
>
<ArrowUpRight
v-if="outgoing"
class="size-3.5 shrink-0"
aria-hidden="true"
/><ArrowDownLeft
v-else
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{{
t(
outgoing
? 'ticketRelationships.outgoing'
: 'ticketRelationships.incoming',
)
}}
</Badge>
<span class="text-muted-foreground font-mono text-xs">{{
ticketKey
}}</span>
@@ -1,57 +0,0 @@
import { computed, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type {
TicketLinkActionOption,
TicketLinkFormData,
} from '@/types/ticket-detail';
export function useTicketLinkActions(
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
form: TicketLinkFormData,
) {
const actions = computed<TicketLinkActionOption[]>(() =>
toValue(ticketLinkTypes).flatMap((type) => {
const choices: TicketLinkActionOption[] = [
{
value: `${type.id}:source`,
label: type.label,
ticketLinkTypeId: type.id,
direction: 'source',
},
];
if (
type.counterpart_label &&
type.counterpart_label !== type.label
) {
choices.push({
value: `${type.id}:counterpart`,
label: type.counterpart_label,
ticketLinkTypeId: type.id,
direction: 'counterpart',
});
}
return choices;
}),
);
const selectedAction = computed({
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
set: (value: string | number | null) => {
const action = actions.value.find(
(option) => option.value === value,
);
if (action) {
form.ticket_link_type_id = action.ticketLinkTypeId;
form.link_direction = action.direction;
}
},
});
const selectedActionLabel = computed(
() =>
actions.value.find(
(action) => action.value === selectedAction.value,
)?.label,
);
return { actions, selectedAction, selectedActionLabel };
}
+5 -1
View File
@@ -1213,7 +1213,7 @@ export const en = {
'ticketDetail.linkedTickets': 'Linked tickets',
'ticketDetail.linkedTicketsDescription':
'Track cross-ticket relationships with project-defined link labels.',
'ticketDetail.linkAction': 'Action',
'ticketDetail.linkType': 'Link type',
'ticketDetail.linkedTicket': 'Linked ticket',
'ticketDetail.saveLink': 'Save link',
'ticketDetail.noLinks':
@@ -1227,6 +1227,10 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}',
'ticketRelationships.noTypes':
'No link types are configured for this project.',
-11
View File
@@ -67,14 +67,3 @@ export type TicketBranchFormData = {
branch_name: string;
repository_connection_id: string;
};
export type TicketLinkFormData = {
ticket_link_type_id: number | string;
link_direction: 'source' | 'counterpart';
linked_ticket_id: number | string;
};
export type TicketLinkActionOption = {
value: string;
label: string;
ticketLinkTypeId: number;
direction: TicketLinkFormData['link_direction'];
};
+1 -1
View File
@@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
});
Route::delete('/', [ProjectController::class, 'destroy'])
->middleware('can:admin-project,project')
->middleware('can:access-admin')
->name('destroy');
Route::middleware('can:admin-project,project')->group(function (): void {
+1 -8
View File
@@ -291,14 +291,7 @@ class ProjectApiTest extends TestCase
->assertOk()
->assertJsonPath('data.key', 'CORE');
$this->withApplicationKey($user)
->deleteJson(route('api.projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertForbidden();
$this->syncProjectPermissions($project, $admin, ['admin']);
$this->assertTrue($admin->can('admin-project', $project));
$this->syncGlobalPermissions($admin, ['admin']);
$this->withApplicationKey($admin)
->deleteJson(route('api.projects.destroy', $project), [
-43
View File
@@ -293,49 +293,6 @@ class TicketApiTest extends TestCase
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
{
[$user, $project] = $this->projectContext();
$status = $project->ticketStatuses()->firstOrFail();
$blockingTicket = $this->ticket($project, $status);
$blockedTicket = $this->ticket($project, $status, 2);
$linkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'counterpart',
])
->assertCreated()
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
->assertJsonPath('meta.message', 'Blocked by link was saved.');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'invalid',
])
->assertUnprocessable()
->assertJsonValidationErrors('link_direction');
}
public function test_ticket_links_and_branch_endpoints_are_available(): void
{
Http::preventStrayRequests();
-225
View File
@@ -1,225 +0,0 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\SearchTickets;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class TicketDiscoveryTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Project $hidden;
private Ticket $ticket;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']);
$this->hidden = Project::factory()->create(['key' => 'HIDE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
app(InitializeProjectTicketMetadata::class)->handle($this->hidden);
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]);
$this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]);
$this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']);
}
public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void
{
foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) {
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc());
}
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']);
}
#[DataProvider('invalidSelectors')]
public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void
{
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors();
}
public static function invalidSelectors(): array
{
return [
[[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']],
[['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]],
];
}
public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void
{
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.id', $this->ticket->id)->count('data', 1)
->where('data.0.status.id', $this->ticket->ticket_status_id)
->missing('data.0.description')->missing('data.0.project')
->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id,
'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null,
])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc());
}
public function test_search_returns_tickets_without_a_status(): void
{
$this->ticket->update(['ticket_status_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)
->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc());
}
public function test_literal_search_does_not_expand_sql_wildcards(): void
{
$this->ticket->update(['title' => 'A 50%_! literal']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
}
public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void
{
$this->ticket->update(['assignee_user_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc());
$team = Team::factory()->create();
$team->users()->attach($this->user);
$this->syncProjectPermissions($this->hidden, $team, ['view']);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc());
$global = User::factory()->create();
$this->syncGlobalPermissions($global, ['admin']);
KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc());
KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc());
}
public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void
{
$first = $this->project->ticketStatuses()->firstOrFail();
$column = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]);
TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]);
$first->allowedTargetStatuses()->sync([$second->id]);
$first->update(['name' => 'Fresh workflow']);
$otherViewer = User::factory()->create();
$this->syncProjectPermissions($this->project, $otherViewer, ['view']);
User::factory()->create();
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.project.id', $this->project->id)->missing('data.project.template_state')
->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false)
->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow')
->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id])
->where('data.ticket_statuses.0.board_column_id', $first->board_column_id)
->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position')
->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar')
->where('meta.people.total', 2)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
}
public function test_numeric_project_keys_and_configured_column_order_are_supported(): void
{
$this->project->update(['key' => '42']);
$this->ticket->update(['ticket_key' => '42-1']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc());
$later = BoardColumn::factory()->for($this->project)->create(['position' => 2]);
$earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]);
$earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.board_columns.0.id', $earlier->id)
->where('data.ticket_statuses.1.id', $earlierStatus->id)
->where('data.ticket_statuses.2.id', $laterStatus->id)->etc());
}
public function test_pagination_limits_and_missing_projects_are_validated(): void
{
foreach ([ListProjects::class, SearchTickets::class] as $tool) {
KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors();
KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors();
}
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors();
}
public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->withApplicationKey($this->user);
$response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk();
$tools = collect($response->json('result.tools'))->keyBy('name');
foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) {
$this->assertTrue($tools[$name]['annotations']['readOnlyHint']);
}
$this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']);
$this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']);
$this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]);
$ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket');
$this->assertSame('Refined via MCP', $ticket['title']);
$this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]);
$target = TicketStatus::factory()->for($this->project)->create(['position' => 2]);
$this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]);
$this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]);
$this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id);
}
private function rpc(string $name, array $arguments): TestResponse
{
return $this->postJson('/mcp/kanbangit', [
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments],
])->assertOk()->assertJsonPath('result.isError', false);
}
private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}
-235
View File
@@ -1,235 +0,0 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\SyncProjectTicketFieldValues;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Mcp\Server\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;
class UpdateTicketTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Ticket $ticket;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
$this->ticket = $this->newTicket(['description' => 'Original', 'story_points' => 5, 'author_user_id' => $this->user->id]);
AcceptanceCriteriaChecklistMapper::syncTicket($this->ticket, [
['text' => 'First', 'checked' => false, 'indent' => 0],
['text' => 'Second', 'checked' => true, 'indent' => 0],
]);
}
public function test_partial_update_and_no_op_preserve_checklist_ids_and_unrelated_fields(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.description', 'Original')->where('data.ticket.story_points', 5)
->where('data.ticket.acceptance_criteria', $before)->where('meta.updated', true)->etc());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->assertSame('Refined', $this->ticket->fresh()->title);
}
public function test_checklist_replacement_adds_reorders_indents_checks_and_removes_by_identity(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$replacement = [
['text' => 'New root', 'checked' => false, 'indent' => 0],
['id' => $before[1]['id'], 'text' => 'Second edited', 'checked' => false, 'indent' => 1],
['id' => $before[0]['id'], 'text' => 'First', 'checked' => true, 'indent' => 0],
];
$this->updateTicket(['acceptance_criteria' => $replacement])->assertOk();
$after = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->assertNotContains($after[0]['id'], array_column($before, 'id'));
$this->assertSame($replacement[1], ['id' => $after[1]['id'], 'text' => $after[1]['text'], 'checked' => $after[1]['checked'], 'indent' => $after[1]['indent']]);
$this->assertSame($before[0]['id'], $after[2]['id']);
$this->updateTicket(['acceptance_criteria' => $after])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->updateTicket(['acceptance_criteria' => [$after[2]]])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-1'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data.ticket.acceptance_criteria', 1)->where('data.ticket.acceptance_criteria.0.id', $before[0]['id'])->etc());
$this->updateTicket(['acceptance_criteria' => []])->assertOk();
$this->assertSame(0, $this->ticket->acceptanceCriteria()->count());
}
public function test_scalar_null_and_custom_field_replacement_have_explicit_clear_semantics(): void
{
$number = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$person = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
$this->updateTicket(['field_values' => [
['ticket_field_definition_id' => $number->id, 'value_number' => 0],
['ticket_field_definition_id' => $person->id, 'value_user_id' => $this->user->id],
]])->assertOk();
$this->updateTicket(['description' => null, 'story_points' => null, 'assignee_user_id' => null, 'author_user_id' => null])->assertOk();
$this->assertSame(2, $this->ticket->fieldValues()->count());
$this->assertNull($this->ticket->fresh()->description);
$this->assertNull($this->ticket->fresh()->story_points);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $number->id, 'value_number' => 3]]])->assertOk();
$this->assertSame(1, $this->ticket->fieldValues()->count());
$this->updateTicket(['field_values' => []])->assertOk();
$this->assertSame(0, $this->ticket->fieldValues()->count());
}
#[DataProvider('invalidInputs')]
public function test_invalid_payload_does_not_partially_change_ticket(array $input): void
{
$before = $this->ticket->fresh()->getAttributes();
$this->updateTicket(['title' => 'Must not change', ...$input])->assertHasErrors();
$this->assertSame($before, $this->ticket->fresh()->getAttributes());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
}
public static function invalidInputs(): array
{
return [
'null title' => [['title' => null]],
'null type' => [['ticket_type_id' => null]],
'null priority' => [['ticket_priority_id' => null]],
'null blocked state' => [['is_blocked' => null]],
'invalid criterion text' => [['acceptance_criteria' => [['text' => []]]]],
'null criteria' => [['acceptance_criteria' => null]],
'null fields' => [['field_values' => null]],
'invalid criterion' => [['acceptance_criteria' => [['id' => 99999, 'text' => 'Foreign']]]],
'blank criterion' => [['acceptance_criteria' => [['text' => ' ']]]],
'skipped indent' => [['acceptance_criteria' => [['text' => 'Child', 'indent' => 1]]]],
'negative points' => [['story_points' => -1]],
'missing blocker' => [['is_blocked' => true]],
'status not editable' => [['ticket_status_id' => 999]],
];
}
public function test_foreign_and_duplicate_criterion_ids_are_rejected_atomically(): void
{
$other = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
AcceptanceCriteriaChecklistMapper::syncTicket($other, [['text' => 'Other', 'checked' => false, 'indent' => 0]]);
$foreign = $other->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['title' => 'Rejected', 'acceptance_criteria' => [['id' => $foreign, 'text' => 'Steal']]])->assertHasErrors();
$id = $this->ticket->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['acceptance_criteria' => [['id' => $id, 'text' => 'One'], ['id' => $id, 'text' => 'Two']]])->assertHasErrors();
$this->assertSame('Original', $this->ticket->fresh()->description);
$this->assertSame('Other', $other->acceptanceCriteria()->firstOrFail()->text);
}
public function test_project_read_and_write_permissions_and_author_rules_are_enforced(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->updateTicket(['title' => 'Allowed'])->assertOk();
$this->updateTicket(['author_user_id' => null])->assertHasErrors(['Only project admins']);
$this->syncProjectPermissions($this->project, $this->user, []);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->assertSame('Allowed', $this->ticket->fresh()->title);
}
public function test_invalid_people_foreign_metadata_custom_types_and_parent_relations_are_rejected(): void
{
$hiddenUser = User::factory()->create();
$this->updateTicket(['assignee_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$this->updateTicket(['author_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$this->updateTicket(['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id])->assertHasErrors();
$this->updateTicket(['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id])->assertHasErrors();
$foreignTicket = Ticket::factory()->for($otherProject)->create();
$this->updateTicket(['parent_ticket_id' => $foreignTicket->id])->assertHasErrors();
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_user_id' => $hiddenUser->id]]])->assertHasErrors(['person']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 2]]])->assertHasErrors(['field type']);
$this->updateTicket(['parent_ticket_id' => $this->ticket->id])->assertHasErrors(['own parent']);
$task = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['parent-enabled']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$this->updateTicket(['ticket_type_id' => $parentType->id])->assertOk();
$task->update(['parent_ticket_id' => $this->ticket->id, 'ticket_type_id' => $parentType->id]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['descendants']);
$this->updateTicket(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id])->assertHasErrors(['child tickets']);
}
public function test_all_editable_scalar_fields_accept_valid_project_values(): void
{
$person = User::factory()->create();
$this->syncProjectPermissions($this->project, $person, ['view']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$parent = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2, 'ticket_type_id' => $parentType->id]);
$priority = TicketPriority::factory()->for($this->project)->create(['position' => 2]);
$input = [
'title' => 'All scalar fields', 'description' => 'Updated description',
'ticket_type_id' => $parentType->id, 'ticket_priority_id' => $priority->id,
'assignee_user_id' => $person->id, 'author_user_id' => $person->id,
'parent_ticket_id' => $parent->id, 'story_points' => 0,
'is_blocked' => true, 'blocked_comment' => 'Dependency',
];
$this->updateTicket($input)->assertOk();
foreach ($input as $field => $value) {
$this->assertSame($value, $this->ticket->fresh()->getAttribute($field));
}
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->updateTicket(['parent_ticket_id' => null])->assertOk();
$this->assertNull($this->ticket->fresh()->parent_ticket_id);
}
public function test_blocker_validation_uses_preserved_state(): void
{
$this->updateTicket(['is_blocked' => true, 'blocked_comment' => 'Waiting'])->assertOk();
$this->updateTicket(['title' => 'Still blocked'])->assertOk();
$this->updateTicket(['blocked_comment' => null])->assertHasErrors(['blocker comment']);
$this->updateTicket(['is_blocked' => false])->assertOk();
$this->assertNull($this->ticket->fresh()->blocked_comment);
}
public function test_action_failure_rolls_back_scalars_checklist_and_worklog(): void
{
$this->mock(SyncProjectTicketFieldValues::class)->shouldReceive('handle')->once()->andThrow(new RuntimeException('Sync failed'));
try {
$this->updateTicket(['title' => 'Rollback', 'acceptance_criteria' => []]);
$this->fail('Expected action failure.');
} catch (RuntimeException $exception) {
$this->assertSame('Sync failed', $exception->getMessage());
}
$this->assertNotSame('Rollback', $this->ticket->fresh()->title);
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
}
private function updateTicket(array $input): TestResponse
{
return KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $this->ticket->id, ...$input]);
}
private function newTicket(array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}
@@ -805,9 +805,6 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('board_columns', [
'id' => $boardColumn->id,
]);
$this->assertDatabaseMissing('ticket_statuses', [
'id' => $ticketStatus->id,
]);
$this->assertDatabaseMissing('tickets', [
'id' => $ticket->id,
]);
@@ -820,75 +817,6 @@ class ProjectControllerTest extends TestCase
]);
}
public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$project = Project::query()->where('key', 'CORE')->firstOrFail();
$baseTemplateId = $project->project_template_id;
$this->assertTrue($user->can('admin-project', $project));
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
$this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'New Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$this->assertDatabaseHas('projects', [
'key' => 'CORE',
'name' => 'New Core Platform',
]);
}
public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$otherProject = Project::factory()->create(['key' => 'OTHER']);
$template = ProjectTemplate::factory()->create([
'source_project_id' => $project->id,
'is_published' => true,
]);
$project->update(['project_template_id' => $template->id]);
$otherProject->update(['project_template_id' => $template->id]);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseHas('project_templates', [
'id' => $template->id,
'source_project_id' => null,
]);
$this->assertDatabaseHas('projects', [
'id' => $otherProject->id,
'project_template_id' => $template->id,
]);
}
/**
* Ensure project deletion requires the exact project key confirmation.
*/
@@ -913,18 +841,16 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure users without project administration cannot delete projects.
* Ensure non-admin users cannot delete projects.
*/
public function test_project_readers_cannot_delete_projects(): void
public function test_non_admin_users_cannot_delete_projects(): void
{
$user = User::factory()->create();
$otherProject = Project::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
$this->syncProjectPermissions($otherProject, $user, ['admin']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
@@ -83,74 +83,6 @@ class ProjectTicketLinkControllerTest extends TestCase
]);
}
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$blockingTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$blockedTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'counterpart',
])
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
->assertSessionHas('status', 'Blocked by link was saved.');
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockingTicket->id,
'target_ticket_id' => $blockedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockedTicket->id,
'summary' => 'Linked CORE-1 as Blocked by.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockingTicket->id,
'summary' => 'Linked CORE-2 as Blocks.',
]);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'source',
])
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertSame(1, TicketLink::query()->count());
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockedTicket->id,
'target_ticket_id' => $blockingTicket->id,
]);
}
/**
* Ensure ticket links cannot target the current ticket or a ticket from another project.
*/
@@ -212,15 +144,6 @@ class ProjectTicketLinkControllerTest extends TestCase
])
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('linked_ticket_id');
$this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket]))
->post(route('projects.tickets.links.store', [$project, $ticket]), [
'linked_ticket_id' => $foreignTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'sideways',
])
->assertSessionHasErrors('link_direction');
}
/**
+3 -60
View File
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h, reactive, ref } from 'vue';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
@@ -398,11 +398,6 @@ if (serve) {
} else {
params.delete('noLinks');
params.delete('linkRejected');
if (data.link_direction === 'counterpart') {
params.set('incoming', '1');
} else {
params.delete('incoming');
}
}
} else {
assert.ok(
@@ -463,40 +458,6 @@ if (serve) {
);
} else {
try {
const { useTicketLinkActions } = await server.ssrLoadModule(
'/resources/js/composables/useTicketLinkActions.ts',
);
const linkTypes = ref([
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
]);
const linkForm = reactive({
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
const { actions, selectedAction, selectedActionLabel } =
useTicketLinkActions(linkTypes, linkForm);
assert.deepEqual(
actions.value.map((action) => action.label),
['Blocks', 'Blocked by', 'Relates to'],
);
assert.equal(selectedActionLabel.value, 'Blocks');
selectedAction.value = '1:counterpart';
assert.equal(selectedActionLabel.value, 'Blocked by');
assert.deepEqual(
{ ...linkForm },
{
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
},
);
selectedAction.value = '2:source';
assert.equal(linkForm.ticket_link_type_id, 2);
assert.equal(linkForm.link_direction, 'source');
selectedAction.value = 'unknown';
assert.equal(selectedActionLabel.value, 'Relates to');
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
@@ -525,24 +486,6 @@ if (serve) {
return result.body.split('id="main-content"')[1];
};
const html = await render();
assert.ok(!html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by'));
const linkFields = [
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
].find(([fieldset]) =>
fieldset.includes('id="ticket_link_type_id"'),
)?.[0];
assert.ok(linkFields);
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
assert.match(
linkFields,
/<option value="1:source"[^>]*>Blocks<\/option>/,
);
assert.match(
linkFields,
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
);
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
for (const text of [
ticket.title,
'Keep every existing ticket action available.',
@@ -620,9 +563,9 @@ if (serve) {
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
);
assert.match(html, /Save link/);
assert.doesNotMatch(html, /Outgoing/);
assert.match(html, /Outgoing/);
const incoming = await render('?incoming');
assert.doesNotMatch(incoming, /Incoming/);
assert.match(incoming, /Incoming/);
assert.match(incoming, /Blocked by/);
assert.match(await render('?noTypes'), /No link types are configured/);
assert.match(
@@ -41,6 +41,8 @@ try {
'Platform delivery',
'Child tickets',
'Nested child with a long title that must wrap on mobile',
'Incoming',
'Blocked by',
]) {
assert.equal(
await section()
@@ -50,13 +52,6 @@ try {
text,
);
}
assert.equal(
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.isVisible(),
true,
);
const collapse = section().getByRole('button', {
name: 'Collapse children of ENG-41',
});
@@ -73,7 +68,7 @@ try {
);
await page.keyboard.press('Space');
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Action').focus();
await control('Link type').focus();
await page.keyboard.press('Tab');
assert.equal(
await control('Linked ticket').evaluate(
@@ -123,13 +118,9 @@ try {
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41,
});
await section()
.locator('[data-ticket-link]')
.getByText('Blocks', { exact: true })
.waitFor();
await section().getByText('Outgoing', { exact: true }).waitFor();
await page.waitForFunction(
() => document.querySelector('#linked_ticket_id')?.value === '',
);
@@ -150,27 +141,6 @@ try {
document.activeElement?.textContent.includes('Linked tickets'),
);
await visit('?noLinks');
await control('Action').selectOption('1:counterpart');
await control('Linked ticket').selectOption('41');
await section()
.locator('p[aria-live="polite"]')
.getByText('Blocked by', { exact: true })
.waitFor();
request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
});
await section()
.locator('[data-ticket-link]')
.getByText('Blocked by', { exact: true })
.waitFor();
await visit('?rejectLink');
await control('Linked ticket').selectOption('41');
await save().click();
@@ -178,7 +148,7 @@ try {
.getByText('Choose another ticket.', { exact: true })
.waitFor();
assert.equal(await control('Linked ticket').inputValue(), '41');
for (const label of ['Action', 'Linked ticket']) {
for (const label of ['Link type', 'Linked ticket']) {
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
}
await page.route('**/tickets/42/links', (route) => route.abort('failed'));