Compare commits
196
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 | ||
|
|
d9cb8d0903 | ||
|
|
19e8ba89bf | ||
|
|
642e49d7b0 | ||
|
|
1d8fff1d51 | ||
|
|
bd7be4e41e | ||
|
|
9895fc36db | ||
|
|
fed6a282a4 | ||
|
|
db8855a056 | ||
|
|
04b0d5e316 | ||
|
|
49e887753c | ||
|
|
b8a1d65122 | ||
|
|
831014f7fe | ||
|
|
fef82cb88f | ||
|
|
40f9b473fc | ||
|
|
5abfae2736 | ||
|
|
18d19fa473 | ||
|
|
7331c122b5 | ||
|
|
a731f9ea10 | ||
|
|
afe99ea6d4 | ||
|
|
e8ee23371b | ||
|
|
96df45456e |
@@ -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
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: mcp-development
|
||||||
|
description: "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."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
||||||
|
<!-- Register MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Facades\Mcp;
|
||||||
|
|
||||||
|
Mcp::web();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating MCP Primitives
|
||||||
|
|
||||||
|
Create MCP tools, resources, prompts, and servers using artisan commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
php artisan make:mcp-resource ResourceName # Create a resource
|
||||||
|
|
||||||
|
php artisan make:mcp-prompt PromptName # Create a prompt
|
||||||
|
|
||||||
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
<!-- MCP Tool Example -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Request;
|
||||||
|
use Laravel\Mcp\Server\Response;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response(['result' => 'success']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
|
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
||||||
|
|
||||||
|
<!-- Register Primitives in MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server;
|
||||||
|
|
||||||
|
class AppServer extends Server
|
||||||
|
{
|
||||||
|
protected array $tools = [
|
||||||
|
\App\Mcp\Tools\MyTool::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $resources = [
|
||||||
|
\App\Mcp\Resources\MyResource::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $prompts = [
|
||||||
|
\App\Mcp\Prompts\MyPrompt::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
2. Test tool via MCP client
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Running `mcp:start` command (it hangs waiting for input)
|
||||||
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
||||||
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
@@ -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
|
## Wayfinder + Inertia
|
||||||
|
|
||||||
Use Wayfinder with the `<Form>` component:
|
Use Wayfinder with `useForm`:
|
||||||
<!-- Wayfinder Form (Vue) -->
|
|
||||||
```vue
|
<!-- Wayfinder useForm -->
|
||||||
<Form v-bind="store.form()"><input name="title" /></Form>
|
```typescript
|
||||||
|
import { store } from "@/actions/App/Http/Controllers/ExampleController";
|
||||||
|
|
||||||
|
const form = useForm({ name: "My Big Post" });
|
||||||
|
form.submit(store());
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
+10
-10
@@ -25,7 +25,6 @@
|
|||||||
**/compose.*.y*ml
|
**/compose.*.y*ml
|
||||||
!**/composer.json
|
!**/composer.json
|
||||||
!**/composer.lock
|
!**/composer.lock
|
||||||
**/Dockerfile*
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/npm-debug.log
|
**/npm-debug.log
|
||||||
**/obj
|
**/obj
|
||||||
@@ -34,16 +33,17 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
# Ignore development-related files and directories
|
# Runtime-generated data must not become part of an image layer.
|
||||||
# For development environment, we will use mount bind to mount the source code to the container
|
storage/framework/cache/data/*
|
||||||
# So we don't need to copy the source code to the container for development
|
storage/framework/sessions/*
|
||||||
tests/
|
storage/framework/testing/*
|
||||||
phpunit.xml
|
storage/framework/views/*
|
||||||
phpunit.xml.dist
|
storage/logs/*
|
||||||
|
|
||||||
|
# Native build artifacts are independent of the Laravel image.
|
||||||
|
native/src-tauri/target
|
||||||
|
native/src-tauri/gen
|
||||||
|
|||||||
+17
-1
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
|||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
LOG_STACK=daily
|
LOG_STACK=stderr
|
||||||
|
|
||||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||||
LOG_DISCORD_WEBHOOK_URL=
|
LOG_DISCORD_WEBHOOK_URL=
|
||||||
@@ -38,6 +38,22 @@ MAIL_ENCRYPTION=tls
|
|||||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
|
||||||
FILESYSTEM_DISK=public
|
FILESYSTEM_DISK=public
|
||||||
|
AVATAR_FILESYSTEM_DISK=public
|
||||||
|
AVATAR_URLS_TEMPORARY=false
|
||||||
|
AVATAR_URL_EXPIRATION_MINUTES=15
|
||||||
|
|
||||||
|
# Production: set AVATAR_FILESYSTEM_DISK=s3 and inject these as runtime secrets.
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=eu-central-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_URL=
|
||||||
|
AWS_ENDPOINT=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: test_and_check_app
|
||||||
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Test and check app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--file Dockerfile \
|
||||||
|
--target test \
|
||||||
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
|
--load \
|
||||||
|
.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.twyco.de
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Run required application checks
|
||||||
|
uses: ./.github/tests/test_and_check_app
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish immutable image
|
||||||
|
if: >-
|
||||||
|
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:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate ref and calculate image name
|
||||||
|
id: image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
image_path="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
image="${REGISTRY}/${image_path}"
|
||||||
|
|
||||||
|
case "${GITHUB_REF}" in
|
||||||
|
refs/heads/main)
|
||||||
|
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
|
||||||
|
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
|
||||||
|
environment="staging"
|
||||||
|
;;
|
||||||
|
refs/tags/*)
|
||||||
|
tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||||
|
echo "Production tags must match vMAJOR.MINOR.PATCH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|
||||||
|
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
|
||||||
|
echo "Production tag must point to a commit contained in main." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
environment="production"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported ref: ${GITHUB_REF}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "image=${image}"
|
||||||
|
echo "tag=${tag}"
|
||||||
|
echo "environment=${environment}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Log in to private registry
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_TOKEN}" ]]; then
|
||||||
|
echo "Registry credentials are missing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${REGISTRY_TOKEN}" |
|
||||||
|
docker login \
|
||||||
|
--username "${REGISTRY_USERNAME}" \
|
||||||
|
--password-stdin \
|
||||||
|
"${REGISTRY}"
|
||||||
|
|
||||||
|
- name: Build and publish runtime image
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.image.outputs.image }}
|
||||||
|
IMAGE_TAG: ${{ steps.image.outputs.tag }}
|
||||||
|
DEPLOYMENT_ENVIRONMENT: ${{ steps.image.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ "${DEPLOYMENT_ENVIRONMENT}" == "production" ]] &&
|
||||||
|
docker buildx imagetools inspect "${IMAGE}:${IMAGE_TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Production image ${IMAGE}:${IMAGE_TAG} already exists and will not be overwritten." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--file Dockerfile \
|
||||||
|
--target runtime \
|
||||||
|
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||||
|
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
|
||||||
|
--label "de.twyco.environment=${DEPLOYMENT_ENVIRONMENT}" \
|
||||||
|
--tag "${IMAGE}:${IMAGE_TAG}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Remove registry credentials
|
||||||
|
if: ${{ always() }}
|
||||||
|
shell: bash
|
||||||
|
run: docker logout "${REGISTRY}" >/dev/null 2>&1 || true
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: linter
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Run Pint
|
|
||||||
run: composer lint
|
|
||||||
|
|
||||||
- name: Lint Frontend
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
# - name: Commit Changes
|
|
||||||
# uses: stefanzweifel/git-auto-commit-action@v7
|
|
||||||
# with:
|
|
||||||
# commit_message: fix code style
|
|
||||||
# commit_options: '--no-verify'
|
|
||||||
@@ -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
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
name: tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- staging
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
php-version: ['8.4']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php-version }}
|
|
||||||
tools: composer:v2
|
|
||||||
coverage: xdebug
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install Node Dependencies
|
|
||||||
run: npm i
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
|
||||||
|
|
||||||
- name: Copy Environment File
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate Application Key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Build Assets
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: ./vendor/bin/phpunit
|
|
||||||
+13
@@ -28,3 +28,16 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
.codex/config.toml
|
.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>
|
<laravel-boost-guidelines>
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
@@ -9,24 +25,18 @@ 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.
|
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
|
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- 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
|
## Skills Activation
|
||||||
|
|
||||||
@@ -34,10 +44,8 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
- `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.
|
- `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.
|
||||||
- `project-route-structure` — Always apply in this project when creating, moving, or refactoring Laravel route definitions in routes/**/*.php; changing route prefixes, names, middleware, scoped bindings, or route files; or adding frontend-facing endpoints that Wayfinder generates. Documents the KanbanGit route-file layout and grouping conventions.
|
- `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
|
- `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
|
## Conventions
|
||||||
|
|
||||||
@@ -56,7 +64,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
## Frontend Bundling
|
## 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
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
# Inertia v2
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
## Vite Error
|
## 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
|
## 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 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).
|
- 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>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
|
||||||
|
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
||||||
|
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c
|
||||||
|
|
||||||
|
FROM ${COMPOSER_IMAGE} AS composer_binary
|
||||||
|
FROM ${NODE_IMAGE} AS node_binary
|
||||||
|
|
||||||
|
FROM ${PHP_IMAGE} AS php_base
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gettext-base \
|
||||||
|
git \
|
||||||
|
libfcgi-bin \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libmagickwand-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libzip-dev \
|
||||||
|
nginx \
|
||||||
|
procps \
|
||||||
|
unzip \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" \
|
||||||
|
bcmath \
|
||||||
|
gd \
|
||||||
|
intl \
|
||||||
|
opcache \
|
||||||
|
pcntl \
|
||||||
|
pdo_mysql \
|
||||||
|
pdo_pgsql \
|
||||||
|
pdo_sqlite \
|
||||||
|
pgsql \
|
||||||
|
soap \
|
||||||
|
zip \
|
||||||
|
&& pecl install imagick \
|
||||||
|
&& docker-php-ext-enable imagick \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
COPY --from=composer_binary /usr/bin/composer /usr/local/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
FROM php_base AS build
|
||||||
|
|
||||||
|
COPY --from=node_binary /usr/local/ /usr/local/
|
||||||
|
COPY . /var/www
|
||||||
|
|
||||||
|
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||||
|
&& corepack enable pnpm \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run build
|
||||||
|
|
||||||
|
FROM build AS test
|
||||||
|
|
||||||
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
|
&& composer audit --locked --no-interaction \
|
||||||
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
FROM build AS production_builder
|
||||||
|
|
||||||
|
RUN composer install \
|
||||||
|
--classmap-authoritative \
|
||||||
|
--no-dev \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--prefer-dist \
|
||||||
|
&& rm -rf node_modules tests phpunit.xml
|
||||||
|
|
||||||
|
FROM php_base AS runtime
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/kanbangit-entrypoint
|
||||||
|
COPY docker/nginx/nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
|
COPY docker/php-fpm-healthcheck.sh /usr/local/bin/php-fpm-healthcheck
|
||||||
|
COPY --from=production_builder --chown=www-data:www-data /var/www /var/www
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/kanbangit-entrypoint /usr/local/bin/php-fpm-healthcheck \
|
||||||
|
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
||||||
|
&& printf '%s\n' \
|
||||||
|
'[global]' \
|
||||||
|
'daemonize = no' \
|
||||||
|
'' \
|
||||||
|
'[www]' \
|
||||||
|
'pm.status_path = /status' \
|
||||||
|
> /usr/local/etc/php-fpm.d/zz-kanbangit.conf \
|
||||||
|
&& mkdir -p \
|
||||||
|
/var/www/bootstrap/cache \
|
||||||
|
/var/www/storage/app/public \
|
||||||
|
/var/www/storage/framework/cache \
|
||||||
|
/var/www/storage/framework/sessions \
|
||||||
|
/var/www/storage/framework/testing \
|
||||||
|
/var/www/storage/framework/views \
|
||||||
|
/var/www/storage/logs \
|
||||||
|
&& ln -s ../storage/app/public /var/www/public/storage \
|
||||||
|
&& chown -R www-data:www-data /var/www/bootstrap/cache /var/www/storage
|
||||||
|
|
||||||
|
USER www-data
|
||||||
|
|
||||||
|
EXPOSE 8080 9000
|
||||||
|
|
||||||
|
ENTRYPOINT ["kanbangit-entrypoint"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
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>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
|
|||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
])->saveQuietly();
|
])->saveQuietly();
|
||||||
});
|
});
|
||||||
|
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function statusContextLabel(TicketStatus $ticketStatus): string
|
private function statusContextLabel(TicketStatus $ticketStatus): string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\ProjectBoard;
|
namespace App\Actions\ProjectBoard;
|
||||||
|
|
||||||
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
|
|||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
])->saveQuietly();
|
])->saveQuietly();
|
||||||
});
|
});
|
||||||
|
$project = $columns->first()?->project;
|
||||||
|
if ($project !== null) {
|
||||||
|
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
|
|||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class UpdateTicketStatusTransitions
|
class UpdateTicketStatusTransitions
|
||||||
{
|
{
|
||||||
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
|
|||||||
*/
|
*/
|
||||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||||
{
|
{
|
||||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
|
||||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
|
||||||
->unique()
|
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||||
->values()
|
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||||
->all();
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||||
$this->projectTemplateOverrideManager->mark(
|
$this->projectTemplateOverrideManager->mark(
|
||||||
$ticketStatus->project,
|
$ticketStatus->project,
|
||||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||||
);
|
);
|
||||||
$this->refreshProjectBaseTemplate->handle(
|
$this->refreshProjectBaseTemplate->handle(
|
||||||
$ticketStatus->project,
|
$ticketStatus->project,
|
||||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
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\Actions\Projects\RefreshProjectBaseTemplate;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class UpdateProjectTicketMetadataSettings
|
class UpdateProjectTicketMetadataSettings
|
||||||
{
|
{
|
||||||
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
|
|||||||
*/
|
*/
|
||||||
public function handle(Project $project, array $validated): Project
|
public function handle(Project $project, array $validated): Project
|
||||||
{
|
{
|
||||||
$project->update([
|
return DB::transaction(function () use ($project, $validated): Project {
|
||||||
'show_author' => Arr::get($validated, 'show_author', false),
|
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
$project->update([
|
||||||
]);
|
'show_author' => Arr::get($validated, 'show_author', false),
|
||||||
$this->projectTemplateOverrideManager->mark(
|
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||||
$project,
|
]);
|
||||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
$this->projectTemplateOverrideManager->mark(
|
||||||
);
|
$project,
|
||||||
$this->refreshProjectBaseTemplate->handle(
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
$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(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
|||||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||||
]),
|
]),
|
||||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
|||||||
use App\Http\Resources\TicketReferenceResource;
|
use App\Http\Resources\TicketReferenceResource;
|
||||||
use App\Http\Resources\TicketStatusResource;
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Http\Resources\TicketTypeResource;
|
use App\Http\Resources\TicketTypeResource;
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
class BuildProjectTicketOptionsPayload
|
class BuildProjectTicketOptionsPayload
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class CreateProject
|
|||||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||||
$sourceTemplate = ProjectTemplate::query()
|
$sourceTemplate = ProjectTemplate::query()
|
||||||
->published()
|
->published()
|
||||||
|
->lockForUpdate()
|
||||||
->findOrFail((int) $sourceTemplateId);
|
->findOrFail((int) $sourceTemplateId);
|
||||||
|
|
||||||
$project->forceFill([
|
$project->forceFill([
|
||||||
@@ -72,6 +73,7 @@ class CreateProject
|
|||||||
$this->duplicateProjectConfiguration->apply(
|
$this->duplicateProjectConfiguration->apply(
|
||||||
$project->fresh() ?? $project,
|
$project->fresh() ?? $project,
|
||||||
$sourceTemplate->configuration_snapshot ?? [],
|
$sourceTemplate->configuration_snapshot ?? [],
|
||||||
|
adoptExisting: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
|
|||||||
{
|
{
|
||||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||||
|
|
||||||
|
$slug = strtolower($project->key).'-base-template';
|
||||||
|
$suffix = 1;
|
||||||
|
$candidate = $slug;
|
||||||
|
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
|
||||||
|
$candidate = $slug.'-'.++$suffix;
|
||||||
|
}
|
||||||
|
|
||||||
return ProjectTemplate::query()->create([
|
return ProjectTemplate::query()->create([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'source_project_id' => $project->id,
|
'source_project_id' => $project->id,
|
||||||
'name' => "{$project->name} base template",
|
'name' => "{$project->name} base template",
|
||||||
'slug' => strtolower($project->key).'-base-template',
|
'slug' => $candidate,
|
||||||
'is_published' => false,
|
'is_published' => false,
|
||||||
'copy_areas' => $copyAreas,
|
'copy_areas' => $copyAreas,
|
||||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||||
|
|||||||
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CreateProjectTemplate
|
class CreateProjectTemplate
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attributes */
|
||||||
|
public function empty(array $attributes): ProjectTemplate
|
||||||
|
{
|
||||||
|
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||||
|
|
||||||
|
return ProjectTemplate::query()->create([
|
||||||
|
'name' => Arr::get($attributes, 'name'),
|
||||||
|
'slug' => Arr::get($attributes, 'slug'),
|
||||||
|
'is_published' => true,
|
||||||
|
'copy_areas' => $areas,
|
||||||
|
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
|
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
|
||||||
|
))->only($areas)->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||||
*/
|
*/
|
||||||
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
||||||
{
|
{
|
||||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
return DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
|
||||||
|
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$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([
|
return $template;
|
||||||
'project_id' => null,
|
}, 3);
|
||||||
'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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DeleteProject
|
class DeleteProject
|
||||||
{
|
{
|
||||||
@@ -10,7 +11,13 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
$project->delete();
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
return $projectName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DeleteProjectTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(ProjectTemplate $template): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($template): void {
|
||||||
|
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
|
||||||
|
$template->delete();
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
|
||||||
|
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
|
||||||
|
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
|
||||||
|
foreach ($project->configurationRelations() as $relation) {
|
||||||
|
$project->{$relation}()->update(['project_template_id' => $base->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class DuplicateProjectConfiguration
|
class DuplicateProjectConfiguration
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||||
|
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
|
|||||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $configuration;
|
return $this->projectConfigurationIdentity->normalize($configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||||
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||||
static fn ($column, int $index): array => [$column->id => $index],
|
static fn ($column): array => [$column->id => $column->configuration_key],
|
||||||
);
|
);
|
||||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||||
static fn ($status, int $index): array => [$status->id => $index],
|
static fn ($status): array => [$status->id => $status->configuration_key],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
static fn ($column): array => ['configuration_key' => $column->configuration_key, 'name' => $column->name, 'position' => $column->position],
|
||||||
)->all();
|
)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||||
static fn ($status): array => [
|
static fn ($status): array => [
|
||||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
'configuration_key' => $status->configuration_key,
|
||||||
|
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
|
||||||
'name' => $status->name,
|
'name' => $status->name,
|
||||||
'position' => $status->position,
|
'position' => $status->position,
|
||||||
],
|
],
|
||||||
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
static fn ($status): array => [
|
||||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
'status_key' => $status->configuration_key,
|
||||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
|
||||||
|
static fn (int $id): ?string => $statusKeys->get($id),
|
||||||
|
)->filter()->sort()->values()->all(),
|
||||||
|
],
|
||||||
)->all();
|
)->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
|
|||||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||||
'filters' => $item->filters ?? [],
|
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$relations = [
|
$relations = [
|
||||||
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
|
|||||||
|
|
||||||
foreach ($simpleAreas as $area => $mapper) {
|
foreach ($simpleAreas as $area => $mapper) {
|
||||||
if (in_array($area, $selectedAreas, true)) {
|
if (in_array($area, $selectedAreas, true)) {
|
||||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
|
||||||
|
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
|
||||||
|
)->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $configuration
|
* @param array<string, mixed> $configuration
|
||||||
* @param array<int|string, int|string> $statusReplacements
|
* @param array<int|string, int|string> $statusReplacements
|
||||||
* @return list<string> areas which retained referenced local values
|
* @return list<string> Areas which retain referenced project configuration.
|
||||||
*/
|
*/
|
||||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||||
|
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
|
||||||
|
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
|
||||||
{
|
{
|
||||||
$configuration = $this->normalizeConfiguration($configuration);
|
$configuration = $this->normalizeConfiguration($configuration);
|
||||||
|
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
|
||||||
$blockedAreas = [];
|
$blockedAreas = [];
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||||
$blocked = $this->syncModels(
|
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
|
||||||
$targetProject->boardColumns()->get(),
|
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
|
||||||
$configuration[self::COPY_BOARD_COLUMNS],
|
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
|
||||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|
||||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
|
||||||
);
|
);
|
||||||
if ($blocked) {
|
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
|
||||||
|
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
|
||||||
|
if (! $statusesCanChange) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$next = $nextStatuses->get($status->configuration_key);
|
||||||
|
$nextColumn = $next['board_column_key'] ?? null;
|
||||||
|
|
||||||
|
return $next === null || ($nextColumn !== $column->configuration_key
|
||||||
|
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
|
||||||
|
$canDeleteColumn, $adoptExisting)) {
|
||||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||||
$columns = $targetProject->boardColumns()->get()->values();
|
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
|
||||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||||
$columnIndex = $item['board_column_index'] ?? null;
|
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
|
||||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
$columnKey = $item['board_column_key'] ?? null;
|
||||||
unset($item['board_column_index']);
|
$item['board_column_id'] = $columnKey === null
|
||||||
|
? null
|
||||||
|
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
|
||||||
|
$item['slug'] = Str::slug($item['name']);
|
||||||
|
unset($item['board_column_key']);
|
||||||
|
|
||||||
return $item;
|
return $item;
|
||||||
})->all();
|
})->all();
|
||||||
$blocked = $this->syncModels(
|
$templateKeys = array_column($items, 'configuration_key');
|
||||||
$targetProject->ticketStatuses()->get(),
|
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|
||||||
$items,
|
|| array_key_exists($status->id, $statusReplacements);
|
||||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
$replacement = $statusReplacements[$status->id] ?? null;
|
||||||
function (Model $status) use ($statusReplacements): bool {
|
if ($replacement === null) {
|
||||||
if (! $status->tickets()->exists()) {
|
return;
|
||||||
return true;
|
}
|
||||||
}
|
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
|
||||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
->where('configuration_key', (string) $replacement)->first();
|
||||||
if ($replacementId === null) {
|
if ($replacementStatus === null && is_numeric($replacement)) {
|
||||||
return false;
|
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
|
||||||
}
|
}
|
||||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
if ($replacementStatus === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
return true;
|
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
|
||||||
},
|
]);
|
||||||
);
|
}
|
||||||
if ($blocked) {
|
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
|
||||||
|
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
|
||||||
|
};
|
||||||
|
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
|
||||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
|
||||||
$status = $statuses->get($index);
|
$status = $statuses->get($transition['status_key']);
|
||||||
if ($status !== null) {
|
if ($status !== null) {
|
||||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
|
||||||
|
static fn (string $key): ?int => $statuses->get($key)?->id,
|
||||||
|
)->filter()->all());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$relations = [
|
$guards = [
|
||||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
|
||||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
|
||||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
self::COPY_REPOSITORY_CONNECTIONS => null,
|
||||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
self::COPY_NOTIFICATION_ACTIONS => null,
|
||||||
];
|
];
|
||||||
|
foreach ($guards as $area => $canDelete) {
|
||||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
|
||||||
if (! isset($configuration[$area])) {
|
if (! isset($configuration[$area])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$blocked = $this->syncModels(
|
$items = $configuration[$area];
|
||||||
$targetProject->{$relation}()->get(),
|
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
|
||||||
$configuration[$area],
|
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
|
||||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
$items = array_map(fn (array $item): array => [
|
||||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
|
||||||
$canDelete,
|
], $items);
|
||||||
);
|
}
|
||||||
if ($blocked) {
|
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
|
||||||
$blockedAreas[] = $area;
|
$blockedAreas[] = $area;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
|
|||||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($blockedAreas));
|
return $blockedAreas;
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach ($blockedAreas as $area) {
|
foreach ($blockedAreas as $area) {
|
||||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||||
}
|
}
|
||||||
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
|
|||||||
/** @param list<string> $copyAreas */
|
/** @param list<string> $copyAreas */
|
||||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||||
{
|
{
|
||||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
|
||||||
|
public function mapNotificationFilters(array $filters, array $mapping): array
|
||||||
|
{
|
||||||
|
return array_map(static function (array $filter) use ($mapping): array {
|
||||||
|
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
|
||||||
|
return $filter;
|
||||||
|
}
|
||||||
|
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
|
||||||
|
$filter['value'] = is_array($filter['value'])
|
||||||
|
? array_map($map, $filter['value'])
|
||||||
|
: ($filter['value'] === null ? null : $map($filter['value']));
|
||||||
|
|
||||||
|
return $filter;
|
||||||
|
}, app(NormalizeNotificationFilters::class)->handle($filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
|
||||||
|
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
|
||||||
|
{
|
||||||
|
$statuses = $project->ticketStatuses()->get();
|
||||||
|
$targets = $statuses->whereIn('configuration_key', $templateKeys);
|
||||||
|
$mapping = [];
|
||||||
|
foreach ($statuses as $status) {
|
||||||
|
if (in_array($status->configuration_key, $templateKeys, true)) {
|
||||||
|
$mapping[$status->id] = $status->id;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$target = $replacements[$status->id] ?? null;
|
||||||
|
if (is_scalar($target)) {
|
||||||
|
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
|
||||||
|
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mapping[$removed->id] = $replacement->id;
|
||||||
|
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
|
||||||
|
->orWhere('to_ticket_status_id', $removed->id)->get();
|
||||||
|
foreach ($edges as $edge) {
|
||||||
|
$from = $mapping[$edge->from_ticket_status_id] ?? null;
|
||||||
|
$to = $mapping[$edge->to_ticket_status_id] ?? null;
|
||||||
|
if ($from !== null && $to !== null && $from !== $to) {
|
||||||
|
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param EloquentCollection<int, Model> $models
|
* @param list<array<string, mixed>> $items
|
||||||
* @param array<int, array<string, mixed>> $items
|
|
||||||
*/
|
*/
|
||||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
|
||||||
{
|
{
|
||||||
foreach ($items as $index => $item) {
|
$relation = ProjectConfigurationIdentity::relations()[$area];
|
||||||
$model = $models->get($index);
|
$models = $project->{$relation}()->get();
|
||||||
$model === null ? $create($item) : $update($model, $item);
|
$byKey = $models->keyBy('configuration_key');
|
||||||
|
$matched = [];
|
||||||
|
$assignments = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$model = $byKey->get($item['configuration_key']);
|
||||||
|
if ($model === null && $adoptExisting) {
|
||||||
|
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
|
||||||
|
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
|
||||||
|
$model = $candidates->count() === 1 ? $candidates->first() : null;
|
||||||
|
}
|
||||||
|
if ($model !== null) {
|
||||||
|
$matched[] = $model->id;
|
||||||
|
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
|
||||||
|
&& $model->tickets()->whereHas('childTickets')->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$assignments[] = [$model, $item];
|
||||||
|
}
|
||||||
|
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
|
||||||
|
foreach ($removed as $model) {
|
||||||
|
if ($canDelete !== null && ! $canDelete($model)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$blocked = false;
|
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
|
||||||
foreach ($models->slice(count($items)) as $model) {
|
foreach ($models as $model) {
|
||||||
if ($canDelete !== null && ! $canDelete($model)) {
|
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
|
||||||
$blocked = true;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
|
||||||
|
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
|
||||||
|
foreach ($models->values() as $index => $model) {
|
||||||
|
$model->forceFill(['position' => $offset + $index])->saveQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($assignments as [$model, $item]) {
|
||||||
|
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
|
||||||
|
}
|
||||||
|
foreach ($removed as $model) {
|
||||||
|
if ($beforeDelete !== null) {
|
||||||
|
$beforeDelete($model);
|
||||||
}
|
}
|
||||||
$model->deleteQuietly();
|
$model->deleteQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $blocked;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
class ProjectConfigurationIdentity
|
||||||
|
{
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public static function relations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
|
||||||
|
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
|
||||||
|
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
|
||||||
|
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||||
|
public function normalize(array $configuration): array
|
||||||
|
{
|
||||||
|
foreach (self::relations() as $area => $relation) {
|
||||||
|
if (! array_key_exists($area, $configuration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($configuration[$area] as $index => &$item) {
|
||||||
|
$item['configuration_key'] ??= (string) Uuid::uuid5(
|
||||||
|
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
|
||||||
|
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
|
||||||
|
if (! array_key_exists('board_column_key', $status)) {
|
||||||
|
$columnIndex = $status['board_column_index'] ?? null;
|
||||||
|
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
|
||||||
|
}
|
||||||
|
unset($status['board_column_index']);
|
||||||
|
$configuration['ticket_statuses'][$index] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
|
||||||
|
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
|
||||||
|
if (array_key_exists('status_key', $transition)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($statuses[$index])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$configuration['status_transitions'][$index] = [
|
||||||
|
'status_key' => $statuses[$index],
|
||||||
|
'target_keys' => array_values(array_filter(array_map(
|
||||||
|
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
|
||||||
|
))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $item */
|
||||||
|
public function signature(string $area, array $item): string
|
||||||
|
{
|
||||||
|
return $area === 'repository_connections'
|
||||||
|
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
|
||||||
|
: (string) ($item['name'] ?? $item['label'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function defaults(): array
|
||||||
|
{
|
||||||
|
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
|
||||||
|
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
|
||||||
|
$configuration['ticket_statuses'] = [[
|
||||||
|
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
|
||||||
|
'position' => 1, 'board_column_key' => null,
|
||||||
|
]];
|
||||||
|
$configuration['ticket_types'] = [
|
||||||
|
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
|
||||||
|
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
|
||||||
|
];
|
||||||
|
$configuration['ticket_priorities'] = [[
|
||||||
|
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
|
||||||
|
]];
|
||||||
|
$configuration['status_transitions'] = [[
|
||||||
|
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
|
||||||
|
]];
|
||||||
|
|
||||||
|
return $configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ProjectTemplateOverrideManager
|
class ProjectTemplateOverrideManager
|
||||||
{
|
{
|
||||||
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$project->loadMissing('projectTemplate');
|
DB::transaction(function () use ($project, $area): void {
|
||||||
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
$project->load('projectTemplate');
|
||||||
return;
|
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
$overrides = array_values(array_unique([
|
$project->forceFill(['template_overrides' => array_values(array_unique([
|
||||||
...($project->template_overrides ?? []),
|
...($project->template_overrides ?? []), $area,
|
||||||
$area,
|
]))])->saveQuietly();
|
||||||
]));
|
}, 3);
|
||||||
|
|
||||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
|
|||||||
*/
|
*/
|
||||||
public function clear(Project $project, array $areas): void
|
public function clear(Project $project, array $areas): void
|
||||||
{
|
{
|
||||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
DB::transaction(function () use ($project, $areas): void {
|
||||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withoutRecording(callable $callback): mixed
|
public function withoutRecording(callable $callback): mixed
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class RefreshProjectBaseTemplate
|
class RefreshProjectBaseTemplate
|
||||||
{
|
{
|
||||||
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
|
|||||||
|
|
||||||
public function handle(Project $project, string $area): void
|
public function handle(Project $project, string $area): void
|
||||||
{
|
{
|
||||||
$project->loadMissing('projectTemplate');
|
DB::transaction(function () use ($project, $area): void {
|
||||||
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
$template = ProjectTemplate::query()->find($project->project_template_id);
|
||||||
return;
|
if ($template === null || $template->is_published) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||||
$project->projectTemplate->configuration_snapshot ?? [],
|
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||||
);
|
$template->forceFill(['configuration_snapshot' => array_replace(
|
||||||
|
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
|
||||||
$project->projectTemplate->forceFill([
|
)])->saveQuietly();
|
||||||
'configuration_snapshot' => array_replace(
|
}, 3);
|
||||||
$snapshot,
|
|
||||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
|
||||||
),
|
|
||||||
])->saveQuietly();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
|
|||||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
'effectiveConfiguration' => $currentConfiguration,
|
||||||
|
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
|
||||||
|
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
|
||||||
'inheritedAreas' => $inheritedAreas,
|
'inheritedAreas' => $inheritedAreas,
|
||||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||||
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
|
||||||
if (! is_array($templateStatuses)) {
|
if (! is_array($templateStatuses)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
$templateKeys = array_column($templateStatuses, 'configuration_key');
|
||||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
$replacementOptions = collect($templateStatuses)->map(
|
||||||
|
static fn (array $status): array => ['id' => $status['configuration_key'], 'name' => $status['name']],
|
||||||
)->all();
|
)->all();
|
||||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
$removedStatuses = $statuses
|
||||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $status->tickets_count > 0)
|
||||||
->map(static fn ($status): array => [
|
->map(static fn ($status): array => ['id' => $status->id, 'name' => $status->name, 'ticketCount' => $status->tickets_count])
|
||||||
'id' => $status->id,
|
->values()->all();
|
||||||
'name' => $status->name,
|
|
||||||
'ticketCount' => $status->tickets_count,
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $removedStatuses === [] ? [] : [
|
return $removedStatuses === [] ? [] : [
|
||||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||||
|
|||||||
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
|
|||||||
*/
|
*/
|
||||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||||
{
|
{
|
||||||
$projectTemplate->loadMissing('sourceProject');
|
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
|
||||||
|
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||||
$sourceProject = $projectTemplate->sourceProject;
|
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
|
||||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
|
||||||
|
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
|
||||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
))->only($copyAreas)->all();
|
||||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||||
$projectTemplate->configuration_snapshot ?? [],
|
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||||
);
|
->values()->all();
|
||||||
$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();
|
|
||||||
|
|
||||||
$projectTemplate->forceFill([
|
$projectTemplate->forceFill([
|
||||||
'name' => $attributes['name'],
|
'name' => Arr::get($attributes, 'name'),
|
||||||
'copy_areas' => $normalizedCopyAreas,
|
'copy_areas' => $copyAreas,
|
||||||
'configuration_snapshot' => $nextConfiguration,
|
'configuration_snapshot' => $nextConfiguration,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Project::query()
|
Project::query()
|
||||||
->where('project_template_id', $projectTemplate->id)
|
->where('project_template_id', $projectTemplate->id)
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||||
foreach ($linkedProjects as $linkedProject) {
|
foreach ($linkedProjects as $linkedProject) {
|
||||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
$linkedProject = Project::query()
|
||||||
|
->whereKey($linkedProject->id)
|
||||||
|
->where('project_template_id', $projectTemplate->id)
|
||||||
|
->lockForUpdate()->first();
|
||||||
|
if ($linkedProject === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||||
$overrides = $linkedProject->template_overrides ?? [];
|
$overrides = $linkedProject->template_overrides ?? [];
|
||||||
$replacementConfiguration = [];
|
$replacementConfiguration = [];
|
||||||
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class UpdateProjectTemplateLink
|
class UpdateProjectTemplateLink
|
||||||
{
|
{
|
||||||
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
|
|||||||
public function switch(Project $project, ProjectTemplate $template): void
|
public function switch(Project $project, ProjectTemplate $template): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($project, $template): void {
|
DB::transaction(function () use ($project, $template): void {
|
||||||
|
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||||
$template->configuration_snapshot ?? [],
|
$template->configuration_snapshot ?? [],
|
||||||
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
|
|||||||
* @param list<string> $areas
|
* @param list<string> $areas
|
||||||
* @param array<int|string, int|string> $statusReplacements
|
* @param array<int|string, int|string> $statusReplacements
|
||||||
*/
|
*/
|
||||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
public function reset(Project $project, array $areas, array $statusReplacements = []): array
|
||||||
{
|
{
|
||||||
$template = $project->projectTemplate;
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
if ($template === null) {
|
if ($template === null) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
|
||||||
|
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
if ($project->project_template_id !== $template->id) {
|
||||||
$template->configuration_snapshot ?? [],
|
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
|
||||||
))->only($areas)->all();
|
}
|
||||||
|
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||||
|
$configuration = collect($baseline)->only($areas)->all();
|
||||||
|
if (in_array('ticket_statuses', $areas, true)
|
||||||
|
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
|
||||||
|
&& isset($baseline['status_transitions'])) {
|
||||||
|
$configuration['status_transitions'] = $baseline['status_transitions'];
|
||||||
|
}
|
||||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||||
$project,
|
$project,
|
||||||
$configuration,
|
$configuration,
|
||||||
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
|
|||||||
$project->fresh(),
|
$project->fresh(),
|
||||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return $blockedAreas;
|
||||||
|
}, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class CreateProjectTicket
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
||||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||||
|
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +30,15 @@ class CreateProjectTicket
|
|||||||
* text: string
|
* text: string
|
||||||
* }>,
|
* }>,
|
||||||
* assignee_user_id?: int|null,
|
* assignee_user_id?: int|null,
|
||||||
|
* author_user_id?: int|null,
|
||||||
|
* blocked_comment?: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
|
* field_values?: array<int, array{
|
||||||
|
* ticket_field_definition_id: int,
|
||||||
|
* value_number?: float|int|string|null,
|
||||||
|
* value_user_id?: int|string|null
|
||||||
|
* }>,
|
||||||
|
* is_blocked?: bool,
|
||||||
* parent_ticket_id?: int|null,
|
* parent_ticket_id?: int|null,
|
||||||
* story_points?: int|null,
|
* story_points?: int|null,
|
||||||
* ticket_priority_id: int,
|
* ticket_priority_id: int,
|
||||||
@@ -50,11 +59,14 @@ class CreateProjectTicket
|
|||||||
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||||
|
|
||||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
|
||||||
|
|
||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
'author_user_id' => $actor?->id,
|
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
||||||
|
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
|
||||||
'description' => Arr::get($attributes, 'description'),
|
'description' => Arr::get($attributes, 'description'),
|
||||||
|
'is_blocked' => $isBlocked,
|
||||||
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'sequence_number' => $nextSequenceNumber,
|
'sequence_number' => $nextSequenceNumber,
|
||||||
@@ -68,6 +80,17 @@ class CreateProjectTicket
|
|||||||
|
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
||||||
|
|
||||||
|
$fieldValues = Arr::get($attributes, 'field_values', []);
|
||||||
|
|
||||||
|
if ($fieldValues !== []) {
|
||||||
|
$this->syncProjectTicketFieldValues->handle(
|
||||||
|
$ticket,
|
||||||
|
$project->ticketFieldDefinitions()->get()->keyBy('id'),
|
||||||
|
collect(),
|
||||||
|
$fieldValues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$occurredAt = CarbonImmutable::now();
|
$occurredAt = CarbonImmutable::now();
|
||||||
|
|
||||||
if ($actor !== null) {
|
if ($actor !== null) {
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'project',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'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
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->findOrFail($linkedTicketId);
|
||||||
|
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||||
|
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||||
|
|
||||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
'ticket_link_type' => $targetLabel,
|
'ticket_link_type' => $linkedTicketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
/**
|
/**
|
||||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||||
*/
|
*/
|
||||||
public static function syncTicket(Ticket $ticket, array $items): void
|
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||||
{
|
{
|
||||||
$normalized = self::normalize($items);
|
$normalized = self::normalize($items);
|
||||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
|||||||
: $existingCriteria->get($criterionId);
|
: $existingCriteria->get($criterionId);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
$criterion === null
|
$reuseUnidentifiedCriteria
|
||||||
|
&& $criterion === null
|
||||||
&& $fallbackIndex < $fallbackCriteria->count()
|
&& $fallbackIndex < $fallbackCriteria->count()
|
||||||
) {
|
) {
|
||||||
/** @var AcceptanceCriterion $candidate */
|
/** @var AcceptanceCriterion $candidate */
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets\Support;
|
||||||
|
|
||||||
|
use League\CommonMark\Environment\Environment;
|
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||||
|
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||||
|
use League\CommonMark\Extension\Table\Table;
|
||||||
|
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||||
|
use League\CommonMark\Parser\MarkdownParser;
|
||||||
|
use League\Uri\Uri;
|
||||||
|
|
||||||
|
class TicketDescriptionFormat
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public static function contract(): array
|
||||||
|
{
|
||||||
|
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function instructions(): string
|
||||||
|
{
|
||||||
|
$contract = self::contract();
|
||||||
|
|
||||||
|
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
||||||
|
." Valid description example (Markdown string):\n".$contract['example'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function criteriaInstructions(): string
|
||||||
|
{
|
||||||
|
return self::contract()['criteria'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function safeLink(string $value): bool
|
||||||
|
{
|
||||||
|
$contract = self::contract();
|
||||||
|
$decoded = rawurldecode($value);
|
||||||
|
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
||||||
|
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$url = Uri::tryNew($decoded);
|
||||||
|
|
||||||
|
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function unsupportedContent(string $value): array
|
||||||
|
{
|
||||||
|
$environment = new Environment;
|
||||||
|
$environment->addExtension(new CommonMarkCoreExtension);
|
||||||
|
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||||
|
$document = (new MarkdownParser($environment))->parse($value);
|
||||||
|
$unsupported = [];
|
||||||
|
foreach ($document->iterator() as $node) {
|
||||||
|
$type = match (true) {
|
||||||
|
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
||||||
|
$node instanceof Table => 'table',
|
||||||
|
$node instanceof Image => 'image',
|
||||||
|
$node instanceof TaskListItemMarker => 'task',
|
||||||
|
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($type !== null) {
|
||||||
|
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($unsupported));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
|||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
User $actor,
|
User $actor,
|
||||||
array $validated,
|
array $validated,
|
||||||
|
bool $replaceCriteriaWithIds = false,
|
||||||
): bool {
|
): bool {
|
||||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
|||||||
);
|
);
|
||||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||||
|
|
||||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||||
if (! $ticketAttributes['is_blocked']) {
|
if (! $ticketAttributes['is_blocked']) {
|
||||||
$ticketAttributes['blocked_comment'] = null;
|
$ticketAttributes['blocked_comment'] = null;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
|||||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||||
$existingFieldValues = $ticket->fieldValues
|
$existingFieldValues = $ticket->fieldValues
|
||||||
->keyBy('ticket_field_definition_id');
|
->keyBy('ticket_field_definition_id');
|
||||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||||
|
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||||
|
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||||
|
|
||||||
$changes = [];
|
$changes = [];
|
||||||
$summaryLabels = [];
|
$summaryLabels = [];
|
||||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ class UpdateUserAvatar
|
|||||||
$color = $this->colorFor($user);
|
$color = $this->colorFor($user);
|
||||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||||
|
|
||||||
$stored = Storage::disk('public')->put(
|
$stored = Storage::disk($this->avatarDisk())->put(
|
||||||
$path,
|
$path,
|
||||||
$this->svg($this->initials($user->name), $color),
|
$this->svg($this->initials($user->name), $color),
|
||||||
|
['ContentType' => 'image/svg+xml'],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $stored) {
|
if (! $stored) {
|
||||||
@@ -53,7 +54,7 @@ class UpdateUserAvatar
|
|||||||
public function upload(User $user, UploadedFile $avatar): User
|
public function upload(User $user, UploadedFile $avatar): User
|
||||||
{
|
{
|
||||||
$oldPath = $user->avatar_path;
|
$oldPath = $user->avatar_path;
|
||||||
$path = $avatar->store('avatars/uploads', 'public');
|
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
||||||
|
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||||
@@ -90,7 +91,14 @@ class UpdateUserAvatar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage::disk('public')->delete($path);
|
Storage::disk($this->avatarDisk())->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function avatarDisk(): string
|
||||||
|
{
|
||||||
|
$disk = config('filesystems.avatar_disk', 'public');
|
||||||
|
|
||||||
|
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initials(string $name): string
|
private function initials(string $name): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||||
@@ -25,20 +26,30 @@ class CreateAdminUser extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$user = User::create([
|
DB::beginTransaction();
|
||||||
'name' => $this->argument('name'),
|
|
||||||
'email' => $this->argument('email'),
|
|
||||||
'password' => Hash::make($this->argument('password')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->avatars->generate($user);
|
try {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $this->argument('name'),
|
||||||
|
'email' => $this->argument('email'),
|
||||||
|
'password' => Hash::make($this->argument('password')),
|
||||||
|
]);
|
||||||
|
|
||||||
$user->permissionAssignments()->create([
|
$this->avatars->generate($user);
|
||||||
'permission' => Permission::GlobalAdmin,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
$user->permissionAssignments()->create([
|
||||||
|
'permission' => Permission::GlobalAdmin,
|
||||||
|
]);
|
||||||
|
|
||||||
return self::SUCCESS;
|
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
$this->error('Admin User could not be created: ', $exception->getMessage());
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
|||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||||
|
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||||
|
|
||||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -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)));
|
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
|
||||||
$this->updateProjectTemplateLink->reset(
|
$blockedAreas = $this->updateProjectTemplateLink->reset(
|
||||||
$project->load('projectTemplate'),
|
$project->load('projectTemplate'),
|
||||||
$areas,
|
$areas,
|
||||||
$statusReplacements,
|
$statusReplacements,
|
||||||
@@ -171,6 +171,10 @@ class ProjectController extends Controller
|
|||||||
? "Selected settings were reset to the {$template->name} template."
|
? "Selected settings were reset to the {$template->name} template."
|
||||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||||
|
|
||||||
|
if ($blockedAreas !== []) {
|
||||||
|
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
|
||||||
|
}
|
||||||
|
|
||||||
return back()->with('status', $status);
|
return back()->with('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,25 +186,22 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$users = User::query()->orderBy('name')->get();
|
||||||
|
|
||||||
return Inertia::render('projects/Access', [
|
return Inertia::render('projects/Access', [
|
||||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
...$this->permissionService->projectAccessMap($users, $project),
|
||||||
'permissionOptions' => PermissionOptionResource::collection(
|
'permissionOptions' => PermissionOptionResource::collection(
|
||||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
|
||||||
'teams' => TeamResource::collection(
|
'teams' => TeamResource::collection(
|
||||||
Team::query()
|
Team::query()
|
||||||
->with('users')
|
->with('users')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'users' => UserResource::collection(
|
'users' => UserResource::collection($users)->resolve(),
|
||||||
User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
)->resolve(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($user, $project, $permissions);
|
$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.");
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +242,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($team, $project, $permissions);
|
$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.");
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Projects\CreateProjectTemplate;
|
use App\Actions\Projects\CreateProjectTemplate;
|
||||||
|
use App\Actions\Projects\DeleteProjectTemplate;
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||||
use App\Actions\Projects\UpdateProjectTemplate;
|
use App\Actions\Projects\UpdateProjectTemplate;
|
||||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||||
|
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
|
||||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\ProjectTemplateResource;
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||||
|
private readonly DeleteProjectTemplate $deleteProjectTemplate,
|
||||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createEmpty(ProjectConfigurationIdentity $identity): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('project-templates/Create', [
|
||||||
|
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||||
|
'project' => null,
|
||||||
|
'initialConfiguration' => $identity->defaults(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$template = $this->createProjectTemplate->empty($request->validated());
|
||||||
|
|
||||||
|
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
|
||||||
|
}
|
||||||
|
|
||||||
public function edit(ProjectTemplate $projectTemplate): Response
|
public function edit(ProjectTemplate $projectTemplate): Response
|
||||||
{
|
{
|
||||||
abort_if(! $projectTemplate->is_published, 404);
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
|
|||||||
abort_if(! $projectTemplate->is_published, 404);
|
abort_if(! $projectTemplate->is_published, 404);
|
||||||
|
|
||||||
$templateName = $projectTemplate->name;
|
$templateName = $projectTemplate->name;
|
||||||
$projectTemplate->delete();
|
$this->deleteProjectTemplate->handle($projectTemplate);
|
||||||
|
|
||||||
return to_route('project-templates.index')
|
return to_route('project-templates.index')
|
||||||
->with('status', "Template {$templateName} was deleted.");
|
->with('status', "Template {$templateName} was deleted.");
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
|
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||||
|
|
||||||
return to_route('projects.tickets.show', [$project, $ticket])
|
return to_route('projects.tickets.show', [$project, $ticket])
|
||||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
->with('status', "{$label} link was saved.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(
|
public function destroy(
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ class HandleAppearance
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
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);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
|
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||||
|
'localization' => [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'timeZone' => config('app.timezone'),
|
||||||
|
],
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||||
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'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([
|
if ($request->routeIs([
|
||||||
'logout',
|
'logout',
|
||||||
|
'native-app.edit',
|
||||||
'password.force-change.edit',
|
'password.force-change.edit',
|
||||||
'user-password.update',
|
'user-password.update',
|
||||||
])) {
|
])) {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Concerns;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||||
|
use App\Enums\NotificationChannel;
|
||||||
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Enums\NotificationFilterAttribute;
|
||||||
|
use App\Enums\NotificationFilterOperator;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
trait ValidatesProjectTemplateConfiguration
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
protected function configurationRules(): array
|
||||||
|
{
|
||||||
|
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
|
||||||
|
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||||
|
$path = 'configuration_snapshot.'.$area;
|
||||||
|
$rules[$path] = ['sometimes', 'array', 'max:200'];
|
||||||
|
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||||
|
}
|
||||||
|
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
|
||||||
|
foreach (['show_author', 'show_story_points'] as $field) {
|
||||||
|
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
|
||||||
|
}
|
||||||
|
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
|
||||||
|
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
|
||||||
|
}
|
||||||
|
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
|
||||||
|
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
|
||||||
|
if ($area !== 'ticket_statuses') {
|
||||||
|
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fields = [
|
||||||
|
'board_columns' => 'configuration_key,name,position',
|
||||||
|
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
|
||||||
|
'ticket_types' => 'configuration_key,name,position,is_parent',
|
||||||
|
'ticket_priorities' => 'configuration_key,name,position',
|
||||||
|
'ticket_fields' => 'configuration_key,label,position,field_type',
|
||||||
|
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
|
||||||
|
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
|
||||||
|
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
|
||||||
|
];
|
||||||
|
foreach ($fields as $area => $allowed) {
|
||||||
|
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
|
||||||
|
}
|
||||||
|
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
|
||||||
|
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
|
||||||
|
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
|
||||||
|
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
|
||||||
|
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
|
||||||
|
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
|
||||||
|
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
|
||||||
|
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
|
||||||
|
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||||
|
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
|
||||||
|
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
|
||||||
|
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
|
||||||
|
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
|
||||||
|
}
|
||||||
|
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
|
||||||
|
foreach (['base_url', 'repository_url'] as $field) {
|
||||||
|
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
|
||||||
|
}
|
||||||
|
$prefix = 'configuration_snapshot.notification_actions.*.';
|
||||||
|
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
|
||||||
|
$rules[$prefix.'enabled'] = ['required', 'boolean'];
|
||||||
|
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
|
||||||
|
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
|
||||||
|
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
|
||||||
|
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
|
||||||
|
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
|
||||||
|
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
|
||||||
|
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
|
||||||
|
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
|
||||||
|
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
|
||||||
|
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
|
||||||
|
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
|
||||||
|
$rules[$prefix.'filters.*.value'] = ['nullable'];
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable(Validator): void> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [function (Validator $validator): void {
|
||||||
|
if ($validator->errors()->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$validated = $validator->validated();
|
||||||
|
$configuration = Arr::get($validated, 'configuration_snapshot', []);
|
||||||
|
$areas = Arr::get($validated, 'copy_areas', []);
|
||||||
|
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
|
||||||
|
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
|
||||||
|
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
|
||||||
|
$key = Arr::get($status, 'board_column_key');
|
||||||
|
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
|
||||||
|
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
|
||||||
|
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|
||||||
|
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
|
||||||
|
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
|
||||||
|
}
|
||||||
|
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
|
||||||
|
&& Arr::get($transition, 'target_keys', []) !== []) {
|
||||||
|
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$repositoryIdentifiers = [];
|
||||||
|
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
|
||||||
|
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
|
||||||
|
if (isset($repositoryIdentifiers[$identifier])) {
|
||||||
|
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
|
||||||
|
}
|
||||||
|
$repositoryIdentifiers[$identifier] = true;
|
||||||
|
}
|
||||||
|
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
|
||||||
|
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
|
||||||
|
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
|
||||||
|
}
|
||||||
|
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
|
||||||
|
$operator = Arr::get($filter, 'operator');
|
||||||
|
$value = Arr::get($filter, 'value');
|
||||||
|
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
|
||||||
|
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|
||||||
|
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
|
||||||
|
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('access-admin') ?? false;
|
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
|
|||||||
Rule::in([$project->key]),
|
Rule::in([$project->key]),
|
||||||
],
|
],
|
||||||
'status_replacements' => ['sometimes', 'array'],
|
'status_replacements' => ['sometimes', 'array'],
|
||||||
'status_replacements.*' => [
|
'status_replacements.*' => ['required'],
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
|||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
$validated = $validator->validated();
|
$validated = $validator->validated();
|
||||||
$areas = array_values(Arr::get(
|
$project->loadMissing('projectTemplate');
|
||||||
$validated,
|
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||||
'areas',
|
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||||
DuplicateProjectConfiguration::copyableAreas(),
|
foreach ($areas as $area) {
|
||||||
));
|
if (! array_key_exists($area, $configuration)) {
|
||||||
|
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||||
$project->loadMissing('projectTemplate');
|
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
|
||||||
);
|
|
||||||
$templateStatuses = Arr::get(
|
|
||||||
$configuration,
|
|
||||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
|
||||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
|
||||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||||
|
foreach ($replacements as $id => $replacement) {
|
||||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||||
if ($status->tickets_count === 0) {
|
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||||
|
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($statuses as $status) {
|
||||||
|
if (in_array($status->configuration_key, $templateKeys, true) || $status->tickets_count === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$replacement = Arr::get($replacements, (string) $status->id);
|
||||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||||
$validator->errors()->add(
|
|
||||||
"status_replacements.{$status->id}",
|
|
||||||
"Choose a replacement status for {$status->name}.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('create-project') ?? false;
|
return ($this->user()?->can('create-project') ?? false)
|
||||||
|
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$project = $this->route('project');
|
||||||
|
$baseTemplate = $project?->projectTemplate;
|
||||||
|
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
|
||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['present', 'array'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||||
|
|
||||||
|
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
|
||||||
|
{
|
||||||
|
use ValidatesProjectTemplateConfiguration;
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [...parent::rules(), ...$this->configurationRules()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('project_id', $project->id),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
|||||||
|
|
||||||
class UpdateProjectTemplateRequest extends FormRequest
|
class UpdateProjectTemplateRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
use ValidatesProjectTemplateConfiguration;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('create-project') ?? false;
|
return $this->user()?->can('create-project') ?? false;
|
||||||
@@ -25,10 +28,12 @@ class UpdateProjectTemplateRequest extends FormRequest
|
|||||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
...$this->configurationRules(),
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['present', 'array'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
'confirm_linked_projects_update' => [
|
'confirm_linked_projects_update' => [
|
||||||
|
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||||
Rule::requiredIf($requiresImpactConfirmation),
|
Rule::requiredIf($requiresImpactConfirmation),
|
||||||
'accepted',
|
'accepted',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
|
|||||||
? null
|
? null
|
||||||
: [
|
: [
|
||||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||||
|
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
|
||||||
|
'local_areas' => Arr::get($templateState, 'localAreas', []),
|
||||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
|||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'project_id' => $this->project_id,
|
'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(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
|||||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'project_id' => $this->project_id,
|
'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(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,22 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Servers;
|
namespace App\Mcp\Servers;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
use App\Mcp\Tools\CreateTicket;
|
||||||
|
use App\Mcp\Tools\GetProjectConfiguration;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
|
use App\Mcp\Tools\ListProjects;
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
|
use App\Mcp\Tools\SearchTickets;
|
||||||
|
use App\Mcp\Tools\UpdateTicket;
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
use Laravel\Mcp\Server\Attributes\Version;
|
use Laravel\Mcp\Server\Attributes\Version;
|
||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.1.0')]
|
#[Version('0.2.1')]
|
||||||
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
|
|
||||||
class KanbanGitServer extends Server
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
|
protected function boot(): void
|
||||||
|
{
|
||||||
|
$this->instructions = 'Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
||||||
|
}
|
||||||
|
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
ListMyAssignedTickets::class,
|
ListMyAssignedTickets::class,
|
||||||
ListTicketsByStatus::class,
|
ListTicketsByStatus::class,
|
||||||
@@ -25,6 +35,12 @@ class KanbanGitServer extends Server
|
|||||||
UpdateTicketDescription::class,
|
UpdateTicketDescription::class,
|
||||||
UpdateTicketAssignee::class,
|
UpdateTicketAssignee::class,
|
||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
|
CreateTicket::class,
|
||||||
|
ListTicketTypes::class,
|
||||||
|
ListProjects::class,
|
||||||
|
GetProjectConfiguration::class,
|
||||||
|
SearchTickets::class,
|
||||||
|
UpdateTicket::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
protected array $resources = [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Support;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
|
||||||
|
trait DescribesTicketWriting
|
||||||
|
{
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return parent::description().' '.TicketDescriptionFormat::instructions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
|||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ticketForKey(User $user, string $ticketKey): Ticket
|
||||||
|
{
|
||||||
|
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
||||||
|
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
||||||
|
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
||||||
|
}
|
||||||
|
$this->ensureCanRead($user, $ticket->project);
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
@@ -120,6 +132,24 @@ class KanbanGitMcp
|
|||||||
return $project;
|
return $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function projectForWrite(User $user, int $projectId): Project
|
||||||
|
{
|
||||||
|
$project = Project::query()->find($projectId);
|
||||||
|
|
||||||
|
if ($project === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'project_id' => 'Project was not found.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureCanWrite($user, $project);
|
||||||
|
|
||||||
|
return $project;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
* @return Collection<int, TicketStatus>|Response
|
* @return Collection<int, TicketStatus>|Response
|
||||||
@@ -183,16 +213,30 @@ class KanbanGitMcp
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
||||||
|
public function paginationPayload(LengthAwarePaginator $paginator): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'has_more_pages' => $paginator->hasMorePages(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function ticketPayload(Ticket $ticket): array
|
public function ticketPayload(Ticket $ticket): array
|
||||||
{
|
{
|
||||||
$ticket->load([
|
$ticket->load([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'author',
|
'author',
|
||||||
'fieldValues.ticketFieldDefinition',
|
'fieldValues.ticketFieldDefinition',
|
||||||
'fieldValues.userValue',
|
'fieldValues.userValue',
|
||||||
|
'parentTicket',
|
||||||
'project',
|
'project',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
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('create_ticket')]
|
||||||
|
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
||||||
|
#[IsDestructive(false)]
|
||||||
|
class CreateTicket extends Tool
|
||||||
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
|
private readonly PermissionService $permissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$projectInput = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
'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)],
|
||||||
|
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
|
||||||
|
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'is_blocked' => ['sometimes', 'boolean'],
|
||||||
|
'blocked_comment' => ['nullable', 'string'],
|
||||||
|
'acceptance_criteria' => ['nullable', 'array', 'list'],
|
||||||
|
'acceptance_criteria.*' => ['array:text,checked,indent'],
|
||||||
|
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||||
|
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
|
||||||
|
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||||
|
'field_values' => ['sometimes', 'array', 'list'],
|
||||||
|
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
|
||||||
|
'field_values.*.ticket_field_definition_id' => [
|
||||||
|
'required', 'integer', 'distinct',
|
||||||
|
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||||
|
],
|
||||||
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->validateTicketConstraints($project, $user, $validated);
|
||||||
|
|
||||||
|
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||||
|
'meta' => [
|
||||||
|
'message' => "{$ticket->ticket_key} was created.",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function validateTicketConstraints(Project $project, User $user, array $validated): void
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
|
||||||
|
|
||||||
|
if ($parentTicketId !== null) {
|
||||||
|
$parentTicket = Ticket::query()
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->with('ticketType')
|
||||||
|
->find($parentTicketId);
|
||||||
|
|
||||||
|
if (! $parentTicket?->ticketType?->is_parent) {
|
||||||
|
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
|
||||||
|
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Arr::exists($validated, 'author_user_id')
|
||||||
|
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
|
||||||
|
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
|
||||||
|
) {
|
||||||
|
$errors['author_user_id'] = 'Only project admins can change the author.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldValues = Arr::get($validated, 'field_values', []);
|
||||||
|
|
||||||
|
if ($fieldValues !== []) {
|
||||||
|
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($fieldValues as $index => $fieldValue) {
|
||||||
|
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
|
||||||
|
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||||
|
|
||||||
|
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
|
||||||
|
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
throw ValidationException::withMessages($errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Type>
|
||||||
|
*/
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
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. '.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(),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
|
||||||
|
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
|
||||||
|
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
|
||||||
|
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
|
||||||
|
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
|
||||||
|
'acceptance_criteria' => $schema->array()->nullable()
|
||||||
|
->items($schema->object([
|
||||||
|
'text' => $schema->string()->description('Checklist item text.')->required(),
|
||||||
|
'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. '.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(),
|
||||||
|
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
|
||||||
|
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
|
||||||
|
])->withoutAdditionalProperties())
|
||||||
|
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('get_project_configuration')]
|
||||||
|
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class GetProjectConfiguration extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
'people_page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
|
||||||
|
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
|
||||||
|
|
||||||
|
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
|
||||||
|
$columnPositions = $columns->pluck('position', 'id');
|
||||||
|
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
|
||||||
|
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
|
||||||
|
)->values();
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => [
|
||||||
|
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
|
||||||
|
'permissions' => [
|
||||||
|
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
|
],
|
||||||
|
'board_columns' => $columns->toArray(),
|
||||||
|
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
|
||||||
|
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
|
||||||
|
'position' => $status->position, 'board_column_id' => $status->board_column_id,
|
||||||
|
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
|
||||||
|
])->all(),
|
||||||
|
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
|
||||||
|
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
|
||||||
|
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
|
||||||
|
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
|
||||||
|
],
|
||||||
|
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
|
||||||
|
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
|
||||||
|
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
|||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
#[Name('get_ticket')]
|
#[Name('get_ticket')]
|
||||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||||
#[IsReadOnly]
|
#[IsReadOnly]
|
||||||
class GetTicket extends Tool
|
class GetTicket extends Tool
|
||||||
{
|
{
|
||||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
||||||
|
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
$ticket = isset($validated['ticket_id'])
|
||||||
|
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
||||||
|
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
||||||
|
|
||||||
return Response::structured([
|
return Response::structured([
|
||||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
->required(),
|
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('list_projects')]
|
||||||
|
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class ListProjects extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
|
||||||
|
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
|
||||||
|
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||||
|
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\TicketTypeResource;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
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;
|
||||||
|
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_ticket_types')]
|
||||||
|
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class ListTicketTypes extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'project_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => [
|
||||||
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Type>
|
||||||
|
*/
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'project_id' => $schema->integer()
|
||||||
|
->description('Project ID. Requires permission to view this project.')
|
||||||
|
->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\MoveProjectTicket;
|
use App\Actions\Tickets\MoveProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class MoveTicketStatus extends Tool
|
class MoveTicketStatus extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly MoveProjectTicket $moveProjectTicket,
|
private readonly MoveProjectTicket $moveProjectTicket,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||||
|
|
||||||
|
#[Name('search_tickets')]
|
||||||
|
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
|
||||||
|
#[IsReadOnly]
|
||||||
|
class SearchTickets extends Tool
|
||||||
|
{
|
||||||
|
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$input = $request->validate([
|
||||||
|
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||||
|
'query' => ['sometimes', 'string', 'max:255'],
|
||||||
|
'project_id' => ['sometimes', 'integer'],
|
||||||
|
'ticket_status_id' => ['sometimes', 'integer'],
|
||||||
|
'ticket_type_id' => ['sometimes', 'integer'],
|
||||||
|
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
|
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
|
||||||
|
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
|
||||||
|
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
|
||||||
|
if (Arr::exists($input, $filter)) {
|
||||||
|
$query->where($filter, $input[$filter]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Arr::exists($input, 'query')) {
|
||||||
|
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
|
||||||
|
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
|
||||||
|
}
|
||||||
|
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
|
||||||
|
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'ticket_key' => $ticket->ticket_key,
|
||||||
|
'title' => $ticket->title,
|
||||||
|
'project_id' => $ticket->project_id,
|
||||||
|
'ticket_status_id' => $ticket->ticket_status_id,
|
||||||
|
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
|
||||||
|
])->all(),
|
||||||
|
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
|
||||||
|
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
|
||||||
|
'project_id' => $schema->integer()->description('Visible project ID.'),
|
||||||
|
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
|
||||||
|
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
|
||||||
|
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||||
|
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Mcp\Support\TicketUpdateInput;
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Description;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||||
|
|
||||||
|
#[Name('update_ticket')]
|
||||||
|
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
||||||
|
#[IsDestructive(false)]
|
||||||
|
class UpdateTicket extends Tool
|
||||||
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
|
private readonly TicketUpdateInput $ticketUpdateInput,
|
||||||
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
|
{
|
||||||
|
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
||||||
|
$user = $this->kanbanGitMcp->user($request);
|
||||||
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
||||||
|
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
||||||
|
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
||||||
|
|
||||||
|
return Response::structured([
|
||||||
|
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||||
|
'meta' => ['updated' => $updated],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, Type> */
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
||||||
|
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
||||||
|
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
|
||||||
|
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||||
|
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
||||||
|
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
||||||
|
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
||||||
|
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
||||||
|
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
||||||
|
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
||||||
|
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
||||||
|
'acceptance_criteria' => $schema->array()->items($schema->object([
|
||||||
|
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
||||||
|
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
||||||
|
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
||||||
|
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
||||||
|
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||||
|
'field_values' => $schema->array()->items($schema->object([
|
||||||
|
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
||||||
|
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
||||||
|
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
||||||
|
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketAssignee extends Tool
|
class UpdateTicketAssignee extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Mcp\Tools;
|
namespace App\Mcp\Tools;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
|
use App\Mcp\Support\DescribesTicketWriting;
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
use App\Mcp\Support\KanbanGitMcp;
|
||||||
|
use App\Rules\VisualTicketDescription;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
use Illuminate\JsonSchema\Types\Type;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\ResponseFactory;
|
use Laravel\Mcp\ResponseFactory;
|
||||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
|||||||
#[IsDestructive(false)]
|
#[IsDestructive(false)]
|
||||||
class UpdateTicketDescription extends Tool
|
class UpdateTicketDescription extends Tool
|
||||||
{
|
{
|
||||||
|
use DescribesTicketWriting;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||||
'ticket_id' => ['required', 'integer'],
|
'ticket_id' => ['required', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||||
$payload = [
|
$payload = [
|
||||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||||
'description' => $validated['description'] ?? null,
|
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
|||||||
return [
|
return [
|
||||||
'description' => $schema->string()
|
'description' => $schema->string()
|
||||||
->nullable()
|
->nullable()
|
||||||
->description('New ticket description. Pass null to clear it.'),
|
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
||||||
'ticket_id' => $schema->integer()
|
'ticket_id' => $schema->integer()
|
||||||
->description('Ticket ID.')
|
->description('Ticket ID.')
|
||||||
->required(),
|
->required(),
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class BoardColumn extends Model
|
class BoardColumn extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BoardColumnFactory> */
|
/** @use HasFactory<BoardColumnFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|||||||
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectTemplate;
|
use App\Models\ProjectTemplate;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
trait SyncsProjectTemplateOwnership
|
trait SyncsProjectTemplateOwnership
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $options */
|
||||||
|
public function save(array $options = []): bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($options): bool {
|
||||||
|
$this->syncProjectTemplateOwnership();
|
||||||
|
if ($this->getAttribute('project_id') !== null) {
|
||||||
|
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||||
|
if ($project?->project_template_id !== null) {
|
||||||
|
$this->setAttribute('project_template_id', $project->project_template_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::save($options);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): ?bool
|
||||||
|
{
|
||||||
|
return DB::transaction(function (): ?bool {
|
||||||
|
if ($this->getAttribute('project_id') !== null) {
|
||||||
|
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::delete();
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
protected function syncProjectTemplateOwnership(): void
|
protected function syncProjectTemplateOwnership(): void
|
||||||
{
|
{
|
||||||
|
if ($this->getAttribute('configuration_key') === null) {
|
||||||
|
$this->setAttribute('configuration_key', (string) Str::uuid());
|
||||||
|
}
|
||||||
|
|
||||||
$projectTemplateId = $this->getAttribute('project_template_id');
|
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||||
$projectId = $this->getAttribute('project_id');
|
$projectId = $this->getAttribute('project_id');
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable(['configuration_key',
|
||||||
'channel',
|
'channel',
|
||||||
'channel_configuration',
|
'channel_configuration',
|
||||||
'delivery_url',
|
'delivery_url',
|
||||||
@@ -31,6 +31,7 @@ class NotificationAction extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<NotificationActionFactory> */
|
/** @use HasFactory<NotificationActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable(['configuration_key',
|
||||||
'base_url',
|
'base_url',
|
||||||
'owner_name',
|
'owner_name',
|
||||||
'project_id',
|
'project_id',
|
||||||
@@ -26,6 +26,7 @@ class RepositoryConnection extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketFieldDefinition extends Model
|
class TicketFieldDefinition extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +23,7 @@ class TicketFieldDefinition extends Model
|
|||||||
|
|
||||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketLinkType extends Model
|
class TicketLinkType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketPriority extends Model
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||||
class TicketStatus extends Model
|
class TicketStatus extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketStatusFactory> */
|
/** @use HasFactory<TicketStatusFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||||
class TicketType extends Model
|
class TicketType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketTypeFactory> */
|
/** @use HasFactory<TicketTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|||||||
+19
-3
@@ -77,12 +77,28 @@ class User extends Authenticatable
|
|||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(
|
||||||
fn (): ?string => filled($this->avatar_path)
|
fn (): ?string => $this->avatarUrl(),
|
||||||
? Storage::disk('public')->url($this->avatar_path)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function avatarUrl(): ?string
|
||||||
|
{
|
||||||
|
if (blank($this->avatar_path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskName = config('filesystems.avatar_disk', 'public');
|
||||||
|
$disk = Storage::disk(is_string($diskName) && $diskName !== '' ? $diskName : 'public');
|
||||||
|
|
||||||
|
if ((bool) config('filesystems.avatar_urls_temporary', false)) {
|
||||||
|
$expirationMinutes = max(1, (int) config('filesystems.avatar_url_expiration_minutes', 15));
|
||||||
|
|
||||||
|
return $disk->temporaryUrl($this->avatar_path, now()->addMinutes($expirationMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $disk->url($this->avatar_path);
|
||||||
|
}
|
||||||
|
|
||||||
public function permissionAssignments(): MorphMany
|
public function permissionAssignments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
|
|||||||
public function deleted(Model $model): void
|
public function deleted(Model $model): void
|
||||||
{
|
{
|
||||||
$this->markOverride($model);
|
$this->markOverride($model);
|
||||||
|
if ($model instanceof TicketStatus && $model->project !== null) {
|
||||||
|
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function markOverride(Model $model): void
|
private function markOverride(Model $model): void
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
|
class VisualTicketDescription implements ValidationRule
|
||||||
|
{
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
|
||||||
|
if ($unsupported !== []) {
|
||||||
|
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -9,8 +9,7 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
"wayfinder-development",
|
"mcp-development",
|
||||||
"inertia-vue-development",
|
"wayfinder-development"
|
||||||
"tailwindcss-development"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
@@ -31,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->dontFlash(['api_token']);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
+24
-10
@@ -12,13 +12,15 @@
|
|||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^v13.26.1",
|
||||||
"laravel/mcp": "^0.6",
|
"laravel/mcp": "^0.6",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"scalar/laravel": "^0.2.1"
|
"scalar/laravel": "^0.2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.20",
|
||||||
"fakerphp/faker": "^1.24",
|
"fakerphp/faker": "^1.24",
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.0",
|
||||||
"laravel/pail": "^1.2.5",
|
"laravel/pail": "^1.2.5",
|
||||||
@@ -46,15 +48,15 @@
|
|||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
"@php artisan key:generate",
|
"@php artisan key:generate",
|
||||||
"@php artisan migrate --force",
|
"@php artisan migrate --force",
|
||||||
"npm install",
|
"pnpm install",
|
||||||
"npm run build"
|
"pnpm run build"
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"@php artisan dev"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"npm run build:ssr",
|
"pnpm run build:ssr",
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"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"
|
"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"
|
||||||
],
|
],
|
||||||
@@ -66,15 +68,27 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm run lint:check",
|
"@ci:frontend",
|
||||||
"npm run format:check",
|
"@ci:backend",
|
||||||
"npm run types:check",
|
"@ci:test"
|
||||||
"@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": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@lint:check",
|
"@lint:check",
|
||||||
"@php artisan test"
|
"@php artisan test --parallel --processes=2"
|
||||||
],
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
|||||||
Generated
+1018
-341
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ return [
|
|||||||
|
|
||||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
'avatar_disk' => env('AVATAR_FILESYSTEM_DISK', env('FILESYSTEM_DISK', 'public')),
|
||||||
|
|
||||||
|
'avatar_urls_temporary' => env('AVATAR_URLS_TEMPORARY', false),
|
||||||
|
|
||||||
|
'avatar_url_expiration_minutes' => env('AVATAR_URL_EXPIRATION_MINUTES', 15),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Filesystem Disks
|
| Filesystem Disks
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<BoardColumn>
|
* @extends Factory<BoardColumn>
|
||||||
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'name' => fake()->unique()->words(2, true),
|
'name' => fake()->unique()->words(2, true),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
|
|||||||
use App\Models\NotificationAction;
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<NotificationAction>
|
* @extends Factory<NotificationAction>
|
||||||
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'channel' => NotificationChannel::Webhook,
|
'channel' => NotificationChannel::Webhook,
|
||||||
'channel_configuration' => [],
|
'channel_configuration' => [],
|
||||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<RepositoryConnection>
|
* @extends Factory<RepositoryConnection>
|
||||||
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'base_url' => 'https://git.'.fake()->domainName(),
|
'base_url' => 'https://git.'.fake()->domainName(),
|
||||||
'owner_name' => fake()->userName(),
|
'owner_name' => fake()->userName(),
|
||||||
'project_id' => Project::factory(),
|
'project_id' => Project::factory(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketFieldDefinition>
|
* @extends Factory<TicketFieldDefinition>
|
||||||
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
|
|||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
||||||
'label' => fake()->unique()->words(2, true),
|
'label' => fake()->unique()->words(2, true),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\TicketLinkType;
|
use App\Models\TicketLinkType;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends Factory<TicketLinkType>
|
* @extends Factory<TicketLinkType>
|
||||||
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
|
|||||||
$label = fake()->unique()->words(2, true);
|
$label = fake()->unique()->words(2, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'configuration_key' => (string) Str::uuid(),
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'counterpart_label' => $label,
|
'counterpart_label' => $label,
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user