Compare commits
19
Commits
v0.3.5
..
0f125f17a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f125f17a3 | ||
|
|
5e7be28daf | ||
|
|
d250f0b380 | ||
|
|
c19cf34505 | ||
|
|
37b2e6034f | ||
|
|
84e427ef5d | ||
|
|
95b53ba74b | ||
|
|
a8952d7867 | ||
|
|
2390daf2de | ||
|
|
b1357cf854 | ||
|
|
2c229c9b39 | ||
|
|
9a7dd7fb59 | ||
|
|
39a039cd1e | ||
|
|
e38c77198d | ||
|
|
ee9b9a2765 | ||
|
|
57711b9659 | ||
|
|
5e6426067a | ||
|
|
58d2edcacd | ||
|
|
3b17c9ee14 |
@@ -0,0 +1,394 @@
|
||||
---
|
||||
name: inertia-vue-development
|
||||
description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Inertia Vue Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating or modifying Vue page components for Inertia
|
||||
- Working with forms in Vue (using `<Form>` or `useForm`)
|
||||
- Implementing client-side navigation with `<Link>` or `router`
|
||||
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
|
||||
- Building Vue-specific features with the Inertia protocol
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Inertia v2 Vue patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Page Components Location
|
||||
|
||||
Vue page components should be placed in the `resources/js/pages` directory.
|
||||
|
||||
### Page Component Structure
|
||||
|
||||
<!-- Basic Vue Page Component -->
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: Array
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<ul>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Client-Side Navigation
|
||||
|
||||
### Basic Link Component
|
||||
|
||||
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
|
||||
|
||||
<!-- Inertia Vue Navigation -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/users">Users</Link>
|
||||
<Link :href="`/users/${user.id}`">View User</Link>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Link with Method
|
||||
|
||||
<!-- Link with POST Method -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/logout" method="post" as="button">
|
||||
Logout
|
||||
</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
Prefetch pages to improve perceived performance:
|
||||
|
||||
<!-- Prefetch on Hover -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/users" prefetch>
|
||||
Users
|
||||
</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
<!-- Router Visit -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { router } from '@inertiajs/vue3'
|
||||
|
||||
function handleClick() {
|
||||
router.visit('/users')
|
||||
}
|
||||
|
||||
// Or with options
|
||||
function createUser() {
|
||||
router.visit('/users', {
|
||||
method: 'post',
|
||||
data: { name: 'John' },
|
||||
onSuccess: () => console.log('Done'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link href="/users">Users</Link>
|
||||
<Link href="/logout" method="post" as="button">Logout</Link>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Form Component (Recommended)
|
||||
|
||||
The recommended way to build forms is with the `<Form>` component:
|
||||
|
||||
<!-- Form Component Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
|
||||
<input type="text" name="name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<input type="email" name="email" />
|
||||
<div v-if="errors.email">{{ errors.email }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
{{ processing ? 'Creating...' : 'Create User' }}
|
||||
</button>
|
||||
|
||||
<div v-if="wasSuccessful">User created!</div>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Form Component With All Props
|
||||
|
||||
<!-- Form Component Full Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
action="/users"
|
||||
method="post"
|
||||
#default="{
|
||||
errors,
|
||||
hasErrors,
|
||||
processing,
|
||||
progress,
|
||||
wasSuccessful,
|
||||
recentlySuccessful,
|
||||
setError,
|
||||
clearErrors,
|
||||
resetAndClearErrors,
|
||||
defaults,
|
||||
isDirty,
|
||||
reset,
|
||||
submit
|
||||
}"
|
||||
>
|
||||
<input type="text" name="name" :value="defaults.name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
{{ processing ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<progress v-if="progress" :value="progress.percentage" max="100">
|
||||
{{ progress.percentage }}%
|
||||
</progress>
|
||||
|
||||
<div v-if="wasSuccessful">Saved!</div>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Form Component Reset Props
|
||||
|
||||
The `<Form>` component supports automatic resetting:
|
||||
|
||||
- `resetOnError` - Reset form data when the request fails
|
||||
- `resetOnSuccess` - Reset form data when the request succeeds
|
||||
- `setDefaultsOnSuccess` - Update default values on success
|
||||
|
||||
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
|
||||
|
||||
<!-- Form with Reset Props -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { Form } from '@inertiajs/vue3'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
action="/users"
|
||||
method="post"
|
||||
reset-on-success
|
||||
set-defaults-on-success
|
||||
#default="{ errors, processing, wasSuccessful }"
|
||||
>
|
||||
<input type="text" name="name" />
|
||||
<div v-if="errors.name">{{ errors.name }}</div>
|
||||
|
||||
<button type="submit" :disabled="processing">
|
||||
Submit
|
||||
</button>
|
||||
</Form>
|
||||
</template>
|
||||
```
|
||||
|
||||
Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||
|
||||
### `useForm` Composable
|
||||
|
||||
For more programmatic control or to follow existing conventions, use the `useForm` composable:
|
||||
|
||||
<!-- useForm Composable Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
form.post('/users', {
|
||||
onSuccess: () => form.reset('password'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<input type="text" v-model="form.name" />
|
||||
<div v-if="form.errors.name">{{ form.errors.name }}</div>
|
||||
|
||||
<input type="email" v-model="form.email" />
|
||||
<div v-if="form.errors.email">{{ form.errors.email }}</div>
|
||||
|
||||
<input type="password" v-model="form.password" />
|
||||
<div v-if="form.errors.password">{{ form.errors.password }}</div>
|
||||
|
||||
<button type="submit" :disabled="form.processing">
|
||||
Create User
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Inertia v2 Features
|
||||
|
||||
### Deferred Props
|
||||
|
||||
Use deferred props to load data after initial page render:
|
||||
|
||||
<!-- Deferred Props with Empty State -->
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: Array
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<div v-if="!users" class="animate-pulse">
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<ul v-else>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Polling
|
||||
|
||||
Automatically refresh data at intervals:
|
||||
|
||||
<!-- Polling Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
defineProps({
|
||||
stats: Object
|
||||
})
|
||||
|
||||
let interval
|
||||
|
||||
onMounted(() => {
|
||||
interval = setInterval(() => {
|
||||
router.reload({ only: ['stats'] })
|
||||
}, 5000) // Poll every 5 seconds
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div>Active Users: {{ stats.activeUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### WhenVisible
|
||||
|
||||
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
|
||||
|
||||
<!-- WhenVisible Example -->
|
||||
```vue
|
||||
<script setup>
|
||||
import { WhenVisible } from '@inertiajs/vue3'
|
||||
|
||||
defineProps({
|
||||
stats: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<!-- stats prop is loaded only when this section scrolls into view -->
|
||||
<WhenVisible data="stats" :buffer="200">
|
||||
<template #fallback>
|
||||
<div class="animate-pulse">Loading stats...</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ fetching }">
|
||||
<div>
|
||||
<p>Total Users: {{ stats.total_users }}</p>
|
||||
<p>Revenue: {{ stats.revenue }}</p>
|
||||
<span v-if="fetching">Refreshing...</span>
|
||||
</div>
|
||||
</template>
|
||||
</WhenVisible>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Server-Side Patterns
|
||||
|
||||
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
|
||||
- Forgetting that Vue components must have a single root element
|
||||
- Forgetting to add loading states (skeleton screens) when using deferred props
|
||||
- Not handling the `undefined` state of deferred props before data loads
|
||||
- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
|
||||
- Forgetting to check if `<Form>` component is available in your Inertia version
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
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,14 +61,10 @@ show(1, { query: { page: 1 } }) // "/posts/1?page=1"
|
||||
|
||||
## Wayfinder + Inertia
|
||||
|
||||
Use Wayfinder with `useForm`:
|
||||
|
||||
<!-- Wayfinder useForm -->
|
||||
```typescript
|
||||
import { store } from "@/actions/App/Http/Controllers/ExampleController";
|
||||
|
||||
const form = useForm({ name: "My Big Post" });
|
||||
form.submit(store());
|
||||
Use Wayfinder with the `<Form>` component:
|
||||
<!-- Wayfinder Form (Vue) -->
|
||||
```vue
|
||||
<Form v-bind="store.form()"><input name="title" /></Form>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
+10
-10
@@ -25,6 +25,7 @@
|
||||
**/compose.*.y*ml
|
||||
!**/composer.json
|
||||
!**/composer.lock
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
@@ -33,17 +34,16 @@
|
||||
vendor
|
||||
**/.DS_Store
|
||||
.editorconfig
|
||||
.env.example
|
||||
.env.mariadb
|
||||
.env.mariadb.example
|
||||
.env.production
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
# Runtime-generated data must not become part of an image layer.
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
|
||||
# Native build artifacts are independent of the Laravel image.
|
||||
native/src-tauri/target
|
||||
native/src-tauri/gen
|
||||
# Ignore development-related files and directories
|
||||
# For development environment, we will use mount bind to mount the source code to the container
|
||||
# So we don't need to copy the source code to the container for development
|
||||
tests/
|
||||
phpunit.xml
|
||||
phpunit.xml.dist
|
||||
|
||||
+1
-17
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
LOG_STACK=stderr
|
||||
LOG_STACK=daily
|
||||
|
||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||
LOG_DISCORD_WEBHOOK_URL=
|
||||
@@ -38,22 +38,6 @@ MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=database
|
||||
SESSION_DRIVER=database
|
||||
|
||||
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}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
MARIADB_DATABASE=kanbangit
|
||||
MARIADB_USER=kanbangit
|
||||
MARIADB_PASSWORD=
|
||||
MARIADB_ROOT_PASSWORD=
|
||||
@@ -1,18 +0,0 @@
|
||||
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 \
|
||||
.
|
||||
@@ -1,147 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
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'
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
@@ -1,228 +0,0 @@
|
||||
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
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
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,16 +28,3 @@ yarn-error.log
|
||||
/.vscode
|
||||
/.zed
|
||||
.codex/config.toml
|
||||
|
||||
/native/node_modules
|
||||
/native/src-tauri/target
|
||||
/native/src-tauri/gen/*
|
||||
!/native/src-tauri/gen/android/
|
||||
!/native/src-tauri/gen/apple/
|
||||
/native/src-tauri/gen/apple/build/
|
||||
/native/src-tauri/gen/apple/Externals/
|
||||
/native/src-tauri/gen/apple/assets/
|
||||
/native/src-tauri/gen/apple/Pods/
|
||||
/native/src-tauri/gen/apple/ExportOptions.plist
|
||||
/native/src-tauri/gen/apple/**/*.xcuserstate
|
||||
/native/src-tauri/gen/apple/**/xcuserdata/
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
# Working Agreements
|
||||
|
||||
## Native app versions and build numbers
|
||||
|
||||
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
|
||||
|
||||
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
|
||||
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
|
||||
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
|
||||
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
|
||||
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
|
||||
|
||||
## Missing programs and SDKs
|
||||
|
||||
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
@@ -25,18 +9,24 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.5
|
||||
- php - 8.4
|
||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- @inertiajs/vue3 (INERTIA_VUE) - v2
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- vue (VUE) - v3
|
||||
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
|
||||
- eslint (ESLINT) - v9
|
||||
- prettier (PRETTIER) - v3
|
||||
|
||||
## Skills Activation
|
||||
|
||||
@@ -44,8 +34,10 @@ 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.
|
||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `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.
|
||||
- `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
|
||||
|
||||
@@ -64,7 +56,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `pnpm run build`, `pnpm 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 `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@@ -138,6 +130,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
|
||||
- Components live in `resources/js/pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
|
||||
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
# Inertia v2
|
||||
|
||||
@@ -173,7 +166,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `pnpm run build` or ask the user to run `pnpm run dev` or `composer run dev`.
|
||||
- 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`.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -210,4 +203,11 @@ Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== inertia-vue/core rules ===
|
||||
|
||||
# Inertia + Vue
|
||||
|
||||
Vue components must have a single root element.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
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,7 +8,6 @@ use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -114,41 +113,6 @@ class PermissionService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, User> $users
|
||||
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
||||
*/
|
||||
public function projectAccessMap(EloquentCollection $users, Project $project): array
|
||||
{
|
||||
$users->loadMissing('teams');
|
||||
$directPermissions = $this->assignmentMap(User::class, $project);
|
||||
$teamPermissions = $this->assignmentMap(Team::class, $project);
|
||||
$globalPermissions = $this->assignmentMap(User::class);
|
||||
$effectivePermissions = [];
|
||||
$globalAdminUserIds = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$permissions = $directPermissions[$user->id] ?? [];
|
||||
|
||||
foreach ($user->teams as $team) {
|
||||
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
|
||||
}
|
||||
|
||||
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
|
||||
$permissions[] = Permission::GlobalAdmin->value;
|
||||
$globalAdminUserIds[] = $user->id;
|
||||
}
|
||||
|
||||
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
|
||||
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
|
||||
Permission::projectAssignable(),
|
||||
fn (Permission $permission): bool => in_array($permission, $expanded, true),
|
||||
)));
|
||||
}
|
||||
|
||||
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
||||
*/
|
||||
@@ -27,14 +19,6 @@ class UpdateTicketStatusTransitions
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
}
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
||||
*/
|
||||
@@ -24,14 +16,6 @@ class UpdateProjectTicketMetadataSettings
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,7 +29,6 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->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\TicketStatusResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Models\Ticket;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectTicketOptionsPayload
|
||||
|
||||
@@ -18,7 +18,6 @@ class CreateProject
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -40,22 +39,18 @@ class CreateProject
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $sourceTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
])->save();
|
||||
} else {
|
||||
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $baseTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
])->save();
|
||||
}
|
||||
|
||||
$project = $project->fresh() ?? $project;
|
||||
|
||||
$this->projectTemplateOverrideManager->withoutRecording(
|
||||
fn () => $this->initializeProjectTicketMetadata->handle($project),
|
||||
);
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||
|
||||
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
@@ -11,13 +10,7 @@ class DeleteProject
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
||||
|
||||
public const COPY_BOARD_COLUMNS = 'board_columns';
|
||||
|
||||
public const COPY_TICKET_STATUSES = 'ticket_statuses';
|
||||
|
||||
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
|
||||
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||
|
||||
public const COPY_TICKET_TYPES = 'ticket_types';
|
||||
|
||||
@@ -28,20 +22,14 @@ class DuplicateProjectConfiguration
|
||||
|
||||
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
|
||||
|
||||
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function copyableAreas(): array
|
||||
{
|
||||
return [
|
||||
self::COPY_PROJECT_SETTINGS,
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
self::COPY_BOARD_WORKFLOW,
|
||||
self::COPY_TICKET_TYPES,
|
||||
self::COPY_TICKET_PRIORITIES,
|
||||
self::COPY_TICKET_FIELDS,
|
||||
@@ -51,67 +39,68 @@ class DuplicateProjectConfiguration
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function acceptedCopyAreas(): array
|
||||
{
|
||||
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string, description: string}> */
|
||||
/**
|
||||
* @return list<array{value: string, label: string, description: string}>
|
||||
*/
|
||||
public static function copyAreaOptions(): array
|
||||
{
|
||||
return [
|
||||
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
|
||||
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
|
||||
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
|
||||
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
|
||||
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
|
||||
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
|
||||
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
|
||||
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
|
||||
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
|
||||
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
|
||||
[
|
||||
'value' => self::COPY_PROJECT_SETTINGS,
|
||||
'label' => 'Project settings',
|
||||
'description' => 'Copy general project preferences like story points and author visibility.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_BOARD_WORKFLOW,
|
||||
'label' => 'Board workflow',
|
||||
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_TICKET_TYPES,
|
||||
'label' => 'Ticket types',
|
||||
'description' => 'Copy available ticket types, including parent-ticket types.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_TICKET_PRIORITIES,
|
||||
'label' => 'Ticket priorities',
|
||||
'description' => 'Copy the project-specific ticket priorities.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_TICKET_FIELDS,
|
||||
'label' => 'Custom ticket fields',
|
||||
'description' => 'Copy custom ticket field definitions without any ticket values.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_TICKET_LINK_TYPES,
|
||||
'label' => 'Ticket link types',
|
||||
'description' => 'Copy available relationship labels between tickets.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_REPOSITORY_CONNECTIONS,
|
||||
'label' => 'Repository connections',
|
||||
'description' => 'Copy linked repositories without changing the new project identity.',
|
||||
],
|
||||
[
|
||||
'value' => self::COPY_NOTIFICATION_ACTIONS,
|
||||
'label' => 'Notification actions',
|
||||
'description' => 'Copy notification rules and delivery settings.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return list<string> */
|
||||
/**
|
||||
* @param list<string> $copyAreas
|
||||
* @return list<string>
|
||||
*/
|
||||
public function normalizeCopyAreas(array $copyAreas): array
|
||||
{
|
||||
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
|
||||
$copyAreas = [
|
||||
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalizeConfiguration(array $configuration): array
|
||||
{
|
||||
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
|
||||
|
||||
if (is_array($workflow)) {
|
||||
$statuses = $workflow['ticket_statuses'] ?? [];
|
||||
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
|
||||
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
|
||||
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
|
||||
$statuses,
|
||||
);
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
|
||||
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
|
||||
$statuses,
|
||||
);
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
/**
|
||||
* @param list<string> $copyAreas
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function export(Project $sourceProject, array $copyAreas): array
|
||||
{
|
||||
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||
@@ -120,10 +109,17 @@ class DuplicateProjectConfiguration
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceProject->load([
|
||||
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
|
||||
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
|
||||
$sourceProject->loadMissing([
|
||||
'boardColumns',
|
||||
'notificationActions',
|
||||
'repositoryConnections',
|
||||
'ticketFieldDefinitions',
|
||||
'ticketLinkTypes',
|
||||
'ticketPriorities',
|
||||
'ticketStatuses.allowedTargetStatuses',
|
||||
'ticketTypes',
|
||||
]);
|
||||
|
||||
$configuration = [];
|
||||
|
||||
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
|
||||
@@ -133,65 +129,120 @@ class DuplicateProjectConfiguration
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
);
|
||||
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
|
||||
$boardColumnIndexes = [];
|
||||
$boardColumns = [];
|
||||
|
||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => [
|
||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||
)->all();
|
||||
}
|
||||
|
||||
$simpleAreas = [
|
||||
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
|
||||
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
|
||||
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
|
||||
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
|
||||
'provider' => $item->provider->value, 'base_url' => $item->base_url,
|
||||
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
|
||||
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
|
||||
'repository_url' => $item->repository_url,
|
||||
],
|
||||
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
|
||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||
'filters' => $item->filters ?? [],
|
||||
],
|
||||
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
|
||||
$boardColumnIndexes[$boardColumn->id] = $index;
|
||||
$boardColumns[] = [
|
||||
'name' => $boardColumn->name,
|
||||
'position' => $boardColumn->position,
|
||||
];
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
|
||||
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
|
||||
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
|
||||
];
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
}
|
||||
|
||||
$ticketStatusIndexes = [];
|
||||
$ticketStatuses = [];
|
||||
|
||||
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
|
||||
$ticketStatusIndexes[$ticketStatus->id] = $index;
|
||||
$ticketStatuses[] = [
|
||||
'board_column_index' => $ticketStatus->board_column_id === null
|
||||
? null
|
||||
: ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null),
|
||||
'name' => $ticketStatus->name,
|
||||
'position' => $ticketStatus->position,
|
||||
'allowed_target_indexes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
|
||||
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
|
||||
->pluck('id')
|
||||
->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null)
|
||||
->filter(static fn (?int $statusIndex): bool => $statusIndex !== null)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
$configuration[self::COPY_BOARD_WORKFLOW] = [
|
||||
'board_columns' => $boardColumns,
|
||||
'ticket_statuses' => $ticketStatuses,
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes
|
||||
->map(fn ($ticketType): array => [
|
||||
'name' => $ticketType->name,
|
||||
'position' => $ticketType->position,
|
||||
'is_parent' => $ticketType->is_parent,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities
|
||||
->map(fn ($ticketPriority): array => [
|
||||
'name' => $ticketPriority->name,
|
||||
'position' => $ticketPriority->position,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions
|
||||
->map(fn ($ticketFieldDefinition): array => [
|
||||
'field_type' => $ticketFieldDefinition->field_type,
|
||||
'label' => $ticketFieldDefinition->label,
|
||||
'position' => $ticketFieldDefinition->position,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes
|
||||
->map(fn ($ticketLinkType): array => [
|
||||
'label' => $ticketLinkType->label,
|
||||
'counterpart_label' => $ticketLinkType->counterpart_label,
|
||||
'position' => $ticketLinkType->position,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections
|
||||
->map(fn ($repositoryConnection): array => [
|
||||
'provider' => $repositoryConnection->provider->value,
|
||||
'base_url' => $repositoryConnection->base_url,
|
||||
'provider_repository_id' => $repositoryConnection->provider_repository_id,
|
||||
'owner_name' => $repositoryConnection->owner_name,
|
||||
'repository_name' => $repositoryConnection->repository_name,
|
||||
'repository_full_name' => $repositoryConnection->repository_full_name,
|
||||
'repository_url' => $repositoryConnection->repository_url,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions
|
||||
->map(fn ($notificationAction): array => [
|
||||
'name' => $notificationAction->name,
|
||||
'enabled' => $notificationAction->enabled,
|
||||
'event_type' => $notificationAction->event_type->value,
|
||||
'channel' => $notificationAction->channel->value,
|
||||
'channel_configuration' => $notificationAction->channel_configuration ?? [],
|
||||
'message_template' => $notificationAction->message_template,
|
||||
'delivery_url' => $notificationAction->delivery_url,
|
||||
'filters' => $notificationAction->filters ?? [],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
@@ -199,141 +250,151 @@ class DuplicateProjectConfiguration
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
public function apply(Project $targetProject, array $configuration): void
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) {
|
||||
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = [];
|
||||
|
||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||
$targetProject->forceFill([
|
||||
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
|
||||
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
|
||||
])->save();
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->boardColumns()->get(),
|
||||
$configuration[self::COPY_BOARD_COLUMNS],
|
||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||
}
|
||||
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
|
||||
$targetProject->ticketStatuses()->delete();
|
||||
$targetProject->boardColumns()->delete();
|
||||
|
||||
$boardColumnIds = [];
|
||||
|
||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
|
||||
$newBoardColumn = $targetProject->boardColumns()->create([
|
||||
'name' => $boardColumn['name'],
|
||||
'position' => $boardColumn['position'],
|
||||
]);
|
||||
|
||||
$boardColumnIds[] = $newBoardColumn->id;
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||
$columns = $targetProject->boardColumns()->get()->values();
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||
$columnIndex = $item['board_column_index'] ?? null;
|
||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||
unset($item['board_column_index']);
|
||||
$ticketStatusIds = [];
|
||||
|
||||
return $item;
|
||||
})->all();
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->ticketStatuses()->get(),
|
||||
$items,
|
||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
function (Model $status) use ($statusReplacements): bool {
|
||||
if (! $status->tickets()->exists()) {
|
||||
return true;
|
||||
}
|
||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacementId === null) {
|
||||
return false;
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
|
||||
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
|
||||
$newTicketStatus = $targetProject->ticketStatuses()->create([
|
||||
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
|
||||
'name' => $ticketStatus['name'],
|
||||
'position' => $ticketStatus['position'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||
}
|
||||
$ticketStatusIds[] = $newTicketStatus->id;
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||
$status = $statuses->get($index);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
|
||||
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
|
||||
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
if (! isset($configuration[$area])) {
|
||||
if ($newTicketStatusId === null) {
|
||||
continue;
|
||||
}
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->{$relation}()->get(),
|
||||
$configuration[$area],
|
||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
$canDelete,
|
||||
|
||||
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
|
||||
->allowedTargetStatuses()
|
||||
->sync(
|
||||
collect($ticketStatus['allowed_target_indexes'] ?? [])
|
||||
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
|
||||
->filter(static fn (?int $statusId): bool => $statusId !== null)
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($targetProject->configurationRelations() as $relation) {
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
|
||||
$targetProject->ticketTypes()->delete();
|
||||
|
||||
foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) {
|
||||
$targetProject->ticketTypes()->create([
|
||||
'name' => $ticketType['name'],
|
||||
'position' => $ticketType['position'],
|
||||
'is_parent' => $ticketType['is_parent'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
});
|
||||
if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) {
|
||||
$targetProject->ticketPriorities()->delete();
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
|
||||
$targetProject->ticketPriorities()->create([
|
||||
'name' => $ticketPriority['name'],
|
||||
'position' => $ticketPriority['position'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $blockedAreas;
|
||||
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
|
||||
$targetProject->ticketFieldDefinitions()->delete();
|
||||
|
||||
foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) {
|
||||
$targetProject->ticketFieldDefinitions()->create([
|
||||
'field_type' => $ticketFieldDefinition['field_type'],
|
||||
'label' => $ticketFieldDefinition['label'],
|
||||
'position' => $ticketFieldDefinition['position'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas */
|
||||
if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) {
|
||||
$targetProject->ticketLinkTypes()->delete();
|
||||
|
||||
foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) {
|
||||
$targetProject->ticketLinkTypes()->create([
|
||||
'label' => $ticketLinkType['label'],
|
||||
'counterpart_label' => $ticketLinkType['counterpart_label'],
|
||||
'position' => $ticketLinkType['position'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) {
|
||||
$targetProject->repositoryConnections()->delete();
|
||||
|
||||
foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) {
|
||||
$targetProject->repositoryConnections()->create([
|
||||
'provider' => $repositoryConnection['provider'],
|
||||
'base_url' => $repositoryConnection['base_url'],
|
||||
'provider_repository_id' => $repositoryConnection['provider_repository_id'],
|
||||
'owner_name' => $repositoryConnection['owner_name'],
|
||||
'repository_name' => $repositoryConnection['repository_name'],
|
||||
'repository_full_name' => $repositoryConnection['repository_full_name'],
|
||||
'repository_url' => $repositoryConnection['repository_url'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) {
|
||||
$targetProject->notificationActions()->delete();
|
||||
|
||||
foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) {
|
||||
$targetProject->notificationActions()->create([
|
||||
'name' => $notificationAction['name'],
|
||||
'enabled' => $notificationAction['enabled'],
|
||||
'event_type' => $notificationAction['event_type'],
|
||||
'channel' => $notificationAction['channel'],
|
||||
'channel_configuration' => $notificationAction['channel_configuration'] ?? [],
|
||||
'message_template' => $notificationAction['message_template'] ?? null,
|
||||
'delivery_url' => $notificationAction['delivery_url'],
|
||||
'filters' => $notificationAction['filters'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $copyAreas
|
||||
*/
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
private static int $suppressionDepth = 0;
|
||||
|
||||
public function mark(Project $project, string $area): void
|
||||
{
|
||||
if (self::$suppressionDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overrides = array_values(array_unique([
|
||||
...($project->template_overrides ?? []),
|
||||
$area,
|
||||
]));
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
{
|
||||
self::$suppressionDepth++;
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
self::$suppressionDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
public function isRecording(): bool
|
||||
{
|
||||
return self::$suppressionDepth === 0;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(Project $project, string $area): void
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$project->projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
|
||||
$project->projectTemplate->forceFill([
|
||||
'configuration_snapshot' => array_replace(
|
||||
$snapshot,
|
||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||
),
|
||||
])->saveQuietly();
|
||||
}
|
||||
}
|
||||
@@ -30,16 +30,10 @@ class ResolveProjectTemplateState
|
||||
? DuplicateProjectConfiguration::copyableAreas()
|
||||
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
|
||||
|
||||
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateConfiguration = collect($normalizedTemplateConfiguration)
|
||||
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
|
||||
->only($selectedAreas)
|
||||
->all();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||
$persistedOverrides = $projectTemplate->is_published
|
||||
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
|
||||
: [];
|
||||
|
||||
$overriddenAreas = [];
|
||||
$inheritedAreas = [];
|
||||
@@ -47,12 +41,13 @@ class ResolveProjectTemplateState
|
||||
foreach ($selectedAreas as $area) {
|
||||
$label = $this->labelForArea($area);
|
||||
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
|
||||
|
||||
if (! $hasTemplateValue) {
|
||||
if (! $hasTemplateValue && ! $hasCurrentValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($area, $persistedOverrides, true)) {
|
||||
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
|
||||
$inheritedAreas[] = [
|
||||
'label' => $label,
|
||||
'value' => $area,
|
||||
@@ -77,7 +72,6 @@ class ResolveProjectTemplateState
|
||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||
'overriddenAreas' => $overriddenAreas,
|
||||
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
|
||||
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
|
||||
'templateConfiguration' => $templateConfiguration,
|
||||
'templateId' => $projectTemplate->id,
|
||||
'templateName' => $projectTemplate->name,
|
||||
@@ -85,44 +79,6 @@ class ResolveProjectTemplateState
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $templateConfiguration
|
||||
* @param list<string> $persistedOverrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
|
||||
{
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
if (! is_array($templateStatuses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||
)->all();
|
||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => [
|
||||
'id' => $status->id,
|
||||
'name' => $status->name,
|
||||
'ticketCount' => $status->tickets_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $removedStatuses === [] ? [] : [
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||
'removedStatuses' => $removedStatuses,
|
||||
'replacementOptions' => $replacementOptions,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function labelForArea(string $area): string
|
||||
{
|
||||
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -11,7 +10,6 @@ class UpdateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -19,68 +17,48 @@ class UpdateProjectTemplate
|
||||
*/
|
||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||
{
|
||||
$projectTemplate->loadMissing('sourceProject');
|
||||
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
|
||||
|
||||
$sourceProject = $projectTemplate->sourceProject;
|
||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$previousConfiguration = $projectTemplate->configuration_snapshot ?? [];
|
||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
$affectedAreas = array_values(array_unique([
|
||||
...array_keys($previousConfiguration),
|
||||
...array_keys($nextConfiguration),
|
||||
]));
|
||||
|
||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
foreach ($projectTemplate->linkedProjects as $linkedProject) {
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas);
|
||||
$replacementConfiguration = [];
|
||||
|
||||
foreach ($affectedAreas as $area) {
|
||||
if (in_array($area, $overrides, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $nextConfiguration)) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $previousConfiguration)
|
||||
foreach ($nextConfiguration as $area => $configuration) {
|
||||
$isNewTemplateArea = ! array_key_exists($area, $previousConfiguration);
|
||||
$matchesPreviousTemplate = array_key_exists($area, $previousConfiguration)
|
||||
&& array_key_exists($area, $currentConfiguration)
|
||||
&& $currentConfiguration[$area] !== []) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
&& $currentConfiguration[$area] === $previousConfiguration[$area];
|
||||
|
||||
if ($isNewTemplateArea || $matchesPreviousTemplate) {
|
||||
$replacementConfiguration[$area] = $configuration;
|
||||
}
|
||||
}
|
||||
|
||||
if ($replacementConfiguration === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementConfiguration[$area] = $nextConfiguration[$area];
|
||||
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
|
||||
}
|
||||
|
||||
if ($replacementConfiguration !== []) {
|
||||
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
|
||||
@@ -10,39 +10,23 @@ class UpdateProjectTemplateLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$localAreas = array_values(array_diff(
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
array_keys($configuration),
|
||||
));
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => $localAreas,
|
||||
])->saveQuietly();
|
||||
])->save();
|
||||
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
public function reset(Project $project): void
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
@@ -50,19 +34,10 @@ class UpdateProjectTemplateLink
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
$statusReplacements,
|
||||
);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ class CreateProjectTicket
|
||||
public function __construct(
|
||||
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -30,15 +29,7 @@ class CreateProjectTicket
|
||||
* text: string
|
||||
* }>,
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment?: 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,
|
||||
* story_points?: int|null,
|
||||
* ticket_priority_id: int,
|
||||
@@ -56,17 +47,18 @@ class CreateProjectTicket
|
||||
$nextSequenceNumber = (int) Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->max('sequence_number') + 1;
|
||||
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||
$ticketStatusQuery = TicketStatus::query()
|
||||
->where(
|
||||
$project->project_template_id === null ? 'project_id' : 'project_template_id',
|
||||
$project->project_template_id ?? $project->id,
|
||||
);
|
||||
|
||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
||||
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
|
||||
'author_user_id' => $actor?->id,
|
||||
'description' => Arr::get($attributes, 'description'),
|
||||
'is_blocked' => $isBlocked,
|
||||
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => $nextSequenceNumber,
|
||||
@@ -80,17 +72,6 @@ class CreateProjectTicket
|
||||
|
||||
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();
|
||||
|
||||
if ($actor !== null) {
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -30,10 +30,9 @@ class UpdateUserAvatar
|
||||
$color = $this->colorFor($user);
|
||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||
|
||||
$stored = Storage::disk($this->avatarDisk())->put(
|
||||
$stored = Storage::disk('public')->put(
|
||||
$path,
|
||||
$this->svg($this->initials($user->name), $color),
|
||||
['ContentType' => 'image/svg+xml'],
|
||||
);
|
||||
|
||||
if (! $stored) {
|
||||
@@ -54,7 +53,7 @@ class UpdateUserAvatar
|
||||
public function upload(User $user, UploadedFile $avatar): User
|
||||
{
|
||||
$oldPath = $user->avatar_path;
|
||||
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
||||
$path = $avatar->store('avatars/uploads', 'public');
|
||||
|
||||
if (! is_string($path)) {
|
||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||
@@ -91,14 +90,7 @@ class UpdateUserAvatar
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->avatarDisk())->delete($path);
|
||||
}
|
||||
|
||||
private function avatarDisk(): string
|
||||
{
|
||||
$disk = config('filesystems.avatar_disk', 'public');
|
||||
|
||||
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function initials(string $name): string
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||
@@ -26,9 +25,6 @@ class CreateAdminUser extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$user = User::create([
|
||||
'name' => $this->argument('name'),
|
||||
'email' => $this->argument('email'),
|
||||
@@ -42,14 +38,7 @@ class CreateAdminUser extends Command
|
||||
]);
|
||||
|
||||
$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,12 +36,11 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$label} link was saved.",
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ class ProjectController extends Controller
|
||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||
private readonly CreateProject $createProject,
|
||||
private readonly DeleteProject $deleteProject,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
@@ -136,10 +135,9 @@ class ProjectController extends Controller
|
||||
|
||||
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$template = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||
->findOrFail($request->integer('source_template_id'));
|
||||
|
||||
$this->updateProjectTemplateLink->switch($project, $template);
|
||||
|
||||
@@ -149,29 +147,16 @@ class ProjectController extends Controller
|
||||
|
||||
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$request->validated();
|
||||
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
);
|
||||
|
||||
$status = Arr::has($validated, 'areas')
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
return back()->with('status', $status);
|
||||
return to_route('projects.settings.general', $project)
|
||||
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
|
||||
}
|
||||
|
||||
public function access(Request $request, Project $project): Response
|
||||
@@ -182,22 +167,25 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
$users = User::query()->orderBy('name')->get();
|
||||
|
||||
return Inertia::render('projects/Access', [
|
||||
...$this->permissionService->projectAccessMap($users, $project),
|
||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||
'permissionOptions' => PermissionOptionResource::collection(
|
||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => session('status'),
|
||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||
'teams' => TeamResource::collection(
|
||||
Team::query()
|
||||
->with('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection($users)->resolve(),
|
||||
'users' => UserResource::collection(
|
||||
User::query()
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -217,9 +205,7 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($user, $project, $permissions);
|
||||
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
return to_route('projects.access', $project)
|
||||
->with('status', "{$user->name} permissions were updated.");
|
||||
}
|
||||
|
||||
@@ -238,9 +224,7 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($team, $project, $permissions);
|
||||
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
return to_route('projects.access', $project)
|
||||
->with('status', "{$team->name} permissions were updated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,9 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$label} link was saved.");
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -16,11 +16,7 @@ class HandleAppearance
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$appearance = $request->cookie('appearance');
|
||||
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||
|
||||
$request->attributes->set('appearance', $appearance);
|
||||
View::share('appearance', $appearance);
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -47,11 +46,6 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||
'localization' => [
|
||||
'locale' => app()->getLocale(),
|
||||
'timeZone' => config('app.timezone'),
|
||||
],
|
||||
'auth' => [
|
||||
'permissions' => [
|
||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||
@@ -61,36 +55,6 @@ class HandleInertiaRequests extends Middleware
|
||||
'user' => $user,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'navigation' => fn (): array => [
|
||||
'project' => $this->projectNavigation($request),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||
*/
|
||||
private function projectNavigation(Request $request): ?array
|
||||
{
|
||||
$project = $request->route('project');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $project instanceof Project || $user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||
|
||||
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'key' => $project->key,
|
||||
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
@@ -24,13 +24,16 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,13 +29,16 @@ class DestroyProjectTicketTypeRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'replacement_ticket_type_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -29,13 +29,16 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class ResetProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
@@ -26,71 +23,11 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'areas' => ['sometimes', 'array', 'min:1'],
|
||||
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||
'confirmation_key' => [
|
||||
'required_without:areas',
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateStatuses = Arr::get(
|
||||
$configuration,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
[],
|
||||
);
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||
$validator->errors()->add(
|
||||
"status_replacements.{$status->id}",
|
||||
"Choose a replacement status for {$status->name}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -101,7 +38,6 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
|
||||
'areas.*.in' => 'Choose a valid template area to reset.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class StoreProjectRequest extends FormRequest
|
||||
'copy_areas' => ['nullable', 'array'],
|
||||
'copy_areas.*' => [
|
||||
'string',
|
||||
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
|
||||
Rule::in(DuplicateProjectConfiguration::copyableAreas()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
@@ -33,7 +36,7 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => ['nullable', 'array'],
|
||||
@@ -67,21 +70,21 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
|
||||
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'linked_ticket_id' => [
|
||||
@@ -39,10 +42,9 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_link_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,16 @@ class StoreTicketStatusRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'board_column_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('board_columns', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
@@ -27,9 +27,8 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
|
||||
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'acceptance_criteria' => ['nullable', 'array'],
|
||||
@@ -69,7 +72,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_field_definitions', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
@@ -84,14 +87,14 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
@@ -147,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
|
||||
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
||||
$requestedTicketType = TicketType::query()
|
||||
->where('project_id', $project->id)
|
||||
->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
|
||||
->find($requestedTicketTypeId);
|
||||
|
||||
if (
|
||||
|
||||
@@ -29,13 +29,16 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||
'allowed_ticket_status_ids.*' => [
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -26,6 +26,9 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
@@ -33,7 +36,7 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -37,7 +37,6 @@ class ProjectResource extends JsonResource
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
||||
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
||||
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
|
||||
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
||||
'template_id' => Arr::get($templateState, 'templateId'),
|
||||
'template_name' => Arr::get($templateState, 'templateName'),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -18,8 +17,6 @@ class ProjectTemplateResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
if ($this->resource instanceof ProjectTemplate) {
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'project_id' => $this->project_id,
|
||||
@@ -30,10 +27,8 @@ class ProjectTemplateResource extends JsonResource
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'is_published' => $this->is_published,
|
||||
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$this->configuration_snapshot ?? [],
|
||||
),
|
||||
'copy_areas' => $this->copy_areas ?? [],
|
||||
'configuration_snapshot' => $this->configuration_snapshot ?? [],
|
||||
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
|
||||
@@ -20,7 +20,6 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,7 +20,6 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
@@ -17,7 +15,7 @@ use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
#[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
|
||||
{
|
||||
protected array $tools = [
|
||||
@@ -27,8 +25,6 @@ class KanbanGitServer extends Server
|
||||
UpdateTicketDescription::class,
|
||||
UpdateTicketAssignee::class,
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -120,24 +120,6 @@ class KanbanGitMcp
|
||||
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
|
||||
* @return Collection<int, TicketStatus>|Response
|
||||
@@ -207,12 +189,10 @@ class KanbanGitMcp
|
||||
public function ticketPayload(Ticket $ticket): array
|
||||
{
|
||||
$ticket->load([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'author',
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
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
|
||||
{
|
||||
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'],
|
||||
'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.'),
|
||||
'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.'),
|
||||
'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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -31,7 +31,6 @@ class NotificationAction extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationActionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
|
||||
+1
-17
@@ -13,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
|
||||
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])]
|
||||
class Project extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
@@ -24,7 +24,6 @@ class Project extends Model
|
||||
return [
|
||||
'show_author' => 'boolean',
|
||||
'show_story_points' => 'boolean',
|
||||
'template_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -109,21 +108,6 @@ class Project extends Model
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function configurationRelations(): array
|
||||
{
|
||||
return [
|
||||
'boardColumns',
|
||||
'notificationActions',
|
||||
'repositoryConnections',
|
||||
'ticketFieldDefinitions',
|
||||
'ticketLinkTypes',
|
||||
'ticketPriorities',
|
||||
'ticketStatuses',
|
||||
'ticketTypes',
|
||||
];
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class)
|
||||
|
||||
@@ -26,7 +26,6 @@ class RepositoryConnection extends Model
|
||||
{
|
||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,6 @@ class TicketFieldDefinition extends Model
|
||||
|
||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -15,7 +15,6 @@ class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -8,14 +8,12 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -29,9 +27,4 @@ class TicketPriority extends Model
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -15,7 +15,6 @@ class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
+3
-19
@@ -77,28 +77,12 @@ class User extends Authenticatable
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
fn (): ?string => $this->avatarUrl(),
|
||||
fn (): ?string => filled($this->avatar_path)
|
||||
? 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
|
||||
{
|
||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectConfigurationObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
public function saved(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
{
|
||||
if (! $this->projectTemplateOverrideManager->isRecording()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = $model->project()->first();
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$area = match (true) {
|
||||
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
|
||||
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($area === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshProjectBaseTemplate->handle($project, $area);
|
||||
$this->projectTemplateOverrideManager->mark($project, $area);
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,9 @@ use App\Actions\Notifications\TicketCreated;
|
||||
use App\Actions\Notifications\TicketStatusChanged;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\Team;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use App\Observers\ProjectConfigurationObserver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
@@ -45,18 +36,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
foreach ([
|
||||
BoardColumn::class,
|
||||
NotificationAction::class,
|
||||
RepositoryConnection::class,
|
||||
TicketFieldDefinition::class,
|
||||
TicketLinkType::class,
|
||||
TicketPriority::class,
|
||||
TicketStatus::class,
|
||||
TicketType::class,
|
||||
] as $model) {
|
||||
$model::observe(ProjectConfigurationObserver::class);
|
||||
}
|
||||
|
||||
if ($this->app->environment() !== 'local') {
|
||||
URL::forceScheme('https');
|
||||
|
||||
+3
-2
@@ -9,7 +9,8 @@
|
||||
"skills": [
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"mcp-development",
|
||||
"wayfinder-development"
|
||||
"wayfinder-development",
|
||||
"inertia-vue-development",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-3
@@ -17,8 +17,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
@@ -33,5 +31,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->dontFlash(['api_token']);
|
||||
//
|
||||
})->create();
|
||||
|
||||
+10
-24
@@ -12,15 +12,13 @@
|
||||
"php": "^8.4",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/fortify": "^1.34",
|
||||
"laravel/framework": "^v13.26.1",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/mcp": "^0.6",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"scalar/laravel": "^0.2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.20",
|
||||
"fakerphp/faker": "^1.24",
|
||||
"laravel/boost": "^2.0",
|
||||
"laravel/pail": "^1.2.5",
|
||||
@@ -48,15 +46,15 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"pnpm install",
|
||||
"pnpm run build"
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
"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"
|
||||
],
|
||||
"dev:ssr": [
|
||||
"pnpm run build:ssr",
|
||||
"npm run build:ssr",
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||
],
|
||||
@@ -68,27 +66,15 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@ci:frontend",
|
||||
"@ci:backend",
|
||||
"@ci:test"
|
||||
],
|
||||
"ci:frontend": [
|
||||
"pnpm audit --audit-level=high",
|
||||
"pnpm run lint:check",
|
||||
"pnpm run types:check",
|
||||
"pnpm run build"
|
||||
],
|
||||
"ci:backend": [
|
||||
"@lint:check"
|
||||
],
|
||||
"ci:test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test --parallel --processes=2"
|
||||
"npm run lint:check",
|
||||
"npm run format:check",
|
||||
"npm run types:check",
|
||||
"@test"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@lint:check",
|
||||
"@php artisan test --parallel --processes=2"
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
|
||||
Generated
+337
-1014
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,6 @@ return [
|
||||
|
||||
'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
|
||||
|
||||
@@ -23,7 +23,6 @@ class ProjectFactory extends Factory
|
||||
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
'template_overrides' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -44,17 +44,13 @@ return new class extends Migration
|
||||
'projects.name',
|
||||
'projects.project_template_id',
|
||||
'project_templates.project_id as template_project_id',
|
||||
'project_templates.is_published as template_is_published',
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
|
||||
if ($projectTemplateId === null || (
|
||||
! (bool) $project->template_is_published
|
||||
&& (int) ($project->template_project_id ?? 0) !== (int) $project->id
|
||||
)) {
|
||||
if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) {
|
||||
$projectTemplateId = DB::table('project_templates')->insertGetId([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
@@ -92,7 +88,7 @@ return new class extends Migration
|
||||
|
||||
$projectModel = Project::query()->find($project->id);
|
||||
|
||||
if ($projectModel !== null && ! (bool) $project->template_is_published) {
|
||||
if ($projectModel !== null) {
|
||||
DB::table('project_templates')
|
||||
->where('id', $projectTemplateId)
|
||||
->update([
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->json('template_overrides')->default('[]')->after('project_template_id');
|
||||
});
|
||||
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||
$template->forceFill([
|
||||
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
),
|
||||
])->saveQuietly();
|
||||
});
|
||||
|
||||
Project::query()
|
||||
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
|
||||
->with('projectTemplate')
|
||||
->eachById(function (Project $project) use ($configuration): void {
|
||||
$templateConfiguration = $configuration->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(array_unique([
|
||||
...array_keys($templateConfiguration),
|
||||
...DuplicateProjectConfiguration::copyableAreas(),
|
||||
]));
|
||||
$currentConfiguration = $configuration->export($project, $areas);
|
||||
$overrides = collect($areas)
|
||||
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|
||||
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('template_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
+12
-32
@@ -1,38 +1,33 @@
|
||||
services:
|
||||
web:
|
||||
container_name: kanbangit-web
|
||||
image: kanbangit-app:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
command: nginx
|
||||
dockerfile: ./docker/nginx/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- kanbangit-storage:/var/www/storage:ro
|
||||
- kanbangit-public-assets:/var/www/public/build:ro
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
PHP_FPM_UPSTREAM: php-fpm:9000
|
||||
networks:
|
||||
- kanbangit-web
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
php-fpm:
|
||||
condition: service_healthy
|
||||
|
||||
php-fpm:
|
||||
container_name: kanbangit-php-fpm
|
||||
image: kanbangit-app:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
dockerfile: ./docker/php-fpm/Dockerfile
|
||||
target: production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- kanbangit-storage:/var/www/storage
|
||||
- kanbangit-public-assets:/var/www/public/build
|
||||
env_file:
|
||||
- .env.production
|
||||
networks:
|
||||
@@ -47,23 +42,6 @@ services:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
container_name: kanbangit-worker
|
||||
image: kanbangit-app:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.production
|
||||
networks:
|
||||
- kanbangit-db
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
||||
mariadb:
|
||||
container_name: kanbangit-db
|
||||
image: mariadb:11.4
|
||||
@@ -87,3 +65,5 @@ networks:
|
||||
|
||||
volumes:
|
||||
mariadb-data-production:
|
||||
kanbangit-storage:
|
||||
kanbangit-public-assets:
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${1:-}" = "nginx" ]; then
|
||||
: "${PHP_FPM_UPSTREAM:=127.0.0.1:9000}"
|
||||
export PHP_FPM_UPSTREAM
|
||||
|
||||
envsubst '${PHP_FPM_UPSTREAM}' \
|
||||
< /etc/nginx/nginx.conf.template \
|
||||
> /tmp/nginx.conf
|
||||
|
||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||
fi
|
||||
|
||||
mkdir -p \
|
||||
bootstrap/cache \
|
||||
storage/framework/cache \
|
||||
storage/framework/sessions \
|
||||
storage/framework/testing \
|
||||
storage/framework/views \
|
||||
storage/logs
|
||||
|
||||
php artisan config:cache --no-ansi
|
||||
php artisan route:cache --no-ansi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM php:8.4-cli-bookworm AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates git unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- \
|
||||
--install-dir=/usr/local/bin --filename=composer
|
||||
WORKDIR /var/www
|
||||
COPY . /var/www
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
|
||||
RUN npm ci && npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /var/www/public /var/www/public
|
||||
WORKDIR /var/www/public
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,33 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 100m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /var/www/public;
|
||||
index index.php index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
location ^~ /storage/ {
|
||||
alias /var/www/storage/app/public/;
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass php-fpm:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
client_body_temp_path /tmp/client_body;
|
||||
proxy_temp_path /tmp/proxy;
|
||||
fastcgi_temp_path /tmp/fastcgi;
|
||||
uwsgi_temp_path /tmp/uwsgi;
|
||||
scgi_temp_path /tmp/scgi;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 3m;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /var/www/public;
|
||||
index index.php;
|
||||
|
||||
location = /nginx-health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /index.php {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param HTTP_PROXY '';
|
||||
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
|
||||
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
|
||||
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
|
||||
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||
fastcgi_pass ${PHP_FPM_UPSTREAM};
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_NAME=/status \
|
||||
SCRIPT_FILENAME=/status \
|
||||
REQUEST_METHOD=GET \
|
||||
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q '^pool:'
|
||||
@@ -0,0 +1,66 @@
|
||||
FROM php:8.4-fpm AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
unzip \
|
||||
libpq-dev \
|
||||
libonig-dev \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libicu-dev \
|
||||
libzip-dev \
|
||||
libmagickwand-dev \
|
||||
libpng-dev \
|
||||
libjpeg-dev \
|
||||
libfreetype6-dev \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
pdo_mysql \
|
||||
pdo_pgsql \
|
||||
pgsql \
|
||||
opcache \
|
||||
intl \
|
||||
zip \
|
||||
bcmath \
|
||||
soap \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& docker-php-ext-install -j$(nproc) gd \
|
||||
&& pecl install imagick \
|
||||
&& docker-php-ext-enable imagick \
|
||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
WORKDIR /var/www
|
||||
COPY . /var/www
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
|
||||
FROM php:8.4-fpm AS production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
libicu-dev \
|
||||
libzip-dev \
|
||||
libfcgi-bin \
|
||||
procps \
|
||||
libmagickwand-dev \
|
||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
||||
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
||||
&& chmod +x /usr/local/bin/php-fpm-healthcheck
|
||||
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY ./storage /var/www/storage-init
|
||||
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
|
||||
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
|
||||
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
RUN printf '%s\n' \
|
||||
'[global]' \
|
||||
'daemonize = no' \
|
||||
'' \
|
||||
'[www]' \
|
||||
'pm.status_path = /status' \
|
||||
> /usr/local/etc/php-fpm.d/zz-docker.conf
|
||||
COPY --from=builder /var/www /var/www
|
||||
WORKDIR /var/www
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
USER www-data
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
EXPOSE 9000
|
||||
CMD ["php-fpm"]
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ ! "$(ls -A /var/www/storage)" ]; then
|
||||
echo "Initializing storage directory..."
|
||||
cp -R /var/www/storage-init/. /var/www/storage
|
||||
chown -R www-data:www-data /var/www/storage
|
||||
fi
|
||||
rm -rf /var/www/storage-init
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
exec "$@"
|
||||
+1
-7
@@ -25,11 +25,8 @@ const prettierOptions = {
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [
|
||||
'bootstrap/ssr/**',
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'native/src-tauri/target/**',
|
||||
'native/src-tauri/gen/**',
|
||||
'node_modules/**',
|
||||
'.github/**',
|
||||
'**/*.yml',
|
||||
'**/*.yaml',
|
||||
@@ -40,10 +37,7 @@ const eslintConfig = [
|
||||
'bootstrap/cache/**',
|
||||
'public/build/**',
|
||||
'public/hot',
|
||||
'resources/js/actions/**',
|
||||
'resources/js/components/ui/**',
|
||||
'resources/js/routes/**',
|
||||
'resources/js/wayfinder/**',
|
||||
'resources/views/mail/**',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
export const appleTeamId = 'ZZX2KZGTHU';
|
||||
export const bundleIdentifier = 'de.kanbangit.ios';
|
||||
|
||||
const plistReader = `import base64, datetime, json, plistlib, sys
|
||||
def encode(value):
|
||||
if isinstance(value, bytes): return base64.b64encode(value).decode()
|
||||
if isinstance(value, datetime.datetime): return value.isoformat() + 'Z'
|
||||
raise TypeError(type(value).__name__)
|
||||
print(json.dumps(plistlib.loads(sys.stdin.buffer.read()), default=encode))`;
|
||||
|
||||
function execute(command, args, options) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: options.inherit ? 'inherit' : 'pipe',
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`${options.step} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status ?? result.signal}`}`,
|
||||
);
|
||||
}
|
||||
return result.stdout?.trim() || '';
|
||||
}
|
||||
|
||||
export function validateProfile(profile, mode, now = new Date()) {
|
||||
const entitlements = profile.Entitlements ?? {};
|
||||
if (
|
||||
profile.TeamIdentifier?.length !== 1 ||
|
||||
profile.TeamIdentifier[0] !== appleTeamId ||
|
||||
entitlements['com.apple.developer.team-identifier'] !== appleTeamId ||
|
||||
entitlements['application-identifier'] !==
|
||||
`${appleTeamId}.${bundleIdentifier}`
|
||||
) {
|
||||
throw new Error(
|
||||
`The profile must explicitly match ${bundleIdentifier} in team ${appleTeamId}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(profile.UUID) ||
|
||||
!(new Date(profile.ExpirationDate) > now) ||
|
||||
!profile.DeveloperCertificates?.length ||
|
||||
!profile.Platform?.includes('iOS')
|
||||
) {
|
||||
throw new Error(
|
||||
'The iOS profile is invalid or expired. Download a current profile from your Apple account.',
|
||||
);
|
||||
}
|
||||
if (mode === 'testflight') {
|
||||
if (
|
||||
entitlements['get-task-allow'] !== false ||
|
||||
entitlements['beta-reports-active'] !== true ||
|
||||
'ProvisionedDevices' in profile ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'TestFlight requires an App Store Connect profile, not a development, Ad Hoc or enterprise profile.',
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
entitlements['get-task-allow'] !== true ||
|
||||
!profile.ProvisionedDevices?.length ||
|
||||
profile.ProvisionsAllDevices
|
||||
) {
|
||||
throw new Error(
|
||||
'Device development requires an iOS App Development profile containing your registered device.',
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function selectIdentity(profile, identities, mode) {
|
||||
const fingerprints = profile.DeveloperCertificates.map((certificate) =>
|
||||
createHash('sha1')
|
||||
.update(Buffer.from(certificate, 'base64'))
|
||||
.digest('hex')
|
||||
.toUpperCase(),
|
||||
);
|
||||
const prefix =
|
||||
mode === 'testflight' ? 'Apple Distribution: ' : 'Apple Development: ';
|
||||
const matches = [
|
||||
...identities.matchAll(/\) ([A-Fa-f\d]{40}) "([^"]+)"/g),
|
||||
].filter(
|
||||
([, fingerprint, name]) =>
|
||||
fingerprints.includes(fingerprint.toUpperCase()) &&
|
||||
name.startsWith(prefix),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`The profile must match exactly one usable ${prefix.replace(': ', '')} certificate with its private key in your local keychain. Select a profile containing the intended certificate.`,
|
||||
);
|
||||
}
|
||||
return matches[0][1].toUpperCase();
|
||||
}
|
||||
|
||||
export function buildIos({
|
||||
mode = 'testflight',
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
directory = dirname(fileURLToPath(import.meta.url)),
|
||||
home = homedir(),
|
||||
run = execute,
|
||||
log = console.log,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
if (!['testflight', 'development', 'simulator'].includes(mode)) {
|
||||
throw new Error('Choose --mode testflight, development or simulator.');
|
||||
}
|
||||
if (platform !== 'darwin') {
|
||||
throw new Error(
|
||||
'iOS builds require a Mac with Xcode, XcodeGen and the matching Rust target installed permanently.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
env.APPLE_DEVELOPMENT_TEAM &&
|
||||
env.APPLE_DEVELOPMENT_TEAM !== appleTeamId
|
||||
) {
|
||||
throw new Error(`APPLE_DEVELOPMENT_TEAM must be ${appleTeamId}.`);
|
||||
}
|
||||
if (env.TAURI_CONFIG) {
|
||||
throw new Error(
|
||||
'Remove TAURI_CONFIG overrides before building the configured iOS app.',
|
||||
);
|
||||
}
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const ios = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.ios.conf.json'), 'utf8'),
|
||||
);
|
||||
const version = config.version;
|
||||
const buildNumber = ios.bundle?.iOS?.bundleVersion;
|
||||
if (
|
||||
ios.identifier !== bundleIdentifier ||
|
||||
ios.bundle?.iOS?.developmentTeam !== appleTeamId ||
|
||||
config.identifier !== 'de.kanbangit.desktop' ||
|
||||
config.productName !== 'KanbanGit' ||
|
||||
!/^\d+\.\d+\.\d+$/.test(version) ||
|
||||
!/^[1-9]\d*$/.test(buildNumber)
|
||||
) {
|
||||
throw new Error(
|
||||
'Check the iOS team, bundle identifier, version and positive integer bundleVersion in the Tauri configuration.',
|
||||
);
|
||||
}
|
||||
const simulator = mode === 'simulator';
|
||||
const configuration = mode === 'testflight' ? 'release' : 'debug';
|
||||
const target = simulator ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
|
||||
const sdk = simulator ? 'iphonesimulator' : 'iphoneos';
|
||||
const project = join(directory, 'src-tauri/gen/apple');
|
||||
const targetDirectory = join(directory, 'src-tauri/target');
|
||||
const outputRoot = join(targetDirectory, `ios-${mode}`);
|
||||
const destination = join(outputRoot, `${version}-${buildNumber}`);
|
||||
if (mode === 'testflight' && existsSync(destination)) {
|
||||
throw new Error(
|
||||
`Build output already exists: ${destination}. Move it aside, or increment bundle.iOS.bundleVersion for a new TestFlight upload.`,
|
||||
);
|
||||
}
|
||||
const buildEnv = Object.fromEntries(
|
||||
Object.entries(env).filter(
|
||||
([key]) =>
|
||||
!key.startsWith('APPLE_') &&
|
||||
!key.startsWith('IOS_') &&
|
||||
!key.startsWith('TAURI_') &&
|
||||
![
|
||||
'SDKROOT',
|
||||
'CARGO_ENCODED_RUSTFLAGS',
|
||||
'RUSTFLAGS',
|
||||
'RUSTC_WRAPPER',
|
||||
'RUSTC_WORKSPACE_WRAPPER',
|
||||
'XCODE_XCCONFIG_FILE',
|
||||
].includes(key),
|
||||
),
|
||||
);
|
||||
function step(label, command, args, options = {}) {
|
||||
log(label);
|
||||
return run(command, args, {
|
||||
cwd: directory,
|
||||
env: buildEnv,
|
||||
step: label,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
function parsePlist(xml) {
|
||||
return JSON.parse(
|
||||
step(
|
||||
'Reading local plist metadata',
|
||||
'python3',
|
||||
['-c', plistReader],
|
||||
{ input: xml },
|
||||
),
|
||||
);
|
||||
}
|
||||
function readProfile(path) {
|
||||
return validateProfile(
|
||||
parsePlist(
|
||||
step('Reading provisioning profile', 'security', [
|
||||
'cms',
|
||||
'-D',
|
||||
'-i',
|
||||
path,
|
||||
]),
|
||||
),
|
||||
mode,
|
||||
now,
|
||||
);
|
||||
}
|
||||
step(
|
||||
'Checking XcodeGen (install permanently with brew install xcodegen if missing)',
|
||||
'xcodegen',
|
||||
['--version'],
|
||||
);
|
||||
step('Checking Xcode', 'xcodebuild', ['-version']);
|
||||
const installedTargets = step('Checking installed Rust targets', 'rustup', [
|
||||
'target',
|
||||
'list',
|
||||
'--installed',
|
||||
]);
|
||||
if (!installedTargets.split(/\s+/).includes(target)) {
|
||||
throw new Error(
|
||||
`Install the missing target permanently with rustup target add ${target}, then retry.`,
|
||||
);
|
||||
}
|
||||
const cargo = step('Selecting the rustup Cargo toolchain', 'rustup', [
|
||||
'which',
|
||||
'cargo',
|
||||
]);
|
||||
buildEnv.RUSTC = step('Selecting the matching Rust compiler', 'rustup', [
|
||||
'which',
|
||||
'rustc',
|
||||
]);
|
||||
buildEnv.PATH = `${dirname(cargo)}:${buildEnv.PATH ?? ''}`;
|
||||
buildEnv.SDKROOT = step('Checking the iOS SDK', 'xcrun', [
|
||||
'--sdk',
|
||||
sdk,
|
||||
'--show-sdk-path',
|
||||
]);
|
||||
buildEnv.IPHONEOS_DEPLOYMENT_TARGET = ios.bundle.iOS.minimumSystemVersion;
|
||||
buildEnv.CARGO_TARGET_DIR = targetDirectory;
|
||||
let profile;
|
||||
let profilePath;
|
||||
let identity;
|
||||
if (!simulator) {
|
||||
if (
|
||||
!env.IOS_PROVISIONING_PROFILE ||
|
||||
!existsSync(env.IOS_PROVISIONING_PROFILE)
|
||||
) {
|
||||
throw new Error(
|
||||
'Set IOS_PROVISIONING_PROFILE to your downloaded .mobileprovision file outside the repository. Apple account setup is manual; this command never creates or downloads profiles.',
|
||||
);
|
||||
}
|
||||
profilePath = realpathSync(env.IOS_PROVISIONING_PROFILE);
|
||||
const withinProject = relative(
|
||||
realpathSync(join(directory, '..')),
|
||||
profilePath,
|
||||
);
|
||||
if (
|
||||
!withinProject.startsWith(
|
||||
`..${process.platform === 'win32' ? '\\' : '/'}`,
|
||||
) &&
|
||||
!isAbsolute(withinProject)
|
||||
) {
|
||||
throw new Error(
|
||||
'Keep provisioning profiles outside the repository.',
|
||||
);
|
||||
}
|
||||
profile = readProfile(profilePath);
|
||||
identity = selectIdentity(
|
||||
profile,
|
||||
step('Checking local signing identities', 'security', [
|
||||
'find-identity',
|
||||
'-v',
|
||||
'-p',
|
||||
'codesigning',
|
||||
]),
|
||||
mode,
|
||||
);
|
||||
}
|
||||
mkdirSync(outputRoot, { recursive: true });
|
||||
const working = mkdtempSync(join(outputRoot, '.building-'));
|
||||
let installedProfile;
|
||||
try {
|
||||
if (profile) {
|
||||
const installedPath = join(
|
||||
home,
|
||||
'Library/Developer/Xcode/UserData/Provisioning Profiles',
|
||||
`${profile.UUID}.mobileprovision`,
|
||||
);
|
||||
mkdirSync(dirname(installedPath), { recursive: true });
|
||||
if (existsSync(installedPath)) {
|
||||
if (
|
||||
!readFileSync(installedPath).equals(
|
||||
readFileSync(profilePath),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'A different provisioning profile already exists with the same UUID. Resolve it in Xcode before retrying.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
copyFileSync(profilePath, installedPath);
|
||||
installedProfile = installedPath;
|
||||
}
|
||||
}
|
||||
step('Generating the Xcode project', 'xcodegen', [
|
||||
'generate',
|
||||
'--spec',
|
||||
join(project, 'project.yml'),
|
||||
]);
|
||||
step(
|
||||
'Building the iOS Rust library',
|
||||
cargo,
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'--lib',
|
||||
'--locked',
|
||||
'--target',
|
||||
target,
|
||||
'--features',
|
||||
'custom-protocol',
|
||||
...(configuration === 'release' ? ['--release'] : []),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const library = join(
|
||||
targetDirectory,
|
||||
target,
|
||||
configuration,
|
||||
'libkanbangit_native.a',
|
||||
);
|
||||
if (!existsSync(library)) {
|
||||
throw new Error(
|
||||
`The iOS Rust library was not produced: ${library}`,
|
||||
);
|
||||
}
|
||||
buildEnv.KANBANGIT_IOS_LIBRARY = library;
|
||||
const archive = join(working, 'KanbanGit.xcarchive');
|
||||
step(
|
||||
'Archiving locally without Apple provisioning updates',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-project',
|
||||
join(project, 'kanbangit-native.xcodeproj'),
|
||||
'-scheme',
|
||||
'kanbangit-native_iOS',
|
||||
'-configuration',
|
||||
configuration,
|
||||
'-sdk',
|
||||
sdk,
|
||||
'-destination',
|
||||
`generic/platform=${simulator ? 'iOS Simulator' : 'iOS'}`,
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-derivedDataPath',
|
||||
join(targetDirectory, `ios-derived-${mode}`),
|
||||
'archive',
|
||||
`MARKETING_VERSION=${version}`,
|
||||
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
||||
`DEVELOPMENT_TEAM=${appleTeamId}`,
|
||||
`PRODUCT_BUNDLE_IDENTIFIER=${bundleIdentifier}`,
|
||||
'CODE_SIGN_STYLE=Manual',
|
||||
'ARCHS=arm64',
|
||||
...(simulator
|
||||
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO']
|
||||
: [
|
||||
`CODE_SIGN_IDENTITY=${identity}`,
|
||||
`PROVISIONING_PROFILE_SPECIFIER=${profile.UUID}`,
|
||||
]),
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
function verifyApp(app) {
|
||||
const info = parsePlist(readFileSync(join(app, 'Info.plist')));
|
||||
if (
|
||||
info.CFBundleIdentifier !== bundleIdentifier ||
|
||||
info.CFBundleShortVersionString !== version ||
|
||||
info.CFBundleVersion !== buildNumber
|
||||
) {
|
||||
throw new Error(
|
||||
'The built app has an unexpected identifier, version or build number.',
|
||||
);
|
||||
}
|
||||
if (!simulator) {
|
||||
step('Verifying the Apple signature and team', 'codesign', [
|
||||
'--verify',
|
||||
'--deep',
|
||||
'--strict',
|
||||
'-R',
|
||||
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf = H"${identity}" and entitlement["application-identifier"] = "${appleTeamId}.${bundleIdentifier}" and entitlement["com.apple.developer.team-identifier"] = "${appleTeamId}" and ${mode === 'testflight' ? '! ' : ''}entitlement["get-task-allow"] exists${mode === 'testflight' ? ' and entitlement["beta-reports-active"] exists' : ''}`,
|
||||
app,
|
||||
]);
|
||||
if (
|
||||
readProfile(join(app, 'embedded.mobileprovision')).UUID !==
|
||||
profile.UUID
|
||||
) {
|
||||
throw new Error(
|
||||
'The app was signed with an unexpected provisioning profile.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
verifyApp(join(archive, 'Products/Applications/KanbanGit.app'));
|
||||
if (!simulator) {
|
||||
const exportOptions = join(working, 'ExportOptions.plist');
|
||||
writeFileSync(
|
||||
exportOptions,
|
||||
JSON.stringify({
|
||||
method:
|
||||
mode === 'testflight'
|
||||
? 'app-store-connect'
|
||||
: 'debugging',
|
||||
destination: 'export',
|
||||
signingStyle: 'manual',
|
||||
teamID: appleTeamId,
|
||||
signingCertificate: identity,
|
||||
provisioningProfiles: { [bundleIdentifier]: profile.UUID },
|
||||
manageAppVersionAndBuildNumber: false,
|
||||
}),
|
||||
);
|
||||
step('Preparing local export options', 'plutil', [
|
||||
'-convert',
|
||||
'xml1',
|
||||
exportOptions,
|
||||
]);
|
||||
step(
|
||||
'Exporting the signed IPA locally (no upload)',
|
||||
'xcodebuild',
|
||||
[
|
||||
'-exportArchive',
|
||||
'-archivePath',
|
||||
archive,
|
||||
'-exportPath',
|
||||
join(working, 'export'),
|
||||
'-exportOptionsPlist',
|
||||
exportOptions,
|
||||
],
|
||||
{ inherit: true },
|
||||
);
|
||||
const ipa = join(working, 'export/KanbanGit.ipa');
|
||||
if (!existsSync(ipa)) {
|
||||
throw new Error('Xcode did not produce the signed IPA.');
|
||||
}
|
||||
const extracted = join(working, 'verify');
|
||||
step('Verifying the exported IPA contents', 'ditto', [
|
||||
'-x',
|
||||
'-k',
|
||||
ipa,
|
||||
extracted,
|
||||
]);
|
||||
verifyApp(join(extracted, 'Payload/KanbanGit.app'));
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
writeFileSync(
|
||||
`${ipa}.sha256`,
|
||||
`${createHash('sha256').update(readFileSync(ipa)).digest('hex')} KanbanGit.ipa\n`,
|
||||
);
|
||||
}
|
||||
if (mode !== 'testflight') {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
}
|
||||
renameSync(working, destination);
|
||||
log(`Verified local ${mode} build: ${destination}`);
|
||||
return destination;
|
||||
} catch (error) {
|
||||
log(`Build did not complete. Diagnostic output remains in ${working}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (installedProfile) {
|
||||
rmSync(installedProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: { mode: { type: 'string', default: 'testflight' } },
|
||||
});
|
||||
buildIos({ mode: values.mode });
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
copyFileSync,
|
||||
readlinkSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { updateSource } from './update-manifest.mjs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
bundleIdentifier,
|
||||
verifySignedMacosApp,
|
||||
} from './release-macos.mjs';
|
||||
|
||||
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function releaseMetadata(
|
||||
env = process.env,
|
||||
directory = nativeDirectory,
|
||||
) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const nativePackage = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||
);
|
||||
const packageVersion = nativePackage.version;
|
||||
const cargoVersion = readFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'utf8',
|
||||
)
|
||||
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
|
||||
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (
|
||||
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
|
||||
config.version !== packageVersion ||
|
||||
config.version !== cargoVersion
|
||||
) {
|
||||
throw new Error(
|
||||
'Tauri, package.json and Cargo.toml must have the same stable release version.',
|
||||
);
|
||||
}
|
||||
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
|
||||
throw new Error(
|
||||
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
config.identifier !== bundleIdentifier ||
|
||||
config.productName !== 'KanbanGit'
|
||||
) {
|
||||
throw new Error(
|
||||
'The macOS product name and bundle identifier must stay unchanged.',
|
||||
);
|
||||
}
|
||||
if (config.plugins?.updater?.pubkey !== updateSource.publicKey) {
|
||||
throw new Error(
|
||||
'The app updater key must match the permanent release key.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!/^\d+(?:\.\d+){0,2}$/.test(config.bundle?.macOS?.bundleVersion || '')
|
||||
) {
|
||||
throw new Error(
|
||||
'Set a numeric macOS bundleVersion for the About build number.',
|
||||
);
|
||||
}
|
||||
if (!nativePackage.releaseNotes?.trim()) {
|
||||
throw new Error(
|
||||
'Add releaseNotes to native/package.json before publishing.',
|
||||
);
|
||||
}
|
||||
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||
const arch = {
|
||||
'aarch64-apple-darwin': 'arm64',
|
||||
'x86_64-apple-darwin': 'x86_64',
|
||||
}[target];
|
||||
if (!arch) {
|
||||
throw new Error(
|
||||
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
|
||||
);
|
||||
}
|
||||
const identity = env.APPLE_SIGNING_IDENTITY;
|
||||
if (
|
||||
!identity?.startsWith('Developer ID Application: ') ||
|
||||
!identity.endsWith(` (${appleTeamId})`) ||
|
||||
/[\r\n"]/.test(identity)
|
||||
) {
|
||||
throw new Error(
|
||||
`A Developer ID Application identity for ${appleTeamId} is required.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: config.version,
|
||||
notes: nativePackage.releaseNotes,
|
||||
target,
|
||||
arch,
|
||||
filename: `KanbanGit-${config.version}-${target}.dmg`,
|
||||
updaterFilename: `KanbanGit-${config.version}-${target}.app.tar.gz`,
|
||||
};
|
||||
}
|
||||
|
||||
export function execute(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
|
||||
);
|
||||
}
|
||||
return result.stdout?.trim() || result.stderr?.trim() || '';
|
||||
}
|
||||
|
||||
export function verifyNotarizedApp(app, metadata, run = execute) {
|
||||
verifySignedMacosApp({
|
||||
app,
|
||||
version: metadata.version,
|
||||
step: (_label, command, args) => run(command, args),
|
||||
});
|
||||
const arch = run('lipo', [
|
||||
'-archs',
|
||||
join(app, 'Contents/MacOS/kanbangit-native'),
|
||||
]).trim();
|
||||
if (arch !== metadata.arch) {
|
||||
throw new Error(
|
||||
'The app architecture does not match its download path.',
|
||||
);
|
||||
}
|
||||
run('xcrun', ['stapler', 'validate', app]);
|
||||
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
|
||||
}
|
||||
|
||||
export function packageNotarizedApp({
|
||||
env = process.env,
|
||||
directory = nativeDirectory,
|
||||
run = execute,
|
||||
} = {}) {
|
||||
const metadata = releaseMetadata(env, directory);
|
||||
const root = join(directory, 'src-tauri/target');
|
||||
const app = join(
|
||||
root,
|
||||
metadata.target,
|
||||
'release/bundle/macos/KanbanGit.app',
|
||||
);
|
||||
verifyNotarizedApp(app, metadata, run);
|
||||
const releaseRoot = join(root, 'macos-release');
|
||||
const destination = join(
|
||||
releaseRoot,
|
||||
`${metadata.version}-${metadata.target}`,
|
||||
);
|
||||
if (existsSync(destination)) {
|
||||
throw new Error(
|
||||
'Release output already exists; start from a clean checkout.',
|
||||
);
|
||||
}
|
||||
mkdirSync(releaseRoot, { recursive: true });
|
||||
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||
try {
|
||||
const archive = join(staging, metadata.filename);
|
||||
const bundle = join(root, metadata.target, 'release/bundle');
|
||||
copyFileSync(
|
||||
join(
|
||||
bundle,
|
||||
'dmg',
|
||||
`KanbanGit_${metadata.version}_${metadata.arch === 'arm64' ? 'aarch64' : 'x64'}.dmg`,
|
||||
),
|
||||
archive,
|
||||
);
|
||||
const mounted = join(staging, 'mounted');
|
||||
mkdirSync(mounted);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
run('hdiutil', [
|
||||
'attach',
|
||||
archive,
|
||||
'-readonly',
|
||||
'-nobrowse',
|
||||
'-mountpoint',
|
||||
mounted,
|
||||
]);
|
||||
try {
|
||||
if (
|
||||
readlinkSync(join(mounted, 'Applications')) !== '/Applications'
|
||||
) {
|
||||
throw new Error(
|
||||
'DMG must contain the Applications installation link.',
|
||||
);
|
||||
}
|
||||
verifyNotarizedApp(join(mounted, 'KanbanGit.app'), metadata, run);
|
||||
} finally {
|
||||
run('hdiutil', ['detach', mounted]);
|
||||
}
|
||||
rmSync(mounted, { recursive: true, force: true });
|
||||
const updateArchive = join(staging, metadata.updaterFilename);
|
||||
copyFileSync(join(bundle, 'macos/KanbanGit.app.tar.gz'), updateArchive);
|
||||
copyFileSync(
|
||||
join(bundle, 'macos/KanbanGit.app.tar.gz.sig'),
|
||||
`${updateArchive}.sig`,
|
||||
);
|
||||
if (!readFileSync(`${updateArchive}.sig`, 'utf8').trim()) {
|
||||
throw new Error('The updater signature is missing.');
|
||||
}
|
||||
const extracted = join(staging, 'extracted');
|
||||
mkdirSync(extracted);
|
||||
run('tar', ['-xzf', updateArchive, '-C', extracted]);
|
||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
for (const name of [metadata.filename, metadata.updaterFilename]) {
|
||||
const hash = createHash('sha256')
|
||||
.update(readFileSync(join(staging, name)))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
join(staging, `${name}.sha256`),
|
||||
`${hash} ${name}\n`,
|
||||
);
|
||||
}
|
||||
renameSync(staging, destination);
|
||||
return { ...metadata, directory: destination, checksum };
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
try {
|
||||
let output;
|
||||
if (process.argv[2] === 'metadata') {
|
||||
output = releaseMetadata();
|
||||
} else if (process.argv[2] === 'package') {
|
||||
output = packageNotarizedApp();
|
||||
} else {
|
||||
throw new Error(
|
||||
'Use metadata or package. Building and notarizing are handled by tauri-action.',
|
||||
);
|
||||
}
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries(output)
|
||||
.filter(([key]) => key !== 'notes')
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(output));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export function createDiskImage({ app, archive, identity, run }) {
|
||||
const contents = join(dirname(archive), 'dmg-contents');
|
||||
mkdirSync(contents);
|
||||
try {
|
||||
run('ditto', [app, join(contents, 'KanbanGit.app')]);
|
||||
symlinkSync('/Applications', join(contents, 'Applications'));
|
||||
run('hdiutil', [
|
||||
'create',
|
||||
'-volname',
|
||||
'KanbanGit',
|
||||
'-srcfolder',
|
||||
contents,
|
||||
'-format',
|
||||
'UDZO',
|
||||
archive,
|
||||
]);
|
||||
run('codesign', ['--sign', identity, '--timestamp', archive]);
|
||||
run('hdiutil', ['verify', archive]);
|
||||
} finally {
|
||||
rmSync(contents, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:macos": "tauri build --debug --bundles app",
|
||||
"release:macos": "node release-macos.mjs",
|
||||
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
|
||||
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
|
||||
"dev:android": "tauri android dev",
|
||||
"build:android": "tauri android build --debug --apk --target aarch64",
|
||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||
"dev:ios": "tauri ios dev",
|
||||
"build:ios:simulator": "node build-ios.mjs --mode simulator",
|
||||
"build:ios:device": "node build-ios.mjs --mode development",
|
||||
"release:ios": "node build-ios.mjs --mode testflight",
|
||||
"open:ios": "tauri ios dev --open --no-dev-server",
|
||||
"prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml",
|
||||
"test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test",
|
||||
"smoke:updater": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test updater_smoke"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
},
|
||||
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user