Compare commits

..
1 Commits
Author SHA1 Message Date
Twyco 09f695a9bc fix: allow project admins to delete projects (KAN-64)
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Failing after 1m52s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-30 11:01:04 +02:00
15 changed files with 131 additions and 283 deletions
+73 -13
View File
@@ -1,18 +1,78 @@
name: test_and_check_app name: "Lint and Test APP"
description: Build the test image and run all application checks description: "Run Pint and the Pest test suite"
runs: runs:
using: composite using: composite
steps: steps:
- name: Test and check app - name: Setup PHP
shell: bash uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
run: | with:
set -Eeuo pipefail php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \ - name: Verify Composer lockfile
--file Dockerfile \ run: |
--target test \ if [ ! -f composer.lock ]; then
--tag "local/kanbangit-test:${GITHUB_SHA}" \ echo "::error::composer.lock not found"
--progress plain \ exit 1
--load \ fi
. shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Run independent application checks
run: |
pnpm exec concurrently \
--names frontend,backend \
--prefix-colors cyan,magenta \
--kill-others-on-fail \
"composer run ci:frontend" \
"composer run ci:backend"
shell: bash
- name: Run Laravel tests
run: composer run ci:test
shell: bash
+12 -15
View File
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
) {} ) {}
/** /**
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated * @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
*/ */
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink 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, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink { return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
Ticket::query() Ticket::query()
->whereKey([$ticket->id, $linkedTicket->id]) ->whereKey([$ticket->id, $linkedTicket->id])
->orderBy('id') ->orderBy('id')
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
if ($ticketLink === null) { if ($ticketLink === null) {
$ticketLink = TicketLink::query()->create([ $ticketLink = TicketLink::query()->create([
'source_ticket_id' => $sourceTicket->id, 'source_ticket_id' => $ticket->id,
'target_ticket_id' => $targetTicket->id, 'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} else { } else {
$ticketLink->update([ $ticketLink->update([
'source_ticket_id' => $sourceTicket->id, 'source_ticket_id' => $ticket->id,
'target_ticket_id' => $targetTicket->id, 'target_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $ticketLinkTypeId, 'ticket_link_type_id' => $ticketLinkTypeId,
]); ]);
} }
$ticketLink->load('ticketLinkType'); $ticketLink->load('ticketLinkType');
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket); $sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket); $targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
$this->createTicketWorklogEntry->handle( $this->createTicketWorklogEntry->handle(
$ticket, $ticket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.", "Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
[ [
'linked_ticket_key' => $linkedTicket->ticket_key, 'linked_ticket_key' => $linkedTicket->ticket_key,
'ticket_link_type' => $ticketLabel, 'ticket_link_type' => $sourceLabel,
], ],
); );
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
$linkedTicket, $linkedTicket,
$user, $user,
TicketWorklogEntryType::LinkUpdated, TicketWorklogEntryType::LinkUpdated,
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.", "Linked {$ticket->ticket_key} as {$targetLabel}.",
[ [
'linked_ticket_key' => $ticket->ticket_key, 'linked_ticket_key' => $ticket->ticket_key,
'ticket_link_type' => $linkedTicketLabel, 'ticket_link_type' => $targetLabel,
], ],
); );
@@ -36,12 +36,11 @@ 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' => "{$label} link was saved.", 'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
], ],
]) ])
->response() ->response()
@@ -34,10 +34,9 @@ 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', "{$label} link was saved."); ->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
} }
public function destroy( public function destroy(
@@ -42,7 +42,6 @@ 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
+22 -26
View File
@@ -1451,20 +1451,20 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.34.0", "version": "v13.26.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4" "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4", "url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4", "reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"composer-runtime-api": "^2.2", "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,20 +1576,18 @@
"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.5.0", "orchestra/testbench-core": "^11.0.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.2.15", "phpstan/phpstan": "^2.0",
"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.6.3", "rector/rector": "^2.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).",
@@ -1625,10 +1623,8 @@
"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": {
@@ -1678,7 +1674,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-09-29T12:46:21+00:00" "time": "2026-08-18T20:31:28+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@@ -2007,16 +2003,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.10.3", "version": "2.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2110,7 +2106,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-21T13:07:34+00:00" "time": "2026-08-11T16:06:25+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -2196,16 +2192,16 @@
}, },
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.36.0", "version": "3.35.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/flysystem.git", "url": "https://github.com/thephpleague/flysystem.git",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25" "reference": "b277b5dc3d56650b68904117124e79c851e12376"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "reference": "b277b5dc3d56650b68904117124e79c851e12376",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2273,9 +2269,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.36.0" "source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
}, },
"time": "2026-09-02T08:00:27+00:00" "time": "2026-07-06T14:42:07+00:00"
}, },
{ {
"name": "league/flysystem-aws-s3-v3", "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": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit" "types:check": "vue-tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+17 -25
View File
@@ -203,14 +203,6 @@ 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:
.: .:
@@ -1083,8 +1075,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.20.0: axios@1.19.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1096,14 +1088,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.21: brace-expansion@1.1.18:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.7: brace-expansion@2.1.4:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.12: brace-expansion@5.0.9:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
braces@3.0.3: braces@3.0.3:
@@ -2679,7 +2671,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.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) 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
@@ -3295,7 +3287,7 @@ snapshots:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
dependencies: 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
@@ -3311,16 +3303,16 @@ snapshots:
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@1.1.21: brace-expansion@1.1.18:
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.7: brace-expansion@2.1.4:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.12: brace-expansion@5.0.9:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -4063,7 +4055,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.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
lodash-es: 4.18.1 lodash-es: 4.18.1
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -4214,15 +4206,15 @@ snapshots:
minimatch@10.2.4: minimatch@10.2.4:
dependencies: dependencies:
brace-expansion: 5.0.12 brace-expansion: 5.0.9
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.21 brace-expansion: 1.1.18
minimatch@9.0.9: minimatch@9.0.9:
dependencies: dependencies:
brace-expansion: 2.1.7 brace-expansion: 2.1.4
minimist@1.2.8: {} minimist@1.2.8: {}
-12
View File
@@ -1,16 +1,4 @@
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,7 +35,6 @@ 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, {
@@ -221,7 +220,7 @@ function submit(): void {
/> />
<fieldset <fieldset
:disabled="form.processing" :disabled="form.processing"
class="grid min-w-0 gap-4 sm:grid-cols-3" class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
> >
<TicketFormField <TicketFormField
id="ticket_link_type_id" id="ticket_link_type_id"
@@ -231,33 +230,13 @@ function submit(): void {
:options=" :options="
ticketLinkTypes.map((type) => ({ ticketLinkTypes.map((type) => ({
value: type.id, value: type.id,
label: `${type.label} / ${type.counterpart_label}`, label: type.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"
@@ -288,11 +267,7 @@ function submit(): void {
<span class="font-mono text-xs">{{ <span class="font-mono text-xs">{{
ticket.ticket_key ticket.ticket_key
}}</span }}</span
><span>{{ ><span>{{ selectedType.label }}</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
+1 -2
View File
@@ -1200,9 +1200,8 @@ 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':
'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.', 'The selected relationship points from this ticket to the linked 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}',
-43
View File
@@ -293,49 +293,6 @@ 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();
@@ -83,74 +83,6 @@ class ProjectTicketLinkControllerTest extends TestCase
]); ]);
} }
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Blocks',
'counterpart_label' => 'Blocked by',
]);
$blockingTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$blockedTicket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
]);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'counterpart',
])
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
->assertSessionHas('status', 'Blocked by link was saved.');
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockingTicket->id,
'target_ticket_id' => $blockedTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockedTicket->id,
'summary' => 'Linked CORE-1 as Blocked by.',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $blockingTicket->id,
'summary' => 'Linked CORE-2 as Blocks.',
]);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
'linked_ticket_id' => $blockingTicket->id,
'ticket_link_type_id' => $ticketLinkType->id,
'link_direction' => 'source',
])
->assertSessionHas('status', 'Blocks link was saved.');
$this->assertSame(1, TicketLink::query()->count());
$this->assertDatabaseHas('ticket_links', [
'source_ticket_id' => $blockedTicket->id,
'target_ticket_id' => $blockingTicket->id,
]);
}
/** /**
* Ensure ticket links cannot target the current ticket or a ticket from another project. * Ensure ticket links cannot target the current ticket or a ticket from another project.
*/ */
@@ -212,15 +144,6 @@ class ProjectTicketLinkControllerTest extends TestCase
]) ])
->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->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,11 +398,6 @@ 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(
@@ -491,8 +486,6 @@ 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,15 +70,6 @@ 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) =>
@@ -127,7 +118,6 @@ 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();
@@ -151,25 +141,6 @@ 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();