Compare commits

..
11 Commits
Author SHA1 Message Date
Twyco 704fe5d241 fix
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 11s
2026-10-01 21:15:43 +02:00
Twyco 73f6ab6918 fix
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 55s
CI/CD / Publish immutable image (push) Skipped
2026-10-01 21:03:01 +02:00
Twyco 6d4a823fe5 fix
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m9s
CI/CD / test_and_check_app (push) Failing after 57s
CI/CD / Publish immutable image (push) Skipped
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
2026-10-01 20:45:34 +02:00
Twyco 6054690607 Merge pull request 'KAN-64: Allow project admins to delete projects' (#171) from fix/KAN-64-cant-delete-project into main
CI/CD / preconditions (push) Successful in 1s
CI/CD / test_and_check_app (push) Failing after 54s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #171
2026-10-01 15:16:03 +00:00
Twyco 723285f820 fix: allow project admins to delete projects (KAN-64) 2026-10-01 15:16:03 +00:00
Twyco 393d074bb7 Merge pull request 'KAN-12: Support both ticket link directions' (#174) from feature/KAN-12-bidirectional-ticket-links into main
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Failing after 11s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #174
2026-10-01 15:15:32 +00:00
Twyco 3ec59ef367 feat: support both ticket link directions (KAN-12)
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Failing after 59s
CI/CD / Publish immutable image (pull_request) Skipped
2026-10-01 08:13:54 +02:00
Twyco 10f88f6029 Merge pull request 'Fix dependency CVEs and provide shared PR base' (#173) from fix/dependency-cves into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m8s
CI/CD / test_and_check_app (push) Successful in 10s
CI/CD / Publish immutable image (push) Successful in 35s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 0s
Reviewed-on: #173
2026-09-30 10:07:53 +00:00
Twyco 3b110f08ae fix
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 51s
CI/CD / test_and_check_app (pull_request) Successful in 5m49s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:57:19 +02:00
Twyco 1a022c257f fix
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 59s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m37s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:53:42 +02:00
Twyco ce36060ba1 fix: patch vulnerable framework and build dependencies
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m7s
CI/CD / test_and_check_app (pull_request) Successful in 6m26s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
2026-09-30 11:19:21 +02:00
20 changed files with 373 additions and 137 deletions
+12 -72
View File
@@ -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
+7 -1
View File
@@ -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,12 @@ class DeleteProject
{ {
$projectName = $project->name; $projectName = $project->name;
$project->delete(); DB::transaction(function () use ($project): void {
$baseTemplate = $project->baseTemplate()->first();
$project->delete();
$baseTemplate?->delete();
});
return $projectName; return $projectName;
} }
+15 -12
View File
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
) {} ) {}
/** /**
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated * @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
*/ */
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink 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,
], ],
); );
@@ -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()
@@ -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(
+1 -1
View File
@@ -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;
} }
/** /**
@@ -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'])],
]; ];
} }
Generated
+26 -22
View File
@@ -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",
+1 -1
View File
@@ -15,7 +15,7 @@
"dev": "vite", "dev": "vite",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"lint:check": "eslint .", "lint:check": "eslint .",
"types:check": "vue-tsc --noEmit" "types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+25 -17
View File
@@ -203,6 +203,14 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
importers: importers:
.: .:
@@ -1075,8 +1083,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axios@1.19.0: axios@1.20.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1088,14 +1096,14 @@ packages:
boolbase@1.0.0: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.18: brace-expansion@1.1.21:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@2.1.4: brace-expansion@2.1.7:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
brace-expansion@5.0.9: brace-expansion@5.0.12:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -2671,7 +2679,7 @@ snapshots:
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': '@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
dependencies: dependencies:
'@types/lodash-es': 4.17.12 '@types/lodash-es': 4.17.12
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
qs: 6.16.0 qs: 6.16.0
@@ -3287,7 +3295,7 @@ snapshots:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
form-data: 4.0.6 form-data: 4.0.6
@@ -3303,16 +3311,16 @@ snapshots:
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@1.1.18: brace-expansion@1.1.21:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.1.4: brace-expansion@2.1.7:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.9: brace-expansion@5.0.12:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -4055,7 +4063,7 @@ snapshots:
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: dependencies:
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -4206,15 +4214,15 @@ snapshots:
minimatch@10.2.4: minimatch@10.2.4:
dependencies: dependencies:
brace-expansion: 5.0.9 brace-expansion: 5.0.12
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.18 brace-expansion: 1.1.21
minimatch@9.0.9: minimatch@9.0.9:
dependencies: dependencies:
brace-expansion: 2.1.4 brace-expansion: 2.1.7
minimist@1.2.8: {} minimist@1.2.8: {}
+12
View File
@@ -1,4 +1,16 @@
minimumReleaseAge: 10080 minimumReleaseAge: 10080
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
minimumReleaseAgeExclude:
- brace-expansion@1.1.21
- brace-expansion@2.1.7
- brace-expansion@5.0.12
allowBuilds: allowBuilds:
unrs-resolver: true unrs-resolver: true
vue-demi: true vue-demi: true
overrides:
axios@>=1.13.0 <1.20.0: ^1.20.0
axios@>=1.15.0 <1.20.0: ^1.20.0
axios@>=1.15.1 <1.20.0: ^1.20.0
axios@>=1.15.2 <1.20.0: ^1.20.0
axios@>=1.16.1 <1.20.0: ^1.20.0
axios@>=1.17.0 <1.20.0: ^1.20.0
@@ -35,6 +35,7 @@ const canAddLink = computed(
); );
const form = useForm({ const form = useForm({
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '', ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
link_direction: 'source',
linked_ticket_id: '', linked_ticket_id: '',
}); });
const protection = useFormProtection(form, { const protection = useFormProtection(form, {
@@ -220,7 +221,7 @@ function submit(): void {
/> />
<fieldset <fieldset
:disabled="form.processing" :disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]" class="grid min-w-0 gap-4 sm:grid-cols-3"
> >
<TicketFormField <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
@@ -230,13 +231,33 @@ function submit(): void {
:options=" :options="
ticketLinkTypes.map((type) => ({ ticketLinkTypes.map((type) => ({
value: type.id, value: type.id,
label: type.label, label: `${type.label} / ${type.counterpart_label}`,
})) }))
" "
:error="form.errors.ticket_link_type_id" :error="form.errors.ticket_link_type_id"
:disabled="form.processing" :disabled="form.processing"
required required
/> />
<TicketFormField
id="link_direction"
v-model="form.link_direction"
kind="select"
:label="t('ticketRelationships.direction')"
:options="[
{
value: 'source',
label: selectedType?.label ?? '',
},
{
value: 'counterpart',
label:
selectedType?.counterpart_label ?? '',
},
]"
:error="form.errors.link_direction"
:disabled="form.processing"
required
/>
<TicketFormField <TicketFormField
id="linked_ticket_id" id="linked_ticket_id"
v-model="form.linked_ticket_id" v-model="form.linked_ticket_id"
@@ -267,7 +288,11 @@ function submit(): void {
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ selectedType.label }}</span ><span>{{
form.link_direction === 'source'
? selectedType.label
: selectedType.counterpart_label
}}</span
><ArrowRight class="size-4" aria-hidden="true" /><span ><ArrowRight class="size-4" aria-hidden="true" /><span
class="font-mono text-xs" class="font-mono text-xs"
>{{ selectedTicket.ticket_key }}</span >{{ selectedTicket.ticket_key }}</span
+2 -1
View File
@@ -1200,8 +1200,9 @@ export const en = {
'Open a child ticket to change its parent. Expand a group to see its descendants.', 'Open a child ticket to change its parent. Expand a group to see its descendants.',
'ticketRelationships.addLink': 'Add or update a link', 'ticketRelationships.addLink': 'Add or update a link',
'ticketRelationships.selectTicket': 'Select a ticket', 'ticketRelationships.selectTicket': 'Select a ticket',
'ticketRelationships.direction': 'This ticket',
'ticketRelationships.directionHelp': 'ticketRelationships.directionHelp':
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.', 'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
'ticketRelationships.outgoing': 'Outgoing', 'ticketRelationships.outgoing': 'Outgoing',
'ticketRelationships.incoming': 'Incoming', 'ticketRelationships.incoming': 'Incoming',
'ticketRelationships.removeLink': 'Remove link to {key}', 'ticketRelationships.removeLink': 'Remove link to {key}',
+1 -1
View File
@@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
}); });
Route::delete('/', [ProjectController::class, 'destroy']) Route::delete('/', [ProjectController::class, 'destroy'])
->middleware('can:access-admin') ->middleware('can:admin-project,project')
->name('destroy'); ->name('destroy');
Route::middleware('can:admin-project,project')->group(function (): void { Route::middleware('can:admin-project,project')->group(function (): void {
+8 -1
View File
@@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('data.key', 'CORE'); ->assertJsonPath('data.key', 'CORE');
$this->syncGlobalPermissions($admin, ['admin']); $this->withApplicationKey($user)
->deleteJson(route('api.projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertForbidden();
$this->syncProjectPermissions($project, $admin, ['admin']);
$this->assertTrue($admin->can('admin-project', $project));
$this->withApplicationKey($admin) $this->withApplicationKey($admin)
->deleteJson(route('api.projects.destroy', $project), [ ->deleteJson(route('api.projects.destroy', $project), [
+43
View File
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
$this->assertDatabaseCount('ticket_worklog_entries', 0); $this->assertDatabaseCount('ticket_worklog_entries', 0);
} }
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
{
[$user, $project] = $this->projectContext();
$status = $project->ticketStatuses()->firstOrFail();
$blockingTicket = $this->ticket($project, $status);
$blockedTicket = $this->ticket($project, $status, 2);
$linkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'counterpart',
])
->assertCreated()
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
->assertJsonPath('meta.message', 'Blocked by link was saved.');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
$this->withApplicationKey($user)
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
->assertOk()
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
$this->withApplicationKey($user)
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $linkType->id,
'link_direction' => 'invalid',
])
->assertUnprocessable()
->assertJsonValidationErrors('link_direction');
}
public function test_ticket_links_and_branch_endpoints_are_available(): void public function test_ticket_links_and_branch_endpoints_are_available(): void
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
@@ -817,6 +817,75 @@ class ProjectControllerTest extends TestCase
]); ]);
} }
public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$project = Project::query()->where('key', 'CORE')->firstOrFail();
$baseTemplateId = $project->project_template_id;
$this->assertTrue($user->can('admin-project', $project));
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
$this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]);
$this->actingAs($user)
->post(route('projects.store'), [
'creation_mode' => 'scratch',
'name' => 'New Core Platform',
'key' => 'CORE',
])
->assertRedirect();
$this->assertDatabaseHas('projects', [
'key' => 'CORE',
'name' => 'New Core Platform',
]);
}
public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void
{
$admin = User::factory()->admin()->create();
$project = Project::factory()->create(['key' => 'CORE']);
$otherProject = Project::factory()->create(['key' => 'OTHER']);
$template = ProjectTemplate::factory()->create([
'source_project_id' => $project->id,
'is_published' => true,
]);
$project->update(['project_template_id' => $template->id]);
$otherProject->update(['project_template_id' => $template->id]);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertRedirect(route('projects.index'));
$this->assertDatabaseHas('project_templates', [
'id' => $template->id,
'source_project_id' => null,
]);
$this->assertDatabaseHas('projects', [
'id' => $otherProject->id,
'project_template_id' => $template->id,
]);
}
/** /**
* Ensure project deletion requires the exact project key confirmation. * Ensure project deletion requires the exact project key confirmation.
*/ */
@@ -841,16 +910,18 @@ class ProjectControllerTest extends TestCase
} }
/** /**
* Ensure non-admin users cannot delete projects. * Ensure users without project administration cannot delete projects.
*/ */
public function test_non_admin_users_cannot_delete_projects(): void public function test_project_readers_cannot_delete_projects(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$otherProject = Project::factory()->create();
$project = Project::factory()->create([ $project = Project::factory()->create([
'key' => 'CORE', 'key' => 'CORE',
]); ]);
$this->syncProjectPermissions($project, $user, ['admin']); $this->syncProjectPermissions($otherProject, $user, ['admin']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user) $this->actingAs($user)
->delete(route('projects.destroy', $project), [ ->delete(route('projects.destroy', $project), [
@@ -83,6 +83,74 @@ class ProjectTicketLinkControllerTest extends TestCase
]); ]);
} }
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$blockingTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$blockedTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'counterpart',
])
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
->assertSessionHas('status', 'Blocked by link was saved.');
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockingTicket->id,
'target_ticket_id' => $blockedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockedTicket->id,
'summary' => 'Linked CORE-1 as Blocked by.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockingTicket->id,
'summary' => 'Linked CORE-2 as Blocks.',
]);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'source',
])
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertSame(1, TicketLink::query()->count());
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockedTicket->id,
'target_ticket_id' => $blockingTicket->id,
]);
}
/** /**
* Ensure ticket links cannot target the current ticket or a ticket from another project. * Ensure ticket links cannot target the current ticket or a ticket from another project.
*/ */
@@ -144,6 +212,15 @@ class ProjectTicketLinkControllerTest extends TestCase
]) ])
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHasErrors('linked_ticket_id'); ->assertSessionHasErrors('linked_ticket_id');
$this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket]))
->post(route('projects.tickets.links.store', [$project, $ticket]), [
'linked_ticket_id' => $foreignTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'sideways',
])
->assertSessionHasErrors('link_direction');
} }
/** /**
+7
View File
@@ -398,6 +398,11 @@ if (serve) {
} else { } else {
params.delete('noLinks'); params.delete('noLinks');
params.delete('linkRejected'); params.delete('linkRejected');
if (data.link_direction === 'counterpart') {
params.set('incoming', '1');
} else {
params.delete('incoming');
}
} }
} else { } else {
assert.ok( assert.ok(
@@ -486,6 +491,8 @@ if (serve) {
return result.body.split('id="main-content"')[1]; return result.body.split('id="main-content"')[1];
}; };
const html = await render(); const html = await render();
assert.ok(html.includes('id="link_direction"'));
assert.ok(html.includes('Blocked by'));
for (const text of [ for (const text of [
ticket.title, ticket.title,
'Keep every existing ticket action available.', 'Keep every existing ticket action available.',
@@ -70,6 +70,15 @@ try {
assert.equal(await collapse.getAttribute('aria-expanded'), 'true'); assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
await control('Link type').focus(); await control('Link type').focus();
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
assert.equal(
await control('This ticket').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await page.keyboard.press('Tab');
assert.equal( assert.equal(
await control('Linked ticket').evaluate( await control('Linked ticket').evaluate(
(el) => (el) =>
@@ -118,6 +127,7 @@ try {
await save().click(); await save().click();
assert.deepEqual((await request).postDataJSON(), { assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1, ticket_link_type_id: 1,
link_direction: 'source',
linked_ticket_id: 41, linked_ticket_id: 41,
}); });
await section().getByText('Outgoing', { exact: true }).waitFor(); await section().getByText('Outgoing', { exact: true }).waitFor();
@@ -141,6 +151,25 @@ try {
document.activeElement?.textContent.includes('Linked tickets'), document.activeElement?.textContent.includes('Linked tickets'),
); );
await visit('?noLinks');
await control('This ticket').selectOption('counterpart');
await control('Linked ticket').selectOption('41');
await section()
.locator('form')
.last()
.getByText('Blocked by', { exact: true })
.waitFor();
request = page.waitForRequest(
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
);
await save().click();
assert.deepEqual((await request).postDataJSON(), {
ticket_link_type_id: 1,
link_direction: 'counterpart',
linked_ticket_id: 41,
});
await section().getByText('Incoming', { exact: true }).waitFor();
await visit('?rejectLink'); await visit('?rejectLink');
await control('Linked ticket').selectOption('41'); await control('Linked ticket').selectOption('41');
await save().click(); await save().click();