diff --git a/.cursor/rules/08-symfony-services.mdc b/.cursor/rules/08-symfony-services.mdc new file mode 100644 index 0000000..799512a --- /dev/null +++ b/.cursor/rules/08-symfony-services.mdc @@ -0,0 +1,29 @@ +--- +description: "Symfony service container, autowiring, config/services.yaml. Use when adding or changing services, interfaces, or DI configuration." +globs: ["config/services.yaml", "config/**/services*.yaml", "src/**/*.php"] +alwaysApply: false +--- + +# Symfony Services and Autowiring + +**Reference**: See [Symfony Autowiring](https://symfony.com/doc/current/service_container/autowiring.html). + +## Interface Bindings + +**Do not add explicit interface → implementation bindings in `config/services.yaml` when:** + +- The interface has **exactly one** concrete implementation in the application, and +- That implementation is in the autowired resource path (e.g. `App\` → `../src/`). + +Symfony's autowiring will automatically inject the single implementation when a type hint asks for the interface. Adding a redundant `InterfaceName: class: ImplementationName` entry is unnecessary and should be avoided. + +**Add explicit configuration only when:** + +- The implementation lives outside the autowired resource (e.g. in a third-party bundle or vendor), or +- You need to pass specific constructor arguments, or +- There are multiple implementations and you must choose one (or use tags/compiler passes), or +- You need an alias to a decorator or a specific service id. + +## When in Doubt + +Prefer omitting the binding and relying on autowiring; if the container fails with "multiple implementations" or "no service for interface", then add the minimal explicit configuration needed. diff --git a/config/services.yaml b/config/services.yaml index a00a5bf..1e60b1b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -133,10 +133,6 @@ services: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface: class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade - # Domain service bindings - App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface: - class: App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard - # Infrastructure adapter bindings App\WorkspaceMgmt\Infrastructure\Adapter\GitAdapterInterface: class: App\WorkspaceMgmt\Infrastructure\Adapter\GitCliAdapter diff --git a/docs/plans/project-workspace-conversation-workflow-high-level.md b/docs/plans/project-workspace-conversation-workflow-high-level.md index cf98acc..88da0e4 100644 --- a/docs/plans/project-workspace-conversation-workflow-high-level.md +++ b/docs/plans/project-workspace-conversation-workflow-high-level.md @@ -43,7 +43,7 @@ When a user wants to start a conversation on a project, and there is a workspace - The workspace is set into status IN_SETUP - The local workspace folder is removed - The git repository mapped to the project that is mapped to the workspace is cloned into the local workspace folder using the github token of the project -- A new branch, whose name has the format `-`, is created from the main branch +- A new branch, whose name has the format `-usermailATdomainDOTtld-SHORTWORKSPACEID` (timestamp with underscore/hyphens for URL/shell safety, sanitized user email, short workspace ID), is created from the main branch - The workspace is set into status AVAILABLE_FOR_CONVERSATION When a user wants to start a conversation on a project, and succeeds to do so because its workspace is in status AVAILABLE_FOR_CONVERSATION, then diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index 08c7f31..67762d0 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -147,7 +147,7 @@ public function startConversation( try { // Dispatch async setup if needed - this will start setup in background - $workspace = $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId); + $workspace = $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId, $accountInfo->email); // If setup was dispatched (workspace is now IN_SETUP), show waiting page if ($workspace->status === WorkspaceStatus::IN_SETUP) { diff --git a/src/Common/Presentation/Resources/templates/base_appshell.html.twig b/src/Common/Presentation/Resources/templates/base_appshell.html.twig index acdd6c2..c04fc0b 100644 --- a/src/Common/Presentation/Resources/templates/base_appshell.html.twig +++ b/src/Common/Presentation/Resources/templates/base_appshell.html.twig @@ -61,4 +61,12 @@
{% block content %}{% endblock %}
+
+

+ Sponsored by + + JOBOO! GmbH + +

+
{% endblock %} diff --git a/src/Organization/Domain/SymfonyEventSubscriber/AccountCoreCreatedSymfonyEventSubscriber.php b/src/Organization/Domain/SymfonyEventSubscriber/AccountCoreCreatedSymfonyEventSubscriber.php index de817fe..2eb537f 100644 --- a/src/Organization/Domain/SymfonyEventSubscriber/AccountCoreCreatedSymfonyEventSubscriber.php +++ b/src/Organization/Domain/SymfonyEventSubscriber/AccountCoreCreatedSymfonyEventSubscriber.php @@ -4,6 +4,7 @@ namespace App\Organization\Domain\SymfonyEventSubscriber; +use App\Account\Facade\AccountFacadeInterface; use App\Account\Facade\SymfonyEvent\AccountCoreCreatedSymfonyEvent; use App\Organization\Domain\Service\OrganizationDomainServiceInterface; use App\Organization\Facade\SymfonyEvent\CurrentlyActiveOrganizationChangedSymfonyEvent; @@ -12,6 +13,7 @@ use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Exception; use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Throwable; @@ -25,7 +27,8 @@ public function __construct( private PrefabFacadeInterface $prefabFacade, private ProjectMgmtFacadeInterface $projectMgmtFacade, private WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, - private LoggerInterface $logger + private AccountFacadeInterface $accountFacade, + private LoggerInterface $logger, ) { } @@ -46,11 +49,18 @@ public function handle( ) ); + $accountInfo = $this->accountFacade->getAccountInfoById($event->accountCoreId); + if ($accountInfo === null) { + throw new RuntimeException( + 'Account not found for workspace setup; accountCoreId: ' . $event->accountCoreId + ); + } + $prefabs = $this->prefabFacade->loadPrefabs(); foreach ($prefabs as $prefab) { try { $projectId = $this->projectMgmtFacade->createProjectFromPrefab($organization->getId(), $prefab); - $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId); + $this->workspaceMgmtFacade->dispatchSetupIfNeeded($projectId, $accountInfo->email); } catch (Throwable $e) { $this->logger->warning('Prefab project creation failed', [ 'organization_id' => $organization->getId(), diff --git a/src/WorkspaceMgmt/Domain/Service/BranchNameGenerator.php b/src/WorkspaceMgmt/Domain/Service/BranchNameGenerator.php new file mode 100644 index 0000000..0b449ac --- /dev/null +++ b/src/WorkspaceMgmt/Domain/Service/BranchNameGenerator.php @@ -0,0 +1,39 @@ +-usermailATdomainDOTtld-SHORTWORKSPACEID + * (underscore and hyphens only in timestamp for URL/shell safety) + */ +final class BranchNameGenerator implements BranchNameGeneratorInterface +{ + public function generate(string $workspaceId, string $userEmail): string + { + $timestamp = DateAndTimeService::getDateTimeImmutable()->format('Y-m-d_H-i-s'); + $sanitized = $this->sanitizeEmailForBranchName($userEmail); + $shortId = mb_substr($workspaceId, 0, 8); + + return $timestamp . '-' . $sanitized . '-' . $shortId; + } + + /** + * Sanitize email for use in branch name: @ → AT, . → DOT, lowercase. + */ + public function sanitizeEmailForBranchName(string $email): string + { + $lower = mb_strtolower($email, 'UTF-8'); + + return str_replace(['@', '.'], ['AT', 'DOT'], $lower); + } +} diff --git a/src/WorkspaceMgmt/Domain/Service/BranchNameGeneratorInterface.php b/src/WorkspaceMgmt/Domain/Service/BranchNameGeneratorInterface.php new file mode 100644 index 0000000..472fce2 --- /dev/null +++ b/src/WorkspaceMgmt/Domain/Service/BranchNameGeneratorInterface.php @@ -0,0 +1,16 @@ +getId(); if ($workspaceId === null) { @@ -77,7 +77,7 @@ public function setup(Workspace $workspace): void } try { - $this->performSetup($workspace); + $this->performSetup($workspace, $userEmail); // Transition to AVAILABLE_FOR_CONVERSATION $workspace->setStatus(WorkspaceStatus::AVAILABLE_FOR_CONVERSATION); @@ -109,7 +109,7 @@ public function getWorkspacePath(Workspace $workspace): string return $this->workspaceRoot . '/' . $workspace->getId(); } - private function performSetup(Workspace $workspace): void + private function performSetup(Workspace $workspace, string $userEmail): void { $workspaceId = $workspace->getId(); $workspacePath = $this->getWorkspacePath($workspace); @@ -133,7 +133,7 @@ private function performSetup(Workspace $workspace): void $this->gitAdapter->clone($projectInfo->gitUrl, $workspacePath, $projectInfo->githubToken); // Step 3: Generate branch name and checkout - $branchName = $this->generateBranchName($workspaceId); + $branchName = $this->branchNameGenerator->generate($workspaceId, $userEmail); $this->logger->debug('Creating branch', ['branchName' => $branchName]); $this->gitAdapter->checkoutNewBranch($workspacePath, $branchName); @@ -168,12 +168,4 @@ private function runProjectSetupSteps(ProjectInfoDto $projectInfo, string $works 'projectType' => $projectInfo->projectType->value, ]); } - - private function generateBranchName(string $workspaceId): string - { - $shortId = mb_substr($workspaceId, 0, 8); - $timestamp = DateAndTimeService::getDateTimeImmutable()->format('Ymd-His'); - - return 'ws-' . $shortId . '-' . $timestamp; - } } diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php index cc4de39..b80baae 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php @@ -66,7 +66,7 @@ public function getWorkspaceForProject(string $projectId): ?WorkspaceInfoDto return $this->toDto($workspace); } - public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto + public function dispatchSetupIfNeeded(string $projectId, string $userEmail): WorkspaceInfoDto { // Use transaction to prevent race conditions when creating workspace $this->entityManager->beginTransaction(); @@ -94,7 +94,7 @@ public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto $this->entityManager->flush(); // Dispatch async setup message - $this->messageBus->dispatch(new SetupWorkspaceMessage($workspaceId)); + $this->messageBus->dispatch(new SetupWorkspaceMessage($workspaceId, $userEmail)); } $this->entityManager->commit(); diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php index d4a3999..23ee01b 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php @@ -27,9 +27,12 @@ public function getWorkspaceForProject(string $projectId): ?WorkspaceInfoDto; * Returns immediately with workspace info. Check workspace status * to determine if setup is in progress. * + * @param string $projectId the project ID + * @param string $userEmail email of the user triggering setup (used for human-friendly branch name) + * * @return WorkspaceInfoDto workspace info (status may be IN_SETUP if setup was dispatched) */ - public function dispatchSetupIfNeeded(string $projectId): WorkspaceInfoDto; + public function dispatchSetupIfNeeded(string $projectId, string $userEmail): WorkspaceInfoDto; /** * Transition workspace to IN_CONVERSATION status. diff --git a/src/WorkspaceMgmt/Infrastructure/Handler/SetupWorkspaceHandler.php b/src/WorkspaceMgmt/Infrastructure/Handler/SetupWorkspaceHandler.php index f83e0e0..905604d 100644 --- a/src/WorkspaceMgmt/Infrastructure/Handler/SetupWorkspaceHandler.php +++ b/src/WorkspaceMgmt/Infrastructure/Handler/SetupWorkspaceHandler.php @@ -43,7 +43,7 @@ public function __invoke(SetupWorkspaceMessage $message): void 'workspaceId' => $message->workspaceId, ]); - $this->setupService->setup($workspace); + $this->setupService->setup($workspace, $message->userEmail); $this->logger->info('Async workspace setup completed', [ 'workspaceId' => $message->workspaceId, diff --git a/src/WorkspaceMgmt/Infrastructure/Message/SetupWorkspaceMessage.php b/src/WorkspaceMgmt/Infrastructure/Message/SetupWorkspaceMessage.php index f5e53e0..0892b59 100644 --- a/src/WorkspaceMgmt/Infrastructure/Message/SetupWorkspaceMessage.php +++ b/src/WorkspaceMgmt/Infrastructure/Message/SetupWorkspaceMessage.php @@ -13,7 +13,8 @@ readonly class SetupWorkspaceMessage implements ImmediateSymfonyMessageInterface { public function __construct( - public string $workspaceId + public string $workspaceId, + public string $userEmail, ) { } } diff --git a/tests/Unit/WorkspaceMgmt/BranchNameGeneratorTest.php b/tests/Unit/WorkspaceMgmt/BranchNameGeneratorTest.php new file mode 100644 index 0000000..71087b4 --- /dev/null +++ b/tests/Unit/WorkspaceMgmt/BranchNameGeneratorTest.php @@ -0,0 +1,77 @@ +generate($workspaceId, $userEmail); + + // Timestamp: YYYY-MM-DD_HH-MM-SS (no space, no colons) + expect($result)->toMatch('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-/'); + // Sanitized email segment + expect($result)->toContain('-userATexampleDOTcom-'); + // Short workspace ID (first 8 chars) + expect($result)->toEndWith('-aaaaaaaa'); + }); + + it('uses first 8 characters of workspace ID as short ID', function (): void { + $generator = new BranchNameGenerator(); + $workspaceId = 'deadbeef-1234-5678-abcd-ffffffffffff'; + $userEmail = 'a@b.co'; + + $result = $generator->generate($workspaceId, $userEmail); + + expect($result)->toEndWith('-deadbeef'); + }); + + it('matches full format regex and contains sanitized email and short workspace id', function (): void { + $generator = new BranchNameGenerator(); + $workspaceId = '12345678-aaaa-bbbb-cccc-dddddddddddd'; + $userEmail = 'foo@bar.baz'; + + $result = $generator->generate($workspaceId, $userEmail); + + // Full format: YYYY-MM-DD_HH-MM-SS-sanitizedEmail-shortId (shortId = 8 chars, URL/shell safe) + expect($result)->toMatch('/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-fooATbarDOTbaz-12345678$/'); + }); + }); + + describe('sanitizeEmailForBranchName', function (): void { + it('replaces @ with AT and . with DOT', function (): void { + $generator = new BranchNameGenerator(); + + expect($generator->sanitizeEmailForBranchName('user@example.com')) + ->toBe('userATexampleDOTcom'); + }); + + it('normalizes email to lowercase', function (): void { + $generator = new BranchNameGenerator(); + + expect($generator->sanitizeEmailForBranchName('User@Example.COM')) + ->toBe('userATexampleDOTcom'); + }); + + it('handles multiple dots in domain', function (): void { + $generator = new BranchNameGenerator(); + + expect($generator->sanitizeEmailForBranchName('a@b.c.d')) + ->toBe('aATbDOTcDOTd'); + }); + + it('handles subdomain-style email', function (): void { + $generator = new BranchNameGenerator(); + + expect($generator->sanitizeEmailForBranchName('manuel@kiessling.net')) + ->toBe('manuelATkiesslingDOTnet'); + }); + }); +});