Compare commits
175
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 | ||
|
|
fcba7673c2 | ||
|
|
2f99b8e65c | ||
|
|
2b85517965 | ||
|
|
7001776c48 | ||
|
|
9d472b5091 | ||
|
|
8bed02a484 | ||
|
|
d07b17ebdb | ||
|
|
859229fa21 | ||
|
|
32dac91620 | ||
|
|
39d0887180 | ||
|
|
340e6bfff1 | ||
|
|
f3f1ffdc52 | ||
|
|
2a56d03dbf | ||
|
|
7e19364757 | ||
|
|
de0c19a86b | ||
|
|
ce250d8be5 | ||
|
|
5c2b62de2a | ||
|
|
b5030e6acc | ||
|
|
6224880219 | ||
|
|
19d102d140 | ||
|
|
e70b6b4de0 | ||
|
|
d9a1288d73 | ||
|
|
1d9df6897d | ||
|
|
0a2807a28c | ||
|
|
8a9113a7af | ||
|
|
0a974cfd49 | ||
|
|
538b17b9b8 | ||
|
|
b2789b4670 | ||
|
|
7c6981dc0a | ||
|
|
b8409a702c | ||
|
|
2acbdb4760 | ||
|
|
9faf02e2c4 | ||
|
|
0941745883 | ||
|
|
5a10c85e61 | ||
|
|
fb4faa6070 | ||
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d | ||
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f | ||
|
|
2e2ddd5b74 | ||
|
|
c58196293f | ||
|
|
8657d75682 | ||
|
|
71d9d1b63e | ||
|
|
68995a9f4c | ||
|
|
3e998a483e | ||
|
|
ceffe4f4a4 | ||
|
|
ce01b8ec55 | ||
|
|
bd774e20f6 |
@@ -1,394 +0,0 @@
|
||||
---
|
||||
name: inertia-vue-development
|
||||
description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Inertia Vue Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating or modifying Vue page components for Inertia
|
||||
- Working with forms in Vue (using `<Form>` or `useForm`)
|
||||
- Implementing client-side navigation with `<Link>` or `router`
|
||||
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
|
||||
- Building Vue-specific features with the Inertia protocol
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Inertia v2 Vue patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Page Components Location
|
||||
|
||||
Vue page components should be placed in the `resources/js/pages` directory.
|
||||
|
||||
### Page Component Structure
|
||||
|
||||
<!-- Basic Vue Page Component -->
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: Array
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<ul>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Client-Side Navigation
|
||||
|
||||
### Basic Link Component
|
||||
|
||||
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
|
||||
|
||||
<!-- Inertia Vue Navigation -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/users">Users</Link>
|
||||
<Link :href="`/users/${user.id}`">View User</Link>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Link with Method
|
||||
|
||||
<!-- Link with POST Method -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/logout" method="post" as="button">
|
||||
Logout
|
||||
</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
Prefetch pages to improve perceived performance:
|
||||
|
||||
<!-- Prefetch on Hover -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/users" prefetch>
|
||||
Users
|
||||
</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
<!-- Router Visit -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { router } from '@inertiajs/vue3'
|
||||
|
||||
function handleClick() {
|
||||
router.visit('/users')
|
||||
}
|
||||
|
||||
// Or with options
|
||||
function createUser() {
|
||||
router.visit('/users', {
|
||||
method: 'post',
|
||||
data: { name: 'John' },
|
||||
onSuccess: () => console.log('Done'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/users">Users</Link>
|
||||
<Link href="/logout" method="post" as="button">Logout</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Form Component (Recommended)
|
||||
|
||||
The recommended way to build forms is with the `<Form>` component:
|
||||
|
||||
<!-- Form Component Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
|
||||
<input type="text" name="name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<input type="email" name="email" />
|
||||
<div v-if="errors.email">{{ errors.email }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
{{ processing ? 'Creating...' : 'Create User' }}
|
||||
</button>
|
||||
|
||||
<div v-if="wasSuccessful">User created!</div>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Form Component With All Props
|
||||
|
||||
<!-- Form Component Full Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
action="/users"
|
||||
method="post"
|
||||
#default="{
|
||||
errors,
|
||||
hasErrors,
|
||||
processing,
|
||||
progress,
|
||||
wasSuccessful,
|
||||
recentlySuccessful,
|
||||
setError,
|
||||
clearErrors,
|
||||
resetAndClearErrors,
|
||||
defaults,
|
||||
isDirty,
|
||||
reset,
|
||||
submit
|
||||
}"
|
||||
>
|
||||
<input type="text" name="name" :value="defaults.name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
{{ processing ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<progress v-if="progress" :value="progress.percentage" max="100">
|
||||
{{ progress.percentage }}%
|
||||
</progress>
|
||||
|
||||
<div v-if="wasSuccessful">Saved!</div>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Form Component Reset Props
|
||||
|
||||
The `<Form>` component supports automatic resetting:
|
||||
|
||||
- `resetOnError` - Reset form data when the request fails
|
||||
- `resetOnSuccess` - Reset form data when the request succeeds
|
||||
- `setDefaultsOnSuccess` - Update default values on success
|
||||
|
||||
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
|
||||
|
||||
<!-- Form with Reset Props -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
action="/users"
|
||||
method="post"
|
||||
reset-on-success
|
||||
set-defaults-on-success
|
||||
#default="{ errors, processing, wasSuccessful }"
|
||||
>
|
||||
<input type="text" name="name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
Submit
|
||||
</button>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||
|
||||
### `useForm` Composable
|
||||
|
||||
For more programmatic control or to follow existing conventions, use the `useForm` composable:
|
||||
|
||||
<!-- useForm Composable Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
form.post('/users', {
|
||||
onSuccess: () => form.reset('password'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<input type="text" v-model="form.name" />
|
||||
<div v-if="form.errors.name">{{ form.errors.name }}</div>
|
||||
|
||||
<input type="email" v-model="form.email" />
|
||||
<div v-if="form.errors.email">{{ form.errors.email }}</div>
|
||||
|
||||
<input type="password" v-model="form.password" />
|
||||
<div v-if="form.errors.password">{{ form.errors.password }}</div>
|
||||
|
||||
<button type="submit" :disabled="form.processing">
|
||||
Create User
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Inertia v2 Features
|
||||
|
||||
### Deferred Props
|
||||
|
||||
Use deferred props to load data after initial page render:
|
||||
|
||||
<!-- Deferred Props with Empty State -->
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: Array
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<div v-if="!users" class="animate-pulse">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<ul v-else>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Polling
|
||||
|
||||
Automatically refresh data at intervals:
|
||||
|
||||
<!-- Polling Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
defineProps({
|
||||
stats: Object
|
||||
})
|
||||
|
||||
let interval
|
||||
|
||||
onMounted(() => {
|
||||
interval = setInterval(() => {
|
||||
router.reload({ only: ['stats'] })
|
||||
}, 5000) // Poll every 5 seconds
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div>Active Users: {{ stats.activeUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### WhenVisible
|
||||
|
||||
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
|
||||
|
||||
<!-- WhenVisible Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { WhenVisible } from '@inertiajs/vue3'
|
||||
|
||||
defineProps({
|
||||
stats: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<!-- stats prop is loaded only when this section scrolls into view -->
|
||||
<WhenVisible data="stats" :buffer="200">
|
||||
<template #fallback>
|
||||
<div class="animate-pulse">Loading stats...</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ fetching }">
|
||||
<div>
|
||||
<p>Total Users: {{ stats.total_users }}</p>
|
||||
<p>Revenue: {{ stats.revenue }}</p>
|
||||
<span v-if="fetching">Refreshing...</span>
|
||||
</div>
|
||||
</template>
|
||||
</WhenVisible>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Server-Side Patterns
|
||||
|
||||
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
|
||||
- Forgetting that Vue components must have a single root element
|
||||
- Forgetting to add loading states (skeleton screens) when using deferred props
|
||||
- Not handling the `undefined` state of deferred props before data loads
|
||||
- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
|
||||
- Forgetting to check if `<Form>` component is available in your Inertia version
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
@@ -61,10 +61,14 @@ show(1, { query: { page: 1 } }) // "/posts/1?page=1"
|
||||
|
||||
## Wayfinder + Inertia
|
||||
|
||||
Use Wayfinder with the `<Form>` component:
|
||||
<!-- Wayfinder Form (Vue) -->
|
||||
```vue
|
||||
<Form v-bind="store.form()"><input name="title" /></Form>
|
||||
Use Wayfinder with `useForm`:
|
||||
|
||||
<!-- Wayfinder useForm -->
|
||||
```typescript
|
||||
import { store } from "@/actions/App/Http/Controllers/ExampleController";
|
||||
|
||||
const form = useForm({ name: "My Big Post" });
|
||||
form.submit(store());
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
+4
-3
@@ -33,9 +33,6 @@
|
||||
vendor
|
||||
**/.DS_Store
|
||||
.editorconfig
|
||||
.env.example
|
||||
.env.mariadb
|
||||
.env.mariadb.example
|
||||
.env.production
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -46,3 +43,7 @@ storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
|
||||
# Native build artifacts are independent of the Laravel image.
|
||||
native/src-tauri/target
|
||||
native/src-tauri/gen
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
MARIADB_DATABASE=kanbangit
|
||||
MARIADB_USER=kanbangit
|
||||
MARIADB_PASSWORD=
|
||||
MARIADB_ROOT_PASSWORD=
|
||||
@@ -2,7 +2,13 @@ name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,9 +17,19 @@ env:
|
||||
REGISTRY: git.twyco.de
|
||||
|
||||
jobs:
|
||||
test_and_check_app:
|
||||
name: test_and_check_app
|
||||
preconditions:
|
||||
if: >
|
||||
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Conditions met
|
||||
run: |
|
||||
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||
|
||||
test_and_check_app:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- preconditions
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -27,6 +43,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
needs:
|
||||
- preconditions
|
||||
- test_and_check_app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: macOS checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||
pull_request:
|
||||
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: Native Rust tests and unsigned build
|
||||
runs-on: ${{ vars.MACOS_CHECK_RUNNER || vars.MACOS_RUNNER || 'macos-arm64' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install locked native dependencies
|
||||
run: pnpm --dir native install --frozen-lockfile
|
||||
|
||||
- name: Run native tests
|
||||
run: pnpm --dir native test
|
||||
|
||||
- name: Check Rust formatting and warnings
|
||||
run: pnpm --dir native check
|
||||
|
||||
- name: Build unsigned macOS app without release credentials
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
with:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: --ci --debug --no-sign --bundles app -- --locked
|
||||
uploadUpdaterJson: false
|
||||
uploadUpdaterSignatures: false
|
||||
uploadWorkflowArtifacts: false
|
||||
@@ -0,0 +1,228 @@
|
||||
name: macOS release to Hetzner
|
||||
|
||||
on:
|
||||
push:
|
||||
# Gitea enforces the configured macos-v* protection when tags are pushed.
|
||||
# Reading /tag_protections requires admin rights unavailable to job tokens.
|
||||
tags:
|
||||
- "macos-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: "Developer ID Application: Nico Schröder (ZZX2KZGTHU)"
|
||||
APPLE_TEAM_ID: ZZX2KZGTHU
|
||||
MACOS_RELEASE_TARGET: ${{ vars.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin' }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_PREFIX: ${{ vars.S3_PREFIX || 'releases/macos' }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Validate release ref and configuration
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
commit: ${{ steps.ref.outputs.commit }}
|
||||
tag: ${{ steps.ref.outputs.tag }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select an existing version tag contained in main
|
||||
id: ref
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
|
||||
tag="$REQUESTED_TAG"
|
||||
else
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
[[ "$tag" =~ ^macos-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Invalid macOS release tag.' >&2; exit 1; }
|
||||
[[ "$MACOS_RUNNER" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo 'Set MACOS_RUNNER to a registered macOS runner label.' >&2; exit 1; }
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
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 checkout --detach "$commit"
|
||||
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Validate app versions and Hetzner configuration
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
||||
run: |
|
||||
node native/macos-ci.mjs metadata
|
||||
node native/publish-macos.mjs config
|
||||
|
||||
release:
|
||||
name: Sign, notarize, verify and publish macOS
|
||||
needs: prepare
|
||||
# Gitea resolves runner labels before outputs from prepare are available.
|
||||
runs-on: ${{ vars.MACOS_RUNNER }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
steps:
|
||||
- name: Check out the validated commit
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.commit }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate runner and required secrets
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_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_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ "$(uname -s)" == Darwin ]] || { echo 'A macOS runner is required.' >&2; exit 1; }
|
||||
case "$MACOS_RELEASE_TARGET:$(uname -m)" in
|
||||
aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;;
|
||||
*) echo 'Runner architecture must match MACOS_RELEASE_TARGET.' >&2; exit 1 ;;
|
||||
esac
|
||||
xcodebuild -version
|
||||
xcrun --find notarytool
|
||||
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 TAURI_SIGNING_PRIVATE_KEY S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||
done
|
||||
|
||||
- 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
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
targets: ${{ env.MACOS_RELEASE_TARGET }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir native install --frozen-lockfile
|
||||
|
||||
- name: Run native checks before importing signing credentials
|
||||
run: |
|
||||
pnpm exec eslint native --max-warnings 0
|
||||
pnpm --dir native test
|
||||
pnpm --dir native check
|
||||
|
||||
- name: Prepare isolated signing keychain
|
||||
id: signing
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
signing_dir="$(mktemp -d "$RUNNER_TEMP/kanbangit-signing.XXXXXX")"
|
||||
printf 'SIGNING_DIRECTORY=%s\n' "$signing_dir" >> "$GITHUB_ENV"
|
||||
security list-keychains -d user > "$signing_dir/original-keychains.txt"
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12"
|
||||
password="$(openssl rand -hex 32)"
|
||||
echo "::add-mask::$password"
|
||||
printf 'keychain=kanbangit-%s-%s\npassword=%s\ncertificate=%s/certificate.p12\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$password" "$signing_dir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import Developer ID certificate
|
||||
uses: Apple-Actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0; deletes its keychain in the post step
|
||||
with:
|
||||
keychain: ${{ steps.signing.outputs.keychain }}
|
||||
keychain-password: ${{ steps.signing.outputs.password }}
|
||||
p12-filepath: ${{ steps.signing.outputs.certificate }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Confirm release signing identity
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
security find-identity -v -p codesigning | grep -F "\"$APPLE_SIGNING_IDENTITY\""
|
||||
|
||||
- name: Build, sign and notarize with Tauri
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
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_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:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: >-
|
||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app,dmg
|
||||
--config '{"bundle":{"createUpdaterArtifacts":true,"macOS":{"hardenedRuntime":true}}}'
|
||||
-- --locked
|
||||
uploadUpdaterJson: false
|
||||
uploadUpdaterSignatures: false
|
||||
uploadWorkflowArtifacts: false
|
||||
|
||||
- name: Verify signed app and packaged download
|
||||
id: package
|
||||
run: node native/macos-ci.mjs package
|
||||
|
||||
- name: Upload release files and promote the verified update feed
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
RELEASE_DIRECTORY: ${{ steps.package.outputs.directory }}
|
||||
run: node native/publish-macos.mjs upload
|
||||
|
||||
- name: Restore keychain search list and remove certificate export
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, pathlib, shlex, shutil, subprocess
|
||||
value = os.environ.get('SIGNING_DIRECTORY')
|
||||
if value:
|
||||
directory = pathlib.Path(value)
|
||||
original = directory / 'original-keychains.txt'
|
||||
try:
|
||||
if original.exists():
|
||||
subprocess.run(['security', 'list-keychains', '-d', 'user', '-s', *shlex.split(original.read_text())], check=True)
|
||||
finally:
|
||||
shutil.rmtree(directory)
|
||||
PY
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Native JavaScript checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "native/**"
|
||||
- ".github/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "eslint.config.mjs"
|
||||
- "resources/css/app.css"
|
||||
pull_request:
|
||||
paths:
|
||||
- "native/**"
|
||||
- ".github/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "eslint.config.mjs"
|
||||
- "resources/css/app.css"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
native_javascript:
|
||||
name: Native launcher and release tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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 locked development dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint native JavaScript
|
||||
run: pnpm exec eslint native --max-warnings 0
|
||||
|
||||
- name: Test native launcher and release safeguards without credentials
|
||||
run: node --test native/tests/*.test.js
|
||||
+13
@@ -28,3 +28,16 @@ yarn-error.log
|
||||
/.vscode
|
||||
/.zed
|
||||
.codex/config.toml
|
||||
|
||||
/native/node_modules
|
||||
/native/src-tauri/target
|
||||
/native/src-tauri/gen/*
|
||||
!/native/src-tauri/gen/android/
|
||||
!/native/src-tauri/gen/apple/
|
||||
/native/src-tauri/gen/apple/build/
|
||||
/native/src-tauri/gen/apple/Externals/
|
||||
/native/src-tauri/gen/apple/assets/
|
||||
/native/src-tauri/gen/apple/Pods/
|
||||
/native/src-tauri/gen/apple/ExportOptions.plist
|
||||
/native/src-tauri/gen/apple/**/*.xcuserstate
|
||||
/native/src-tauri/gen/apple/**/xcuserdata/
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
@@ -9,7 +25,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4
|
||||
- php - 8.5
|
||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
@@ -21,12 +37,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- @inertiajs/vue3 (INERTIA_VUE) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- vue (VUE) - v3
|
||||
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
|
||||
- eslint (ESLINT) - v9
|
||||
- prettier (PRETTIER) - v3
|
||||
|
||||
## Skills Activation
|
||||
|
||||
@@ -36,8 +46,6 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -56,7 +64,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `pnpm run build`, `pnpm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@@ -130,7 +138,6 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
|
||||
- Components live in `resources/js/pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
|
||||
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
# Inertia v2
|
||||
|
||||
@@ -166,7 +173,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `pnpm run build` or ask the user to run `pnpm run dev` or `composer run dev`.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -203,11 +210,4 @@ Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== inertia-vue/core rules ===
|
||||
|
||||
# Inertia + Vue
|
||||
|
||||
Vue components must have a single root element.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
+5
-3
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
||||
COPY . /var/www
|
||||
|
||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
&& corepack enable pnpm \
|
||||
&& pnpm install --frozen-lockfile \
|
||||
&& pnpm run build
|
||||
|
||||
FROM build AS test
|
||||
|
||||
RUN touch .env \
|
||||
RUN cp .env.example .env \
|
||||
&& php artisan key:generate\
|
||||
&& composer audit --locked --no-interaction \
|
||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -113,6 +114,41 @@ class PermissionService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, User> $users
|
||||
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
||||
*/
|
||||
public function projectAccessMap(EloquentCollection $users, Project $project): array
|
||||
{
|
||||
$users->loadMissing('teams');
|
||||
$directPermissions = $this->assignmentMap(User::class, $project);
|
||||
$teamPermissions = $this->assignmentMap(Team::class, $project);
|
||||
$globalPermissions = $this->assignmentMap(User::class);
|
||||
$effectivePermissions = [];
|
||||
$globalAdminUserIds = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$permissions = $directPermissions[$user->id] ?? [];
|
||||
|
||||
foreach ($user->teams as $team) {
|
||||
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
|
||||
}
|
||||
|
||||
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
|
||||
$permissions[] = Permission::GlobalAdmin->value;
|
||||
$globalAdminUserIds[] = $user->id;
|
||||
}
|
||||
|
||||
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
|
||||
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
|
||||
Permission::projectAssignable(),
|
||||
fn (Permission $permission): bool => in_array($permission, $expanded, true),
|
||||
)));
|
||||
}
|
||||
|
||||
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
|
||||
}
|
||||
|
||||
private function statusContextLabel(TicketStatus $ticketStatus): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
$project = $columns->first()?->project;
|
||||
if ($project !== null) {
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
|
||||
*/
|
||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||
{
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
|
||||
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
return $ticketStatus->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
|
||||
*/
|
||||
public function handle(Project $project, array $validated): Project
|
||||
{
|
||||
$project->update([
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
return DB::transaction(function () use ($project, $validated): Project {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->update([
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
|
||||
return $project->fresh();
|
||||
return $project->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||
];
|
||||
|
||||
@@ -36,6 +36,7 @@ class CreateProject
|
||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||
$sourceTemplate = ProjectTemplate::query()
|
||||
->published()
|
||||
->lockForUpdate()
|
||||
->findOrFail((int) $sourceTemplateId);
|
||||
|
||||
$project->forceFill([
|
||||
@@ -72,6 +73,7 @@ class CreateProject
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh() ?? $project,
|
||||
$sourceTemplate->configuration_snapshot ?? [],
|
||||
adoptExisting: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
|
||||
{
|
||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||
|
||||
$slug = strtolower($project->key).'-base-template';
|
||||
$suffix = 1;
|
||||
$candidate = $slug;
|
||||
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
|
||||
$candidate = $slug.'-'.++$suffix;
|
||||
}
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
'name' => "{$project->name} base template",
|
||||
'slug' => strtolower($project->key).'-base-template',
|
||||
'slug' => $candidate,
|
||||
'is_published' => false,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||
|
||||
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $attributes */
|
||||
public function empty(array $attributes): ProjectTemplate
|
||||
{
|
||||
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'slug' => Arr::get($attributes, 'slug'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $areas,
|
||||
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
|
||||
))->only($areas)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||
*/
|
||||
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
||||
{
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
|
||||
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
$template = $sourceProject->projectTemplate;
|
||||
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
|
||||
$template = new ProjectTemplate;
|
||||
$template->slug = Arr::get($attributes, 'slug');
|
||||
}
|
||||
$template->forceFill([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
])->save();
|
||||
$sourceProject->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
|
||||
])->saveQuietly();
|
||||
foreach ($sourceProject->configurationRelations() as $relation) {
|
||||
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
|
||||
}
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => $attributes['name'],
|
||||
'slug' => $attributes['slug'],
|
||||
'is_published' => true,
|
||||
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
]);
|
||||
return $template;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
@@ -10,7 +11,13 @@ class DeleteProject
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
$project->delete();
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
|
||||
$template->delete();
|
||||
foreach ($projects as $project) {
|
||||
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
|
||||
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
|
||||
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
|
||||
foreach ($project->configurationRelations() as $relation) {
|
||||
$project->{$relation}()->update(['project_template_id' => $base->id]);
|
||||
}
|
||||
}
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
return $this->projectConfigurationIdentity->normalize($configuration);
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column): array => [$column->id => $column->configuration_key],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status): array => [$status->id => $status->configuration_key],
|
||||
);
|
||||
|
||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => [
|
||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||
'configuration_key' => $status->configuration_key,
|
||||
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
|
||||
|
||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||
static fn ($status): array => [
|
||||
'status_key' => $status->configuration_key,
|
||||
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?string => $statusKeys->get($id),
|
||||
)->filter()->sort()->values()->all(),
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
|
||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||
'filters' => $item->filters ?? [],
|
||||
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
|
||||
],
|
||||
];
|
||||
$relations = [
|
||||
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
|
||||
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
|
||||
)->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
* @return list<string> Areas which retain referenced project configuration.
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
|
||||
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||
$blockedAreas = [];
|
||||
|
||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->boardColumns()->get(),
|
||||
$configuration[self::COPY_BOARD_COLUMNS],
|
||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
|
||||
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
|
||||
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
|
||||
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
|
||||
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|
||||
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
|
||||
);
|
||||
if ($blocked) {
|
||||
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
|
||||
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
|
||||
if (! $statusesCanChange) {
|
||||
return false;
|
||||
}
|
||||
$next = $nextStatuses->get($status->configuration_key);
|
||||
$nextColumn = $next['board_column_key'] ?? null;
|
||||
|
||||
return $next === null || ($nextColumn !== $column->configuration_key
|
||||
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
|
||||
},
|
||||
);
|
||||
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
|
||||
$canDeleteColumn, $adoptExisting)) {
|
||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||
$columns = $targetProject->boardColumns()->get()->values();
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||
$columnIndex = $item['board_column_index'] ?? null;
|
||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||
unset($item['board_column_index']);
|
||||
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
|
||||
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
|
||||
$columnKey = $item['board_column_key'] ?? null;
|
||||
$item['board_column_id'] = $columnKey === null
|
||||
? null
|
||||
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
|
||||
$item['slug'] = Str::slug($item['name']);
|
||||
unset($item['board_column_key']);
|
||||
|
||||
return $item;
|
||||
})->all();
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->ticketStatuses()->get(),
|
||||
$items,
|
||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
function (Model $status) use ($statusReplacements): bool {
|
||||
if (! $status->tickets()->exists()) {
|
||||
return true;
|
||||
}
|
||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacementId === null) {
|
||||
return false;
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if ($blocked) {
|
||||
$templateKeys = array_column($items, 'configuration_key');
|
||||
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|
||||
|| array_key_exists($status->id, $statusReplacements);
|
||||
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
|
||||
$replacement = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacement === null) {
|
||||
return;
|
||||
}
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
|
||||
->where('configuration_key', (string) $replacement)->first();
|
||||
if ($replacementStatus === null && is_numeric($replacement)) {
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
|
||||
}
|
||||
if ($replacementStatus === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
|
||||
]);
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
|
||||
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
|
||||
};
|
||||
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
|
||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||
$status = $statuses->get($index);
|
||||
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
|
||||
$status = $statuses->get($transition['status_key']);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
|
||||
static fn (string $key): ?int => $statuses->get($key)?->id,
|
||||
)->filter()->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||
$guards = [
|
||||
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
|
||||
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
|
||||
self::COPY_REPOSITORY_CONNECTIONS => null,
|
||||
self::COPY_NOTIFICATION_ACTIONS => null,
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
foreach ($guards as $area => $canDelete) {
|
||||
if (! isset($configuration[$area])) {
|
||||
continue;
|
||||
}
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->{$relation}()->get(),
|
||||
$configuration[$area],
|
||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
$canDelete,
|
||||
);
|
||||
if ($blocked) {
|
||||
$items = $configuration[$area];
|
||||
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
|
||||
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
|
||||
$items = array_map(fn (array $item): array => [
|
||||
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
|
||||
], $items);
|
||||
}
|
||||
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
return $blockedAreas;
|
||||
});
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
}
|
||||
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
|
||||
/** @param list<string> $copyAreas */
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
|
||||
public function mapNotificationFilters(array $filters, array $mapping): array
|
||||
{
|
||||
return array_map(static function (array $filter) use ($mapping): array {
|
||||
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
|
||||
return $filter;
|
||||
}
|
||||
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
|
||||
$filter['value'] = is_array($filter['value'])
|
||||
? array_map($map, $filter['value'])
|
||||
: ($filter['value'] === null ? null : $map($filter['value']));
|
||||
|
||||
return $filter;
|
||||
}, app(NormalizeNotificationFilters::class)->handle($filters));
|
||||
}
|
||||
|
||||
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
|
||||
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
|
||||
{
|
||||
$statuses = $project->ticketStatuses()->get();
|
||||
$targets = $statuses->whereIn('configuration_key', $templateKeys);
|
||||
$mapping = [];
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true)) {
|
||||
$mapping[$status->id] = $status->id;
|
||||
|
||||
continue;
|
||||
}
|
||||
$target = $replacements[$status->id] ?? null;
|
||||
if (is_scalar($target)) {
|
||||
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
|
||||
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
|
||||
}
|
||||
}
|
||||
$mapping[$removed->id] = $replacement->id;
|
||||
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
|
||||
->orWhere('to_ticket_status_id', $removed->id)->get();
|
||||
foreach ($edges as $edge) {
|
||||
$from = $mapping[$edge->from_ticket_status_id] ?? null;
|
||||
$to = $mapping[$edge->to_ticket_status_id] ?? null;
|
||||
if ($from !== null && $to !== null && $from !== $to) {
|
||||
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
$relation = ProjectConfigurationIdentity::relations()[$area];
|
||||
$models = $project->{$relation}()->get();
|
||||
$byKey = $models->keyBy('configuration_key');
|
||||
$matched = [];
|
||||
$assignments = [];
|
||||
foreach ($items as $item) {
|
||||
$model = $byKey->get($item['configuration_key']);
|
||||
if ($model === null && $adoptExisting) {
|
||||
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
|
||||
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
|
||||
$model = $candidates->count() === 1 ? $candidates->first() : null;
|
||||
}
|
||||
if ($model !== null) {
|
||||
$matched[] = $model->id;
|
||||
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
|
||||
return true;
|
||||
}
|
||||
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
|
||||
&& $model->tickets()->whereHas('childTickets')->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$assignments[] = [$model, $item];
|
||||
}
|
||||
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
|
||||
foreach ($removed as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
|
||||
foreach ($models as $model) {
|
||||
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
|
||||
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
|
||||
foreach ($models->values() as $index => $model) {
|
||||
$model->forceFill(['position' => $offset + $index])->saveQuietly();
|
||||
}
|
||||
}
|
||||
foreach ($assignments as [$model, $item]) {
|
||||
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
|
||||
}
|
||||
foreach ($removed as $model) {
|
||||
if ($beforeDelete !== null) {
|
||||
$beforeDelete($model);
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class ProjectConfigurationIdentity
|
||||
{
|
||||
/** @return array<string, string> */
|
||||
public static function relations(): array
|
||||
{
|
||||
return [
|
||||
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
|
||||
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
|
||||
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
|
||||
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalize(array $configuration): array
|
||||
{
|
||||
foreach (self::relations() as $area => $relation) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($configuration[$area] as $index => &$item) {
|
||||
$item['configuration_key'] ??= (string) Uuid::uuid5(
|
||||
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
|
||||
);
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
|
||||
if (! array_key_exists('board_column_key', $status)) {
|
||||
$columnIndex = $status['board_column_index'] ?? null;
|
||||
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
|
||||
}
|
||||
unset($status['board_column_index']);
|
||||
$configuration['ticket_statuses'][$index] = $status;
|
||||
}
|
||||
|
||||
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
|
||||
if (array_key_exists('status_key', $transition)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($statuses[$index])) {
|
||||
continue;
|
||||
}
|
||||
$configuration['status_transitions'][$index] = [
|
||||
'status_key' => $statuses[$index],
|
||||
'target_keys' => array_values(array_filter(array_map(
|
||||
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
public function signature(string $area, array $item): string
|
||||
{
|
||||
return $area === 'repository_connections'
|
||||
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
|
||||
: (string) ($item['name'] ?? $item['label'] ?? '');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function defaults(): array
|
||||
{
|
||||
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
|
||||
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
|
||||
$configuration['ticket_statuses'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
|
||||
'position' => 1, 'board_column_key' => null,
|
||||
]];
|
||||
$configuration['ticket_types'] = [
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
|
||||
];
|
||||
$configuration['ticket_priorities'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
|
||||
]];
|
||||
$configuration['status_transitions'] = [[
|
||||
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
|
||||
]];
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overrides = array_values(array_unique([
|
||||
...($project->template_overrides ?? []),
|
||||
$area,
|
||||
]));
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->load('projectTemplate');
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
$project->forceFill(['template_overrides' => array_values(array_unique([
|
||||
...($project->template_overrides ?? []), $area,
|
||||
]))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $areas): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
|
||||
|
||||
public function handle(Project $project, string $area): void
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$project->projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
|
||||
$project->projectTemplate->forceFill([
|
||||
'configuration_snapshot' => array_replace(
|
||||
$snapshot,
|
||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||
),
|
||||
])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$template = ProjectTemplate::query()->find($project->project_template_id);
|
||||
if ($template === null || $template->is_published) {
|
||||
return;
|
||||
}
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$template->forceFill(['configuration_snapshot' => array_replace(
|
||||
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
|
||||
)])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
|
||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||
|
||||
return [
|
||||
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
||||
'effectiveConfiguration' => $currentConfiguration,
|
||||
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
|
||||
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
|
||||
'inheritedAreas' => $inheritedAreas,
|
||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
|
||||
if (! is_array($templateStatuses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$templateKeys = array_column($templateStatuses, 'configuration_key');
|
||||
$replacementOptions = collect($templateStatuses)->map(
|
||||
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
|
||||
)->all();
|
||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => [
|
||||
'id' => $status->id,
|
||||
'name' => $status->name,
|
||||
'ticketCount' => $status->tickets_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
$removedStatuses = $statuses
|
||||
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
|
||||
->values()->all();
|
||||
|
||||
return $removedStatuses === [] ? [] : [
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||
|
||||
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
|
||||
*/
|
||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||
{
|
||||
$projectTemplate->loadMissing('sourceProject');
|
||||
|
||||
$sourceProject = $projectTemplate->sourceProject;
|
||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
|
||||
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
|
||||
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
|
||||
))->only($copyAreas)->all();
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()->all();
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$linkedProject = Project::query()
|
||||
->whereKey($linkedProject->id)
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->lockForUpdate()->first();
|
||||
if ($linkedProject === null) {
|
||||
continue;
|
||||
}
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
$replacementConfiguration = [];
|
||||
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UpdateProjectTemplateLink
|
||||
{
|
||||
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): array
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
if ($template === null) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
if ($project->project_template_id !== $template->id) {
|
||||
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
|
||||
}
|
||||
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$configuration = collect($baseline)->only($areas)->all();
|
||||
if (in_array('ticket_statuses', $areas, true)
|
||||
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
|
||||
&& isset($baseline['status_transitions'])) {
|
||||
$configuration['status_transitions'] = $baseline['status_transitions'];
|
||||
}
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
|
||||
return $blockedAreas;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
||||
/**
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||
{
|
||||
$normalized = self::normalize($items);
|
||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
||||
: $existingCriteria->get($criterionId);
|
||||
|
||||
while (
|
||||
$criterion === null
|
||||
$reuseUnidentifiedCriteria
|
||||
&& $criterion === null
|
||||
&& $fallbackIndex < $fallbackCriteria->count()
|
||||
) {
|
||||
/** @var AcceptanceCriterion $candidate */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets\Support;
|
||||
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Extension\Table\Table;
|
||||
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||
use League\CommonMark\Parser\MarkdownParser;
|
||||
use League\Uri\Uri;
|
||||
|
||||
class TicketDescriptionFormat
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function contract(): array
|
||||
{
|
||||
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function instructions(): string
|
||||
{
|
||||
$contract = self::contract();
|
||||
|
||||
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
||||
." Valid description example (Markdown string):\n".$contract['example'];
|
||||
}
|
||||
|
||||
public static function criteriaInstructions(): string
|
||||
{
|
||||
return self::contract()['criteria'];
|
||||
}
|
||||
|
||||
public static function safeLink(string $value): bool
|
||||
{
|
||||
$contract = self::contract();
|
||||
$decoded = rawurldecode($value);
|
||||
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
||||
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
||||
return true;
|
||||
}
|
||||
$url = Uri::tryNew($decoded);
|
||||
|
||||
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function unsupportedContent(string $value): array
|
||||
{
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
$document = (new MarkdownParser($environment))->parse($value);
|
||||
$unsupported = [];
|
||||
foreach ($document->iterator() as $node) {
|
||||
$type = match (true) {
|
||||
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
||||
$node instanceof Table => 'table',
|
||||
$node instanceof Image => 'image',
|
||||
$node instanceof TaskListItemMarker => 'task',
|
||||
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
||||
default => null,
|
||||
};
|
||||
if ($type !== null) {
|
||||
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($unsupported));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
||||
Ticket $ticket,
|
||||
User $actor,
|
||||
array $validated,
|
||||
bool $replaceCriteriaWithIds = false,
|
||||
): bool {
|
||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
||||
);
|
||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||
if (! $ticketAttributes['is_blocked']) {
|
||||
$ticketAttributes['blocked_comment'] = null;
|
||||
}
|
||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||
$existingFieldValues = $ticket->fieldValues
|
||||
->keyBy('ticket_field_definition_id');
|
||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
|
||||
$changes = [];
|
||||
$summaryLabels = [];
|
||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
||||
}
|
||||
|
||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||
}
|
||||
|
||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||
|
||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
'message' => "{$label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ class ProjectController extends Controller
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$blockedAreas = $this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
@@ -171,6 +171,10 @@ class ProjectController extends Controller
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
if ($blockedAreas !== []) {
|
||||
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
|
||||
}
|
||||
|
||||
return back()->with('status', $status);
|
||||
}
|
||||
|
||||
@@ -182,25 +186,22 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
$users = User::query()->orderBy('name')->get();
|
||||
|
||||
return Inertia::render('projects/Access', [
|
||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||
...$this->permissionService->projectAccessMap($users, $project),
|
||||
'permissionOptions' => PermissionOptionResource::collection(
|
||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => session('status'),
|
||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||
'teams' => TeamResource::collection(
|
||||
Team::query()
|
||||
->with('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection(
|
||||
User::query()
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection($users)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -220,7 +221,9 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($user, $project, $permissions);
|
||||
|
||||
return to_route('projects.access', $project)
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
->with('status', "{$user->name} permissions were updated.");
|
||||
}
|
||||
|
||||
@@ -239,7 +242,9 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($team, $project, $permissions);
|
||||
|
||||
return to_route('projects.access', $project)
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
->with('status', "{$team->name} permissions were updated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Projects\CreateProjectTemplate;
|
||||
use App\Actions\Projects\DeleteProjectTemplate;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Actions\Projects\UpdateProjectTemplate;
|
||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
|
||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||
private readonly DeleteProjectTemplate $deleteProjectTemplate,
|
||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||
) {}
|
||||
|
||||
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function createEmpty(ProjectConfigurationIdentity $identity): Response
|
||||
{
|
||||
return Inertia::render('project-templates/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'project' => null,
|
||||
'initialConfiguration' => $identity->defaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
|
||||
{
|
||||
$template = $this->createProjectTemplate->empty($request->validated());
|
||||
|
||||
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
|
||||
}
|
||||
|
||||
public function edit(ProjectTemplate $projectTemplate): Response
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$templateName = $projectTemplate->name;
|
||||
$projectTemplate->delete();
|
||||
$this->deleteProjectTemplate->handle($projectTemplate);
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$templateName} was deleted.");
|
||||
|
||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
->with('status', "{$label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -16,7 +16,11 @@ class HandleAppearance
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
$appearance = $request->cookie('appearance');
|
||||
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||
|
||||
$request->attributes->set('appearance', $appearance);
|
||||
View::share('appearance', $appearance);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||
'localization' => [
|
||||
'locale' => app()->getLocale(),
|
||||
'timeZone' => config('app.timezone'),
|
||||
],
|
||||
'auth' => [
|
||||
'permissions' => [
|
||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
||||
'user' => $user,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'navigation' => fn (): array => [
|
||||
'project' => $this->projectNavigation($request),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||
*/
|
||||
private function projectNavigation(Request $request): ?array
|
||||
{
|
||||
$project = $request->route('project');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $project instanceof Project || $user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||
|
||||
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'key' => $project->key,
|
||||
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Enums\NotificationFilterAttribute;
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
trait ValidatesProjectTemplateConfiguration
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
protected function configurationRules(): array
|
||||
{
|
||||
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$path = 'configuration_snapshot.'.$area;
|
||||
$rules[$path] = ['sometimes', 'array', 'max:200'];
|
||||
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
}
|
||||
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
|
||||
foreach (['show_author', 'show_story_points'] as $field) {
|
||||
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
|
||||
if ($area !== 'ticket_statuses') {
|
||||
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
|
||||
}
|
||||
}
|
||||
$fields = [
|
||||
'board_columns' => 'configuration_key,name,position',
|
||||
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
|
||||
'ticket_types' => 'configuration_key,name,position,is_parent',
|
||||
'ticket_priorities' => 'configuration_key,name,position',
|
||||
'ticket_fields' => 'configuration_key,label,position,field_type',
|
||||
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
|
||||
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
|
||||
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
|
||||
];
|
||||
foreach ($fields as $area => $allowed) {
|
||||
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
|
||||
}
|
||||
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
|
||||
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
|
||||
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
|
||||
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
|
||||
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
|
||||
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
|
||||
foreach (['base_url', 'repository_url'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
|
||||
}
|
||||
$prefix = 'configuration_snapshot.notification_actions.*.';
|
||||
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
|
||||
$rules[$prefix.'enabled'] = ['required', 'boolean'];
|
||||
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
|
||||
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
|
||||
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
|
||||
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
|
||||
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
|
||||
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
|
||||
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
|
||||
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
|
||||
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
|
||||
$rules[$prefix.'filters.*.value'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
$validated = $validator->validated();
|
||||
$configuration = Arr::get($validated, 'configuration_snapshot', []);
|
||||
$areas = Arr::get($validated, 'copy_areas', []);
|
||||
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
|
||||
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
|
||||
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
|
||||
$key = Arr::get($status, 'board_column_key');
|
||||
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
|
||||
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
|
||||
}
|
||||
}
|
||||
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
|
||||
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|
||||
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
|
||||
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
|
||||
}
|
||||
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
|
||||
&& Arr::get($transition, 'target_keys', []) !== []) {
|
||||
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
|
||||
}
|
||||
}
|
||||
$repositoryIdentifiers = [];
|
||||
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
|
||||
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
|
||||
if (isset($repositoryIdentifiers[$identifier])) {
|
||||
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
|
||||
}
|
||||
$repositoryIdentifiers[$identifier] = true;
|
||||
}
|
||||
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
|
||||
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
|
||||
}
|
||||
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
|
||||
$operator = Arr::get($filter, 'operator');
|
||||
$value = Arr::get($filter, 'value');
|
||||
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
|
||||
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|
||||
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'status_replacements.*' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
foreach ($areas as $area) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
|
||||
}
|
||||
}
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateStatuses = Arr::get(
|
||||
$configuration,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
[],
|
||||
);
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
foreach ($replacements as $id => $replacement) {
|
||||
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
|
||||
}
|
||||
}
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||
$validator->errors()->add(
|
||||
"status_replacements.{$status->id}",
|
||||
"Choose a replacement status for {$status->name}.",
|
||||
);
|
||||
$replacement = Arr::get($replacements, (string) $status->id);
|
||||
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
return ($this->user()?->can('create-project') ?? false)
|
||||
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$project = $this->route('project');
|
||||
$baseTemplate = $project?->projectTemplate;
|
||||
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
|
||||
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [...parent::rules(), ...$this->configurationRules()];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
@@ -25,10 +28,12 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||
|
||||
return [
|
||||
...$this->configurationRules(),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
|
||||
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
|
||||
? null
|
||||
: [
|
||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
|
||||
'local_areas' => Arr::get($templateState, 'localAreas', []),
|
||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -2,24 +2,32 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetProjectConfiguration;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListProjects;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\SearchTickets;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
#[Version('0.2.1')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected function boot(): void
|
||||
{
|
||||
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
|
||||
protected array $tools = [
|
||||
ListMyAssignedTickets::class,
|
||||
ListTicketsByStatus::class,
|
||||
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
ListProjects::class,
|
||||
GetProjectConfiguration::class,
|
||||
SearchTickets::class,
|
||||
UpdateTicket::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
|
||||
trait DescribesTicketWriting
|
||||
{
|
||||
public function description(): string
|
||||
{
|
||||
return parent::description().' '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function ticketForKey(User $user, string $ticketKey): Ticket
|
||||
{
|
||||
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
||||
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
||||
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
||||
}
|
||||
$this->ensureCanRead($user, $ticket->project);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
@@ -201,6 +213,18 @@ class KanbanGitMcp
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
||||
public function paginationPayload(LengthAwarePaginator $paginator): array
|
||||
{
|
||||
return [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'has_more_pages' => $paginator->hasMorePages(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -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\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
|
||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
|
||||
'checked' => $schema->boolean()->default(false),
|
||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||
'field_values' => $schema->array()
|
||||
->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
#[Name('get_ticket')]
|
||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[IsReadOnly]
|
||||
class GetTicket extends Tool
|
||||
{
|
||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
||||
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
||||
$ticket = isset($validated['ticket_id'])
|
||||
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
||||
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class MoveTicketStatus extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly MoveProjectTicket $moveProjectTicket,
|
||||
|
||||
@@ -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;
|
||||
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketAssignee extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketDescription extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||
$payload = [
|
||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||
'description' => $validated['description'] ?? null,
|
||||
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||
];
|
||||
|
||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
||||
return [
|
||||
'description' => $schema->string()
|
||||
->nullable()
|
||||
->description('New ticket description. Pass null to clear it.'),
|
||||
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
|
||||
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait SyncsProjectTemplateOwnership
|
||||
{
|
||||
/** @param array<string, mixed> $options */
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
return DB::transaction(function () use ($options): bool {
|
||||
$this->syncProjectTemplateOwnership();
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
if ($project?->project_template_id !== null) {
|
||||
$this->setAttribute('project_template_id', $project->project_template_id);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::save($options);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function delete(): ?bool
|
||||
{
|
||||
return DB::transaction(function (): ?bool {
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
}
|
||||
|
||||
return parent::delete();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
protected function syncProjectTemplateOwnership(): void
|
||||
{
|
||||
if ($this->getAttribute('configuration_key') === null) {
|
||||
$this->setAttribute('configuration_key', (string) Str::uuid());
|
||||
}
|
||||
|
||||
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||
$projectId = $this->getAttribute('project_id');
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'channel',
|
||||
'channel_configuration',
|
||||
'delivery_url',
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketFieldDefinition extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
|
||||
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
if ($model instanceof TicketStatus && $model->project !== null) {
|
||||
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
|
||||
}
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -10,8 +10,6 @@
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"mcp-development",
|
||||
"wayfinder-development",
|
||||
"inertia-vue-development",
|
||||
"tailwindcss-development"
|
||||
"wayfinder-development"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->dontFlash(['api_token']);
|
||||
})->create();
|
||||
|
||||
+21
-8
@@ -20,6 +20,7 @@
|
||||
"scalar/laravel": "^0.2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.20",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"laravel/boost": "^2.0",
|
||||
"laravel/pail": "^1.2.5",
|
||||
@@ -47,15 +48,15 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
"pnpm install",
|
||||
"pnpm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"dev:ssr": [
|
||||
"npm run build:ssr",
|
||||
"pnpm run build:ssr",
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||
],
|
||||
@@ -67,15 +68,27 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm audit --audit-level=high",
|
||||
"npm run lint:check",
|
||||
"npm run types:check",
|
||||
"@test"
|
||||
"@ci:frontend",
|
||||
"@ci:backend",
|
||||
"@ci:test"
|
||||
],
|
||||
"ci:frontend": [
|
||||
"pnpm audit --audit-level=high",
|
||||
"pnpm run lint:check",
|
||||
"pnpm run types:check",
|
||||
"pnpm run build"
|
||||
],
|
||||
"ci:backend": [
|
||||
"@lint:check"
|
||||
],
|
||||
"ci:test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test --parallel --processes=2"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@lint:check",
|
||||
"@php artisan test"
|
||||
"@php artisan test --parallel --processes=2"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
|
||||
Generated
+241
-23
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "7d965721310f81816807d6b78c5f9654",
|
||||
"content-hash": "4d18577105a240f2d8cce1c098398730",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -1451,20 +1451,20 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.26.1",
|
||||
"version": "v13.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -1576,18 +1576,20 @@
|
||||
"league/flysystem-sftp-v3": "^3.25.1",
|
||||
"mockery/mockery": "^1.6.10",
|
||||
"opis/json-schema": "^2.4.1",
|
||||
"orchestra/testbench-core": "^11.0.0",
|
||||
"orchestra/testbench-core": "^11.5.0",
|
||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||
"php-http/discovery": "^1.15",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan": "2.2.15",
|
||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.3",
|
||||
"rector/rector": "2.6.3",
|
||||
"resend/resend-php": "^1.0",
|
||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||
"symfony/http-client": "^7.4.0 || ^8.0.0",
|
||||
"symfony/mercure": "^0.8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0"
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0",
|
||||
"web-token/jwt-library": "^4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||
@@ -1623,8 +1625,10 @@
|
||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
||||
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
|
||||
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
|
||||
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
|
||||
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
|
||||
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1674,7 +1678,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-08-18T20:31:28+00:00"
|
||||
"time": "2026-09-29T12:46:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -2003,16 +2007,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2106,7 +2110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2192,16 +2196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.35.2",
|
||||
"version": "3.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2269,9 +2273,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
@@ -7020,6 +7024,99 @@
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "brianium/paratest",
|
||||
"version": "v7.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paratestphp/paratest.git",
|
||||
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
|
||||
"reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-reflection": "*",
|
||||
"ext-simplexml": "*",
|
||||
"fidry/cpu-core-counter": "^1.3.0",
|
||||
"jean85/pretty-package-versions": "^2.1.1",
|
||||
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
|
||||
"phpunit/php-file-iterator": "^6.0.1 || ^7",
|
||||
"phpunit/php-timer": "^8 || ^9",
|
||||
"phpunit/phpunit": "^12.5.14 || ^13.0.5",
|
||||
"sebastian/environment": "^8.0.3 || ^9",
|
||||
"symfony/console": "^7.4.7 || ^8.0.7",
|
||||
"symfony/process": "^7.4.5 || ^8.0.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^14.0.0",
|
||||
"ext-pcntl": "*",
|
||||
"ext-pcov": "*",
|
||||
"ext-posix": "*",
|
||||
"phpstan/phpstan": "^2.1.44",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.4",
|
||||
"phpstan/phpstan-phpunit": "^2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.10",
|
||||
"symfony/filesystem": "^7.4.6 || ^8.0.6"
|
||||
},
|
||||
"bin": [
|
||||
"bin/paratest",
|
||||
"bin/paratest_for_phpstorm"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParaTest\\": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Brian Scaturro",
|
||||
"email": "scaturrob@gmail.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Filippo Tessarotto",
|
||||
"email": "zoeslam@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Parallel testing for PHP",
|
||||
"homepage": "https://github.com/paratestphp/paratest",
|
||||
"keywords": [
|
||||
"concurrent",
|
||||
"parallel",
|
||||
"phpunit",
|
||||
"testing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/paratestphp/paratest/issues",
|
||||
"source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/Slamdunk",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://paypal.me/filippotessarotto",
|
||||
"type": "paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-29T15:46:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fakerphp/faker",
|
||||
"version": "v1.24.1",
|
||||
@@ -7083,6 +7180,67 @@
|
||||
},
|
||||
"time": "2024-11-21T13:46:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fidry/cpu-core-counter",
|
||||
"version": "1.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/theofidry/cpu-core-counter.git",
|
||||
"reference": "db9508f7b1474469d9d3c53b86f817e344732678"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
|
||||
"reference": "db9508f7b1474469d9d3c53b86f817e344732678",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fidry/makefile": "^0.2.0",
|
||||
"fidry/php-cs-fixer-config": "^1.1.2",
|
||||
"phpstan/extension-installer": "^1.2.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^8.5.31 || ^9.5.26",
|
||||
"webmozarts/strict-phpunit": "^7.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Fidry\\CpuCoreCounter\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Théo FIDRY",
|
||||
"email": "theo.fidry@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Tiny utility to get the number of CPU cores.",
|
||||
"keywords": [
|
||||
"CPU",
|
||||
"core"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/theofidry/cpu-core-counter/issues",
|
||||
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/theofidry",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-14T07:29:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "filp/whoops",
|
||||
"version": "2.18.4",
|
||||
@@ -7205,6 +7363,66 @@
|
||||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v2.4.3",
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<BoardColumn>
|
||||
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<NotificationAction>
|
||||
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'channel' => NotificationChannel::Webhook,
|
||||
'channel_configuration' => [],
|
||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<RepositoryConnection>
|
||||
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'base_url' => 'https://git.'.fake()->domainName(),
|
||||
'owner_name' => fake()->userName(),
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketFieldDefinition>
|
||||
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
||||
'label' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketLinkType>
|
||||
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
|
||||
$label = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'label' => $label,
|
||||
'counterpart_label' => $label,
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketPriority>
|
||||
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketStatus>
|
||||
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'board_column_id' => null,
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketType>
|
||||
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'is_parent' => false,
|
||||
'name' => fake()->unique()->jobTitle(),
|
||||
'position' => 1,
|
||||
|
||||
+165
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
# UI Rework 1: Englische Lokalisierungsgrundlage
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: Keine.
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Englische Lokalisierungsgrundlage als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/app.ts](../../resources/js/app.ts); [resources/js/ssr.ts](../../resources/js/ssr.ts); [resources/js/types](../../resources/js/types). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Eine zentrale Übersetzungsstruktur für Vue einführen.
|
||||
2. Englischen Katalog, Interpolation, Pluralformen und Formatierung festlegen.
|
||||
3. Serverseitige Validierungstexte einheitlich Englisch halten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Neue UI-Texte verwenden Schlüssel.
|
||||
- [ ] Fehlende Schlüssel fallen im Test auf.
|
||||
- [ ] Spätere Sprachen benötigen keine Änderungen an Komponenten.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,39 +0,0 @@
|
||||
# UI Rework 10: Persönliches Dashboard mit vorhandenen Daten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Persönliches Dashboard mit vorhandenen Daten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/Dashboard.vue](../../resources/js/pages/Dashboard.vue); [app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php](../../app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php); [routes/web.php](../../routes/web.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Persönliches Dashboard — 30b69ed84e5c4bdf8cd3fe26d7a9e803. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Dashboard aus zugewiesenen Tickets und sichtbaren Projekten aufbauen.
|
||||
2. Vorhandene Action über einen Inertia-Controller und Resources anbinden.
|
||||
3. Aus dem Mockup nicht belegbare Kennzahlen und Aktivitätsfeeds weglassen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Keine Platzhalterdaten.
|
||||
- [ ] Keine fremden Tickets.
|
||||
- [ ] Leere und gefüllte Dashboards funktionieren.
|
||||
- [ ] Neue Datenweitergabe ist durch Featuretests abgesichert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/DashboardTest.php](../../tests/Feature/DashboardTest.php), [tests/Feature/Api/AssignedTicketsApiTest.php](../../tests/Feature/Api/AssignedTicketsApiTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,36 +0,0 @@
|
||||
# UI Rework 11: Ticketkarten und wiederverwendbare Ticketlisten
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 3](epic-1-issue-3.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Ticketkarten und wiederverwendbare Ticketlisten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/components](../../resources/js/components); [resources/js/types/models/ticket-model.ts](../../resources/js/types/models/ticket-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Neue Karten und Listenzeilen mit Ticketkey, Titel, Typ, Priorität, Zuweisung und Blockerzustand entwickeln.
|
||||
2. Darstellung mit und ohne optionale Daten definieren.
|
||||
3. Gemeinsame Props und Ereignisse für Board, Backlog und Dashboard festlegen. Ticketlinks über Wayfinder aufbauen und Fokusführung sowie Touch-Ziele prüfen; die Komponenten dürfen keine eigenen Datenabfragen auslösen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Lange Titel bleiben bedienbar.
|
||||
- [ ] Prioritäten und Status stammen aus Projektdaten.
|
||||
- [ ] Links, Fokus und Touch-Ziele funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,38 +0,0 @@
|
||||
# UI Rework 12: Boardansicht für Desktop und Mobilgeräte
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Boardansicht für Desktop und Mobilgeräte als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Actions/Projects/BuildProjectBoardPayload.php](../../app/Actions/Projects/BuildProjectBoardPayload.php); [app/Http/Resources/BoardColumnResource.php](../../app/Http/Resources/BoardColumnResource.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Horizontales Board mit konfigurierten Spalten neu bauen.
|
||||
2. Mobil eine Spalte mit Auswahl anbieten.
|
||||
3. Statusunterteilung, Zähler, Leerzustände und vorhandene Statusauswahl darstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Beliebige Spaltenanzahl funktioniert.
|
||||
- [ ] Mehrere Status pro Spalte bleiben sichtbar.
|
||||
- [ ] Board und Backlog behalten ihre bestehende fachliche Trennung.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,40 +0,0 @@
|
||||
# UI Rework 13: Drag-and-drop für bestehende Statuswechsel
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 12](epic-1-issue-12.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Drag-and-drop für bestehende Statuswechsel als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Http/Requests/MoveProjectTicketRequest.php](../../app/Http/Requests/MoveProjectTicketRequest.php); [app/Actions/Tickets/MoveProjectTicket.php](../../app/Actions/Tickets/MoveProjectTicket.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Ziehen per Maus und Touch ergänzen.
|
||||
2. Zulässige Ziele aus Workflowdaten ableiten.
|
||||
3. Bei mehreren Zielstatus Auswahl zeigen.
|
||||
4. Vorhandenen Statusendpunkt über Wayfinder verwenden und Fehler zurückrollen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Unzulässige Ziele sind gesperrt.
|
||||
- [ ] Alternative Tastatur-/Menübedienung bleibt möglich.
|
||||
- [ ] Kein doppeltes Worklog.
|
||||
- [ ] Keine persistierte freie Kartenreihenfolge.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,37 +0,0 @@
|
||||
# UI Rework 14: Backlog und Ticketbaum
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Backlog und Ticketbaum als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Backlog.vue](../../resources/js/pages/projects/Backlog.vue); [resources/js/components/TicketTreeList.vue](../../resources/js/components/TicketTreeList.vue); [app/Actions/Projects/BuildProjectBacklogPayload.php](../../app/Actions/Projects/BuildProjectBacklogPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Backlog mit vorhandenen Filtern und Hierarchie neu darstellen.
|
||||
2. Eltern-Kind-Beziehungen, einklappbare Ebenen und vorhandene Aktionen integrieren.
|
||||
3. Mobile Listenansicht erstellen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Verschachtelte Tickets und leere Treffer funktionieren.
|
||||
- [ ] Berechtigungen und Statusübergänge bleiben erhalten.
|
||||
- [ ] Filter verändern keine fachlichen Daten.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBacklogTest.php](../../tests/Feature/Projects/ProjectBacklogTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,38 +0,0 @@
|
||||
# UI Rework 15: Neue Ticketdetailansicht und Seitenstruktur
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md), [Issue 11](epic-1-issue-11.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Neue Ticketdetailansicht und Seitenstruktur als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Actions/Projects/BuildProjectTicketDetailPayload.php](../../app/Actions/Projects/BuildProjectTicketDetailPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Detailansicht in klar abgegrenzte Komponenten aufteilen.
|
||||
2. Desktop-Inhalt und Metadatenbereich sowie mobile Stapelansicht neu aufbauen.
|
||||
3. Stabile Einhängepunkte für Bearbeitung, Checkliste, Beziehungen, Historie und Git definieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Alle bestehenden Informationen bleiben zugänglich.
|
||||
- [ ] Keine bestehende Funktion wird beim Umbau entfernt.
|
||||
- [ ] Direkte Ticket-URLs funktionieren.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
@@ -1,38 +0,0 @@
|
||||
# UI Rework 16: Ticketanlage und Bearbeitungsformulare
|
||||
|
||||
Epic: [Epic 1](epic-1.md)
|
||||
|
||||
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 15](epic-1-issue-15.md)
|
||||
|
||||
## Ziel und Grenzen
|
||||
|
||||
Ticketanlage und Bearbeitungsformulare als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
|
||||
|
||||
## Einstieg
|
||||
|
||||
Gezielt prüfen: [resources/js/pages/projects/TicketCreate.vue](../../resources/js/pages/projects/TicketCreate.vue); [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Requests/StoreProjectTicketRequest.php](../../app/Http/Requests/StoreProjectTicketRequest.php); [app/Http/Requests/UpdateProjectTicketRequest.php](../../app/Http/Requests/UpdateProjectTicketRequest.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
|
||||
|
||||
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
|
||||
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
|
||||
|
||||
## Implementierungsweg
|
||||
|
||||
1. Formulare für vorhandene Ticketattribute neu erstellen.
|
||||
2. Gemeinsame Feldkomponenten für Anlage und Bearbeitung verwenden.
|
||||
3. Projektkonfigurierte Felder, Autor-/Story-Point-Schalter und Validierung erhalten.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Anlage und Bearbeitung speichern dieselben Attribute wie bisher.
|
||||
- [ ] Fehler und ungespeicherte Änderungen sind abgedeckt.
|
||||
- [ ] Gesperrte Aktionen bleiben serverseitig abgesichert.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
|
||||
|
||||
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
|
||||
|
||||
## Grenze der Agentensitzung
|
||||
|
||||
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user