Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1291a0fcad | ||
|
|
5769d84526 | ||
|
|
99264c7bc8 | ||
|
|
1b448e363c | ||
|
|
65e9df6d6d | ||
|
|
7d7b723f69 | ||
|
|
7a656de7ce | ||
|
|
811dbf7fa4 | ||
|
|
5567eea06f | ||
|
|
ca1f243234 | ||
|
|
9ed8d0c6cc | ||
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 | ||
|
|
752ba2cd38 | ||
|
|
1cf7524a7b | ||
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e | ||
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 | ||
|
|
2a637affdf | ||
|
|
188aa07059 | ||
|
|
fa8a0cc512 | ||
|
|
d18fffe665 | ||
|
|
aece653970 | ||
|
|
2b1cf6ec13 | ||
|
|
38355c9507 | ||
|
|
d0c5d65f0e | ||
|
|
4f9bf2dec4 | ||
|
|
4ee047311f | ||
|
|
41b6dc8f10 | ||
|
|
3d5ee223e8 | ||
|
|
9dbb491dc9 | ||
|
|
40b66bf7ce | ||
|
|
dc9f400f34 | ||
|
|
48f5fc410b | ||
|
|
f6725ac025 | ||
|
|
8aa7bf6860 | ||
|
|
ba9c4fd2e8 | ||
|
|
74bcf5d30c | ||
|
|
20f9314659 | ||
|
|
3e5c316b8f | ||
|
|
4dd73ec583 | ||
|
|
d8ca4975e5 | ||
|
|
f2e1bd6a0d | ||
|
|
b0cc9dad14 | ||
|
|
52304c7eb2 | ||
|
|
b4cd6a5808 | ||
|
|
f52cffeaeb | ||
|
|
1c620654a3 |
@@ -1,78 +1,18 @@
|
|||||||
name: "Lint and Test APP"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
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
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ name: macOS release to Hetzner
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Gitea enforces the configured macos-v* protection when tags are pushed.
|
||||||
|
# Reading /tag_protections requires admin rights unavailable to job tokens.
|
||||||
tags:
|
tags:
|
||||||
- "macos-v*"
|
- "macos-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -15,6 +17,8 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
# Partial release files may be replaced until the feed is promoted.
|
||||||
|
# Keep every publisher in this group to prevent overlapping rebuilds.
|
||||||
group: macos-release
|
group: macos-release
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
@@ -35,7 +39,6 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
commit: ${{ steps.ref.outputs.commit }}
|
commit: ${{ steps.ref.outputs.commit }}
|
||||||
tag: ${{ steps.ref.outputs.tag }}
|
tag: ${{ steps.ref.outputs.tag }}
|
||||||
runner: ${{ steps.ref.outputs.runner }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -62,31 +65,13 @@ jobs:
|
|||||||
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
||||||
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
|
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
|
||||||
git checkout --detach "$commit"
|
git checkout --detach "$commit"
|
||||||
printf 'commit=%s\ntag=%s\nrunner=%s\n' "$commit" "$tag" "$MACOS_RUNNER" >> "$GITHUB_OUTPUT"
|
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Require protected macOS release tags
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
curl --fail --silent --show-error \
|
|
||||||
--header "Authorization: Bearer $GITEA_TOKEN" \
|
|
||||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/tag_protections" \
|
|
||||||
--output "$RUNNER_TEMP/macos-tag-protections.json"
|
|
||||||
node --input-type=module - <<'JS'
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
const rules = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/macos-tag-protections.json`, 'utf8'));
|
|
||||||
if (!rules.some((rule) => rule.name_pattern === 'macos-v*')) {
|
|
||||||
throw new Error('Configure a protected macos-v* tag rule in Gitea before publishing.');
|
|
||||||
}
|
|
||||||
JS
|
|
||||||
|
|
||||||
- name: Validate app versions and Hetzner configuration
|
- name: Validate app versions and Hetzner configuration
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
||||||
@@ -97,7 +82,8 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Sign, notarize, verify and publish macOS
|
name: Sign, notarize, verify and publish macOS
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ${{ needs.prepare.outputs.runner }}
|
# Gitea resolves runner labels before outputs from prepare are available.
|
||||||
|
runs-on: ${{ vars.MACOS_RUNNER }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
@@ -114,6 +100,7 @@ jobs:
|
|||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
run: |
|
run: |
|
||||||
@@ -126,7 +113,7 @@ jobs:
|
|||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
xcrun --find notarytool
|
xcrun --find notarytool
|
||||||
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
||||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||||
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -191,14 +178,19 @@ jobs:
|
|||||||
- name: Build, sign and notarize with Tauri
|
- name: Build, sign and notarize with Tauri
|
||||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||||
env:
|
env:
|
||||||
|
# The DMG bundler reads these variables separately from --ci.
|
||||||
|
CI: "true"
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "false"
|
||||||
APPLE_ID: dev@petsch-studios.de
|
APPLE_ID: dev@petsch-studios.de
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
projectPath: native
|
projectPath: native
|
||||||
tauriScript: pnpm exec tauri
|
tauriScript: pnpm exec tauri
|
||||||
args: >-
|
args: >-
|
||||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
|
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
|
||||||
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
|
--config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
|
||||||
-- --locked
|
-- --locked
|
||||||
uploadUpdaterJson: false
|
uploadUpdaterJson: false
|
||||||
uploadUpdaterSignatures: false
|
uploadUpdaterSignatures: false
|
||||||
@@ -208,7 +200,7 @@ jobs:
|
|||||||
id: package
|
id: package
|
||||||
run: node native/macos-ci.mjs package
|
run: node native/macos-ci.mjs package
|
||||||
|
|
||||||
- name: Publish immutable files and verify public Hetzner downloads
|
- name: Upload release files and promote the verified update feed
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Working Agreements
|
# Working Agreements
|
||||||
|
|
||||||
|
## Native app versions and build numbers
|
||||||
|
|
||||||
|
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
|
||||||
|
|
||||||
|
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
|
||||||
|
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
|
||||||
|
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
|
||||||
|
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
|
||||||
|
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
|
||||||
|
|
||||||
## Missing programs and SDKs
|
## Missing programs and SDKs
|
||||||
|
|
||||||
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
|
|||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
])->saveQuietly();
|
])->saveQuietly();
|
||||||
});
|
});
|
||||||
|
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function statusContextLabel(TicketStatus $ticketStatus): string
|
private function statusContextLabel(TicketStatus $ticketStatus): string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
|
|||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
])->saveQuietly();
|
])->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\DuplicateProjectConfiguration;
|
||||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class UpdateTicketStatusTransitions
|
class UpdateTicketStatusTransitions
|
||||||
{
|
{
|
||||||
@@ -20,6 +22,8 @@ class UpdateTicketStatusTransitions
|
|||||||
*/
|
*/
|
||||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||||
{
|
{
|
||||||
|
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', []))
|
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||||
->unique()
|
->unique()
|
||||||
@@ -37,5 +41,6 @@ class UpdateTicketStatusTransitions
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $ticketStatus->fresh();
|
return $ticketStatus->fresh();
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
|
|||||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class UpdateProjectTicketMetadataSettings
|
class UpdateProjectTicketMetadataSettings
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,8 @@ class UpdateProjectTicketMetadataSettings
|
|||||||
*/
|
*/
|
||||||
public function handle(Project $project, array $validated): Project
|
public function handle(Project $project, array $validated): Project
|
||||||
{
|
{
|
||||||
|
return DB::transaction(function () use ($project, $validated): Project {
|
||||||
|
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$project->update([
|
$project->update([
|
||||||
'show_author' => Arr::get($validated, 'show_author', false),
|
'show_author' => Arr::get($validated, 'show_author', false),
|
||||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||||
@@ -34,5 +37,6 @@ class UpdateProjectTicketMetadataSettings
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $project->fresh();
|
return $project->fresh();
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class CreateProject
|
|||||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||||
$sourceTemplate = ProjectTemplate::query()
|
$sourceTemplate = ProjectTemplate::query()
|
||||||
->published()
|
->published()
|
||||||
|
->lockForUpdate()
|
||||||
->findOrFail((int) $sourceTemplateId);
|
->findOrFail((int) $sourceTemplateId);
|
||||||
|
|
||||||
$project->forceFill([
|
$project->forceFill([
|
||||||
@@ -72,6 +73,7 @@ class CreateProject
|
|||||||
$this->duplicateProjectConfiguration->apply(
|
$this->duplicateProjectConfiguration->apply(
|
||||||
$project->fresh() ?? $project,
|
$project->fresh() ?? $project,
|
||||||
$sourceTemplate->configuration_snapshot ?? [],
|
$sourceTemplate->configuration_snapshot ?? [],
|
||||||
|
adoptExisting: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
|
|||||||
{
|
{
|
||||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
$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([
|
return ProjectTemplate::query()->create([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'source_project_id' => $project->id,
|
'source_project_id' => $project->id,
|
||||||
'name' => "{$project->name} base template",
|
'name' => "{$project->name} base template",
|
||||||
'slug' => strtolower($project->key).'-base-template',
|
'slug' => $candidate,
|
||||||
'is_published' => false,
|
'is_published' => false,
|
||||||
'copy_areas' => $copyAreas,
|
'copy_areas' => $copyAreas,
|
||||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||||
|
|||||||
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CreateProjectTemplate
|
class CreateProjectTemplate
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
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
|
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||||
*/
|
*/
|
||||||
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
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();
|
||||||
return ProjectTemplate::query()->create([
|
$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,
|
'project_id' => null,
|
||||||
'source_project_id' => $sourceProject->id,
|
'source_project_id' => $sourceProject->id,
|
||||||
'name' => $attributes['name'],
|
'name' => Arr::get($attributes, 'name'),
|
||||||
'slug' => $attributes['slug'],
|
|
||||||
'is_published' => true,
|
'is_published' => true,
|
||||||
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
|
'copy_areas' => $copyAreas,
|
||||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $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 $template;
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DeleteProject
|
class DeleteProject
|
||||||
{
|
{
|
||||||
@@ -10,7 +11,13 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
$project->delete();
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
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;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class DuplicateProjectConfiguration
|
class DuplicateProjectConfiguration
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
|
|||||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $configuration;
|
return $this->projectConfigurationIdentity->normalize($configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||||
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||||
static fn ($column, int $index): array => [$column->id => $index],
|
static fn ($column): array => [$column->id => $column->configuration_key],
|
||||||
);
|
);
|
||||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||||
static fn ($status, int $index): array => [$status->id => $index],
|
static fn ($status): array => [$status->id => $status->configuration_key],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
$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();
|
)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||||
static fn ($status): array => [
|
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,
|
'name' => $status->name,
|
||||||
'position' => $status->position,
|
'position' => $status->position,
|
||||||
],
|
],
|
||||||
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
static fn ($status): array => [
|
||||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
'status_key' => $status->configuration_key,
|
||||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
|
||||||
|
static fn (int $id): ?string => $statusKeys->get($id),
|
||||||
|
)->filter()->sort()->values()->all(),
|
||||||
|
],
|
||||||
)->all();
|
)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
|
|||||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||||
'filters' => $item->filters ?? [],
|
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$relations = [
|
$relations = [
|
||||||
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
foreach ($simpleAreas as $area => $mapper) {
|
foreach ($simpleAreas as $area => $mapper) {
|
||||||
if (in_array($area, $selectedAreas, true)) {
|
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<string, mixed> $configuration
|
||||||
* @param array<int|string, int|string> $statusReplacements
|
* @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);
|
$configuration = $this->normalizeConfiguration($configuration);
|
||||||
|
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
|
||||||
$blockedAreas = [];
|
$blockedAreas = [];
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||||
$blocked = $this->syncModels(
|
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
|
||||||
$targetProject->boardColumns()->get(),
|
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
|
||||||
$configuration[self::COPY_BOARD_COLUMNS],
|
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
|
||||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|
||||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
|| $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;
|
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||||
$columns = $targetProject->boardColumns()->get()->values();
|
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
|
||||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||||
$columnIndex = $item['board_column_index'] ?? null;
|
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
|
||||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
$columnKey = $item['board_column_key'] ?? null;
|
||||||
unset($item['board_column_index']);
|
$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;
|
return $item;
|
||||||
})->all();
|
})->all();
|
||||||
$blocked = $this->syncModels(
|
$templateKeys = array_column($items, 'configuration_key');
|
||||||
$targetProject->ticketStatuses()->get(),
|
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|
||||||
$items,
|
|| array_key_exists($status->id, $statusReplacements);
|
||||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
$replacement = $statusReplacements[$status->id] ?? null;
|
||||||
function (Model $status) use ($statusReplacements): bool {
|
if ($replacement === null) {
|
||||||
if (! $status->tickets()->exists()) {
|
return;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
|
||||||
if ($replacementId === null) {
|
->where('configuration_key', (string) $replacement)->first();
|
||||||
return false;
|
if ($replacementStatus === null && is_numeric($replacement)) {
|
||||||
|
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
|
||||||
}
|
}
|
||||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
if ($replacementStatus === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
return true;
|
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
|
||||||
},
|
]);
|
||||||
);
|
}
|
||||||
if ($blocked) {
|
$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;
|
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
|
||||||
$status = $statuses->get($index);
|
$status = $statuses->get($transition['status_key']);
|
||||||
if ($status !== null) {
|
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 = [
|
$guards = [
|
||||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
|
||||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
|
||||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
self::COPY_REPOSITORY_CONNECTIONS => null,
|
||||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
self::COPY_NOTIFICATION_ACTIONS => null,
|
||||||
];
|
];
|
||||||
|
foreach ($guards as $area => $canDelete) {
|
||||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
|
||||||
if (! isset($configuration[$area])) {
|
if (! isset($configuration[$area])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$blocked = $this->syncModels(
|
$items = $configuration[$area];
|
||||||
$targetProject->{$relation}()->get(),
|
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
|
||||||
$configuration[$area],
|
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
|
||||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
$items = array_map(fn (array $item): array => [
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
|
||||||
$canDelete,
|
], $items);
|
||||||
);
|
}
|
||||||
if ($blocked) {
|
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
|
||||||
$blockedAreas[] = $area;
|
$blockedAreas[] = $area;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
|
|||||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($blockedAreas));
|
return $blockedAreas;
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach ($blockedAreas as $area) {
|
foreach ($blockedAreas as $area) {
|
||||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||||
}
|
}
|
||||||
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
|
|||||||
/** @param list<string> $copyAreas */
|
/** @param list<string> $copyAreas */
|
||||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
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 list<array<string, mixed>> $items
|
||||||
* @param array<int, 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) {
|
$relation = ProjectConfigurationIdentity::relations()[$area];
|
||||||
$model = $models->get($index);
|
$models = $project->{$relation}()->get();
|
||||||
$model === null ? $create($item) : $update($model, $item);
|
$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;
|
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
|
||||||
foreach ($models->slice(count($items)) as $model) {
|
foreach ($models as $model) {
|
||||||
if ($canDelete !== null && ! $canDelete($model)) {
|
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
|
||||||
$blocked = true;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
$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;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ProjectTemplateOverrideManager
|
class ProjectTemplateOverrideManager
|
||||||
{
|
{
|
||||||
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$project->loadMissing('projectTemplate');
|
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) {
|
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$project->forceFill(['template_overrides' => array_values(array_unique([
|
||||||
$overrides = array_values(array_unique([
|
...($project->template_overrides ?? []), $area,
|
||||||
...($project->template_overrides ?? []),
|
]))])->saveQuietly();
|
||||||
$area,
|
}, 3);
|
||||||
]));
|
|
||||||
|
|
||||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
|
|||||||
*/
|
*/
|
||||||
public function clear(Project $project, array $areas): void
|
public function clear(Project $project, array $areas): void
|
||||||
{
|
{
|
||||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
DB::transaction(function () use ($project, $areas): void {
|
||||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
$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
|
public function withoutRecording(callable $callback): mixed
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class RefreshProjectBaseTemplate
|
class RefreshProjectBaseTemplate
|
||||||
{
|
{
|
||||||
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
|
|||||||
|
|
||||||
public function handle(Project $project, string $area): void
|
public function handle(Project $project, string $area): void
|
||||||
{
|
{
|
||||||
$project->loadMissing('projectTemplate');
|
DB::transaction(function () use ($project, $area): void {
|
||||||
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
$template = ProjectTemplate::query()->find($project->project_template_id);
|
||||||
|
if ($template === null || $template->is_published) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||||
$project->projectTemplate->configuration_snapshot ?? [],
|
$template->forceFill(['configuration_snapshot' => array_replace(
|
||||||
);
|
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
|
||||||
|
)])->saveQuietly();
|
||||||
$project->projectTemplate->forceFill([
|
}, 3);
|
||||||
'configuration_snapshot' => array_replace(
|
|
||||||
$snapshot,
|
|
||||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
|
||||||
),
|
|
||||||
])->saveQuietly();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
|
|||||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||||
|
|
||||||
return [
|
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,
|
'inheritedAreas' => $inheritedAreas,
|
||||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||||
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
|
||||||
if (! is_array($templateStatuses)) {
|
if (! is_array($templateStatuses)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
$templateKeys = array_column($templateStatuses, 'configuration_key');
|
||||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
$replacementOptions = collect($templateStatuses)->map(
|
||||||
|
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
|
||||||
)->all();
|
)->all();
|
||||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
$removedStatuses = $statuses
|
||||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
|
||||||
->map(static fn ($status): array => [
|
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
|
||||||
'id' => $status->id,
|
->values()->all();
|
||||||
'name' => $status->name,
|
|
||||||
'ticketCount' => $status->tickets_count,
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $removedStatuses === [] ? [] : [
|
return $removedStatuses === [] ? [] : [
|
||||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||||
|
|||||||
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
|
|||||||
*/
|
*/
|
||||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||||
{
|
{
|
||||||
$projectTemplate->loadMissing('sourceProject');
|
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
|
||||||
|
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||||
$sourceProject = $projectTemplate->sourceProject;
|
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
|
||||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
|
||||||
|
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
|
||||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
))->only($copyAreas)->all();
|
||||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
|
||||||
$projectTemplate->configuration_snapshot ?? [],
|
|
||||||
);
|
|
||||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
|
||||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||||
->values()
|
->values()->all();
|
||||||
->all();
|
|
||||||
|
|
||||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
|
||||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
|
||||||
|
|
||||||
$projectTemplate->forceFill([
|
$projectTemplate->forceFill([
|
||||||
'name' => $attributes['name'],
|
'name' => Arr::get($attributes, 'name'),
|
||||||
'copy_areas' => $normalizedCopyAreas,
|
'copy_areas' => $copyAreas,
|
||||||
'configuration_snapshot' => $nextConfiguration,
|
'configuration_snapshot' => $nextConfiguration,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Project::query()
|
Project::query()
|
||||||
->where('project_template_id', $projectTemplate->id)
|
->where('project_template_id', $projectTemplate->id)
|
||||||
->orderBy('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) {
|
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);
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||||
$overrides = $linkedProject->template_overrides ?? [];
|
$overrides = $linkedProject->template_overrides ?? [];
|
||||||
$replacementConfiguration = [];
|
$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\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class UpdateProjectTemplateLink
|
class UpdateProjectTemplateLink
|
||||||
{
|
{
|
||||||
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
|
|||||||
public function switch(Project $project, ProjectTemplate $template): void
|
public function switch(Project $project, ProjectTemplate $template): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($project, $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();
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
$template->configuration_snapshot ?? [],
|
$template->configuration_snapshot ?? [],
|
||||||
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
|
|||||||
* @param list<string> $areas
|
* @param list<string> $areas
|
||||||
* @param array<int|string, int|string> $statusReplacements
|
* @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;
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
if ($template === null) {
|
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();
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
if ($project->project_template_id !== $template->id) {
|
||||||
$template->configuration_snapshot ?? [],
|
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
|
||||||
))->only($areas)->all();
|
}
|
||||||
|
$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(
|
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||||
$project,
|
$project,
|
||||||
$configuration,
|
$configuration,
|
||||||
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
|
|||||||
$project->fresh(),
|
$project->fresh(),
|
||||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return $blockedAreas;
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->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()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'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
|
* @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);
|
$normalized = self::normalize($items);
|
||||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
: $existingCriteria->get($criterionId);
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
$criterion === null
|
$reuseUnidentifiedCriteria
|
||||||
|
&& $criterion === null
|
||||||
&& $fallbackIndex < $fallbackCriteria->count()
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
) {
|
) {
|
||||||
/** @var AcceptanceCriterion $candidate */
|
/** @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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
|||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
User $actor,
|
User $actor,
|
||||||
array $validated,
|
array $validated,
|
||||||
|
bool $replaceCriteriaWithIds = false,
|
||||||
): bool {
|
): bool {
|
||||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
|||||||
);
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$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']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
|||||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||||
$existingFieldValues = $ticket->fieldValues
|
$existingFieldValues = $ticket->fieldValues
|
||||||
->keyBy('ticket_field_definition_id');
|
->keyBy('ticket_field_definition_id');
|
||||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||||
|
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||||
|
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
|||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$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']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class ProjectController extends Controller
|
|||||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
|
||||||
$this->updateProjectTemplateLink->reset(
|
$blockedAreas = $this->updateProjectTemplateLink->reset(
|
||||||
$project->load('projectTemplate'),
|
$project->load('projectTemplate'),
|
||||||
$areas,
|
$areas,
|
||||||
$statusReplacements,
|
$statusReplacements,
|
||||||
@@ -171,6 +171,10 @@ class ProjectController extends Controller
|
|||||||
? "Selected settings were reset to the {$template->name} template."
|
? "Selected settings were reset to the {$template->name} template."
|
||||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
: "{$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);
|
return back()->with('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Projects\CreateProjectTemplate;
|
use App\Actions\Projects\CreateProjectTemplate;
|
||||||
|
use App\Actions\Projects\DeleteProjectTemplate;
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||||
use App\Actions\Projects\UpdateProjectTemplate;
|
use App\Actions\Projects\UpdateProjectTemplate;
|
||||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||||
|
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
|
||||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\ProjectTemplateResource;
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||||
|
private readonly DeleteProjectTemplate $deleteProjectTemplate,
|
||||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
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
|
public function edit(ProjectTemplate $projectTemplate): Response
|
||||||
{
|
{
|
||||||
abort_if(! $projectTemplate->is_published, 404);
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
|
|||||||
abort_if(! $projectTemplate->is_published, 404);
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|
||||||
$templateName = $projectTemplate->name;
|
$templateName = $projectTemplate->name;
|
||||||
$projectTemplate->delete();
|
$this->deleteProjectTemplate->handle($projectTemplate);
|
||||||
|
|
||||||
return to_route('project-templates.index')
|
return to_route('project-templates.index')
|
||||||
->with('status', "Template {$templateName} was deleted.");
|
->with('status', "Template {$templateName} was deleted.");
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$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])
|
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(
|
public function destroy(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
|||||||
|
|
||||||
if ($request->routeIs([
|
if ($request->routeIs([
|
||||||
'logout',
|
'logout',
|
||||||
|
'native-app.edit',
|
||||||
'password.force-change.edit',
|
'password.force-change.edit',
|
||||||
'user-password.update',
|
'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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
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]),
|
Rule::in([$project->key]),
|
||||||
],
|
],
|
||||||
'status_replacements' => ['sometimes', 'array'],
|
'status_replacements' => ['sometimes', 'array'],
|
||||||
'status_replacements.*' => [
|
'status_replacements.*' => ['required'],
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
|||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$validated = $validator->validated();
|
$validated = $validator->validated();
|
||||||
$areas = array_values(Arr::get(
|
$project->loadMissing('projectTemplate');
|
||||||
$validated,
|
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||||
'areas',
|
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||||
DuplicateProjectConfiguration::copyableAreas(),
|
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)) {
|
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||||
$project->loadMissing('projectTemplate');
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||||
$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');
|
|
||||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
foreach ($replacements as $id => $replacement) {
|
||||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||||
if ($status->tickets_count === 0) {
|
|| ! 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
$replacement = Arr::get($replacements, (string) $status->id);
|
||||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||||
$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
|
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
|
protected function prepareForValidation(): void
|
||||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$project = $this->route('project');
|
||||||
|
$baseTemplate = $project?->projectTemplate;
|
||||||
|
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
'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' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['present', 'array'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'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),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
|||||||
|
|
||||||
class UpdateProjectTemplateRequest extends FormRequest
|
class UpdateProjectTemplateRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
use ValidatesProjectTemplateConfiguration;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('create-project') ?? false;
|
return $this->user()?->can('create-project') ?? false;
|
||||||
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
|
|||||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...$this->configurationRules(),
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['present', 'array'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
'confirm_linked_projects_update' => [
|
'confirm_linked_projects_update' => [
|
||||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
|
|||||||
? null
|
? null
|
||||||
: [
|
: [
|
||||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
'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_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||||
|
|||||||
@@ -2,24 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Mcp\Tools\CreateTicket;
|
use App\Mcp\Tools\CreateTicket;
|
||||||
|
use App\Mcp\Tools\GetProjectConfiguration;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
|
use App\Mcp\Tools\ListProjects;
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
use App\Mcp\Tools\ListTicketTypes;
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
|
use App\Mcp\Tools\SearchTickets;
|
||||||
|
use App\Mcp\Tools\UpdateTicket;
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
use Laravel\Mcp\Server\Attributes\Version;
|
use Laravel\Mcp\Server\Attributes\Version;
|
||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.1.0')]
|
#[Version('0.2.1')]
|
||||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
|
||||||
class KanbanGitServer extends Server
|
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 = [
|
protected array $tools = [
|
||||||
ListMyAssignedTickets::class,
|
ListMyAssignedTickets::class,
|
||||||
ListTicketsByStatus::class,
|
ListTicketsByStatus::class,
|
||||||
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
|
|||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
CreateTicket::class,
|
CreateTicket::class,
|
||||||
ListTicketTypes::class,
|
ListTicketTypes::class,
|
||||||
|
ListProjects::class,
|
||||||
|
GetProjectConfiguration::class,
|
||||||
|
SearchTickets::class,
|
||||||
|
UpdateTicket::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
|||||||
return $ticket;
|
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
|
* @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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
|
|||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class CreateTicket extends Tool
|
class CreateTicket extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly CreateProjectTicket $createProjectTicket,
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
|
|||||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'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_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_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)],
|
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||||
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
|
|||||||
return [
|
return [
|
||||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||||
'title' => $schema->string()->max(255)->description('Ticket title.')->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_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_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(),
|
'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),
|
'checked' => $schema->boolean()->default(false),
|
||||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||||
])->withoutAdditionalProperties())
|
])->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()
|
'field_values' => $schema->array()
|
||||||
->items($schema->object([
|
->items($schema->object([
|
||||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||||
|
|||||||
@@ -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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
|||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
#[Name('get_ticket')]
|
#[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]
|
#[IsReadOnly]
|
||||||
class GetTicket extends Tool
|
class GetTicket extends Tool
|
||||||
{
|
{
|
||||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$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);
|
$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([
|
return Response::structured([
|
||||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
->required(),
|
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\MoveProjectTicket;
|
use App\Actions\Tickets\MoveProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class MoveTicketStatus extends Tool
|
class MoveTicketStatus extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly MoveProjectTicket $moveProjectTicket,
|
private readonly MoveProjectTicket $moveProjectTicket,
|
||||||
|
|||||||
@@ -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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketAssignee extends Tool
|
class UpdateTicketAssignee extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\ResponseFactory;
|
use Laravel\Mcp\ResponseFactory;
|
||||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketDescription extends Tool
|
class UpdateTicketDescription extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||||
$payload = [
|
$payload = [
|
||||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||||
'description' => $validated['description'] ?? null,
|
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
return [
|
return [
|
||||||
'description' => $schema->string()
|
'description' => $schema->string()
|
||||||
->nullable()
|
->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()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID.')
|
||||||
->required(),
|
->required(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
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
|
class BoardColumn extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BoardColumnFactory> */
|
/** @use HasFactory<BoardColumnFactory> */
|
||||||
|
|||||||
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
trait SyncsProjectTemplateOwnership
|
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
|
protected function syncProjectTemplateOwnership(): void
|
||||||
{
|
{
|
||||||
|
if ($this->getAttribute('configuration_key') === null) {
|
||||||
|
$this->setAttribute('configuration_key', (string) Str::uuid());
|
||||||
|
}
|
||||||
|
|
||||||
$projectTemplateId = $this->getAttribute('project_template_id');
|
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||||
$projectId = $this->getAttribute('project_id');
|
$projectId = $this->getAttribute('project_id');
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable(['configuration_key',
|
||||||
'channel',
|
'channel',
|
||||||
'channel_configuration',
|
'channel_configuration',
|
||||||
'delivery_url',
|
'delivery_url',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable(['configuration_key',
|
||||||
'base_url',
|
'base_url',
|
||||||
'owner_name',
|
'owner_name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketFieldDefinition extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketLinkType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
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
|
class TicketStatus extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketStatusFactory> */
|
/** @use HasFactory<TicketStatusFactory> */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketTypeFactory> */
|
/** @use HasFactory<TicketTypeFactory> */
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
|
|||||||
public function deleted(Model $model): void
|
public function deleted(Model $model): void
|
||||||
{
|
{
|
||||||
$this->markOverride($model);
|
$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
|
private function markOverride(Model $model): void
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1451,20 +1451,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.26.1",
|
"version": "v13.34.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"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",
|
"composer-runtime-api": "^2.2",
|
||||||
"doctrine/inflector": "^2.0.5",
|
"doctrine/inflector": "^2.0.5",
|
||||||
"dragonmantank/cron-expression": "^3.4",
|
"dragonmantank/cron-expression": "^3.4",
|
||||||
@@ -1576,18 +1576,20 @@
|
|||||||
"league/flysystem-sftp-v3": "^3.25.1",
|
"league/flysystem-sftp-v3": "^3.25.1",
|
||||||
"mockery/mockery": "^1.6.10",
|
"mockery/mockery": "^1.6.10",
|
||||||
"opis/json-schema": "^2.4.1",
|
"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",
|
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||||
"php-http/discovery": "^1.15",
|
"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",
|
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||||
"predis/predis": "^2.3 || ^3.0",
|
"predis/predis": "^2.3 || ^3.0",
|
||||||
"rector/rector": "^2.3",
|
"rector/rector": "2.6.3",
|
||||||
"resend/resend-php": "^1.0",
|
"resend/resend-php": "^1.0",
|
||||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||||
"symfony/http-client": "^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/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": {
|
"suggest": {
|
||||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
"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/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/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/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/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",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -1674,7 +1678,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"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",
|
"name": "laravel/mcp",
|
||||||
@@ -2003,16 +2007,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.10.0",
|
"version": "2.10.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2106,7 +2110,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-11T16:06:25+00:00"
|
"time": "2026-09-21T13:07:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
@@ -2192,16 +2196,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem",
|
"name": "league/flysystem",
|
||||||
"version": "3.35.2",
|
"version": "3.36.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/flysystem.git",
|
"url": "https://github.com/thephpleague/flysystem.git",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2269,9 +2273,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
"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",
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<BoardColumn>
|
* @extends Factory<BoardColumn>
|
||||||
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'name' => fake()->unique()->words(2, true),
|
'name' => fake()->unique()->words(2, true),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
|
|||||||
use App\Models\NotificationAction;
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<NotificationAction>
|
* @extends Factory<NotificationAction>
|
||||||
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'channel' => NotificationChannel::Webhook,
|
'channel' => NotificationChannel::Webhook,
|
||||||
'channel_configuration' => [],
|
'channel_configuration' => [],
|
||||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
'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\Project;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<RepositoryConnection>
|
* @extends Factory<RepositoryConnection>
|
||||||
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'base_url' => 'https://git.'.fake()->domainName(),
|
'base_url' => 'https://git.'.fake()->domainName(),
|
||||||
'owner_name' => fake()->userName(),
|
'owner_name' => fake()->userName(),
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketFieldDefinition>
|
* @extends Factory<TicketFieldDefinition>
|
||||||
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
||||||
'label' => fake()->unique()->words(2, true),
|
'label' => fake()->unique()->words(2, true),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketLinkType;
|
use App\Models\TicketLinkType;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketLinkType>
|
* @extends Factory<TicketLinkType>
|
||||||
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
|
|||||||
$label = fake()->unique()->words(2, true);
|
$label = fake()->unique()->words(2, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'counterpart_label' => $label,
|
'counterpart_label' => $label,
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketPriority>
|
* @extends Factory<TicketPriority>
|
||||||
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
|
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketStatus>
|
* @extends Factory<TicketStatus>
|
||||||
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'board_column_id' => null,
|
'board_column_id' => null,
|
||||||
'name' => fake()->unique()->words(2, true),
|
'name' => fake()->unique()->words(2, true),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketType;
|
use App\Models\TicketType;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketType>
|
* @extends Factory<TicketType>
|
||||||
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'is_parent' => false,
|
'is_parent' => false,
|
||||||
'name' => fake()->unique()->jobTitle(),
|
'name' => fake()->unique()->jobTitle(),
|
||||||
'position' => 1,
|
'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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
|
||||||
|
export const appleTeamId = 'ZZX2KZGTHU';
|
||||||
|
export const bundleIdentifier = 'de.kanbangit.ios';
|
||||||
|
|
||||||
|
const plistReader = `import base64, datetime, json, plistlib, sys
|
||||||
|
def encode(value):
|
||||||
|
if isinstance(value, bytes): return base64.b64encode(value).decode()
|
||||||
|
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
|
||||||
|
raise TypeError(type(value).__name__)
|
||||||
|
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
|
||||||
|
|
||||||
|
function execute(command, args, options) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
input: options.input,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.stdout?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProfile(profile, mode, now = new Date()) {
|
||||||
|
const entitlements = profile.Entitlements ?? {};
|
||||||
|
if (
|
||||||
|
profile.TeamIdentifier?.length !== 1 ||
|
||||||
|
profile.TeamIdentifier[0] !== appleTeamId ||
|
||||||
|
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
|
||||||
|
entitlements['application-identifier'] !==
|
||||||
|
`${appleTeamId}.${bundleIdentifier}`
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
|
||||||
|
!(new Date(profile.ExpirationDate) > now) ||
|
||||||
|
!profile.DeveloperCertificates?.length ||
|
||||||
|
!profile.Platform?.includes('iOS')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === 'testflight') {
|
||||||
|
if (
|
||||||
|
entitlements['get-task-allow'] !== false ||
|
||||||
|
entitlements['beta-reports-active'] !== true ||
|
||||||
|
'ProvisionedDevices' in profile ||
|
||||||
|
profile.ProvisionsAllDevices
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
entitlements['get-task-allow'] !== true ||
|
||||||
|
!profile.ProvisionedDevices?.length ||
|
||||||
|
profile.ProvisionsAllDevices
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Device development requires an iOS App Development profile containing your registered device.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectIdentity(profile, identities, mode) {
|
||||||
|
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
|
||||||
|
createHash('sha1')
|
||||||
|
.update(Buffer.from(certificate, 'base64'))
|
||||||
|
.digest('hex')
|
||||||
|
.toUpperCase(),
|
||||||
|
);
|
||||||
|
const prefix =
|
||||||
|
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
|
||||||
|
const matches = [
|
||||||
|
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
|
||||||
|
].filter(
|
||||||
|
([, fingerprint, name]) =>
|
||||||
|
fingerprints.includes(fingerprint.toUpperCase()) &&
|
||||||
|
name.startsWith(prefix),
|
||||||
|
);
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return matches[0][1].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIos({
|
||||||
|
mode = 'testflight',
|
||||||
|
env = process.env,
|
||||||
|
platform = process.platform,
|
||||||
|
directory = dirname(fileURLToPath(import.meta.url)),
|
||||||
|
home = homedir(),
|
||||||
|
run = execute,
|
||||||
|
log = console.log,
|
||||||
|
now = new Date(),
|
||||||
|
} = {}) {
|
||||||
|
if (!['testflight', 'development', 'simulator'].includes(mode)) {
|
||||||
|
throw new Error('Choose --mode testflight, development or simulator.');
|
||||||
|
}
|
||||||
|
if (platform !== 'darwin') {
|
||||||
|
throw new Error(
|
||||||
|
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
env.APPLE_DEVELOPMENT_TEAM &&
|
||||||
|
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
|
||||||
|
) {
|
||||||
|
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
|
||||||
|
}
|
||||||
|
if (env.TAURI_CONFIG) {
|
||||||
|
throw new Error(
|
||||||
|
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = JSON.parse(
|
||||||
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const ios = JSON.parse(
|
||||||
|
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const version = config.version;
|
||||||
|
const buildNumber = ios.bundle?.iOS?.bundleVersion;
|
||||||
|
if (
|
||||||
|
ios.identifier !== bundleIdentifier ||
|
||||||
|
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
|
||||||
|
config.identifier !== 'de.kanbangit.desktop' ||
|
||||||
|
config.productName !== 'KanbanGit' ||
|
||||||
|
!/^\d+\.\d+\.\d+$/.test(version) ||
|
||||||
|
!/^[1-9]\d*$/.test(buildNumber)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const simulator = mode === 'simulator';
|
||||||
|
const configuration = mode === 'testflight' ? 'release' : 'debug';
|
||||||
|
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
|
||||||
|
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
|
||||||
|
const project = join(directory, 'src-tauri/gen/apple');
|
||||||
|
const targetDirectory = join(directory, 'src-tauri/target');
|
||||||
|
const outputRoot = join(targetDirectory, `ios-${mode}`);
|
||||||
|
const destination = join(outputRoot, `${version}-${buildNumber}`);
|
||||||
|
if (mode === 'testflight' && existsSync(destination)) {
|
||||||
|
throw new Error(
|
||||||
|
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buildEnv = Object.fromEntries(
|
||||||
|
Object.entries(env).filter(
|
||||||
|
([key]) =>
|
||||||
|
!key.startsWith('APPLE_') &&
|
||||||
|
!key.startsWith('IOS_') &&
|
||||||
|
!key.startsWith('TAURI_') &&
|
||||||
|
![
|
||||||
|
'SDKROOT',
|
||||||
|
'CARGO_ENCODED_RUSTFLAGS',
|
||||||
|
'RUSTFLAGS',
|
||||||
|
'RUSTC_WRAPPER',
|
||||||
|
'RUSTC_WORKSPACE_WRAPPER',
|
||||||
|
'XCODE_XCCONFIG_FILE',
|
||||||
|
].includes(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
function step(label, command, args, options = {}) {
|
||||||
|
log(label);
|
||||||
|
return run(command, args, {
|
||||||
|
cwd: directory,
|
||||||
|
env: buildEnv,
|
||||||
|
step: label,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function parsePlist(xml) {
|
||||||
|
return JSON.parse(
|
||||||
|
step(
|
||||||
|
'Reading local plist metadata',
|
||||||
|
'python3',
|
||||||
|
['-c', plistReader],
|
||||||
|
{ input: xml },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function readProfile(path) {
|
||||||
|
return validateProfile(
|
||||||
|
parsePlist(
|
||||||
|
step('Reading provisioning profile', 'security', [
|
||||||
|
'cms',
|
||||||
|
'-D',
|
||||||
|
'-i',
|
||||||
|
path,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
mode,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
step(
|
||||||
|
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
|
||||||
|
'xcodegen',
|
||||||
|
['--version'],
|
||||||
|
);
|
||||||
|
step('Checking Xcode', 'xcodebuild', ['-version']);
|
||||||
|
const installedTargets = step('Checking installed Rust targets', 'rustup', [
|
||||||
|
'target',
|
||||||
|
'list',
|
||||||
|
'--installed',
|
||||||
|
]);
|
||||||
|
if (!installedTargets.split(/\s+/).includes(target)) {
|
||||||
|
throw new Error(
|
||||||
|
`Install the missing target permanently with rustup target add ${target}, then retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
|
||||||
|
'which',
|
||||||
|
'cargo',
|
||||||
|
]);
|
||||||
|
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
|
||||||
|
'which',
|
||||||
|
'rustc',
|
||||||
|
]);
|
||||||
|
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
|
||||||
|
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
|
||||||
|
'--sdk',
|
||||||
|
sdk,
|
||||||
|
'--show-sdk-path',
|
||||||
|
]);
|
||||||
|
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
|
||||||
|
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
||||||
|
let profile;
|
||||||
|
let profilePath;
|
||||||
|
let identity;
|
||||||
|
if (!simulator) {
|
||||||
|
if (
|
||||||
|
!env.IOS_PROVISIONING_PROFILE ||
|
||||||
|
!existsSync(env.IOS_PROVISIONING_PROFILE)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
|
||||||
|
const withinProject = relative(
|
||||||
|
realpathSync(join(directory, '..')),
|
||||||
|
profilePath,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!withinProject.startsWith(
|
||||||
|
`..${process.platform === 'win32' ? '\\' : '/'}`,
|
||||||
|
) &&
|
||||||
|
!isAbsolute(withinProject)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Keep provisioning profiles outside the repository.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
profile = readProfile(profilePath);
|
||||||
|
identity = selectIdentity(
|
||||||
|
profile,
|
||||||
|
step('Checking local signing identities', 'security', [
|
||||||
|
'find-identity',
|
||||||
|
'-v',
|
||||||
|
'-p',
|
||||||
|
'codesigning',
|
||||||
|
]),
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mkdirSync(outputRoot, { recursive: true });
|
||||||
|
const working = mkdtempSync(join(outputRoot, '.building-'));
|
||||||
|
let installedProfile;
|
||||||
|
try {
|
||||||
|
if (profile) {
|
||||||
|
const installedPath = join(
|
||||||
|
home,
|
||||||
|
'Library/Developer/Xcode/UserData/Provisioning Profiles',
|
||||||
|
`${profile.UUID}.mobileprovision`,
|
||||||
|
);
|
||||||
|
mkdirSync(dirname(installedPath), { recursive: true });
|
||||||
|
if (existsSync(installedPath)) {
|
||||||
|
if (
|
||||||
|
!readFileSync(installedPath).equals(
|
||||||
|
readFileSync(profilePath),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyFileSync(profilePath, installedPath);
|
||||||
|
installedProfile = installedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step('Generating the Xcode project', 'xcodegen', [
|
||||||
|
'generate',
|
||||||
|
'--spec',
|
||||||
|
join(project, 'project.yml'),
|
||||||
|
]);
|
||||||
|
step(
|
||||||
|
'Building the iOS Rust library',
|
||||||
|
cargo,
|
||||||
|
[
|
||||||
|
'build',
|
||||||
|
'--manifest-path',
|
||||||
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
|
'--lib',
|
||||||
|
'--locked',
|
||||||
|
'--target',
|
||||||
|
target,
|
||||||
|
'--features',
|
||||||
|
'custom-protocol',
|
||||||
|
...(configuration === 'release' ? ['--release'] : []),
|
||||||
|
],
|
||||||
|
{ inherit: true },
|
||||||
|
);
|
||||||
|
const library = join(
|
||||||
|
targetDirectory,
|
||||||
|
target,
|
||||||
|
configuration,
|
||||||
|
'libkanbangit_native.a',
|
||||||
|
);
|
||||||
|
if (!existsSync(library)) {
|
||||||
|
throw new Error(
|
||||||
|
`The iOS Rust library was not produced: ${library}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
buildEnv.KANBANGIT_IOS_LIBRARY = library;
|
||||||
|
const archive = join(working, 'KanbanGit.xcarchive');
|
||||||
|
step(
|
||||||
|
'Archiving locally without Apple provisioning updates',
|
||||||
|
'xcodebuild',
|
||||||
|
[
|
||||||
|
'-project',
|
||||||
|
join(project, 'kanbangit-native.xcodeproj'),
|
||||||
|
'-scheme',
|
||||||
|
'kanbangit-native_iOS',
|
||||||
|
'-configuration',
|
||||||
|
configuration,
|
||||||
|
'-sdk',
|
||||||
|
sdk,
|
||||||
|
'-destination',
|
||||||
|
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
|
||||||
|
'-archivePath',
|
||||||
|
archive,
|
||||||
|
'-derivedDataPath',
|
||||||
|
join(targetDirectory, `ios-derived-${mode}`),
|
||||||
|
'archive',
|
||||||
|
`MARKETING_VERSION=${version}`,
|
||||||
|
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
||||||
|
`DEVELOPMENT_TEAM=${appleTeamId}`,
|
||||||
|
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
|
||||||
|
'CODE_SIGN_STYLE=Manual',
|
||||||
|
'ARCHS=arm64',
|
||||||
|
...(simulator
|
||||||
|
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
|
||||||
|
: [
|
||||||
|
`CODE_SIGN_IDENTITY=${identity}`,
|
||||||
|
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
{ inherit: true },
|
||||||
|
);
|
||||||
|
function verifyApp(app) {
|
||||||
|
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
|
||||||
|
if (
|
||||||
|
info.CFBundleIdentifier !== bundleIdentifier ||
|
||||||
|
info.CFBundleShortVersionString !== version ||
|
||||||
|
info.CFBundleVersion !== buildNumber
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The built app has an unexpected identifier, version or build number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!simulator) {
|
||||||
|
step('Verifying the Apple signature and team', 'codesign', [
|
||||||
|
'--verify',
|
||||||
|
'--deep',
|
||||||
|
'--strict',
|
||||||
|
'-R',
|
||||||
|
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
|
||||||
|
app,
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
|
||||||
|
profile.UUID
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The app was signed with an unexpected provisioning profile.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
|
||||||
|
if (!simulator) {
|
||||||
|
const exportOptions = join(working, 'ExportOptions.plist');
|
||||||
|
writeFileSync(
|
||||||
|
exportOptions,
|
||||||
|
JSON.stringify({
|
||||||
|
method:
|
||||||
|
mode === 'testflight'
|
||||||
|
? 'app-store-connect'
|
||||||
|
: 'debugging',
|
||||||
|
destination: 'export',
|
||||||
|
signingStyle: 'manual',
|
||||||
|
teamID: appleTeamId,
|
||||||
|
signingCertificate: identity,
|
||||||
|
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
step('Preparing local export options', 'plutil', [
|
||||||
|
'-convert',
|
||||||
|
'xml1',
|
||||||
|
exportOptions,
|
||||||
|
]);
|
||||||
|
step(
|
||||||
|
'Exporting the signed IPA locally (no upload)',
|
||||||
|
'xcodebuild',
|
||||||
|
[
|
||||||
|
'-exportArchive',
|
||||||
|
'-archivePath',
|
||||||
|
archive,
|
||||||
|
'-exportPath',
|
||||||
|
join(working, 'export'),
|
||||||
|
'-exportOptionsPlist',
|
||||||
|
exportOptions,
|
||||||
|
],
|
||||||
|
{ inherit: true },
|
||||||
|
);
|
||||||
|
const ipa = join(working, 'export/KanbanGit.ipa');
|
||||||
|
if (!existsSync(ipa)) {
|
||||||
|
throw new Error('Xcode did not produce the signed IPA.');
|
||||||
|
}
|
||||||
|
const extracted = join(working, 'verify');
|
||||||
|
step('Verifying the exported IPA contents', 'ditto', [
|
||||||
|
'-x',
|
||||||
|
'-k',
|
||||||
|
ipa,
|
||||||
|
extracted,
|
||||||
|
]);
|
||||||
|
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
|
||||||
|
rmSync(extracted, { recursive: true, force: true });
|
||||||
|
writeFileSync(
|
||||||
|
`${ipa}.sha256`,
|
||||||
|
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode !== 'testflight') {
|
||||||
|
rmSync(destination, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
renameSync(working, destination);
|
||||||
|
log(`Verified local ${mode} build: ${destination}`);
|
||||||
|
return destination;
|
||||||
|
} catch (error) {
|
||||||
|
log(`Build did not complete. Diagnostic output remains in ${working}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (installedProfile) {
|
||||||
|
rmSync(installedProfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: { mode: { type: 'string', default: 'testflight' } },
|
||||||
|
});
|
||||||
|
buildIos({ mode: values.mode });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
appendFileSync,
|
appendFileSync,
|
||||||
|
copyFileSync,
|
||||||
|
readlinkSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { updateSource } from './update-manifest.mjs';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import {
|
import {
|
||||||
appleTeamId,
|
appleTeamId,
|
||||||
@@ -28,9 +31,10 @@ export function releaseMetadata(
|
|||||||
const config = JSON.parse(
|
const config = JSON.parse(
|
||||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||||
);
|
);
|
||||||
const packageVersion = JSON.parse(
|
const nativePackage = JSON.parse(
|
||||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||||
).version;
|
);
|
||||||
|
const packageVersion = nativePackage.version;
|
||||||
const cargoVersion = readFileSync(
|
const cargoVersion = readFileSync(
|
||||||
join(directory, 'src-tauri/Cargo.toml'),
|
join(directory, 'src-tauri/Cargo.toml'),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -59,6 +63,23 @@ export function releaseMetadata(
|
|||||||
'The macOS product name and bundle identifier must stay unchanged.',
|
'The macOS product name and bundle identifier must stay unchanged.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
||||||
|
throw new Error(
|
||||||
|
'The app updater key must match the permanent release key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Set a numeric macOS bundleVersion for the About build number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!nativePackage.releaseNotes?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Add releaseNotes to native/package.json before publishing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||||
const arch = {
|
const arch = {
|
||||||
'aarch64-apple-darwin': 'arm64',
|
'aarch64-apple-darwin': 'arm64',
|
||||||
@@ -81,9 +102,11 @@ export function releaseMetadata(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: config.version,
|
version: config.version,
|
||||||
|
notes: nativePackage.releaseNotes,
|
||||||
target,
|
target,
|
||||||
arch,
|
arch,
|
||||||
filename: `KanbanGit-${config.version}-${target}.zip`,
|
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
||||||
|
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,25 +169,65 @@ export function packageNotarizedApp({
|
|||||||
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||||
try {
|
try {
|
||||||
const archive = join(staging, metadata.filename);
|
const archive = join(staging, metadata.filename);
|
||||||
run('ditto', [
|
const bundle = join(root, metadata.target, 'release/bundle');
|
||||||
'-c',
|
copyFileSync(
|
||||||
'-k',
|
join(
|
||||||
'--sequesterRsrc',
|
bundle,
|
||||||
'--keepParent',
|
'dmg',
|
||||||
app,
|
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
||||||
|
),
|
||||||
archive,
|
archive,
|
||||||
|
);
|
||||||
|
const mounted = join(staging, 'mounted');
|
||||||
|
mkdirSync(mounted);
|
||||||
|
run('hdiutil', ['verify', archive]);
|
||||||
|
run('hdiutil', [
|
||||||
|
'attach',
|
||||||
|
archive,
|
||||||
|
'-readonly',
|
||||||
|
'-nobrowse',
|
||||||
|
'-mountpoint',
|
||||||
|
mounted,
|
||||||
]);
|
]);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'DMG must contain the Applications installation link.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
||||||
|
} finally {
|
||||||
|
run('hdiutil', ['detach', mounted]);
|
||||||
|
}
|
||||||
|
rmSync(mounted, { recursive: true, force: true });
|
||||||
|
const updateArchive = join(staging, metadata.updaterFilename);
|
||||||
|
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
||||||
|
copyFileSync(
|
||||||
|
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
||||||
|
`${updateArchive}.sig`,
|
||||||
|
);
|
||||||
|
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
||||||
|
throw new Error('The updater signature is missing.');
|
||||||
|
}
|
||||||
|
const extracted = join(staging, 'extracted');
|
||||||
|
mkdirSync(extracted);
|
||||||
|
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
||||||
|
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||||
|
rmSync(extracted, { recursive: true, force: true });
|
||||||
const checksum = createHash('sha256')
|
const checksum = createHash('sha256')
|
||||||
.update(readFileSync(archive))
|
.update(readFileSync(archive))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
.update(readFileSync(join(staging, name)))
|
||||||
|
.digest('hex');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
`${archive}.sha256`,
|
join(staging, `${name}.sha256`),
|
||||||
`${checksum} ${metadata.filename}\n`,
|
`${hash} ${name}\n`,
|
||||||
);
|
);
|
||||||
const extracted = join(staging, 'extracted');
|
}
|
||||||
run('ditto', ['-x', '-k', archive, extracted]);
|
|
||||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
|
||||||
rmSync(extracted, { recursive: true, force: true });
|
|
||||||
renameSync(staging, destination);
|
renameSync(staging, destination);
|
||||||
return { ...metadata, directory: destination, checksum };
|
return { ...metadata, directory: destination, checksum };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,6 +254,7 @@ if (
|
|||||||
appendFileSync(
|
appendFileSync(
|
||||||
process.env.GITHUB_OUTPUT,
|
process.env.GITHUB_OUTPUT,
|
||||||
Object.entries(output)
|
Object.entries(output)
|
||||||
|
.filter(([key]) => key !== 'notes')
|
||||||
.map(([key, value]) => `${key}=${value}\n`)
|
.map(([key, value]) => `${key}=${value}\n`)
|
||||||
.join(''),
|
.join(''),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
export function createDiskImage({ app, archive, identity, run }) {
|
||||||
|
const contents = join(dirname(archive), 'dmg-contents');
|
||||||
|
mkdirSync(contents);
|
||||||
|
try {
|
||||||
|
run('ditto', [app, join(contents, 'KanbanGit.app')]);
|
||||||
|
symlinkSync('/Applications', join(contents, 'Applications'));
|
||||||
|
run('hdiutil', [
|
||||||
|
'create',
|
||||||
|
'-volname',
|
||||||
|
'KanbanGit',
|
||||||
|
'-srcfolder',
|
||||||
|
contents,
|
||||||
|
'-format',
|
||||||
|
'UDZO',
|
||||||
|
archive,
|
||||||
|
]);
|
||||||
|
run('codesign', ['--sign', identity, '--timestamp', archive]);
|
||||||
|
run('hdiutil', ['verify', archive]);
|
||||||
|
} finally {
|
||||||
|
rmSync(contents, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.0",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
"build:android": "tauri android build --debug --apk --target aarch64",
|
"build:android": "tauri android build --debug --apk --target aarch64",
|
||||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||||
"dev:ios": "tauri ios dev",
|
"dev:ios": "tauri ios dev",
|
||||||
"build:ios:simulator": "rm -rf src-tauri/gen/apple/build/arm64-sim/KanbanGit.app && tauri ios build --debug --target aarch64-sim --no-sign --ci",
|
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||||
"build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only",
|
"build:ios:device": "node build-ios.mjs --mode development",
|
||||||
|
"release:ios": "node build-ios.mjs --mode testflight",
|
||||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test"
|
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
||||||
|
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
}
|
},
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import {
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
updateManifest,
|
||||||
|
updateSource,
|
||||||
|
verifyUpdateSignature,
|
||||||
|
} from './update-manifest.mjs';
|
||||||
|
|
||||||
export function storageConfiguration(env = process.env) {
|
export function storageConfiguration(env = process.env) {
|
||||||
const region = env.S3_REGION;
|
const region = env.S3_REGION;
|
||||||
@@ -56,14 +63,47 @@ export function storageConfiguration(env = process.env) {
|
|||||||
|
|
||||||
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
||||||
|
|
||||||
|
export function conditionalEtag(etag) {
|
||||||
|
const value =
|
||||||
|
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
|
||||||
|
? etag.slice(1, -1)
|
||||||
|
: etag;
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot atomically replace an object without a valid S3 ETag.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresExistingObjectCheck(error) {
|
||||||
|
// AWS CLI can crash while formatting Hetzner's empty error Message.
|
||||||
|
// This only permits a readback; content checks and conditional writes still apply.
|
||||||
|
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishMacos({
|
export async function publishMacos({
|
||||||
env = process.env,
|
env = process.env,
|
||||||
directory,
|
directory,
|
||||||
metadata,
|
metadata,
|
||||||
run = execute,
|
run = execute,
|
||||||
fetchPublic = fetch,
|
fetchPublic = fetch,
|
||||||
|
wait = setTimeout,
|
||||||
|
publicKey = updateSource.publicKey,
|
||||||
|
feedBase = updateSource.baseUrl,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const storage = storageConfiguration(env);
|
const storage = storageConfiguration(env);
|
||||||
|
if (`${storage.publicBase}/${storage.prefix}` !== feedBase) {
|
||||||
|
throw new Error(
|
||||||
|
'Release storage does not match the update source bundled in the app.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||||
@@ -77,6 +117,21 @@ export async function publishMacos({
|
|||||||
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||||
throw new Error('Release archive does not match its SHA-256 file.');
|
throw new Error('Release archive does not match its SHA-256 file.');
|
||||||
}
|
}
|
||||||
|
const updater = readFileSync(join(directory, metadata.updaterFilename));
|
||||||
|
const signature = readFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sig`),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
verifyUpdateSignature(updater, signature, publicKey);
|
||||||
|
const updateChecksum = readFileSync(
|
||||||
|
join(directory, `${metadata.updaterFilename}.sha256`),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
updateChecksum.toString() !==
|
||||||
|
`${sha256(updater)} ${metadata.updaterFilename}\n`
|
||||||
|
) {
|
||||||
|
throw new Error('Updater archive does not match its SHA-256 file.');
|
||||||
|
}
|
||||||
const common = [
|
const common = [
|
||||||
'--endpoint-url',
|
'--endpoint-url',
|
||||||
storage.endpoint,
|
storage.endpoint,
|
||||||
@@ -86,6 +141,7 @@ export async function publishMacos({
|
|||||||
's3api',
|
's3api',
|
||||||
];
|
];
|
||||||
function s3(operation, args) {
|
function s3(operation, args) {
|
||||||
|
try {
|
||||||
return run('aws', [
|
return run('aws', [
|
||||||
...common,
|
...common,
|
||||||
operation,
|
operation,
|
||||||
@@ -93,8 +149,34 @@ export async function publishMacos({
|
|||||||
storage.bucket,
|
storage.bucket,
|
||||||
...args,
|
...args,
|
||||||
]);
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const keyIndex = args.indexOf('--key');
|
||||||
|
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
|
||||||
|
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const versioningOutput = s3('get-bucket-versioning', [
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
]).trim();
|
||||||
|
let versioning;
|
||||||
|
try {
|
||||||
|
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
|
||||||
|
versioning = JSON.parse(versioningOutput || '{}');
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON from S3 bucket versioning check.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!versioning ||
|
||||||
|
typeof versioning !== 'object' ||
|
||||||
|
Array.isArray(versioning)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid S3 bucket versioning response: expected an object.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const versioning = JSON.parse(s3('get-bucket-versioning', []));
|
|
||||||
if (versioning.Status) {
|
if (versioning.Status) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
||||||
@@ -103,55 +185,150 @@ export async function publishMacos({
|
|||||||
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||||
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
||||||
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
||||||
async function verifyPublic(name, contents) {
|
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||||
const response = await fetchPublic(`${publicPrefix}/${name}`, {
|
const latestKey = `${latestPrefix}/latest.json`;
|
||||||
|
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchPublic(`${prefix}/${name}`, {
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
|
cache: 'no-store',
|
||||||
signal: AbortSignal.timeout(60000),
|
signal: AbortSignal.timeout(60000),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Public download request failed for ${name}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await wait(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
if (
|
if (
|
||||||
!response.ok ||
|
|
||||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||||
sha256(contents)
|
sha256(contents)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Public download verification failed for ${name}.`);
|
throw new Error(
|
||||||
|
`Public download verification failed for ${name}: SHA-256 mismatch.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await response.body?.cancel();
|
||||||
|
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
if (!retryable || attempt === 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Public download verification failed for ${name}: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await wait(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function putImmutable(name, contents, contentType) {
|
function assertUnpublishedRelease(allowSameVersion = false) {
|
||||||
|
const file = join(staging, 'published-latest.json');
|
||||||
|
try {
|
||||||
|
s3('get-object', ['--key', latestKey, file]);
|
||||||
|
} catch (error) {
|
||||||
|
if (/\(NoSuchKey\)/.test(error.message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requiresExistingObjectCheck(error)) {
|
||||||
|
// A broken CLI error message cannot distinguish missing from denied.
|
||||||
|
// Only an authenticated, explicitly empty listing proves absence.
|
||||||
|
const listing = JSON.parse(
|
||||||
|
s3('list-objects-v2', [
|
||||||
|
'--prefix',
|
||||||
|
latestKey,
|
||||||
|
'--max-keys',
|
||||||
|
'1',
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
listing?.KeyCount === 0 &&
|
||||||
|
listing.IsTruncated === false &&
|
||||||
|
Array.isArray(listing.Contents ?? []) &&
|
||||||
|
(listing.Contents ?? []).length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const published = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
const comparison = compareVersions(metadata.version, published.version);
|
||||||
|
if (allowSameVersion && comparison < 0) {
|
||||||
|
throw new Error('Refusing to downgrade the stable download.');
|
||||||
|
}
|
||||||
|
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
|
||||||
|
throw new Error(
|
||||||
|
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The macos-release concurrency group serializes the entire publication.
|
||||||
|
// Never run independent publishers against the same release destination.
|
||||||
|
function putReleaseFile(name, contents, contentType) {
|
||||||
const file = join(staging, name);
|
const file = join(staging, name);
|
||||||
writeFileSync(file, contents);
|
writeFileSync(file, contents);
|
||||||
try {
|
function put(condition) {
|
||||||
s3('put-object', [
|
s3('put-object', [
|
||||||
'--key',
|
'--key',
|
||||||
`${keyPrefix}/${name}`,
|
`${keyPrefix}/${name}`,
|
||||||
'--body',
|
'--body',
|
||||||
file,
|
file,
|
||||||
'--if-none-match',
|
...condition,
|
||||||
'*',
|
|
||||||
'--content-type',
|
'--content-type',
|
||||||
contentType,
|
contentType,
|
||||||
'--cache-control',
|
'--cache-control',
|
||||||
'public, max-age=31536000, immutable',
|
'no-cache, max-age=0, must-revalidate',
|
||||||
'--content-md5',
|
'--content-md5',
|
||||||
createHash('md5').update(contents).digest('base64'),
|
createHash('md5').update(contents).digest('base64'),
|
||||||
'--metadata',
|
'--metadata',
|
||||||
`sha256=${sha256(contents)}`,
|
`sha256=${sha256(contents)}`,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
put(['--if-none-match', '*']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const existing = join(staging, `existing-${name}`);
|
const existing = join(staging, `existing-${name}`);
|
||||||
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
const response = JSON.parse(
|
||||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
s3('get-object', [
|
||||||
throw new Error(
|
'--key',
|
||||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
`${keyPrefix}/${name}`,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
existing,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||||
|
assertUnpublishedRelease();
|
||||||
|
put(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const files = [
|
const files = [
|
||||||
[metadata.filename, archive, 'application/zip'],
|
[metadata.filename, archive, 'application/x-apple-diskimage'],
|
||||||
|
[metadata.updaterFilename, updater, 'application/gzip'],
|
||||||
|
[
|
||||||
|
`${metadata.updaterFilename}.sig`,
|
||||||
|
Buffer.from(signature),
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${metadata.updaterFilename}.sha256`,
|
||||||
|
updateChecksum,
|
||||||
|
'text/plain; charset=utf-8',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
`${metadata.filename}.sha256`,
|
`${metadata.filename}.sha256`,
|
||||||
checksumFile,
|
checksumFile,
|
||||||
@@ -159,10 +336,10 @@ export async function publishMacos({
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
for (const [name, contents, type] of files) {
|
for (const [name, contents, type] of files) {
|
||||||
putImmutable(name, contents, type);
|
putReleaseFile(name, contents, type);
|
||||||
await verifyPublic(name, contents);
|
await verifyPublic(name, contents);
|
||||||
}
|
}
|
||||||
// This completion record is written only after both public downloads match.
|
// Completion metadata is written only after every public artifact matches.
|
||||||
const manifest = Buffer.from(
|
const manifest = Buffer.from(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -176,8 +353,128 @@ export async function publishMacos({
|
|||||||
2,
|
2,
|
||||||
) + '\n',
|
) + '\n',
|
||||||
);
|
);
|
||||||
putImmutable('release.json', manifest, 'application/json');
|
putReleaseFile('release.json', manifest, 'application/json');
|
||||||
await verifyPublic('release.json', manifest);
|
await verifyPublic('release.json', manifest);
|
||||||
|
const latest = Buffer.from(
|
||||||
|
JSON.stringify(
|
||||||
|
updateManifest(
|
||||||
|
metadata,
|
||||||
|
publicPrefix,
|
||||||
|
signature,
|
||||||
|
metadata.notes,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
|
await verifyPublic('update.json', latest);
|
||||||
|
// An older release must not populate an absent download alias either.
|
||||||
|
assertUnpublishedRelease(true);
|
||||||
|
const downloadName = 'KanbanGit.dmg';
|
||||||
|
const downloadKey = `${latestPrefix}/${downloadName}`;
|
||||||
|
const downloadFile = join(staging, downloadName);
|
||||||
|
writeFileSync(downloadFile, archive);
|
||||||
|
function publishDownload(condition) {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--body',
|
||||||
|
downloadFile,
|
||||||
|
...condition,
|
||||||
|
'--content-type',
|
||||||
|
'application/x-apple-diskimage',
|
||||||
|
'--cache-control',
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(archive).digest('base64'),
|
||||||
|
'--metadata',
|
||||||
|
`sha256=${checksum}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
publishDownload(['--if-none-match', '*']);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previousFile = join(staging, 'previous-download.dmg');
|
||||||
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
downloadKey,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
previousFile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (sha256(readFileSync(previousFile)) !== checksum) {
|
||||||
|
assertUnpublishedRelease();
|
||||||
|
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verifyPublic(
|
||||||
|
downloadName,
|
||||||
|
archive,
|
||||||
|
`${storage.publicBase}/${latestPrefix}`,
|
||||||
|
);
|
||||||
|
const latestFile = join(staging, 'latest.json');
|
||||||
|
writeFileSync(latestFile, latest);
|
||||||
|
function publishLatest(condition) {
|
||||||
|
s3('put-object', [
|
||||||
|
'--key',
|
||||||
|
latestKey,
|
||||||
|
'--body',
|
||||||
|
latestFile,
|
||||||
|
...condition,
|
||||||
|
'--content-type',
|
||||||
|
'application/json',
|
||||||
|
'--cache-control',
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
'--content-md5',
|
||||||
|
createHash('md5').update(latest).digest('base64'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
publishLatest(['--if-none-match', '*']);
|
||||||
|
} catch (error) {
|
||||||
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const previousFile = join(staging, 'previous-latest.json');
|
||||||
|
const response = JSON.parse(
|
||||||
|
s3('get-object', [
|
||||||
|
'--key',
|
||||||
|
latestKey,
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
previousFile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const previousBytes = readFileSync(previousFile);
|
||||||
|
const previous = JSON.parse(previousBytes);
|
||||||
|
const comparison = compareVersions(
|
||||||
|
metadata.version,
|
||||||
|
previous.version,
|
||||||
|
);
|
||||||
|
if (comparison < 0) {
|
||||||
|
throw new Error('Refusing to downgrade the update feed.');
|
||||||
|
}
|
||||||
|
if (comparison === 0) {
|
||||||
|
if (!previousBytes.equals(latest)) {
|
||||||
|
throw new Error(
|
||||||
|
'Published update metadata differs; bump the app version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await verifyPublic(
|
||||||
|
'latest.json',
|
||||||
|
latest,
|
||||||
|
`${storage.publicBase}/${latestPrefix}`,
|
||||||
|
);
|
||||||
return `${publicPrefix}/${metadata.filename}`;
|
return `${publicPrefix}/${metadata.filename}`;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(staging, { recursive: true, force: true });
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
|
import { createDiskImage } from './macos-dmg.mjs';
|
||||||
|
|
||||||
export const appleTeamId = 'ZZX2KZGTHU';
|
export const appleTeamId = 'ZZX2KZGTHU';
|
||||||
export const bundleIdentifier = 'de.kanbangit.desktop';
|
export const bundleIdentifier = 'de.kanbangit.desktop';
|
||||||
@@ -105,6 +106,11 @@ export function releaseMacos({
|
|||||||
throw new Error('--resume requires the Apple submission UUID.');
|
throw new Error('--resume requires the Apple submission UUID.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!env.TAURI_SIGNING_PRIVATE_KEY?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Set TAURI_SIGNING_PRIVATE_KEY to the permanent updater signing key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
const identity = env.APPLE_SIGNING_IDENTITY?.trim();
|
||||||
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
const profile = env.APPLE_NOTARY_KEYCHAIN_PROFILE?.trim();
|
||||||
|
|
||||||
@@ -405,20 +411,42 @@ export function releaseMacos({
|
|||||||
app,
|
app,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const name = `KanbanGit-${config.version}-${target}.zip`;
|
const name = `KanbanGit-${config.version}-${target}.dmg`;
|
||||||
const archive = join(staging, name);
|
const archive = join(staging, name);
|
||||||
step('Packaging notarized download', 'ditto', [
|
createDiskImage({
|
||||||
'-c',
|
|
||||||
'-k',
|
|
||||||
'--sequesterRsrc',
|
|
||||||
'--keepParent',
|
|
||||||
app,
|
app,
|
||||||
archive,
|
archive,
|
||||||
]);
|
identity,
|
||||||
|
run: (command, args) =>
|
||||||
|
step('Packaging notarized download', command, args),
|
||||||
|
});
|
||||||
const checksum = createHash('sha256')
|
const checksum = createHash('sha256')
|
||||||
.update(readFileSync(archive))
|
.update(readFileSync(archive))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
writeFileSync(`${archive}.sha256`, `${checksum} ${name}\n`);
|
||||||
|
const updaterName = `KanbanGit-${config.version}-${target}.app.tar.gz`;
|
||||||
|
const updaterArchive = join(staging, updaterName);
|
||||||
|
step('Packaging updater archive', 'tar', [
|
||||||
|
'-czf',
|
||||||
|
updaterArchive,
|
||||||
|
'-C',
|
||||||
|
dirname(app),
|
||||||
|
'KanbanGit.app',
|
||||||
|
]);
|
||||||
|
step('Signing updater archive', 'pnpm', [
|
||||||
|
'exec',
|
||||||
|
'tauri',
|
||||||
|
'signer',
|
||||||
|
'sign',
|
||||||
|
updaterArchive,
|
||||||
|
]);
|
||||||
|
const updaterChecksum = createHash('sha256')
|
||||||
|
.update(readFileSync(updaterArchive))
|
||||||
|
.digest('hex');
|
||||||
|
writeFileSync(
|
||||||
|
`${updaterArchive}.sha256`,
|
||||||
|
`${updaterChecksum} ${updaterName}\n`,
|
||||||
|
);
|
||||||
rmSync(submission, { force: true });
|
rmSync(submission, { force: true });
|
||||||
renameSync(staging, destination);
|
renameSync(staging, destination);
|
||||||
log(`Verified release: ${join(destination, name)}`);
|
log(`Verified release: ${join(destination, name)}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.0"
|
version = "0.2.8"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -13,6 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
semver = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -27,3 +30,11 @@ custom-protocol = ["tauri/custom-protocol"]
|
|||||||
name = "macos_smoke"
|
name = "macos_smoke"
|
||||||
harness = false
|
harness = false
|
||||||
required-features = ["smoke-test"]
|
required-features = ["smoke-test"]
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
tauri-plugin-updater = "=2.12.0"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "updater_smoke"
|
||||||
|
harness = false
|
||||||
|
required-features = ["smoke-test"]
|
||||||
|
|||||||
@@ -1,3 +1,74 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||||
|
build_navigation();
|
||||||
|
}
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"x86_64" => "x86_64",
|
||||||
|
_ => panic!("Unsupported macOS architecture"),
|
||||||
|
};
|
||||||
|
assert!(Command::new("xcrun")
|
||||||
|
.args([
|
||||||
|
"--sdk",
|
||||||
|
"macosx",
|
||||||
|
"clang",
|
||||||
|
"-fobjc-arc",
|
||||||
|
"-fblocks",
|
||||||
|
"-Wall",
|
||||||
|
"-Werror",
|
||||||
|
"-mmacosx-version-min=14.0",
|
||||||
|
"-arch",
|
||||||
|
arch,
|
||||||
|
"-c",
|
||||||
|
"src/navigation.m",
|
||||||
|
"-o"
|
||||||
|
])
|
||||||
|
.arg(&object)
|
||||||
|
.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());
|
||||||
|
println!("cargo:rustc-link-search=native={}", output.display());
|
||||||
|
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||||
|
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||||
|
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.SystemClock
|
|||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
|||||||
instrumentation.waitForIdleSync()
|
instrumentation.waitForIdleSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||||
|
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||||
|
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit().clear().apply {
|
||||||
|
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||||
|
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||||
|
}.commit()
|
||||||
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
val launcher = AtomicReference<WebView>()
|
||||||
|
fun findLauncher(view: View): WebView? {
|
||||||
|
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||||
|
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||||
|
findLauncher(view.getChildAt(index))?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
waitFor {
|
||||||
|
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||||
|
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||||
|
}
|
||||||
|
if (mode == "saved") {
|
||||||
|
waitFor {
|
||||||
|
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||||
|
if (state == "\"connecting\"") {
|
||||||
|
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||||
|
}
|
||||||
|
state == "\"connectionError\""
|
||||||
|
}
|
||||||
|
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||||
|
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||||
|
scenario.onActivity {
|
||||||
|
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||||
|
assertFalse(it.workspace!!.canGoBack())
|
||||||
|
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||||
|
}
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||||
|
} else {
|
||||||
|
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||||
|
scenario.onActivity { assertNull(it.workspace) }
|
||||||
|
}
|
||||||
|
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||||
|
val remote = AtomicReference<WebView>()
|
||||||
|
waitFor {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||||
|
visible && remote.get() != null
|
||||||
|
}
|
||||||
|
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||||
|
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||||
|
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||||
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
val saved = preferences.getString("server", null)
|
val saved = preferences.getString("server", null)
|
||||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
val restored = saved?.let(NavigationPolicy::installation)
|
||||||
updateLauncher()
|
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||||
if (saved != null) openWorkspace(server)
|
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||||
}
|
}
|
||||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||||
"/action/resume" -> if (ready) showWorkspace()
|
"/action/resume" -> if (ready) showWorkspace()
|
||||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
|||||||
val firstLoad = !ready
|
val firstLoad = !ready
|
||||||
ready = true
|
ready = true
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
updateLauncher()
|
|
||||||
if (firstLoad) showWorkspace()
|
if (firstLoad) showWorkspace()
|
||||||
|
updateLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
|||||||
remote.visibility = View.GONE
|
remote.visibility = View.GONE
|
||||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||||
startTimeout(remote)
|
startTimeout(remote)
|
||||||
remote.loadUrl(server)
|
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startTimeout(view: WebView) {
|
private fun startTimeout(view: WebView) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
|||||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||||
|
|
||||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||||
val base = URI(server)
|
val base = URI(server)
|
||||||
val target = URI(destination)
|
val target = URI(destination)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||||
|
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||||
|
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||||
val server = "https://example.com:8443/app/"
|
val server = "https://example.com:8443/app/"
|
||||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 845 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.1 KiB |