Compare commits

...
Author SHA1 Message Date
Twyco 1291a0fcad fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-15 11:38:17 +02:00
Twyco 5769d84526 Merge pull request 'Fix/ios unsaved form navigation' (#180) from fix/ios-unsaved-form-navigation into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m47s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m24s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 26s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m9s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 22s
Reviewed-on: #180
2026-10-15 07:55:42 +00:00
Twyco 99264c7bc8 fix: pnpm packages
CI/CD / preconditions (pull_request) Successful in 4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 2m55s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m16s
2026-10-15 09:50:04 +02:00
Twyco 1b448e363c fix: restore iOS navigation with unsaved forms 2026-10-15 09:46:19 +02:00
Twyco 65e9df6d6d fix: pnpm packages
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m34s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m49s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 2m15s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Canceled after 31s
2026-10-15 09:22:09 +02:00
Twyco 7d7b723f69 Merge pull request 'fix: restore macOS navigation with unsaved forms' (#179) from fix/macos-unsaved-form-navigation into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m18s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 28s
CI/CD / test_and_check_app (push) Failing after 19s
CI/CD / preconditions (push) Successful in 3s
CI/CD / Publish immutable image (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m14s
Reviewed-on: #179
2026-10-15 07:06:48 +00:00
Twyco 7a656de7ce fix: restore macOS navigation with unsaved forms 2026-10-15 07:06:48 +00:00
Twyco 811dbf7fa4 Merge pull request 'KAN-73: Explain and validate MCP ticket description format' (#178) from feature/KAN-73-mcp-description-contract into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 1m26s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #178
2026-10-15 07:06:05 +00:00
Twyco 5567eea06f KAN-73: document and validate MCP ticket description format 2026-10-15 07:06:05 +00:00
Twyco ca1f243234 Merge pull request 'KAN-72: Complete MCP ticket editing and discovery' (#177) from feature/KAN-72-complete-mcp-ticket-editing into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 1m46s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #177
2026-10-15 07:04:52 +00:00
Twyco 9ed8d0c6cc feat(KAN-72): complete MCP ticket editing and discovery
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-02 13:34:00 +02:00
Twyco ba12e65e81 Merge pull request 'Fix KAN-64 project deletion on MariaDB' (#176) from fix/KAN-64-project-delete-response into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 10s
Reviewed-on: #176
2026-10-02 10:17:19 +00:00
Twyco 85b9409e3c Fix project deletion with linked ticket statuses on MariaDB 2026-10-02 10:17:19 +00:00
Twyco f862a6a226 Merge pull request 'KAN-12: Simplify links to action and ticket' (#175) from feature/KAN-12-simplify-link-actions into main
CI/CD / Publish immutable image (push) Successful in 14s
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 8s
Reviewed-on: #175
2026-10-01 20:36:03 +00:00
Twyco c8f494fe7c feat: simplify ticket links to action and ticket (KAN-12)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m2s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 22:32:02 +02:00
Twyco f7c3873c19 Merge pull request 'KAN-68: Add WYSIWYG ticket description editor' (#172) from feature/KAN-68-wysiwyg-description-editor into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m10s
CI/CD / test_and_check_app (push) Successful in 8s
CI/CD / Publish immutable image (push) Successful in 40s
Reviewed-on: #172
2026-10-01 19:22:22 +00:00
Twyco d8e1659f4b feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 19:21:22 +00:00
Twyco 704fe5d241 fix
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 11s
2026-10-01 21:15:43 +02:00
Twyco 73f6ab6918 fix
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 55s
CI/CD / Publish immutable image (push) Skipped
2026-10-01 21:03:01 +02:00
Twyco 6d4a823fe5 fix
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m9s
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
2026-10-01 20:45:34 +02:00
Twyco 6054690607 Merge pull request 'KAN-64: Allow project admins to delete projects' (#171) from fix/KAN-64-cant-delete-project into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 54s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #171
2026-10-01 15:16:03 +00:00
Twyco 723285f820 fix: allow project admins to delete projects (KAN-64) 2026-10-01 15:16:03 +00:00
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
Twyco f4434a3203 Merge pull request 'KAN-70: Integrate native workspace and update settings' (#170) from feature/KAN-70-native-app-workspace-settings into main
Native JavaScript checks / Native launcher and release tests (push) Successful in 59s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m30s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m57s
CI/CD / preconditions (push) Successful in 1s
CI/CD / Publish immutable image (push) Successful in 13s
Reviewed-on: #170
2026-09-29 15:24:44 +00:00
Twyco 912cfc28c2 feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 17:11:10 +02:00
150 changed files with 8414 additions and 722 deletions
+12 -72
View File
@@ -1,78 +1,18 @@
name: "Lint and Test APP"
description: "Run Pint and the Pest test suite"
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- 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
- name: Verify Composer lockfile
- name: Test and check app
shell: bash
run: |
if [ ! -f composer.lock ]; then
echo "::error::composer.lock not found"
exit 1
fi
shell: bash
set -Eeuo pipefail
- 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
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
'position' => $index + 1,
])->saveQuietly();
});
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
}
private function statusContextLabel(TicketStatus $ticketStatus): string
@@ -2,6 +2,7 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn;
use App\Models\Project;
use Illuminate\Support\Collection;
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
'position' => $index + 1,
])->saveQuietly();
});
$project = $columns->first()?->project;
if ($project !== null) {
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
}
}
}
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateTicketStatusTransitions
{
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
*/
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
{
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
->unique()
->values()
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
return $ticketStatus->fresh();
return $ticketStatus->fresh();
}, 3);
}
}
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTicketMetadataSettings
{
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
*/
public function handle(Project $project, array $validated): Project
{
$project->update([
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return DB::transaction(function () use ($project, $validated): Project {
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->update([
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh();
return $project->fresh();
}, 3);
}
}
+2
View File
@@ -36,6 +36,7 @@ class CreateProject
if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query()
->published()
->lockForUpdate()
->findOrFail((int) $sourceTemplateId);
$project->forceFill([
@@ -72,6 +73,7 @@ class CreateProject
$this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [],
adoptExisting: true,
);
}
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
{
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
$slug = strtolower($project->key).'-base-template';
$suffix = 1;
$candidate = $slug;
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
$candidate = $slug.'-'.++$suffix;
}
return ProjectTemplate::query()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template',
'slug' => $candidate,
'is_published' => false,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
+43 -10
View File
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {}
/** @param array<string, mixed> $attributes */
public function empty(array $attributes): ProjectTemplate
{
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([
'name' => Arr::get($attributes, 'name'),
'slug' => Arr::get($attributes, 'slug'),
'is_published' => true,
'copy_areas' => $areas,
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
))->only($areas)->all(),
]);
}
/**
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
{
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
$template = $sourceProject->projectTemplate;
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
$template = new ProjectTemplate;
$template->slug = Arr::get($attributes, 'slug');
}
$template->forceFill([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => Arr::get($attributes, 'name'),
'is_published' => true,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
])->save();
$sourceProject->forceFill([
'project_template_id' => $template->id,
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
])->saveQuietly();
foreach ($sourceProject->configurationRelations() as $relation) {
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
}
return ProjectTemplate::query()->create([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
return $template;
}, 3);
}
}
+8 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
class DeleteProject
{
@@ -10,7 +11,13 @@ class DeleteProject
{
$projectName = $project->name;
$project->delete();
DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first();
$project->ticketStatuses()->delete();
$project->delete();
$baseTemplate?->delete();
});
return $projectName;
}
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Projects;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class DeleteProjectTemplate
{
public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(ProjectTemplate $template): void
{
DB::transaction(function () use ($template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
$template->delete();
foreach ($projects as $project) {
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
foreach ($project->configurationRelations() as $relation) {
$project->{$relation}()->update(['project_template_id' => $base->id]);
}
}
}, 3);
}
}
@@ -2,9 +2,13 @@
namespace App\Actions\Projects;
use App\Actions\Notifications\NormalizeNotificationFilters;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class DuplicateProjectConfiguration
{
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
) {}
/** @return list<string> */
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
return $configuration;
return $this->projectConfigurationIdentity->normalize($configuration);
}
/** @param list<string> $copyAreas @return array<string, mixed> */
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
];
}
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index],
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column): array => [$column->id => $column->configuration_key],
);
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index],
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status): array => [$status->id => $status->configuration_key],
);
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
)->all();
}
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'configuration_key' => $status->configuration_key,
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
'name' => $status->name,
'position' => $status->position,
],
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?int => $statusIndexes->get($id),
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
static fn ($status): array => [
'status_key' => $status->configuration_key,
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?string => $statusKeys->get($id),
)->filter()->sort()->values()->all(),
],
)->all();
}
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [],
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
],
];
$relations = [
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
)->all();
}
}
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
/**
* @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values
* @return list<string> Areas which retain referenced project configuration.
*/
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
{
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
}, 3);
}
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
{
$configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
$blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
}
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels(
$targetProject->boardColumns()->get(),
$configuration[self::COPY_BOARD_COLUMNS],
fn (array $item) => $targetProject->boardColumns()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
);
if ($blocked) {
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
if (! $statusesCanChange) {
return false;
}
$next = $nextStatuses->get($status->configuration_key);
$nextColumn = $next['board_column_key'] ?? null;
return $next === null || ($nextColumn !== $column->configuration_key
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
},
);
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
$canDeleteColumn, $adoptExisting)) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
}
}
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values();
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$columnIndex = $item['board_column_index'] ?? null;
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
unset($item['board_column_index']);
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
$columnKey = $item['board_column_key'] ?? null;
$item['board_column_id'] = $columnKey === null
? null
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
$item['slug'] = Str::slug($item['name']);
unset($item['board_column_key']);
return $item;
})->all();
$blocked = $this->syncModels(
$targetProject->ticketStatuses()->get(),
$items,
fn (array $item) => $targetProject->ticketStatuses()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
function (Model $status) use ($statusReplacements): bool {
if (! $status->tickets()->exists()) {
return true;
}
$replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementId === null) {
return false;
}
$status->tickets()->update(['ticket_status_id' => $replacementId]);
return true;
},
);
if ($blocked) {
$templateKeys = array_column($items, 'configuration_key');
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|| array_key_exists($status->id, $statusReplacements);
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
$replacement = $statusReplacements[$status->id] ?? null;
if ($replacement === null) {
return;
}
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
->where('configuration_key', (string) $replacement)->first();
if ($replacementStatus === null && is_numeric($replacement)) {
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
}
if ($replacementStatus === null) {
throw ValidationException::withMessages([
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
]);
}
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
};
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
$blockedAreas[] = self::COPY_TICKET_STATUSES;
}
}
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($index);
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
$status = $statuses->get($transition['status_key']);
if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
static fn (string $key): ?int => $statuses->get($key)?->id,
)->filter()->all());
}
}
}
$relations = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
$guards = [
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
self::COPY_REPOSITORY_CONNECTIONS => null,
self::COPY_NOTIFICATION_ACTIONS => null,
];
foreach ($relations as $area => [$relation, $canDelete]) {
foreach ($guards as $area => $canDelete) {
if (! isset($configuration[$area])) {
continue;
}
$blocked = $this->syncModels(
$targetProject->{$relation}()->get(),
$configuration[$area],
fn (array $item) => $targetProject->{$relation}()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
$canDelete,
);
if ($blocked) {
$items = $configuration[$area];
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
$items = array_map(fn (array $item): array => [
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
], $items);
}
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
$blockedAreas[] = $area;
}
}
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
}
return array_values(array_unique($blockedAreas));
return $blockedAreas;
});
foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
}
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
/** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
}
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
public function mapNotificationFilters(array $filters, array $mapping): array
{
return array_map(static function (array $filter) use ($mapping): array {
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
return $filter;
}
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
$filter['value'] = is_array($filter['value'])
? array_map($map, $filter['value'])
: ($filter['value'] === null ? null : $map($filter['value']));
return $filter;
}, app(NormalizeNotificationFilters::class)->handle($filters));
}
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
{
$statuses = $project->ticketStatuses()->get();
$targets = $statuses->whereIn('configuration_key', $templateKeys);
$mapping = [];
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true)) {
$mapping[$status->id] = $status->id;
continue;
}
$target = $replacements[$status->id] ?? null;
if (is_scalar($target)) {
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
}
}
$mapping[$removed->id] = $replacement->id;
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
->orWhere('to_ticket_status_id', $removed->id)->get();
foreach ($edges as $edge) {
$from = $mapping[$edge->from_ticket_status_id] ?? null;
$to = $mapping[$edge->to_ticket_status_id] ?? null;
if ($from !== null && $to !== null && $from !== $to) {
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
}
}
}
/**
* @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
* @param list<array<string, mixed>> $items
*/
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
{
foreach ($items as $index => $item) {
$model = $models->get($index);
$model === null ? $create($item) : $update($model, $item);
$relation = ProjectConfigurationIdentity::relations()[$area];
$models = $project->{$relation}()->get();
$byKey = $models->keyBy('configuration_key');
$matched = [];
$assignments = [];
foreach ($items as $item) {
$model = $byKey->get($item['configuration_key']);
if ($model === null && $adoptExisting) {
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
$model = $candidates->count() === 1 ? $candidates->first() : null;
}
if ($model !== null) {
$matched[] = $model->id;
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
return true;
}
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
&& $model->tickets()->whereHas('childTickets')->exists()) {
return true;
}
}
$assignments[] = [$model, $item];
}
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
foreach ($removed as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
return true;
}
}
$blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
$blocked = true;
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
foreach ($models as $model) {
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
}
}
continue;
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
foreach ($models->values() as $index => $model) {
$model->forceFill(['position' => $offset + $index])->saveQuietly();
}
}
foreach ($assignments as [$model, $item]) {
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
}
foreach ($removed as $model) {
if ($beforeDelete !== null) {
$beforeDelete($model);
}
$model->deleteQuietly();
}
return $blocked;
return false;
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Actions\Projects;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
class ProjectConfigurationIdentity
{
/** @return array<string, string> */
public static function relations(): array
{
return [
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
];
}
/** @param array<string, mixed> $configuration @return array<string, mixed> */
public function normalize(array $configuration): array
{
foreach (self::relations() as $area => $relation) {
if (! array_key_exists($area, $configuration)) {
continue;
}
foreach ($configuration[$area] as $index => &$item) {
$item['configuration_key'] ??= (string) Uuid::uuid5(
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
);
}
unset($item);
}
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
if (! array_key_exists('board_column_key', $status)) {
$columnIndex = $status['board_column_index'] ?? null;
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
}
unset($status['board_column_index']);
$configuration['ticket_statuses'][$index] = $status;
}
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
if (array_key_exists('status_key', $transition)) {
continue;
}
if (! isset($statuses[$index])) {
continue;
}
$configuration['status_transitions'][$index] = [
'status_key' => $statuses[$index],
'target_keys' => array_values(array_filter(array_map(
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
))),
];
}
return $configuration;
}
/** @param array<string, mixed> $item */
public function signature(string $area, array $item): string
{
return $area === 'repository_connections'
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
: (string) ($item['name'] ?? $item['label'] ?? '');
}
/** @return array<string, mixed> */
public function defaults(): array
{
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
$configuration['ticket_statuses'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
'position' => 1, 'board_column_key' => null,
]];
$configuration['ticket_types'] = [
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
];
$configuration['ticket_priorities'] = [[
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
]];
$configuration['status_transitions'] = [[
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
]];
return $configuration;
}
}
@@ -3,6 +3,7 @@
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
class ProjectTemplateOverrideManager
{
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
return;
}
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$overrides = array_values(array_unique([
...($project->template_overrides ?? []),
$area,
]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->load('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$project->forceFill(['template_overrides' => array_values(array_unique([
...($project->template_overrides ?? []), $area,
]))])->saveQuietly();
}, 3);
}
/**
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
*/
public function clear(Project $project, array $areas): void
{
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
DB::transaction(function () use ($project, $areas): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
}, 3);
}
public function withoutRecording(callable $callback): mixed
@@ -3,6 +3,8 @@
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class RefreshProjectBaseTemplate
{
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
public function handle(Project $project, string $area): void
{
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
$project->projectTemplate->configuration_snapshot ?? [],
);
$project->projectTemplate->forceFill([
'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
DB::transaction(function () use ($project, $area): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$template = ProjectTemplate::query()->find($project->project_template_id);
if ($template === null || $template->is_published) {
return;
}
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$template->forceFill(['configuration_snapshot' => array_replace(
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
)])->saveQuietly();
}, 3);
}
}
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'effectiveConfiguration' => $currentConfiguration,
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published,
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
return [];
}
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
if (! is_array($templateStatuses)) {
return [];
}
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$replacementOptions = $statuses->take(count($templateStatuses))->map(
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
$templateKeys = array_column($templateStatuses, 'configuration_key');
$replacementOptions = collect($templateStatuses)->map(
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
)->all();
$removedStatuses = $statuses->slice(count($templateStatuses))
->filter(static fn ($status): bool => $status->tickets_count > 0)
->map(static fn ($status): array => [
'id' => $status->id,
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
$removedStatuses = $statuses
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
->values()->all();
return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
+22 -25
View File
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject');
$sourceProject = $projectTemplate->sourceProject;
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()
->all();
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
))->only($copyAreas)->all();
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()->all();
$projectTemplate->forceFill([
'name' => $attributes['name'],
'copy_areas' => $normalizedCopyAreas,
'name' => Arr::get($attributes, 'name'),
'copy_areas' => $copyAreas,
'configuration_snapshot' => $nextConfiguration,
])->save();
Project::query()
->where('project_template_id', $projectTemplate->id)
->orderBy('id')
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
$linkedProject = Project::query()
->whereKey($linkedProject->id)
->where('project_template_id', $projectTemplate->id)
->lockForUpdate()->first();
if ($linkedProject === null) {
continue;
}
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? [];
$replacementConfiguration = [];
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
}
}
});
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}, 3);
}
}
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UpdateProjectTemplateLink
{
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
* @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements
*/
public function reset(Project $project, array $areas, array $statusReplacements = []): void
public function reset(Project $project, array $areas, array $statusReplacements = []): array
{
$template = $project->projectTemplate;
if ($template === null) {
return;
return [];
}
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
))->only($areas)->all();
if ($project->project_template_id !== $template->id) {
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
}
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
$configuration = collect($baseline)->only($areas)->all();
if (in_array('ticket_statuses', $areas, true)
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
&& isset($baseline['status_transitions'])) {
$configuration['status_transitions'] = $baseline['status_transitions'];
}
$blockedAreas = $this->duplicateProjectConfiguration->apply(
$project,
$configuration,
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
$project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
return $blockedAreas;
}, 3);
}
}
+15 -12
View File
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
) {}
/**
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $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): TicketLink {
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id')
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
} else {
$ticketLink->update([
'source_ticket_id' => $ticket->id,
'target_ticket_id' => $linkedTicket->id,
'source_ticket_id' => $sourceTicket->id,
'target_ticket_id' => $targetTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId,
]);
}
$ticketLink->load('ticketLinkType');
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
$this->createTicketWorklogEntry->handle(
$ticket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
[
'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $sourceLabel,
'ticket_link_type' => $ticketLabel,
],
);
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
$linkedTicket,
$user,
TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$targetLabel}.",
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
[
'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $targetLabel,
'ticket_link_type' => $linkedTicketLabel,
],
);
@@ -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): void
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
{
$normalized = self::normalize($items);
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
: $existingCriteria->get($criterionId);
while (
$criterion === null
$reuseUnidentifiedCriteria
&& $criterion === null
&& $fallbackIndex < $fallbackCriteria->count()
) {
/** @var AcceptanceCriterion $candidate */
@@ -0,0 +1,78 @@
<?php
namespace App\Actions\Tickets\Support;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
use League\CommonMark\Parser\MarkdownParser;
use League\Uri\Uri;
class TicketDescriptionFormat
{
/** @return array<string, mixed> */
public static function contract(): array
{
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
}
public static function instructions(): string
{
$contract = self::contract();
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
." Valid description example (Markdown string):\n".$contract['example'];
}
public static function criteriaInstructions(): string
{
return self::contract()['criteria'];
}
public static function safeLink(string $value): bool
{
$contract = self::contract();
$decoded = rawurldecode($value);
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
return false;
}
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
return true;
}
$url = Uri::tryNew($decoded);
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
}
/** @return list<string> */
public static function unsupportedContent(string $value): array
{
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$document = (new MarkdownParser($environment))->parse($value);
$unsupported = [];
foreach ($document->iterator() as $node) {
$type = match (true) {
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
$node instanceof Table => 'table',
$node instanceof Image => 'image',
$node instanceof TaskListItemMarker => 'task',
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
default => null,
};
if ($type !== null) {
$unsupported[] = self::contract()['unsupported_tokens'][$type];
}
}
return array_values(array_unique($unsupported));
}
}
+6 -3
View File
@@ -51,6 +51,7 @@ class UpdateProjectTicket
Ticket $ticket,
User $actor,
array $validated,
bool $replaceCriteriaWithIds = false,
): bool {
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
@@ -58,7 +59,7 @@ class UpdateProjectTicket
);
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
if (! $ticketAttributes['is_blocked']) {
$ticketAttributes['blocked_comment'] = null;
}
@@ -91,7 +92,9 @@ class UpdateProjectTicket
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
$existingFieldValues = $ticket->fieldValues
->keyBy('ticket_field_definition_id');
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$currentAcceptanceCriteria = $replaceCriteriaWithIds
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
$changes = [];
$summaryLabels = [];
@@ -209,7 +212,7 @@ class UpdateProjectTicket
}
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
}
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
@@ -36,11 +36,12 @@ 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' => "{$ticketLink->ticketLinkType->label} link was saved.",
'message' => "{$label} link was saved.",
],
])
->response()
+5 -1
View File
@@ -161,7 +161,7 @@ class ProjectController extends Controller
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []);
$this->updateProjectTemplateLink->reset(
$blockedAreas = $this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'),
$areas,
$statusReplacements,
@@ -171,6 +171,10 @@ class ProjectController extends Controller
? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline.";
if ($blockedAreas !== []) {
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
}
return back()->with('status', $status);
}
@@ -3,9 +3,12 @@
namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DeleteProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
{
public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate,
private readonly DeleteProjectTemplate $deleteProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate,
) {}
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
]);
}
public function createEmpty(ProjectConfigurationIdentity $identity): Response
{
return Inertia::render('project-templates/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'project' => null,
'initialConfiguration' => $identity->defaults(),
]);
}
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
{
$template = $this->createProjectTemplate->empty($request->validated());
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
}
public function edit(ProjectTemplate $projectTemplate): Response
{
abort_if(! $projectTemplate->is_published, 404);
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name;
$projectTemplate->delete();
$this->deleteProjectTemplate->handle($projectTemplate);
return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted.");
@@ -34,9 +34,10 @@ 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', "{$ticketLink->ticketLinkType->label} link was saved.");
->with('status', "{$label} link was saved.");
}
public function destroy(
@@ -23,6 +23,7 @@ class RequirePasswordChange
if ($request->routeIs([
'logout',
'native-app.edit',
'password.force-change.edit',
'user-password.update',
])) {
@@ -0,0 +1,141 @@
<?php
namespace App\Http\Requests\Concerns;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
use App\Enums\NotificationFilterAttribute;
use App\Enums\NotificationFilterOperator;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
trait ValidatesProjectTemplateConfiguration
{
/** @return array<string, mixed> */
protected function configurationRules(): array
{
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
$path = 'configuration_snapshot.'.$area;
$rules[$path] = ['sometimes', 'array', 'max:200'];
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
}
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
foreach (['show_author', 'show_story_points'] as $field) {
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
}
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
if ($area !== 'ticket_statuses') {
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
}
}
$fields = [
'board_columns' => 'configuration_key,name,position',
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
'ticket_types' => 'configuration_key,name,position,is_parent',
'ticket_priorities' => 'configuration_key,name,position',
'ticket_fields' => 'configuration_key,label,position,field_type',
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
];
foreach ($fields as $area => $allowed) {
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
}
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
}
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
foreach (['base_url', 'repository_url'] as $field) {
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
}
$prefix = 'configuration_snapshot.notification_actions.*.';
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
$rules[$prefix.'enabled'] = ['required', 'boolean'];
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
$rules[$prefix.'filters.*.value'] = ['nullable'];
return $rules;
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$validated = $validator->validated();
$configuration = Arr::get($validated, 'configuration_snapshot', []);
$areas = Arr::get($validated, 'copy_areas', []);
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
$key = Arr::get($status, 'board_column_key');
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
}
}
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
}
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
&& Arr::get($transition, 'target_keys', []) !== []) {
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
}
}
$repositoryIdentifiers = [];
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
if (isset($repositoryIdentifiers[$identifier])) {
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
}
$repositoryIdentifiers[$identifier] = true;
}
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
}
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
$operator = Arr::get($filter, 'operator');
$value = Arr::get($filter, 'value');
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
}
}
}
}];
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('access-admin') ?? false;
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
}
/**
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
Rule::in([$project->key]),
],
'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
'status_replacements.*' => ['required'],
];
}
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
/** @var Project $project */
$project = $this->route('project');
$validated = $validator->validated();
$areas = array_values(Arr::get(
$validated,
'areas',
DuplicateProjectConfiguration::copyableAreas(),
));
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
foreach ($areas as $area) {
if (! array_key_exists($area, $configuration)) {
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
}
}
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return;
}
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
$replacements = Arr::get($validated, 'status_replacements', []);
foreach ($statuses->slice(count($templateStatuses)) as $status) {
if ($status->tickets_count === 0) {
foreach ($replacements as $id => $replacement) {
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
}
}
foreach ($statuses as $status) {
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
continue;
}
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
$replacement = Arr::get($replacements, (string) $status->id);
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
}
}
},
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
return ($this->user()?->can('create-project') ?? false)
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
}
protected function prepareForValidation(): void
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
*/
public function rules(): array
{
$project = $this->route('project');
$baseTemplate = $project?->projectTemplate;
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
];
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
{
use ValidatesProjectTemplateConfiguration;
public function rules(): array
{
return [...parent::rules(), ...$this->configurationRules()];
}
}
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
fn ($query) => $query->where('project_id', $project->id),
),
],
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
];
}
@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest
{
use ValidatesProjectTemplateConfiguration;
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [
...$this->configurationRules(),
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas' => ['present', 'array'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::excludeIf(! $requiresImpactConfirmation),
+2
View File
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
? null
: [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
'local_areas' => Arr::get($templateState, 'localAreas', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
+15 -3
View File
@@ -2,24 +2,32 @@
namespace App\Mcp\Servers;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
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;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')]
#[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.')]
#[Version('0.2.1')]
class KanbanGitServer extends Server
{
protected function boot(): void
{
$this->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 to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
}
protected array $tools = [
ListMyAssignedTickets::class,
ListTicketsByStatus::class,
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
MoveTicketStatus::class,
CreateTicket::class,
ListTicketTypes::class,
ListProjects::class,
GetProjectConfiguration::class,
SearchTickets::class,
UpdateTicket::class,
];
protected array $resources = [
@@ -0,0 +1,13 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
trait DescribesTicketWriting
{
public function description(): string
{
return parent::description().' '.TicketDescriptionFormat::instructions();
}
}
+24
View File
@@ -13,6 +13,7 @@ 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;
@@ -78,6 +79,17 @@ 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
*/
@@ -201,6 +213,18 @@ 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>
*/
+110
View File
@@ -0,0 +1,110 @@
<?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 App\Rules\VisualTicketDescription;
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['description'][] = new VisualTicketDescription;
$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;
}
}
+8 -3
View File
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Enums\Permission;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)]
class CreateTicket extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly CreateProjectTicket $createProjectTicket,
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
return [
'project_id' => $schema->integer()->description('Project ID.')->required(),
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
'description' => $schema->string()->nullable()->description('Ticket description.'),
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
'checked' => $schema->boolean()->default(false),
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
])->withoutAdditionalProperties())
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
'field_values' => $schema->array()
->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
+75
View File
@@ -0,0 +1,75 @@
<?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.'),
];
}
}
+8 -5
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 with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[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.')]
#[IsReadOnly]
class GetTicket extends Tool
{
@@ -25,11 +25,14 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'ticket_id' => ['required', 'integer'],
'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]*$/'],
]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
$ticket = isset($validated['ticket_id'])
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -43,8 +46,8 @@ class GetTicket extends Tool
{
return [
'ticket_id' => $schema->integer()
->description('Ticket ID.')
->required(),
->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.'),
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?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.'),
];
}
}
+3
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools;
use App\Actions\Tickets\MoveProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)]
class MoveTicketStatus extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly MoveProjectTicket $moveProjectTicket,
+83
View File
@@ -0,0 +1,83 @@
<?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.'),
];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
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
{
use DescribesTicketWriting;
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. '.TicketDescriptionFormat::instructions()),
'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. '.TicketDescriptionFormat::criteriaInstructions()),
'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.'),
];
}
}
+3
View File
@@ -3,6 +3,7 @@
namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)]
class UpdateTicketAssignee extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket,
+9 -3
View File
@@ -2,10 +2,14 @@
namespace App\Mcp\Tools;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\DescribesTicketWriting;
use App\Mcp\Support\KanbanGitMcp;
use App\Rules\VisualTicketDescription;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[IsDestructive(false)]
class UpdateTicketDescription extends Tool
{
use DescribesTicketWriting;
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket,
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'description' => ['nullable', 'string'],
'description' => ['nullable', 'string', new VisualTicketDescription],
'ticket_id' => ['required', 'integer'],
]);
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
$payload = [
...$this->kanbanGitMcp->updatePayload($ticket),
'description' => $validated['description'] ?? null,
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
];
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
return [
'description' => $schema->string()
->nullable()
->description('New ticket description. Pass null to clear it.'),
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
'ticket_id' => $schema->integer()
->description('Ticket ID.')
->required(),
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
trait SyncsProjectTemplateOwnership
{
/** @param array<string, mixed> $options */
public function save(array $options = []): bool
{
return DB::transaction(function () use ($options): bool {
$this->syncProjectTemplateOwnership();
if ($this->getAttribute('project_id') !== null) {
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
if ($project?->project_template_id !== null) {
$this->setAttribute('project_template_id', $project->project_template_id);
}
}
return parent::save($options);
}, 3);
}
public function delete(): ?bool
{
return DB::transaction(function (): ?bool {
if ($this->getAttribute('project_id') !== null) {
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
}
return parent::delete();
}, 3);
}
protected function syncProjectTemplateOwnership(): void
{
if ($this->getAttribute('configuration_key') === null) {
$this->setAttribute('configuration_key', (string) Str::uuid());
}
$projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id');
+1 -1
View File
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
#[Fillable(['configuration_key',
'channel',
'channel_configuration',
'delivery_url',
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
#[Fillable(['configuration_key',
'base_url',
'owner_name',
'project_id',
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model
{
/**
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
+1 -1
View File
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
public function deleted(Model $model): void
{
$this->markOverride($model);
if ($model instanceof TicketStatus && $model->project !== null) {
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
}
}
private function markOverride(Model $model): void
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Rules;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class VisualTicketDescription implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
if ($unsupported !== []) {
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
}
}
}
Generated
+26 -22
View File
@@ -1451,20 +1451,20 @@
},
{
"name": "laravel/framework",
"version": "v13.26.1",
"version": "v13.34.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
"shasum": ""
},
"require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
@@ -1576,18 +1576,20 @@
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1",
"orchestra/testbench-core": "^11.0.0",
"orchestra/testbench-core": "^11.5.0",
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan": "2.2.15",
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
"predis/predis": "^2.3 || ^3.0",
"rector/rector": "^2.3",
"rector/rector": "2.6.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"
"symfony/translation": "^7.4.0 || ^8.0.0",
"web-token/jwt-library": "^4.1"
},
"suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1623,8 +1625,10 @@
"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)."
"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)."
},
"type": "library",
"extra": {
@@ -1674,7 +1678,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-08-18T20:31:28+00:00"
"time": "2026-09-29T12:46:21+00:00"
},
{
"name": "laravel/mcp",
@@ -2003,16 +2007,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.0",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -2106,7 +2110,7 @@
"type": "tidelift"
}
],
"time": "2026-08-11T16:06:25+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -2192,16 +2196,16 @@
},
{
"name": "league/flysystem",
"version": "3.35.2",
"version": "3.36.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": ""
},
"require": {
@@ -2269,9 +2273,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
},
"time": "2026-07-06T14:42:07+00:00"
"time": "2026-09-02T08:00:27+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\BoardColumn;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<BoardColumn>
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->unique()->words(2, true),
'position' => 1,
'project_id' => Project::factory(),
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
use App\Models\NotificationAction;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<NotificationAction>
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'channel' => NotificationChannel::Webhook,
'channel_configuration' => [],
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
use App\Models\Project;
use App\Models\RepositoryConnection;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<RepositoryConnection>
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'base_url' => 'https://git.'.fake()->domainName(),
'owner_name' => fake()->userName(),
'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketFieldDefinition>
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
'label' => fake()->unique()->words(2, true),
'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketLinkType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketLinkType>
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
$label = fake()->unique()->words(2, true);
return [
'configuration_key' => (string) Str::uuid(),
'label' => $label,
'counterpart_label' => $label,
'position' => 1,
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketPriority;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketPriority>
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
'position' => 1,
'project_id' => Project::factory(),
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketStatus>
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'board_column_id' => null,
'name' => fake()->unique()->words(2, true),
'position' => 1,
+2
View File
@@ -5,6 +5,7 @@ namespace Database\Factories;
use App\Models\Project;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<TicketType>
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
public function definition(): array
{
return [
'configuration_key' => (string) Str::uuid(),
'is_parent' => false,
'name' => fake()->unique()->jobTitle(),
'position' => 1,
@@ -0,0 +1,165 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectConfigurationIdentity;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
private const TABLES = [
'board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities',
'ticket_field_definitions', 'ticket_link_types', 'repository_connections', 'notification_actions',
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->uuid('configuration_key')->nullable()->index();
});
DB::table($name)->orderBy('id')->chunkById(100, function ($rows) use ($name): void {
foreach ($rows as $row) {
DB::table($name)->where('id', $row->id)->update(['configuration_key' => (string) Str::uuid()]);
}
});
}
$configuration = app(DuplicateProjectConfiguration::class);
$identity = app(ProjectConfigurationIdentity::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration, $identity): void {
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
$source = $template->sourceProject;
if (! $template->is_published && $template->project !== null) {
$snapshot = $configuration->export($template->project, DuplicateProjectConfiguration::copyableAreas());
} elseif ($source !== null) {
$sourceConfiguration = $configuration->export($source, array_keys($snapshot));
$replacements = [];
$usedKeys = [];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
foreach ($snapshot[$area] ?? [] as $index => $item) {
$matches = collect($sourceConfiguration[$area] ?? [])->filter(
fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item),
);
$baselineMatches = collect($snapshot[$area])->filter(fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item));
if ($matches->count() > 1 || $baselineMatches->count() > 1) {
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['position'] ?? null) === ($item['position'] ?? null));
if ($area === 'ticket_statuses') {
$columnKey = $replacements[$item['board_column_key'] ?? ''] ?? ($item['board_column_key'] ?? null);
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['board_column_key'] ?? null) === $columnKey);
}
}
if ($matches->count() === 1 && ! in_array($matches->first()['configuration_key'], $usedKeys[$area] ?? [], true)) {
$usedKeys[$area][] = $matches->first()['configuration_key'];
$replacements[$item['configuration_key']] = $matches->first()['configuration_key'];
$snapshot[$area][$index]['configuration_key'] = $matches->first()['configuration_key'];
}
}
}
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
$snapshot['ticket_statuses'][$index]['board_column_key'] = $replacements[$status['board_column_key'] ?? ''] ?? ($status['board_column_key'] ?? null);
}
foreach ($snapshot['status_transitions'] ?? [] as $index => $transition) {
$snapshot['status_transitions'][$index] = [
'status_key' => $replacements[$transition['status_key']] ?? $transition['status_key'],
'target_keys' => array_map(static fn (string $key): string => $replacements[$key] ?? $key, $transition['target_keys']),
];
}
}
if ($source !== null) {
$statusKeys = $source->ticketStatuses()->pluck('configuration_key', 'id')->all();
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $statusKeys);
}
}
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
});
Project::query()->with('projectTemplate')->eachById(function (Project $project) use ($identity): void {
$snapshot = $project->projectTemplate?->configuration_snapshot ?? [];
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
$models = $project->{$relation}()->get();
$usedIds = [];
$columnKeys = $area === 'ticket_statuses' ? $project->boardColumns()->pluck('configuration_key', 'id')->all() : [];
foreach ($snapshot[$area] ?? [] as $item) {
$matches = $models->filter(fn ($model): bool => $identity->signature($area, $model->attributesToArray()) === $identity->signature($area, $item));
$matches = $matches->reject(static fn ($model): bool => in_array($model->id, $usedIds, true));
if ($matches->count() > 1) {
$matches = $matches->filter(static fn ($model): bool => $model->position === ($item['position'] ?? null));
if ($area === 'ticket_statuses') {
$matches = $matches->filter(static fn ($model): bool => ($columnKeys[$model->board_column_id] ?? null) === ($item['board_column_key'] ?? null));
}
}
if ($matches->count() === 1) {
$model = $matches->first();
$usedIds[] = $model->id;
DB::table($model->getTable())->where('id', $model->id)->update(['configuration_key' => $item['configuration_key']]);
}
}
}
});
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->unique(['project_id', 'configuration_key']);
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$configuration = app(DuplicateProjectConfiguration::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
$columnIndexes = array_flip(array_column($snapshot['board_columns'] ?? [], 'configuration_key'));
$statusIndexes = array_flip(array_column($snapshot['ticket_statuses'] ?? [], 'configuration_key'));
if (isset($snapshot['status_transitions'])) {
$transitions = array_fill(0, count($statusIndexes), []);
foreach ($snapshot['status_transitions'] as $transition) {
$index = $statusIndexes[$transition['status_key']] ?? null;
if ($index !== null) {
$transitions[$index] = array_values(array_filter(array_map(
static fn (string $key): ?int => $statusIndexes[$key] ?? null,
$transition['target_keys'],
), static fn (?int $index): bool => $index !== null));
}
}
$snapshot['status_transitions'] = $transitions;
}
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
$status['board_column_index'] = $columnIndexes[$status['board_column_key'] ?? ''] ?? null;
unset($status['board_column_key']);
$snapshot['ticket_statuses'][$index] = $status;
}
$sourceStatusIds = $template->sourceProject?->ticketStatuses()->pluck('id', 'configuration_key')->all() ?? [];
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $sourceStatusIds);
}
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
foreach ($snapshot[$area] ?? [] as $index => $item) {
unset($item['configuration_key']);
$snapshot[$area][$index] = $item;
}
}
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
});
foreach (self::TABLES as $name) {
Schema::table($name, function (Blueprint $table): void {
$table->dropUnique(['project_id', 'configuration_key']);
$table->dropIndex(['configuration_key']);
$table->dropColumn('configuration_key');
});
}
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
"version": "0.2.5",
"version": "0.2.8",
"private": true,
"type": "module",
"scripts": {
@@ -25,5 +25,5 @@
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
},
"releaseNotes": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
}
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.2.5"
version = "0.2.8"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.2.5"
version = "0.2.8"
description = "KanbanGit native application"
edition = "2021"
publish = false
+23
View File
@@ -9,8 +9,10 @@ fn build_navigation() {
use std::{env, path::PathBuf, process::Command};
println!("cargo:rerun-if-changed=src/navigation.m");
println!("cargo:rerun-if-changed=src/settings.m");
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let object = output.join("navigation.o");
let settings_object = output.join("settings.o");
let archive = output.join("libkanban_navigation.a");
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
"aarch64" => "arm64",
@@ -37,10 +39,31 @@ fn build_navigation() {
.status()
.expect("Xcode clang is required")
.success());
assert!(Command::new("xcrun")
.args([
"--sdk",
"macosx",
"clang",
"-fobjc-arc",
"-fblocks",
"-Wall",
"-Werror",
"-mmacosx-version-min=14.0",
"-arch",
arch,
"-c",
"src/settings.m",
"-o"
])
.arg(&settings_object)
.status()
.expect("Xcode clang is required")
.success());
assert!(Command::new("xcrun")
.args(["ar", "crs"])
.arg(&archive)
.arg(&object)
.arg(&settings_object)
.status()
.expect("Xcode ar is required")
.success());
@@ -65,3 +65,32 @@ struct WorkspaceConnection {
return true
}
}
enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
struct NativeSettingsRequest {
let id: Int
let action: String
let payload: [String: Any]
static func parse(_ body: String) throws -> NativeSettingsRequest {
guard body.utf8.count <= 8192,
let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
Set(value.keys) == Set(["version", "id", "action", "payload"]),
let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
version == 1 else { throw NativeSettingsError.incompatibleVersion }
guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
let action = value["action"] as? String,
let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
switch action {
case "getState", "settingsReady", "openSettings":
guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
case "setWorkspace":
guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
default: throw NativeSettingsError.invalidRequest
}
return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
}
}
@@ -40,7 +40,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar()
private var settingsAvailable = false
private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl!
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -49,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { javaScriptConfirmation?.completion(false) }
private func text(_ key: String) -> String {
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
}
@@ -165,7 +170,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
NSLayoutConstraint.activate([
toolbarHeight,
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
@@ -195,12 +202,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func open(_ url: URL) {
if server?.url == url, connection.ready { showWorkspace(); return }
completeJavaScriptConfirmation(false)
let selected = WorkspaceServer.selecting(url, current: server)
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey)
timeout?.cancel()
workspace?.stopLoading()
workspace?.navigationDelegate = nil
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
workspace?.removeFromSuperview()
connection = WorkspaceConnection()
server = selected
@@ -209,7 +218,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// Remote content receives no native message handlers or Tauri bridge.
let settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
setSettingsAvailable(false)
let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self
webview.uiDelegate = self
@@ -256,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
@objc private func showSettings() {
completeJavaScriptConfirmation(false)
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = false
@@ -269,10 +283,81 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = true
toolbar.isHidden = false
toolbar.isHidden = settingsAvailable
workspace?.isHidden = false
}
private func setSettingsAvailable(_ available: Bool) {
settingsAvailable = available
toolbarHeight.constant = available ? 0 : 44
toolbar.isHidden = available || !launcher.isHidden || !connection.ready
}
func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
guard message.webView === workspace, message.frameInfo.isMainFrame,
let source = message.frameInfo.request.url, server?.allows(source) == true,
let current = workspace?.url, server?.allows(current) == true,
let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
do {
let request = try NativeSettingsRequest.parse(body)
var result: Any = NSNull()
switch request.action {
case "getState":
result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
"capabilities": ["workspace"]] as [String: Any]
case "settingsReady": setSettingsAvailable(true)
case "openSettings": showSettings()
case "setWorkspace":
guard let address = request.payload["server"] as? String,
let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
let owner = workspace
DispatchQueue.main.async { [weak self] in
guard let self, self.workspace === owner else { return }
self.open(url)
}
default: throw NativeSettingsError.invalidRequest
}
let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
reply(String(decoding: data, as: UTF8.self), nil)
} catch {
reply(nil, String(describing: error))
}
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
guard webView === workspace, viewIfLoaded?.window != nil,
presentedViewController == nil, javaScriptConfirmation == nil else {
completionHandler(false)
return
}
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
message: message, preferredStyle: .alert)
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
self?.completeJavaScriptConfirmation(false)
}
alert.addAction(cancel)
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
self?.completeJavaScriptConfirmation(true)
})
alert.preferredAction = cancel
javaScriptConfirmation = (alert, completionHandler)
present(alert, animated: true)
}
func completeJavaScriptConfirmation(_ accepted: Bool) {
guard let confirmation = javaScriptConfirmation else { return }
javaScriptConfirmation = nil
if confirmation.alert.presentingViewController == nil {
confirmation.completion(accepted)
} else {
confirmation.alert.dismiss(animated: false) {
confirmation.completion(accepted)
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else {
@@ -299,6 +384,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
guard webView === workspace else { return }
setSettingsAvailable(false)
pendingNavigation = navigation
beginNavigation()
}
@@ -338,3 +424,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
showSettings()
}
}
private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
weak var controller: WorkspaceViewController?
init(controller: WorkspaceViewController) { self.controller = controller }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void) {
guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
controller.handleSettings(message, reply: replyHandler)
}
}
@@ -9,6 +9,7 @@
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
"confirmSwitch" = "Switch server";
"cancel" = "Cancel";
"ok" = "OK";
"connecting" = "Connecting to your server…";
"connected" = "Your workspace is open. Use Server settings to change servers.";
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
@@ -135,6 +135,171 @@ final class WorkspaceTests: XCTestCase {
"Changing workspaces must retain native gesture navigation")
}
@MainActor
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
let animationsEnabled = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(animationsEnabled) }
let name = "ios-confirmation-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
<a id="back" href="#back">Back</a><script>
window.visits = 0;
document.querySelector('#back').onclick = event => {
event.preventDefault();
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
};
</script></body></html>
""")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(pristineVisits as? Int, 1)
XCTAssertNil(controller.presentedViewController)
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
var expectedVisits = 1
for (index, accepted) in [false, true, false, true].enumerated() {
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
let alert = try await waitForConfirmation(controller)
XCTAssertEqual(alert.title, "example.invalid")
XCTAssertEqual(alert.message, "Discard unsaved changes?")
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
if index == 3 {
await withCheckedContinuation { continuation in
alert.dismiss(animated: false) { continuation.resume() }
}
}
controller.completeJavaScriptConfirmation(accepted)
controller.completeJavaScriptConfirmation(!accepted)
_ = try await click.value
if accepted { expectedVisits += 1 }
let visits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "Unsaved project")
XCTAssertNil(controller.presentedViewController)
}
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
controller.present(otherAlert, animated: false)
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
XCTAssertTrue(controller.presentedViewController === otherAlert)
await withCheckedContinuation { continuation in
otherAlert.dismiss(animated: false) { continuation.resume() }
}
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
_ = try await waitForConfirmation(controller)
controller.open(URL(string: "https://other.invalid/")!)
_ = try await pendingClick.value
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
let oldVisits = try await webview.evaluateJavaScript("window.visits")
XCTAssertEqual(oldVisits as? Int, expectedVisits)
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
XCTAssertNil(controller.presentedViewController)
}
@MainActor
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
for _ in 0..<100 {
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
try await Task.sleep(for: .milliseconds(50))
}
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
}
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
for body in [
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
}
@MainActor
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
let name = "KAN70-settings-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let url = URL(string: "https://example.invalid/")!
controller.open(url)
let originalProfile = controller.server!.profile
let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
XCTAssertEqual(state?["platform"] as? String, "ios")
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
XCTAssertNil(state?["updates"])
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
let rejected = try await callScript(webview, """
try {
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
return false;
} catch { return true; }
""")
XCTAssertEqual(rejected as? Bool, true)
XCTAssertFalse(toolbar.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
controller.view.layoutIfNeeded()
XCTAssertTrue(toolbar.isHidden)
XCTAssertEqual(toolbar.frame.height, 0)
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
XCTAssertEqual(invalid as? Bool, true)
XCTAssertEqual(controller.server?.url, url)
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
for _ in 0..<50 {
if controller.workspace !== webview { break }
try await Task.sleep(for: .milliseconds(20))
}
XCTAssertFalse(controller.workspace === webview)
XCTAssertNotEqual(controller.server?.profile, originalProfile)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
XCTAssertEqual(stale as? Bool, true)
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
}
@MainActor
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
try await withCheckedThrowingContinuation { continuation in
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
continuation.resume(with: result)
}
}
}
@MainActor
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
for _ in 0..<100 {
@@ -201,7 +366,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url)
let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server)
@@ -20,6 +20,7 @@
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
@@ -27,10 +28,18 @@
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
remoteInfo = "kanbangit-native_iOS";
};
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
@@ -45,12 +54,13 @@
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
@@ -63,8 +73,10 @@
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -156,6 +168,8 @@
5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */,
5F196E34D1B9E14FBD7191F4 /* settings.m */,
1CDE5C303A96423E2E6A194D /* settings.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
);
name = src;
@@ -182,7 +196,7 @@
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
isa = PBXGroup;
children = (
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
);
@@ -193,6 +207,7 @@
isa = PBXGroup;
children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
);
name = ui;
path = ../../../ui;
@@ -240,7 +255,7 @@
packageProductDependencies = (
);
productName = "kanbangit-native_iOS";
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
productType = "com.apple.product-type.application";
};
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
@@ -271,6 +286,7 @@
buildRules = (
);
dependencies = (
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
);
name = WorkspaceTests;
packageProductDependencies = (
@@ -334,6 +350,7 @@
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -342,6 +359,7 @@
buildActionMask = 2147483647;
files = (
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -408,6 +426,11 @@
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
};
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -441,7 +464,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -457,7 +480,7 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.5;
MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
@@ -618,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -634,7 +657,7 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
MARKETING_VERSION = 0.2.5;
MARKETING_VERSION = 0.2.8;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
@@ -681,6 +704,7 @@
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
};
name = debug;
};
@@ -700,6 +724,7 @@
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
};
name = release;
};
+8 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.5"
CURRENT_PROJECT_VERSION: "5"
MARKETING_VERSION: "0.2.8"
CURRENT_PROJECT_VERSION: "7"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
@@ -40,9 +40,11 @@ targets:
kanbangit-native_iOS:
type: application
platform: iOS
productName: KanbanGit
sources:
- path: Sources
- path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js
- path: Assets.xcassets
- path: kanbangit-native_iOS
- path: LaunchScreen.storyboard
@@ -114,8 +116,11 @@ targets:
WorkspaceTests:
type: bundle.unit-test
platform: iOS
dependencies:
- target: kanbangit-native_iOS
sources:
- path: ../../../ui/workspace-navigation.js
- path: ../../../ui/workspace-settings.js
- path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift
@@ -124,6 +129,7 @@ targets:
base:
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
DEVELOPMENT_TEAM: ZZX2KZGTHU
CODE_SIGN_STYLE: Manual
SWIFT_VERSION: "5.0"
+83
View File
@@ -17,6 +17,15 @@ struct Connection {
}
impl Connection {
#[cfg(target_os = "macos")]
fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
main && self.label.as_deref() == Some(label)
&& self
.server
.as_ref()
.is_some_and(|server| allows_server_navigation(&server.url, url))
}
fn begin_navigation(&mut self) -> u64 {
self.revision += 1;
self.phase = "connecting";
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
install_workspace_navigation(webview.inner().cast());
})
.map_err(|_| "connectionError")?;
#[cfg(target_os = "macos")]
crate::settings::install(app, &window).map_err(|_| "connectionError")?;
let handle = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
Ok(())
}
#[cfg(target_os = "macos")]
pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
let state = app.state::<Mutex<Connection>>();
let state = state.lock().unwrap();
state.allows_settings(label, url, main)
}
#[cfg(target_os = "macos")]
pub fn settings_action(
app: &AppHandle,
action: crate::settings::Action,
) -> Result<serde_json::Value, &'static str> {
use crate::settings::Action;
match action {
Action::GetState {} => {
let workspace = app
.state::<Mutex<Connection>>()
.lock()
.unwrap()
.server
.as_ref()
.map(|server| server.url.to_string())
.ok_or("bridgeUnavailable")?;
return Ok(
serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
"version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
"updates":crate::updates::snapshot(app)}),
);
}
Action::SettingsReady {} => {}
Action::OpenSettings {} => show_launcher(app),
Action::SetWorkspace { server } => {
if let Err(error) = open_workspace(app, &server) {
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
show_launcher(app);
return Err(error);
}
}
Action::CheckForUpdates {} => crate::updates::check_inline(app),
Action::SetUpdatePreferences {
automatic,
interval_hours,
} => crate::updates::save_preferences(app, automatic, interval_hours)?,
Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
}
Ok(serde_json::Value::Null)
}
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() {
"/action/updates" => {
@@ -464,6 +523,30 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn settings_only_accept_the_current_workspace_main_frame() {
let mut state = Connection {
server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
label: Some("workspace-1".into()),
..Default::default()
};
let url = Url::parse("https://example.com/settings/app").unwrap();
assert!(state.allows_settings("workspace-1", &url, true));
assert!(!state.allows_settings("workspace-1", &url, false));
assert!(!state.allows_settings("workspace-0", &url, true));
for address in [
"https://evil.test",
"http://example.com",
"https://example.com:444/",
"file:///tmp/page",
] {
assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
}
state.label = Some("workspace-2".into());
assert!(!state.allows_settings("workspace-1", &url, true));
}
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
+3
View File
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
#[cfg(target_os = "macos")]
mod settings;
+49
View File
@@ -1,6 +1,55 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import <objc/runtime.h>
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
@end
@implementation KanbanWorkspaceUIDelegate
- (BOOL)respondsToSelector:(SEL)selector {
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
}
- (id)forwardingTargetForSelector:(SEL)selector {
if ([self.originalDelegate respondsToSelector:selector]) {
return self.originalDelegate;
}
return [super forwardingTargetForSelector:selector];
}
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
NSWindow *window = webView.window;
if (!window || window.attachedSheet) {
completionHandler(NO);
return;
}
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleWarning;
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
alert.informativeText = message;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
completionHandler(response == NSAlertFirstButtonReturn);
}];
}
@end
static char workspaceUIDelegateKey;
void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES;
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
return;
}
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
delegate.originalDelegate = webview.UIDelegate;
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
webview.UIDelegate = delegate;
}
+47
View File
@@ -0,0 +1,47 @@
#import <WebKit/WebKit.h>
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
typedef void (*SettingsFree)(char *);
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
@property(nonatomic, weak) WKWebView *webview;
@property(nonatomic, copy) NSString *label;
@property(nonatomic) SettingsCallback callback;
@property(nonatomic) SettingsFree releaseReply;
@end
@implementation KanbanSettingsHandler
- (void)userContentController:(WKUserContentController *)controller
didReceiveScriptMessage:(WKScriptMessage *)message
replyHandler:(void (^)(id, NSString *))replyHandler {
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
![message.body isKindOfClass:NSString.class]) {
replyHandler(nil, @"untrustedFrame");
return;
}
char *reply = self.callback(self.label.UTF8String,
message.frameInfo.request.URL.absoluteString.UTF8String,
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
NSString *value = [NSString stringWithUTF8String:reply];
self.releaseReply(reply);
replyHandler(value, nil);
}
@end
void install_workspace_settings(void *pointer, const char *label, const char *script,
SettingsCallback callback, SettingsFree releaseReply) {
WKWebView *webview = (__bridge WKWebView *)pointer;
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
handler.webview = webview;
handler.label = [NSString stringWithUTF8String:label];
handler.callback = callback;
handler.releaseReply = releaseReply;
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
initWithSource:[NSString stringWithUTF8String:script]
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
// The initial document may already have started loading before the handler is attached.
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
}
+147
View File
@@ -0,0 +1,147 @@
use serde::Deserialize;
use serde_json::{json, Value};
use std::ffi::{c_char, CStr, CString};
use std::sync::OnceLock;
use tauri::{AppHandle, Url};
static APP: OnceLock<AppHandle> = OnceLock::new();
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub version: u8,
pub id: u64,
pub action: String,
pub payload: Value,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "action",
content = "payload",
rename_all = "camelCase",
deny_unknown_fields
)]
pub enum Action {
GetState {},
SettingsReady {},
OpenSettings {},
SetWorkspace {
server: String,
},
CheckForUpdates {},
SetUpdatePreferences {
automatic: bool,
#[serde(rename = "intervalHours")]
interval_hours: u64,
},
InstallUpdate {
version: String,
},
}
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
if value.len() > 8192 {
return Err("invalidRequest");
}
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
if request.version != 1 {
return Err("incompatibleVersion");
}
let action =
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
.map_err(|_| "invalidRequest")?;
Ok((request.id, action))
}
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
let _ = APP.set(app.clone());
let label = CString::new(window.label()).unwrap();
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
window.with_webview(move |view| unsafe {
unsafe extern "C" {
fn install_workspace_settings(
view: *mut std::ffi::c_void,
label: *const c_char,
script: *const c_char,
callback: extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
bool,
) -> *mut c_char,
free: extern "C" fn(*mut c_char),
);
}
install_workspace_settings(
view.inner().cast(),
label.as_ptr(),
script.as_ptr(),
receive,
release,
);
})
}
extern "C" fn release(value: *mut c_char) {
if !value.is_null() {
unsafe {
drop(CString::from_raw(value));
}
}
}
extern "C" fn receive(
label: *const c_char,
url: *const c_char,
body: *const c_char,
main: bool,
) -> *mut c_char {
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
if label.is_null() || url.is_null() || body.is_null() {
return Err("invalidRequest");
}
let label = unsafe { CStr::from_ptr(label) }
.to_str()
.map_err(|_| "invalidRequest")?;
let url = unsafe { CStr::from_ptr(url) }
.to_str()
.map_err(|_| "invalidRequest")?;
let body = unsafe { CStr::from_ptr(body) }
.to_str()
.map_err(|_| "invalidRequest")?;
let app = APP.get().ok_or("bridgeUnavailable")?;
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
if !crate::desktop::allows_settings(app, label, &url, main) {
return Err("untrustedFrame");
}
let (id, action) = parse(body)?;
let result = crate::desktop::settings_action(app, action)?;
Ok(json!({"id": id, "result": result}))
})
.unwrap_or(Err("bridgeUnavailable"));
let value = result.unwrap_or_else(|error| json!({"error": error}));
CString::new(value.to_string()).unwrap().into_raw()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
for value in [
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
] {
assert!(parse(value).is_err(), "{value}");
}
assert!(parse(&"x".repeat(8193)).is_err());
assert!(matches!(
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
Ok((1, Action::GetState {}))
));
}
}
+45 -14
View File
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
if manual {
show(app);
}
check_with_mode(app, manual);
}
pub fn check_inline(app: &AppHandle) {
check_with_mode(app, true);
}
fn check_with_mode(app: &AppHandle, manual: bool) {
let begin = app
.state::<Mutex<State>>()
.lock()
@@ -297,7 +303,7 @@ fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(),
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
Ok(())
}
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
let update = {
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
@@ -400,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
fail(app, "preferencesError");
return;
}
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
let directory = app.path().app_config_dir()?;
fs::create_dir_all(&directory)?;
let pending = directory.join("update-preferences.pending");
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
if saved.is_err() {
fail(app, "preferencesError");
return;
if let Err(error) =
save_preferences(app, preferences.automatic, preferences.interval_hours)
{
fail(app, error);
}
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
refresh(app);
}
_ => {}
}
}
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
serde_json::to_value(&app.state::<Mutex<State>>().lock().unwrap().view).unwrap()
}
pub fn save_preferences(
app: &AppHandle,
automatic: bool,
interval_hours: u64,
) -> Result<(), &'static str> {
let preferences = Preferences {
automatic,
interval_hours,
};
if !preferences.valid() {
return Err("preferencesError");
}
let state = app.state::<Mutex<State>>();
let mut state = state.lock().unwrap();
if state.busy() {
return Err("updateBusy");
}
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
let directory = app.path().app_config_dir()?;
fs::create_dir_all(&directory)?;
let pending = directory.join("update-preferences.pending");
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
fs::rename(pending, directory.join("update-preferences.json"))?;
Ok(())
})();
saved.map_err(|_| "preferencesError")?;
state.view.preferences = preferences;
drop(state);
refresh(app);
Ok(())
}
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.plugin(
tauri_plugin_updater::Builder::new()
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.2.5",
"version": "0.2.8",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
"bundleVersion": "10",
"bundleVersion": "12",
"dmg": {
"windowSize": {
"width": 660,
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "5"
"bundleVersion": "7"
}
}
}
+36 -8
View File
@@ -18,6 +18,7 @@ fn main() {
.setup(move |app, _| {
let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?;
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
if needs_setup {
if startup == "invalid" {
std::fs::write(directory.join("active-server"), "invalid configuration")?;
@@ -118,9 +119,25 @@ fn main() {
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
catch (error) { denied = denied && String(error).includes('not allowed'); }
}
const api = window.kanbanNative;
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
const initial = await api.request('getState');
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
const changed = await api.request('getState');
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
const frame = document.createElement('iframe');
document.body.append(frame);
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
let frameDenied = false;
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
if (!frameDenied) throw new Error('Subframe settings access accepted');
const input = document.createElement('input');
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
@@ -129,7 +146,7 @@ fn main() {
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})();
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
"#).unwrap();
} else {
webview.eval(r#"
@@ -161,12 +178,10 @@ fn main() {
location.href = '/__native_smoke_result?passed=false&stage=preservation';
}
"#).unwrap();
let launcher = app.get_webview_window("launcher").unwrap();
launcher.show().unwrap();
launcher.eval(r#"
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
document.querySelector('#server-form').requestSubmit();
document.querySelector('#confirm-switch').click();
window.eval(r#"
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
});
"#).unwrap();
});
} else {
@@ -240,6 +255,19 @@ fn main() {
closed.load(Ordering::SeqCst),
"The close button must quit the application"
);
if online {
assert!(std::fs::read_to_string(config_dir.join("active-server"))
.unwrap()
.starts_with("https://kanbangit.de/login/\n"));
let preferences: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
)
.unwrap();
assert_eq!(
preferences,
serde_json::json!({"automatic": false, "intervalHours": 24})
);
}
let _ = std::fs::remove_dir_all(config_dir);
assert!(
passed.load(Ordering::SeqCst),
+92 -1
View File
@@ -17,6 +17,53 @@ static void waitUntil(BOOL (^condition)(void), NSString *message) {
check(condition(), message);
}
static id evaluate(WKWebView *webview, NSString *script) {
__block BOOL finished = NO;
__block id result;
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
check(error == nil, error.localizedDescription);
result = value;
finished = YES;
}];
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
return result;
}
static NSButton *findButton(NSView *view, NSString *title) {
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
return (NSButton *)view;
}
for (NSView *child in view.subviews) {
NSButton *button = findButton(child, title);
if (button) { return button; }
}
return nil;
}
static BOOL containsText(NSView *view, NSString *text) {
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
return YES;
}
for (NSView *child in view.subviews) {
if (containsText(child, text)) { return YES; }
}
return NO;
}
@interface WorkspaceUI : NSObject <WKUIDelegate>
@property(nonatomic) BOOL closed;
@property(nonatomic) BOOL uploaded;
@end
@implementation WorkspaceUI
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
self.uploaded = YES;
completionHandler(nil);
}
@end
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished;
@end
@@ -46,9 +93,18 @@ int main(void) {
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.navigationDelegate = pages;
WorkspaceUI *originalUI = [WorkspaceUI new];
webview.UIDelegate = originalUI;
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
install_workspace_navigation((__bridge void *)webview);
id<WKUIDelegate> installedUI = webview.UIDelegate;
install_workspace_navigation((__bridge void *)webview);
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
[installedUI webViewDidClose:webview];
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
@"Existing file upload callbacks must remain available");
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
@@ -69,6 +125,41 @@ int main(void) {
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
@"Forward must restore the second page");
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
window.releasedWhenClosed = NO;
window.title = @"KanbanGit navigation test";
window.contentView = webview;
[window orderFront:nil];
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
evaluate(webview, @"document.querySelector('#upload').click()");
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
evaluate(webview, @"document.querySelector('#back').click()");
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
evaluate(webview, @"document.querySelector('#duplicate').click()");
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
__block BOOL finished = NO;
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
check(error == nil, error.localizedDescription);
finished = YES;
}];
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
check(!finished, @"The original click must wait for the user's decision");
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
NSButton *button = findButton(window.attachedSheet.contentView, choice);
check(button != nil, @"The native confirmation must offer OK and Cancel");
[button performClick:nil];
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
@"Cancelling a confirmation must preserve the draft");
}
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
window.contentView = nil;
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
[window orderOut:nil];
[window close];
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
test(
'WebKit owns interactive navigation and preserves native back/forward history',
'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks',
{ skip: process.platform !== 'darwin' },
() => {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
@@ -48,7 +48,7 @@ test(
assert.equal(
result.status,
0,
result.stderr || result.error?.message,
result.stderr || result.error?.message || result.signal,
);
assert.match(result.stdout, /PASS: WebKit/);
} finally {
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import test from 'node:test';
const script = readFileSync(
new URL('../ui/workspace-settings.js', import.meta.url),
'utf8',
);
function context(platform = 'apple', frame = false, protocol = 'https:') {
const calls = [];
const window = { dispatchEvent() {} };
window.top = frame ? {} : window;
if (platform === 'apple') {
window.webkit = {
messageHandlers: {
kanbanSettings: {
postMessage: async (body) => {
const request = JSON.parse(body);
calls.push(request);
return JSON.stringify({
id: request.id,
result: { workspace: 'https://example.test/' },
});
},
},
},
};
}
vm.runInNewContext(script, {
window,
location: { protocol },
Event,
setTimeout,
clearTimeout,
});
return { window, calls };
}
test('Apple WebViews expose a versioned request/reply API without Tauri', async () => {
const { window, calls } = context('apple');
assert.equal(window.kanbanNative.version, 1);
assert.equal(
(await window.kanbanNative.request('getState')).workspace,
'https://example.test/',
);
assert.deepEqual(calls[0], {
version: 1,
id: 1,
action: 'getState',
payload: {},
});
await assert.rejects(
window.kanbanNative.request('shell'),
/unsupportedAction/,
);
assert.equal(calls.length, 1);
assert.equal(window.__TAURI_INTERNALS__, undefined);
assert.equal(context('apple', true).window.kanbanNative, undefined);
assert.equal(
context('apple', false, 'http:').window.kanbanNative,
undefined,
);
});
test('normal browsers receive no native settings API', () => {
assert.equal(context('browser').window.kanbanNative, undefined);
});
test('native errors reject the caller without losing future requests', async () => {
const { window } = context();
window.webkit.messageHandlers.kanbanSettings.postMessage = async () =>
JSON.stringify({ error: 'invalidServer' });
await assert.rejects(
window.kanbanNative.request('setWorkspace', { server: 'bad' }),
/invalidServer/,
);
});
+58
View File
@@ -0,0 +1,58 @@
(() => {
if (
window !== window.top ||
location.protocol !== 'https:' ||
window.kanbanNative
) {
return;
}
const handler = window.webkit?.messageHandlers?.kanbanSettings;
if (!handler) {
return;
}
let sequence = 0;
const actions = new Set([
'getState',
'settingsReady',
'openSettings',
'setWorkspace',
'checkForUpdates',
'setUpdatePreferences',
'installUpdate',
]);
Object.defineProperty(window, 'kanbanNative', {
value: Object.freeze({
version: 1,
async request(action, payload = {}) {
if (!actions.has(action)) {
throw new Error('unsupportedAction');
}
const message = JSON.stringify({
version: 1,
id: ++sequence,
action,
payload,
});
let timer;
try {
const response = await Promise.race([
handler.postMessage(message).then(JSON.parse),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error('bridgeTimeout')),
10000,
);
}),
]);
if (response.error) {
throw new Error(response.error);
}
return response.result;
} finally {
clearTimeout(timer);
}
},
}),
});
window.dispatchEvent(new Event('kanban-native-ready'));
})();
+7 -2
View File
@@ -15,7 +15,7 @@
"dev": "vite",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"types:check": "vue-tsc --noEmit"
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -48,6 +48,11 @@
"dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7",
"@tiptap/core": "^3.31.3",
"@tiptap/markdown": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/vue-3": "^3.31.3",
"@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,7 +62,7 @@
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13",
"vue": "^3.5.43",
"vue-input-otp": "^0.3.2"
},
"optionalDependencies": {
+645 -138
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1,4 +1,22 @@
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:
'@vue/server-renderer@<3.5.42': ^3.5.42
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
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
audit:
ignore:
- GHSA-vfj7-8cjw-p6xm
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './theme.css';
@import './ticket-description.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
+71
View File
@@ -0,0 +1,71 @@
.ticket-description {
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: var(--text-sm);
line-height: 1.75;
}
.ticket-description > * + * {
margin-top: 0.75rem;
}
.ticket-description h1 {
font-size: 1.5rem;
font-weight: 700;
}
.ticket-description h2 {
font-size: 1.25rem;
font-weight: 700;
}
.ticket-description h3,
.ticket-description h4,
.ticket-description h5,
.ticket-description h6 {
font-size: 1.125rem;
font-weight: 600;
}
.ticket-description ul {
list-style: disc;
padding-left: 1.5rem;
}
.ticket-description ol {
list-style: decimal;
padding-left: 1.5rem;
}
.ticket-description li > ul,
.ticket-description li > ol {
margin-top: 0.25rem;
}
.ticket-description blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.ticket-description a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 3px;
}
.ticket-description code {
border-radius: 0.25rem;
background: var(--color-muted);
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
.ticket-description pre {
max-width: 100%;
overflow-x: auto;
border-radius: 0.375rem;
background: var(--color-muted);
padding: 0.75rem;
white-space: pre;
}
.ticket-description pre code {
padding: 0;
background: none;
}
.ticket-description hr {
border-color: var(--color-border);
margin-block: 1rem;
}
.ticket-description:focus-visible {
outline: none;
}
+9
View File
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import '../css/app.css';
import {
initializeNativeApp,
registerNativeRecovery,
} from '@/composables/useNativeApp';
import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
@@ -21,6 +25,11 @@ createInertiaApp({
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el);
registerNativeRecovery();
window.addEventListener('kanban-native-ready', () => {
void initializeNativeApp();
});
void initializeNativeApp();
},
progress: {
color: 'var(--primary)',
@@ -3,7 +3,6 @@ import { Link, useForm } from '@inertiajs/vue3';
import { Layers3, LockKeyhole } from 'lucide-vue-next';
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -12,7 +11,13 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import { index, update } from '@/routes/project-templates';
import {
index,
update,
store as storeStandalone,
} from '@/routes/project-templates';
import TemplateConfigurationEditor from '@/components/projects/TemplateConfigurationEditor.vue';
import { createTemplateConfiguration } from '@/lib/project-template-configuration';
import { show } from '@/routes/projects';
import { store } from '@/routes/projects/templates';
import type { ProjectDuplicateCopyAreaOption } from '@/types';
@@ -22,11 +27,18 @@ import type { ProjectTemplateModel } from '@/types/models/project-template-model
const props = defineProps<{
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
project?: ProjectModel;
project?: ProjectModel | null;
initialConfiguration?: Record<string, unknown>;
template?: ProjectTemplateModel;
}>();
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const publishingBase = computed(() =>
props.project?.project_template &&
!props.project.project_template.is_published
? props.project.project_template
: null,
);
const source = computed(() => props.template?.source_project ?? props.project);
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
const form = useForm(
@@ -34,14 +46,23 @@ const form = useForm(
{
name:
props.template?.name ??
t('templates.defaultName', { project: props.project?.name ?? '' }),
(props.project
? t('templates.defaultName', { project: props.project.name })
: ''),
slug:
props.template?.slug ??
`${props.project?.key.toLowerCase()}-template`,
publishingBase.value?.slug ??
(props.project
? `${props.project.key.toLowerCase()}-template`
: ''),
copy_areas: props.template
? [...props.template.copy_areas]
: props.copyAreaOptions.map((area) => area.value),
confirm_linked_projects_update: false,
configuration_snapshot: createTemplateConfiguration(
props.template?.configuration_snapshot ??
props.initialConfiguration,
),
},
);
const protection = useFormProtection(form);
@@ -53,13 +74,10 @@ const copyAreasError = computed(
)?.[1],
);
const cancelTarget = computed(() =>
props.template ? index() : show(props.project!.id),
props.project ? show(props.project.id) : index(),
);
function submit(): void {
if (
!source.value ||
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
) {
if (linkedCount.value > 0 && !form.confirm_linked_projects_update) {
return;
}
form.transform((data) =>
@@ -67,6 +85,7 @@ function submit(): void {
? {
name: data.name,
copy_areas: data.copy_areas,
configuration_snapshot: data.configuration_snapshot,
...(linkedCount.value > 0
? {
confirm_linked_projects_update:
@@ -74,10 +93,21 @@ function submit(): void {
}
: {}),
}
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
: {
name: data.name,
slug: data.slug,
copy_areas: data.copy_areas,
...(!props.project
? { configuration_snapshot: data.configuration_snapshot }
: {}),
},
);
protection.submit(
props.template ? update(props.template.id) : store(props.project!.id),
props.template
? update(props.template.id)
: props.project
? store(props.project.id)
: storeStandalone(),
{ preserveScroll: true },
);
}
@@ -90,12 +120,6 @@ function submit(): void {
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<FeedbackState
v-if="!source"
state="error"
:title="t('templates.missingSource')"
:description="t('templates.missingSourceHelp')"
/>
<div
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
>
@@ -115,7 +139,7 @@ function submit(): void {
name="name"
required
maxlength="255"
:disabled="form.processing || !source"
:disabled="form.processing"
:placeholder="t('templates.namePlaceholder')"
:invalid="!!form.errors.name"
aria-describedby="name-error"
@@ -139,7 +163,11 @@ function submit(): void {
name="slug"
required
maxlength="255"
:disabled="!!template || form.processing || !source"
:disabled="
!!template ||
!!publishingBase ||
form.processing
"
:placeholder="t('templates.slugPlaceholder')"
:invalid="!!form.errors.slug"
aria-describedby="slug-help slug-error"
@@ -152,7 +180,7 @@ function submit(): void {
>
{{
t(
template
template || publishingBase
? 'templates.slugLocked'
: 'templates.slugHelp',
)
@@ -166,7 +194,7 @@ function submit(): void {
</section>
<fieldset
class="bg-card min-w-0 rounded-md border p-5"
:disabled="form.processing || !source"
:disabled="form.processing"
aria-describedby="areas-help copy-areas-error"
>
<legend class="text-title px-1">
@@ -231,6 +259,14 @@ function submit(): void {
:message="copyAreasError"
/>
</fieldset>
<TemplateConfigurationEditor
v-if="template || !project"
v-model="form.configuration_snapshot"
:areas="form.copy_areas"
:options="copyAreaOptions"
:errors="form.errors"
:disabled="form.processing"
/>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
@@ -310,7 +346,7 @@ function submit(): void {
id="confirm-linked"
v-model="form.confirm_linked_projects_update"
type="checkbox"
:disabled="form.processing || !source"
:disabled="form.processing"
:aria-invalid="!!form.errors.confirm_linked_projects_update"
aria-describedby="linked-help linked-error"
class="accent-primary mt-0.5 size-5 shrink-0"
@@ -335,8 +371,7 @@ function submit(): void {
:loading="form.processing"
:loading-label="t('forms.saving')"
:disabled="
!source ||
(linkedCount > 0 && !form.confirm_linked_projects_update)
linkedCount > 0 && !form.confirm_linked_projects_update
"
>{{
t(template ? 'templates.saveChanges' : 'templates.saveNew')
@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import { notificationLabel } from '@/lib/notification-labels';
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
import type {
TemplateConfiguration,
TemplateConfigurationEditorProps,
TemplateConfigurationItem,
TemplateListArea,
TemplateTextField,
TemplateNotificationFilter,
} from '@/types/project-template-configuration';
const props = defineProps<TemplateConfigurationEditorProps>();
const configuration = defineModel<TemplateConfiguration>({ required: true });
const { t } = useLocalization();
const { areaText } = useProjectTemplateAreas();
const listAreas = computed(
() =>
props.options
.map((option) => option.value)
.filter(
(area) =>
props.areas.includes(area) &&
area !== 'project_settings' &&
area !== 'status_transitions',
) as TemplateListArea[],
);
const selectClass =
'border-input bg-background min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm';
const filterAttributes = [
'status.from.id',
'status.to.id',
'status.from.label',
'status.to.label',
'project.id',
'project.key',
'project.name',
'ticket.id',
'ticket.key',
'ticket.title',
'ticket.url',
'actor.id',
'actor.name',
'assignee.from.id',
'assignee.from.name',
'assignee.to.id',
'assignee.to.name',
];
const filterOperators = ['equals', 'not_equals', 'exists', 'not_exists', 'in'];
function fields(area: TemplateListArea): TemplateTextField[] {
if (area === 'ticket_fields') {
return [{ key: 'label', label: t('templateEditor.label') }];
}
if (area === 'ticket_link_types') {
return [
{ key: 'label', label: t('templateEditor.label') },
{
key: 'counterpart_label',
label: t('templateEditor.counterpart'),
},
];
}
if (area === 'repository_connections') {
return [
{ key: 'base_url', label: t('templateEditor.server') },
{
key: 'provider_repository_id',
label: t('templateEditor.repositoryId'),
},
{ key: 'owner_name', label: t('templateEditor.owner') },
{
key: 'repository_name',
label: t('templateEditor.repositoryName'),
},
{
key: 'repository_full_name',
label: t('templateEditor.fullName'),
},
{ key: 'repository_url', label: t('templateEditor.repositoryUrl') },
];
}
return [{ key: 'name', label: t('templateEditor.name') }];
}
function reindex(area: TemplateListArea): void {
const counters = new Map<string, number>();
configuration.value[area].forEach((item, index) => {
if (
area === 'repository_connections' ||
area === 'notification_actions'
) {
return;
}
const group = item.board_column_key ?? '';
counters.set(group, (counters.get(group) ?? 0) + 1);
item.position =
area === 'ticket_statuses' ? counters.get(group) : index + 1;
});
}
function add(area: TemplateListArea): void {
const item: TemplateConfigurationItem = {
configuration_key: crypto.randomUUID(),
};
if (area === 'ticket_fields') {
Object.assign(item, { label: '', field_type: 'number' });
} else if (area === 'ticket_link_types') {
Object.assign(item, { label: '', counterpart_label: '' });
} else if (area === 'repository_connections') {
Object.assign(item, {
provider: 'gitea',
base_url: '',
provider_repository_id: '',
owner_name: '',
repository_name: '',
repository_full_name: '',
repository_url: '',
});
} else {
Object.assign(item, { name: '' });
}
if (area === 'ticket_statuses') {
item.board_column_key = null;
configuration.value.status_transitions.push({
status_key: item.configuration_key,
target_keys: [],
});
}
if (area === 'ticket_types') {
item.is_parent = false;
}
if (area === 'notification_actions') {
Object.assign(item, {
enabled: true,
event_type: 'ticket_status_changed',
channel: 'webhook',
delivery_url: '',
message_template: '',
channel_configuration: { allowed_mentions: [] },
filters: [],
});
}
configuration.value[area].push(item);
reindex(area);
}
function remove(area: TemplateListArea, index: number): void {
const item = configuration.value[area][index];
if (area === 'board_columns') {
configuration.value.ticket_statuses.forEach((status) => {
if (status.board_column_key === item.configuration_key) {
status.board_column_key = null;
}
});
reindex('ticket_statuses');
}
if (area === 'ticket_statuses') {
configuration.value.status_transitions =
configuration.value.status_transitions.filter(
(transition) =>
transition.status_key !== item.configuration_key,
);
configuration.value.status_transitions.forEach((transition) => {
transition.target_keys = transition.target_keys.filter(
(key) => key !== item.configuration_key,
);
});
}
configuration.value[area].splice(index, 1);
reindex(area);
}
function move(area: TemplateListArea, index: number, offset: number): void {
const entries = configuration.value[area];
const next = index + offset;
if (next < 0 || next >= entries.length) {
return;
}
[entries[index], entries[next]] = [entries[next], entries[index]];
reindex(area);
}
function targets(statusKey: string): string[] {
return (
configuration.value.status_transitions.find(
(transition) => transition.status_key === statusKey,
)?.target_keys ?? []
);
}
function toggleTarget(from: string, to: string, checked: boolean): void {
let transition = configuration.value.status_transitions.find(
(entry) => entry.status_key === from,
);
if (!transition) {
transition = { status_key: from, target_keys: [] };
configuration.value.status_transitions.push(transition);
}
transition.target_keys = checked
? [...new Set([...transition.target_keys, to])]
: transition.target_keys.filter((key) => key !== to);
}
function filterChanged(filter: TemplateNotificationFilter): void {
if (['exists', 'not_exists'].includes(filter.operator)) {
filter.value = null;
} else if (filter.operator === 'in') {
filter.value = Array.isArray(filter.value)
? filter.value
: filter.value
? [filter.value]
: [];
} else if (Array.isArray(filter.value)) {
filter.value = filter.value[0] ?? '';
}
}
function mention(
item: TemplateConfigurationItem,
value: string,
checked: boolean,
): void {
const settings = (item.channel_configuration ??= { allowed_mentions: [] });
settings.allowed_mentions = checked
? [...new Set([...(settings.allowed_mentions ?? []), value])]
: (settings.allowed_mentions ?? []).filter((entry) => entry !== value);
}
function areaErrors(area: string): string[] {
return [
...new Set(
Object.entries(props.errors)
.filter(
([key]) =>
key === `configuration_snapshot.${area}` ||
key.startsWith(`configuration_snapshot.${area}.`),
)
.map(([, value]) => value)
.filter((value): value is string => !!value),
),
];
}
</script>
<template>
<fieldset class="gap-column grid min-w-0" :disabled="disabled">
<legend class="text-title mb-4">{{ t('templateEditor.title') }}</legend>
<p class="text-muted-foreground text-caption">
{{ t('templateEditor.help') }}
</p>
<details
v-if="areas.includes('project_settings')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('project_settings', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<Label class="flex items-center gap-3"
><Checkbox
v-model="configuration.project_settings.show_author"
/>{{ t('templateEditor.showAuthor') }}</Label
>
<Label class="flex items-center gap-3"
><Checkbox
v-model="
configuration.project_settings.show_story_points
"
/>{{ t('templateEditor.showPoints') }}</Label
>
<InputError
v-for="error in areaErrors('project_settings')"
:key="error"
:message="error"
/>
</div>
</details>
<details
v-for="area in listAreas"
:key="area"
class="bg-card min-w-0 rounded-md border p-5"
:open="
['board_columns', 'ticket_statuses', 'ticket_types'].includes(
area,
)
"
>
<summary class="cursor-pointer font-medium">
{{ areaText(area, 'label') }} ({{ configuration[area].length }})
</summary>
<div class="mt-4 grid min-w-0 gap-4">
<article
v-for="(item, index) in configuration[area]"
:key="item.configuration_key"
class="grid min-w-0 gap-4 rounded-md border p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-caption font-medium">{{
item.name ||
item.label ||
t('templateEditor.entry', { number: index + 1 })
}}</span>
<div class="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:disabled="index === 0"
:aria-label="t('templateEditor.moveUp')"
@click="move(area, index, -1)"
><ArrowUp
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:disabled="
index === configuration[area].length - 1
"
:aria-label="t('templateEditor.moveDown')"
@click="move(area, index, 1)"
><ArrowDown
/></Button>
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="t('templateEditor.remove')"
@click="remove(area, index)"
><Trash2
/></Button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div
v-for="field in fields(area)"
:key="field.key"
class="grid min-w-0 gap-2"
>
<Label
:for="`${item.configuration_key}-${field.key}`"
>{{ field.label }}</Label
>
<Input
:id="`${item.configuration_key}-${field.key}`"
v-model="item[field.key]"
required
/>
</div>
<div
v-if="area === 'ticket_statuses'"
class="grid gap-2"
>
<Label :for="`${item.configuration_key}-column`">{{
t('templateEditor.column')
}}</Label>
<select
:id="`${item.configuration_key}-column`"
v-model="item.board_column_key"
:class="selectClass"
@change="reindex('ticket_statuses')"
>
<option :value="null">
{{ t('templateEditor.backlog') }}
</option>
<option
v-for="column in configuration.board_columns"
:key="column.configuration_key"
:value="column.configuration_key"
>
{{ column.name }}
</option>
</select>
</div>
<Label
v-if="area === 'ticket_types'"
class="flex items-center gap-3"
><Checkbox v-model="item.is_parent" />{{
t('templateEditor.parentType')
}}</Label
>
<div v-if="area === 'ticket_fields'" class="grid gap-2">
<Label :for="`${item.configuration_key}-type`">{{
t('templateEditor.fieldType')
}}</Label>
<select
:id="`${item.configuration_key}-type`"
v-model="item.field_type"
:class="selectClass"
>
<option value="number">
{{ t('templateEditor.number') }}
</option>
<option value="user">
{{ t('templateEditor.user') }}
</option>
</select>
</div>
</div>
<template v-if="area === 'notification_actions'">
<Label class="flex items-center gap-3"
><Checkbox v-model="item.enabled" />{{
t('templateEditor.enabled')
}}</Label
>
<div class="grid gap-4 sm:grid-cols-2">
<Label class="grid gap-2"
>{{ t('templateEditor.event')
}}<select
v-model="item.event_type"
:class="selectClass"
>
<option
v-for="event in [
'ticket_status_changed',
'ticket_create',
'ticket_assignee_changed',
]"
:key="event"
:value="event"
>
{{
notificationLabel(
t,
'event',
event,
event,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('templateEditor.channel')
}}<select
v-model="item.channel"
:class="selectClass"
>
<option value="webhook">
{{ t('notifications.channel.webhook') }}
</option>
<option value="discord">
{{ t('notifications.channel.discord') }}
</option>
</select></Label
>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.deliveryUrl')
}}<Input
v-model="item.delivery_url"
required
type="url"
/></Label>
<Label class="grid gap-2 sm:col-span-2"
>{{ t('templateEditor.message')
}}<textarea
v-model="item.message_template"
:class="selectClass"
rows="4"
:required="item.channel === 'discord'"
maxlength="2000"
/>
</Label>
</div>
<fieldset
v-if="item.channel === 'discord'"
class="grid gap-3 rounded-md border p-3"
>
<legend>
{{ t('templateEditor.allowedMentions') }}
</legend>
<Label
v-for="value in ['everyone', 'roles', 'users']"
:key="value"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
item.channel_configuration?.allowed_mentions?.includes(
value,
) ?? false
"
@update:model-value="
mention(item, value, $event === true)
"
/>{{
notificationLabel(
t,
'mention',
value,
value,
)
}}
</Label>
</fieldset>
<div class="grid gap-3">
<div
v-for="(filter, filterIndex) in item.filters"
:key="filterIndex"
class="grid gap-3 rounded-md border p-3 sm:grid-cols-2"
>
<Label class="grid gap-2"
>{{ t('notifications.attribute')
}}<select
v-model="filter.attribute"
@change="
filter.value =
filter.operator === 'in'
? []
: null
"
:class="selectClass"
>
<option
v-for="attribute in filterAttributes"
:key="attribute"
:value="attribute"
>
{{
notificationLabel(
t,
'attribute',
attribute,
attribute,
)
}}
</option>
</select></Label
>
<Label class="grid gap-2"
>{{ t('notifications.operator')
}}<select
v-model="filter.operator"
@change="filterChanged(filter)"
:class="selectClass"
>
<option
v-for="operator in filterOperators"
:key="operator"
:value="operator"
>
{{
notificationLabel(
t,
'operator',
operator,
operator,
)
}}
</option>
</select></Label
>
<Label
v-if="
!['exists', 'not_exists'].includes(
filter.operator,
)
"
class="grid gap-2"
>{{ t('notifications.value')
}}<Input
:model-value="
Array.isArray(filter.value)
? filter.value.join(', ')
: (filter.value ?? '')
"
@update:model-value="
filter.value = String($event)
"
/></Label>
<Button
type="button"
variant="outline"
@click="
item.filters?.splice(filterIndex, 1)
"
>{{
t('notifications.removeFilter')
}}</Button
>
</div>
<Button
type="button"
variant="outline"
@click="
(item.filters ??= []).push({
attribute: 'status.to.label',
operator: 'equals',
value: '',
})
"
><Plus />{{
t('notifications.addFilter')
}}</Button
>
</div>
</template>
</article>
<InputError
v-for="error in areaErrors(area)"
:key="error"
:message="error"
/>
<Button
type="button"
variant="outline"
class="justify-self-start"
@click="add(area)"
><Plus />{{ t('templateEditor.addEntry') }}</Button
>
</div>
</details>
<details
v-if="areas.includes('status_transitions')"
class="bg-card rounded-md border p-5"
open
>
<summary class="cursor-pointer font-medium">
{{ areaText('status_transitions', 'label') }}
</summary>
<div class="mt-4 grid gap-4">
<fieldset
v-for="status in configuration.ticket_statuses"
:key="status.configuration_key"
class="grid gap-3 rounded-md border p-4"
>
<legend class="px-1 font-medium">{{ status.name }}</legend>
<Label
v-for="target in configuration.ticket_statuses.filter(
(entry) =>
entry.configuration_key !==
status.configuration_key,
)"
:key="target.configuration_key"
class="flex items-center gap-3"
>
<Checkbox
:model-value="
targets(status.configuration_key).includes(
target.configuration_key,
)
"
@update:model-value="
toggleTarget(
status.configuration_key,
target.configuration_key,
$event === true,
)
"
/>{{ target.name }}
</Label>
</fieldset>
<InputError
v-for="error in areaErrors('status_transitions')"
:key="error"
:message="error"
/>
</div>
</details>
</fieldset>
</template>
@@ -19,6 +19,7 @@ import {
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import type { TemplateResetForm } from '@/types/project-template-configuration';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
const props = defineProps<{
@@ -27,10 +28,7 @@ const props = defineProps<{
templateState?: ProjectTemplateState | null;
}>();
const { t } = useLocalization();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
const form = useForm<TemplateResetForm>({
areas: [],
status_replacements: {},
});
@@ -245,6 +243,15 @@ function resetArea(): void {
v-if="statusRequirement"
class="gap-column grid min-w-0"
>
<p
v-if="
statusRequirement.replacementOptions
.length === 0
"
class="text-destructive text-sm"
>
{{ t('inheritance.noReplacementTargets') }}
</p>
<p class="text-caption text-muted-foreground">
{{ t('inheritance.statusHelp') }}
</p>

Some files were not shown because too many files have changed in this diff Show More