From d77f4a099b57c90156a173623857aabb7375b500 Mon Sep 17 00:00:00 2001 From: martinyde Date: Wed, 27 May 2026 13:14:37 +0200 Subject: [PATCH 1/8] Added codeowner and project entities --- CHANGELOG.md | 14 +- migrations/Version20260527103011.php | 51 +++++ src/Command/SyncServiceAgreementsCommand.php | 12 +- .../Admin/SecurityContractCrudController.php | 29 +-- src/Entity/CodeOwner.php | 84 +++++++ src/Entity/Project.php | 184 +++++++++++++++ src/Entity/SecurityContract.php | 132 ++--------- src/Repository/CodeOwnerRepository.php | 25 +++ src/Repository/ProjectRepository.php | 25 +++ src/Service/ServiceAgreementSyncService.php | 211 +++++++++++++----- 10 files changed, 589 insertions(+), 178 deletions(-) create mode 100644 migrations/Version20260527103011.php create mode 100644 src/Entity/CodeOwner.php create mode 100644 src/Entity/Project.php create mode 100644 src/Repository/CodeOwnerRepository.php create mode 100644 src/Repository/ProjectRepository.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c21c572..0d9031d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- [#80](https://github.com/itk-dev/devops_itksites/pull/80) 5566: Service agreements +- Adapt `/api/serviceagreements` consumer to new payload shape + - Add `Project` entity (top-level Economics project) with relations to + `CodeOwner` (ManyToMany), `GitRepo` (ManyToMany, matched by repo name), + and `SecurityContract` (OneToMany) + - Add `CodeOwner` entity (economicsId/name/email) + - Slim `SecurityContract` to the nested-agreement fields; drop + `projectName`, `clientName`, `leantimeUrl`, `projectTrackerKey`, + `gitRepos`, `quarterlyHours`, `cybersecurityPrice`, `cybersecurityNote`; + add ManyToOne to `Project` + - Rewrite `ServiceAgreementSyncService` to upsert projects, codeowners, + and contracts in one pass; warn when `githubRepos` names cannot be + linked to existing `GitRepo` rows +- [#80](https://github.com/itk-dev/devops_itksites/pull/80 ) 5566: Service agreements - Add security contract entity with crud controller - Add Abstract full crud controller and extend on it in some cases - Add economics service and sync action/command for service agreement synchronization diff --git a/migrations/Version20260527103011.php b/migrations/Version20260527103011.php new file mode 100644 index 0000000..b1f8b2d --- /dev/null +++ b/migrations/Version20260527103011.php @@ -0,0 +1,51 @@ +addSql('CREATE TABLE code_owner (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_2335FF304416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, name VARCHAR(255) NOT NULL, leantime_id VARCHAR(255) DEFAULT NULL, leantime_url VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_2FB3D0EE4416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project_code_owner (project_id BINARY(16) NOT NULL, code_owner_id BINARY(16) NOT NULL, INDEX IDX_3B938402166D1F9C (project_id), INDEX IDX_3B93840287BD19D2 (code_owner_id), PRIMARY KEY (project_id, code_owner_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project_git_repo (project_id BINARY(16) NOT NULL, git_repo_id BINARY(16) NOT NULL, INDEX IDX_CB848708166D1F9C (project_id), INDEX IDX_CB8487083E8A2A0D (git_repo_id), PRIMARY KEY (project_id, git_repo_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE project_code_owner ADD CONSTRAINT FK_3B938402166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_code_owner ADD CONSTRAINT FK_3B93840287BD19D2 FOREIGN KEY (code_owner_id) REFERENCES code_owner (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_git_repo ADD CONSTRAINT FK_CB848708166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_git_repo ADD CONSTRAINT FK_CB8487083E8A2A0D FOREIGN KEY (git_repo_id) REFERENCES git_repo (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE security_contract ADD project_id BINARY(16) DEFAULT NULL, DROP project_name, DROP client_name, DROP leantime_url, DROP git_repos, DROP project_tracker_key, DROP quarterly_hours, DROP cybersecurity_price, DROP cybersecurity_note'); + $this->addSql('ALTER TABLE security_contract ADD CONSTRAINT FK_8AE4AF8B166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)'); + $this->addSql('CREATE INDEX IDX_8AE4AF8B166D1F9C ON security_contract (project_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE project_code_owner DROP FOREIGN KEY FK_3B938402166D1F9C'); + $this->addSql('ALTER TABLE project_code_owner DROP FOREIGN KEY FK_3B93840287BD19D2'); + $this->addSql('ALTER TABLE project_git_repo DROP FOREIGN KEY FK_CB848708166D1F9C'); + $this->addSql('ALTER TABLE project_git_repo DROP FOREIGN KEY FK_CB8487083E8A2A0D'); + $this->addSql('DROP TABLE code_owner'); + $this->addSql('DROP TABLE project'); + $this->addSql('DROP TABLE project_code_owner'); + $this->addSql('DROP TABLE project_git_repo'); + $this->addSql('ALTER TABLE security_contract DROP FOREIGN KEY FK_8AE4AF8B166D1F9C'); + $this->addSql('DROP INDEX IDX_8AE4AF8B166D1F9C ON security_contract'); + $this->addSql('ALTER TABLE security_contract ADD project_name VARCHAR(255) NOT NULL, ADD client_name VARCHAR(255) DEFAULT NULL, ADD leantime_url VARCHAR(255) DEFAULT NULL, ADD git_repos LONGTEXT DEFAULT NULL, ADD project_tracker_key VARCHAR(255) DEFAULT NULL, ADD quarterly_hours DOUBLE PRECISION DEFAULT NULL, ADD cybersecurity_price DOUBLE PRECISION DEFAULT NULL, ADD cybersecurity_note LONGTEXT DEFAULT NULL, DROP project_id'); + } +} diff --git a/src/Command/SyncServiceAgreementsCommand.php b/src/Command/SyncServiceAgreementsCommand.php index 55fcb64..8c4ee44 100644 --- a/src/Command/SyncServiceAgreementsCommand.php +++ b/src/Command/SyncServiceAgreementsCommand.php @@ -26,9 +26,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); try { - $count = $this->syncService->syncAll(); + $result = $this->syncService->syncAll(); - $io->success(sprintf('Synced %d service agreements successfully.', $count)); + $io->success(sprintf('Synced %d projects successfully.', $result['projects'])); + + if (!empty($result['unmatchedRepoNames'])) { + $io->warning(sprintf( + 'Could not link %d GitHub repo name(s) to existing GitRepo entries: %s', + count($result['unmatchedRepoNames']), + implode(', ', $result['unmatchedRepoNames']), + )); + } } catch (\Throwable $e) { $io->error($e->getMessage()); diff --git a/src/Controller/Admin/SecurityContractCrudController.php b/src/Controller/Admin/SecurityContractCrudController.php index 758851d..1cac814 100644 --- a/src/Controller/Admin/SecurityContractCrudController.php +++ b/src/Controller/Admin/SecurityContractCrudController.php @@ -13,7 +13,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\DateField; use EasyCorp\Bundle\EasyAdminBundle\Field\FormField; use EasyCorp\Bundle\EasyAdminBundle\Field\NumberField; -use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; @@ -37,8 +36,8 @@ public static function getEntityFqcn(): string public function configureCrud(Crud $crud): Crud { return $crud - ->setDefaultSort(['projectName' => 'ASC']) - ->setSearchFields(['projectName', 'clientName', 'hostingProvider']) + ->setDefaultSort(['project.name' => 'ASC']) + ->setSearchFields(['project.name', 'hostingProvider', 'serverSize']) ->showEntityActionsInlined() ->setPageTitle(Crud::PAGE_INDEX, 'Service Agreements') ->setHelp(Crud::PAGE_INDEX, 'Service agreements are synced from Economics. Click "Sync all" to update.'); @@ -59,12 +58,13 @@ public function configureFields(string $pageName): iterable yield FormField::addFieldset('Project'); yield BooleanField::new('active')->renderAsSwitch(false)->setColumns(2); yield BooleanField::new('eol')->setLabel('EOL')->renderAsSwitch(false)->setColumns(2); - yield TextField::new('projectName')->setColumns(8); - yield TextField::new('clientName')->hideOnIndex(); + yield TextField::new('project.name')->setLabel('Project')->setColumns(8); + yield TextField::new('project.leantimeId')->setLabel('Leantime ID')->hideOnIndex(); + yield TextField::new('projectGitRepos')->setLabel('GitHub repos')->hideOnIndex(); yield TextField::new('hostingProvider'); yield FormField::addFieldset('Links'); - yield UrlField::new('leantimeUrl')->setLabel('Leantime URL')->hideOnIndex(); + yield UrlField::new('project.leantimeUrl')->setLabel('Leantime URL')->hideOnIndex(); yield UrlField::new('documentUrl')->setLabel('Document URL')->hideOnIndex(); yield FormField::addFieldset('Contact'); @@ -73,15 +73,10 @@ public function configureFields(string $pageName): iterable yield FormField::addFieldset('Budget'); yield NumberField::new('monthlyPrice')->setTextAlign('right')->setColumns(6); - yield NumberField::new('quarterlyHours')->setTextAlign('right')->setColumns(6); - yield NumberField::new('cybersecurityPrice')->setTextAlign('right')->hideOnIndex()->setColumns(6); - yield TextareaField::new('cybersecurityNote')->hideOnIndex()->setColumns(12); yield FormField::addFieldset('Infrastructure'); yield BooleanField::new('dedicatedServer')->renderAsSwitch(false)->hideOnIndex(); yield TextField::new('serverSize')->hideOnIndex(); - yield TextareaField::new('gitRepos')->hideOnIndex(); - yield TextField::new('projectTrackerKey')->hideOnIndex(); yield FormField::addFieldset('Validity'); yield DateField::new('validFrom')->setColumns(6); @@ -92,9 +87,17 @@ public function configureFields(string $pageName): iterable public function syncAll(): RedirectResponse { try { - $count = $this->syncService->syncAll(); + $result = $this->syncService->syncAll(); - $this->addFlash('info', sprintf('Synced %d service agreements.', $count)); + $this->addFlash('info', sprintf('Synced %d projects.', $result['projects'])); + + if (!empty($result['unmatchedRepoNames'])) { + $this->addFlash('warning', sprintf( + 'Could not link %d GitHub repo name(s) to existing GitRepo entries: %s', + count($result['unmatchedRepoNames']), + implode(', ', $result['unmatchedRepoNames']), + )); + } } catch (\Throwable $e) { $this->addFlash('error', sprintf('An error occurred while syncing: %s', $e->getMessage())); } diff --git a/src/Entity/CodeOwner.php b/src/Entity/CodeOwner.php new file mode 100644 index 0000000..4d3e641 --- /dev/null +++ b/src/Entity/CodeOwner.php @@ -0,0 +1,84 @@ + + */ + #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'codeOwners')] + private Collection $projects; + + public function __construct() + { + $this->projects = new ArrayCollection(); + } + + #[\Override] + public function __toString(): string + { + return $this->name; + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * @return Collection + */ + public function getProjects(): Collection + { + return $this->projects; + } +} diff --git a/src/Entity/Project.php b/src/Entity/Project.php new file mode 100644 index 0000000..acebcb9 --- /dev/null +++ b/src/Entity/Project.php @@ -0,0 +1,184 @@ + + */ + #[ORM\ManyToMany(targetEntity: CodeOwner::class, inversedBy: 'projects', cascade: ['persist'])] + #[ORM\JoinTable(name: 'project_code_owner')] + private Collection $codeOwners; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: GitRepo::class)] + #[ORM\JoinTable(name: 'project_git_repo')] + private Collection $gitRepos; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: SecurityContract::class, mappedBy: 'project', cascade: ['persist'])] + private Collection $serviceAgreements; + + public function __construct() + { + $this->codeOwners = new ArrayCollection(); + $this->gitRepos = new ArrayCollection(); + $this->serviceAgreements = new ArrayCollection(); + } + + #[\Override] + public function __toString(): string + { + return $this->name; + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getLeantimeId(): ?string + { + return $this->leantimeId; + } + + public function setLeantimeId(?string $leantimeId): static + { + $this->leantimeId = $leantimeId; + + return $this; + } + + public function getLeantimeUrl(): ?string + { + return $this->leantimeUrl; + } + + public function setLeantimeUrl(?string $leantimeUrl): static + { + $this->leantimeUrl = $leantimeUrl; + + return $this; + } + + /** + * @return Collection + */ + public function getCodeOwners(): Collection + { + return $this->codeOwners; + } + + public function addCodeOwner(CodeOwner $codeOwner): static + { + if (!$this->codeOwners->contains($codeOwner)) { + $this->codeOwners->add($codeOwner); + } + + return $this; + } + + public function removeCodeOwner(CodeOwner $codeOwner): static + { + $this->codeOwners->removeElement($codeOwner); + + return $this; + } + + /** + * @return Collection + */ + public function getGitRepos(): Collection + { + return $this->gitRepos; + } + + public function addGitRepo(GitRepo $gitRepo): static + { + if (!$this->gitRepos->contains($gitRepo)) { + $this->gitRepos->add($gitRepo); + } + + return $this; + } + + public function removeGitRepo(GitRepo $gitRepo): static + { + $this->gitRepos->removeElement($gitRepo); + + return $this; + } + + /** + * @return Collection + */ + public function getServiceAgreements(): Collection + { + return $this->serviceAgreements; + } + + public function addServiceAgreement(SecurityContract $serviceAgreement): static + { + if (!$this->serviceAgreements->contains($serviceAgreement)) { + $this->serviceAgreements->add($serviceAgreement); + $serviceAgreement->setProject($this); + } + + return $this; + } + + public function removeServiceAgreement(SecurityContract $serviceAgreement): static + { + if ($this->serviceAgreements->removeElement($serviceAgreement)) { + if ($serviceAgreement->getProject() === $this) { + $serviceAgreement->setProject(null); + } + } + + return $this; + } +} diff --git a/src/Entity/SecurityContract.php b/src/Entity/SecurityContract.php index 390cdd7..c03cdef 100644 --- a/src/Entity/SecurityContract.php +++ b/src/Entity/SecurityContract.php @@ -12,11 +12,9 @@ class SecurityContract extends AbstractBaseEntity implements \Stringable #[ORM\Column(unique: true)] private ?int $economicsId = null; - #[ORM\Column(length: 255)] - private string $projectName = ''; - - #[ORM\Column(length: 255, nullable: true)] - private ?string $clientName = null; + #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'serviceAgreements')] + #[ORM\JoinColumn(nullable: true)] + private ?Project $project = null; #[ORM\Column(length: 255, nullable: true)] private ?string $hostingProvider = null; @@ -39,9 +37,6 @@ class SecurityContract extends AbstractBaseEntity implements \Stringable #[ORM\Column] private bool $eol = false; - #[ORM\Column(length: 255, nullable: true)] - private ?string $leantimeUrl = null; - #[ORM\Column(length: 255, nullable: true)] private ?string $clientContactName = null; @@ -54,61 +49,48 @@ class SecurityContract extends AbstractBaseEntity implements \Stringable #[ORM\Column(length: 255, nullable: true)] private ?string $serverSize = null; - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $gitRepos = null; - #[ORM\Column(type: Types::JSON, nullable: true)] private ?array $systemOwnerNotices = null; - #[ORM\Column(length: 255, nullable: true)] - private ?string $projectTrackerKey = null; - - #[ORM\Column(nullable: true)] - private ?float $quarterlyHours = null; - - #[ORM\Column(nullable: true)] - private ?float $cybersecurityPrice = null; - - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $cybersecurityNote = null; - public function __toString(): string { - return $this->projectName; + return $this->project?->getName() ?? (string) $this->economicsId; } - public function getEconomicsId(): ?int + public function getProjectGitRepos(): ?string { - return $this->economicsId; - } + if (null === $this->project) { + return null; + } - public function setEconomicsId(int $economicsId): static - { - $this->economicsId = $economicsId; + $names = array_map( + static fn (GitRepo $repo): string => (string) $repo, + $this->project->getGitRepos()->toArray(), + ); - return $this; + return [] === $names ? null : implode(', ', $names); } - public function getProjectName(): string + public function getEconomicsId(): ?int { - return $this->projectName; + return $this->economicsId; } - public function setProjectName(string $projectName): static + public function setEconomicsId(int $economicsId): static { - $this->projectName = $projectName; + $this->economicsId = $economicsId; return $this; } - public function getClientName(): ?string + public function getProject(): ?Project { - return $this->clientName; + return $this->project; } - public function setClientName(?string $clientName): static + public function setProject(?Project $project): static { - $this->clientName = $clientName; + $this->project = $project; return $this; } @@ -197,18 +179,6 @@ public function setEol(bool $eol): static return $this; } - public function getLeantimeUrl(): ?string - { - return $this->leantimeUrl; - } - - public function setLeantimeUrl(?string $leantimeUrl): static - { - $this->leantimeUrl = $leantimeUrl; - - return $this; - } - public function getClientContactName(): ?string { return $this->clientContactName; @@ -257,18 +227,6 @@ public function setServerSize(?string $serverSize): static return $this; } - public function getGitRepos(): ?string - { - return $this->gitRepos; - } - - public function setGitRepos(?string $gitRepos): static - { - $this->gitRepos = $gitRepos; - - return $this; - } - public function getSystemOwnerNotices(): ?array { return $this->systemOwnerNotices; @@ -280,52 +238,4 @@ public function setSystemOwnerNotices(?array $systemOwnerNotices): static return $this; } - - public function getProjectTrackerKey(): ?string - { - return $this->projectTrackerKey; - } - - public function setProjectTrackerKey(?string $projectTrackerKey): static - { - $this->projectTrackerKey = $projectTrackerKey; - - return $this; - } - - public function getQuarterlyHours(): ?float - { - return $this->quarterlyHours; - } - - public function setQuarterlyHours(?float $quarterlyHours): static - { - $this->quarterlyHours = $quarterlyHours; - - return $this; - } - - public function getCybersecurityPrice(): ?float - { - return $this->cybersecurityPrice; - } - - public function setCybersecurityPrice(?float $cybersecurityPrice): static - { - $this->cybersecurityPrice = $cybersecurityPrice; - - return $this; - } - - public function getCybersecurityNote(): ?string - { - return $this->cybersecurityNote; - } - - public function setCybersecurityNote(?string $cybersecurityNote): static - { - $this->cybersecurityNote = $cybersecurityNote; - - return $this; - } } diff --git a/src/Repository/CodeOwnerRepository.php b/src/Repository/CodeOwnerRepository.php new file mode 100644 index 0000000..9e85b43 --- /dev/null +++ b/src/Repository/CodeOwnerRepository.php @@ -0,0 +1,25 @@ + + * + * @method CodeOwner|null find($id, $lockMode = null, $lockVersion = null) + * @method CodeOwner|null findOneBy(array $criteria, array $orderBy = null) + * @method CodeOwner[] findAll() + * @method CodeOwner[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class CodeOwnerRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CodeOwner::class); + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..d5c2e25 --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,25 @@ + + * + * @method Project|null find($id, $lockMode = null, $lockVersion = null) + * @method Project|null findOneBy(array $criteria, array $orderBy = null) + * @method Project[] findAll() + * @method Project[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ProjectRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Project::class); + } +} diff --git a/src/Service/ServiceAgreementSyncService.php b/src/Service/ServiceAgreementSyncService.php index 4c7be95..99b8c0c 100644 --- a/src/Service/ServiceAgreementSyncService.php +++ b/src/Service/ServiceAgreementSyncService.php @@ -2,92 +2,213 @@ namespace App\Service; +use App\Entity\CodeOwner; +use App\Entity\GitRepo; +use App\Entity\Project; use App\Entity\SecurityContract; +use App\Repository\CodeOwnerRepository; +use App\Repository\GitRepoRepository; +use App\Repository\ProjectRepository; +use App\Repository\SecurityContractRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** - * Syncs service agreements from the Economics API (economics.itkdev.dk) - * into local SecurityContract entities. + * Syncs projects (with nested service agreements and codeowners) from the + * Economics API (economics.itkdev.dk) into local Project / CodeOwner / + * SecurityContract entities. */ readonly class ServiceAgreementSyncService { - /** - * @param EntityManagerInterface $entityManager Doctrine entity manager for persisting contracts - * @param HttpClientInterface $economicsClient scoped HTTP client for the Economics API - */ + private const string ENDPOINT = '/api/projects'; + private const string DEFAULT_TIMEZONE = 'UTC'; + public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $economicsClient, + private ProjectRepository $projectRepository, + private CodeOwnerRepository $codeOwnerRepository, + private SecurityContractRepository $securityContractRepository, + private GitRepoRepository $gitRepoRepository, ) { } /** - * Fetch all service agreements from the Economics API and sync them locally. + * Fetch all projects from the Economics API and sync them locally. * - * Creates new, updates existing (matched by economics ID), and removes - * contracts that no longer exist in the API response. - * - * @return int the number of agreements synced + * @return array{projects:int, unmatchedRepoNames:list} * * @throws \RuntimeException|\Exception if the API request fails */ - public function syncAll(): int + public function syncAll(): array { try { - $response = $this->economicsClient->request('GET', '/api/serviceagreements'); - $agreements = $response->toArray(); + $response = $this->economicsClient->request('GET', self::ENDPOINT); + $projectsData = $response->toArray(); } catch (ExceptionInterface $e) { - throw new \RuntimeException('Failed to fetch service agreements from Economics API: '.$e->getMessage(), 0, $e); + throw new \RuntimeException('Failed to fetch projects from Economics API: '.$e->getMessage(), 0, $e); } - $existingContracts = $this->entityManager->getRepository(SecurityContract::class)->findAll(); - $existingByEconomicsId = []; - foreach ($existingContracts as $contract) { - $existingByEconomicsId[$contract->getEconomicsId()] = $contract; + $existingProjects = []; + foreach ($this->projectRepository->findAll() as $project) { + $existingProjects[$project->getEconomicsId()] = $project; } - $seenIds = []; + $existingCodeOwners = []; + foreach ($this->codeOwnerRepository->findAll() as $codeOwner) { + $existingCodeOwners[$codeOwner->getEconomicsId()] = $codeOwner; + } - foreach ($agreements as $data) { - $economicsId = $data['id']; - $seenIds[] = $economicsId; + $existingContracts = []; + foreach ($this->securityContractRepository->findAll() as $contract) { + $existingContracts[$contract->getEconomicsId()] = $contract; + } - $contract = $existingByEconomicsId[$economicsId] ?? new SecurityContract(); + $existingGitReposByRepo = []; + foreach ($this->gitRepoRepository->findAll() as $repo) { + $existingGitReposByRepo[$repo->getRepo()] = $repo; + } - $this->mapDataToContract($contract, $data); + $seenProjectIds = []; + $seenCodeOwnerIds = []; + $seenContractIds = []; + $unmatchedRepoNames = []; + + foreach ($projectsData as $data) { + $project = $existingProjects[$data['id']] ?? new Project(); + $project->setEconomicsId($data['id']); + $project->setName($data['name'] ?? ''); + $project->setLeantimeId(isset($data['projectTrackerId']) ? (string) $data['projectTrackerId'] : null); + $project->setLeantimeUrl($data['leantimeUrl'] ?? null); + + $this->syncCodeOwners($project, $data['codeowners'] ?? [], $existingCodeOwners, $seenCodeOwnerIds); + $this->syncGitRepos($project, $data['githubRepos'] ?? null, $existingGitReposByRepo, $unmatchedRepoNames); + + $this->entityManager->persist($project); + $existingProjects[$data['id']] = $project; + $seenProjectIds[] = $data['id']; + + $serviceAgreementData = $data['serviceAgreement'] ?? null; + if (is_array($serviceAgreementData) && isset($serviceAgreementData['id'])) { + $contract = $existingContracts[$serviceAgreementData['id']] ?? new SecurityContract(); + $this->mapServiceAgreementToContract($contract, $serviceAgreementData, $project); + $this->entityManager->persist($contract); + $existingContracts[$serviceAgreementData['id']] = $contract; + $seenContractIds[] = $serviceAgreementData['id']; + } + } - if (null === $contract->getEconomicsId()) { - $contract->setEconomicsId($economicsId); + foreach ($existingContracts as $economicsId => $contract) { + if (!in_array($economicsId, $seenContractIds, true)) { + $this->entityManager->remove($contract); } + } - $this->entityManager->persist($contract); + foreach ($existingProjects as $economicsId => $project) { + if (!in_array($economicsId, $seenProjectIds, true)) { + $this->entityManager->remove($project); + } } - foreach ($existingContracts as $contract) { - if (!in_array($contract->getEconomicsId(), $seenIds, true)) { - $this->entityManager->remove($contract); + foreach ($existingCodeOwners as $economicsId => $codeOwner) { + if (!in_array($economicsId, $seenCodeOwnerIds, true)) { + $this->entityManager->remove($codeOwner); } } $this->entityManager->flush(); - return count($agreements); + return [ + 'projects' => count($projectsData), + 'unmatchedRepoNames' => array_keys($unmatchedRepoNames), + ]; } /** - * Map API response data onto a SecurityContract entity. + * @param array> $codeOwnersData + * @param array $existingCodeOwners passed by reference so newly-created owners are reused within one sync run + * @param list $seenCodeOwnerIds * - * @param array $data a single service agreement from the API + * @param-out array $existingCodeOwners + * @param-out list $seenCodeOwnerIds + */ + private function syncCodeOwners(Project $project, array $codeOwnersData, array &$existingCodeOwners, array &$seenCodeOwnerIds): void + { + $desired = []; + foreach ($codeOwnersData as $ownerData) { + if (!isset($ownerData['id'])) { + continue; + } + + $economicsId = (int) $ownerData['id']; + $owner = $existingCodeOwners[$economicsId] ?? new CodeOwner(); + $owner->setEconomicsId($economicsId); + $owner->setName((string) ($ownerData['name'] ?? '')); + $owner->setEmail((string) ($ownerData['email'] ?? '')); + + $existingCodeOwners[$economicsId] = $owner; + $seenCodeOwnerIds[] = $economicsId; + + $this->entityManager->persist($owner); + $desired[$economicsId] = $owner; + } + + foreach ($project->getCodeOwners() as $existing) { + if (!isset($desired[$existing->getEconomicsId()])) { + $project->removeCodeOwner($existing); + } + } + foreach ($desired as $owner) { + $project->addCodeOwner($owner); + } + } + + /** + * @param array $existingGitReposByRepo + * @param array $unmatchedRepoNames accumulator across all projects + */ + private function syncGitRepos(Project $project, ?string $githubReposString, array $existingGitReposByRepo, array &$unmatchedRepoNames): void + { + $names = []; + if (null !== $githubReposString && '' !== $githubReposString) { + foreach (preg_split('/\r\n|\r|\n/', $githubReposString) ?: [] as $line) { + $trimmed = trim($line); + if ('' !== $trimmed) { + $names[] = $trimmed; + } + } + } + + $desired = []; + foreach ($names as $repoName) { + if (isset($existingGitReposByRepo[$repoName])) { + $repo = $existingGitReposByRepo[$repoName]; + $desired[spl_object_id($repo)] = $repo; + } else { + $unmatchedRepoNames[$repoName] = true; + } + } + + foreach ($project->getGitRepos() as $existing) { + if (!isset($desired[spl_object_id($existing)])) { + $project->removeGitRepo($existing); + } + } + foreach ($desired as $repo) { + $project->addGitRepo($repo); + } + } + + /** + * @param array $data * * @throws \Exception */ - private function mapDataToContract(SecurityContract $contract, array $data): void + private function mapServiceAgreementToContract(SecurityContract $contract, array $data, Project $project): void { $contract->setEconomicsId($data['id']); - $contract->setProjectName($data['projectName'] ?? ''); - $contract->setClientName($data['clientName'] ?? null); + $contract->setProject($project); $contract->setHostingProvider($data['hostingProvider'] ?? null); $contract->setDocumentUrl($data['documentUrl'] ?? null); $contract->setMonthlyPrice(isset($data['price']) ? (float) $data['price'] : null); @@ -95,29 +216,17 @@ private function mapDataToContract(SecurityContract $contract, array $data): voi $contract->setValidTo($this->parseDate($data['validTo'] ?? null)); $contract->setActive($data['isActive'] ?? false); $contract->setEol($data['isEol'] ?? false); - $contract->setLeantimeUrl($data['leantimeUrl'] ?? null); $contract->setClientContactName($data['clientContactName'] ?? null); $contract->setClientContactEmail($data['clientContactEmail'] ?? null); $contract->setDedicatedServer($data['dedicatedServer'] ?? false); $contract->setServerSize($data['serverSize'] ?? null); - $contract->setGitRepos($data['gitRepos'] ?? null); $contract->setSystemOwnerNotices($data['systemOwnerNotices'] ?? null); - $contract->setProjectTrackerKey($data['projectTrackerKey'] ?? null); - - $cyber = $data['cybersecurityAgreement'] ?? null; - if (is_array($cyber)) { - $contract->setQuarterlyHours(isset($cyber['quarterlyHours']) ? (float) $cyber['quarterlyHours'] : null); - $contract->setCybersecurityPrice(isset($cyber['price']) ? (float) $cyber['price'] : null); - $contract->setCybersecurityNote($cyber['note'] ?? null); - } } /** * Parse the Economics API date format ({date, timezone_type, timezone}) into a DateTimeImmutable. * - * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date object from the API - * - * @return \DateTimeImmutable|null the parsed date, or null if no valid date data was provided + * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData * * @throws \Exception if the date string cannot be parsed */ @@ -127,7 +236,7 @@ private function parseDate(?array $dateData): ?\DateTimeImmutable return null; } - $timezone = new \DateTimeZone($dateData['timezone'] ?? 'UTC'); + $timezone = new \DateTimeZone($dateData['timezone'] ?? self::DEFAULT_TIMEZONE); return new \DateTimeImmutable($dateData['date'], $timezone); } From a3846dd7600c1615b8dc059ca3da9472e7622ead Mon Sep 17 00:00:00 2001 From: martinyde Date: Wed, 27 May 2026 14:21:41 +0200 Subject: [PATCH 2/8] Add advisory list across each repo --- src/Controller/Admin/DashboardController.php | 1 + .../Admin/RepoAdvisoryController.php | 87 +++++++++++++++++++ src/Repository/GitRepoRepository.php | 32 +++++++ src/Repository/ProjectRepository.php | 23 +++++ templates/admin/repo_advisory/index.html.twig | 79 +++++++++++++++++ 5 files changed, 222 insertions(+) create mode 100644 src/Controller/Admin/RepoAdvisoryController.php create mode 100644 templates/admin/repo_advisory/index.html.twig diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 7044546..5ca9619 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -57,6 +57,7 @@ public function configureMenuItems(): iterable yield MenuItem::linkTo(PackageCrudController::class, 'Packages', 'fas fa-cube'); yield MenuItem::linkTo(PackageVersionCrudController::class, 'Package Versions', 'fas fa-cubes'); yield MenuItem::linkTo(AdvisoryCrudController::class, 'Advisories', 'fas fa-skull-crossbones')->setBadge($this->advisoryRepository->count([]), 'dark'); + yield MenuItem::linkToRoute('Repo advisories', 'fas fa-shield-virus', 'admin_repo_advisories'); yield MenuItem::linkTo(ModuleCrudController::class, 'Modules', 'fas fa-cube'); yield MenuItem::linkTo(ModuleVersionCrudController::class, 'Modules Versions', 'fas fa-cubes'); yield MenuItem::linkTo(DockerImageCrudController::class, 'Docker Images', 'fas fa-cube'); diff --git a/src/Controller/Admin/RepoAdvisoryController.php b/src/Controller/Admin/RepoAdvisoryController.php new file mode 100644 index 0000000..ea67dd2 --- /dev/null +++ b/src/Controller/Admin/RepoAdvisoryController.php @@ -0,0 +1,87 @@ +gitRepoRepository->findReposWithAdvisoryCount(); + + $rows = []; + foreach ($reposWithCount as $entry) { + $repo = $entry['repo']; + $projects = $this->projectRepository->findByGitRepo($repo); + + $codeOwners = []; + foreach ($projects as $project) { + foreach ($project->getCodeOwners() as $codeOwner) { + $codeOwners[$codeOwner->getId()?->toRfc4122() ?? ''] = $codeOwner; + } + } + + $rows[] = [ + 'repo' => $repo, + 'advisoryCount' => $entry['advisoryCount'], + 'projects' => $projects, + 'codeOwners' => array_values($codeOwners), + ]; + } + + return $this->render('admin/repo_advisory/index.html.twig', [ + 'rows' => $rows, + 'csrf_intent' => self::CSRF_INTENT, + ]); + } + + #[Route('/admin/repo-advisories/{repo}/print-codeowner', name: 'admin_repo_advisories_print_codeowner', methods: ['POST'])] + public function printCodeOwner(Request $request, GitRepo $repo): RedirectResponse + { + if (!$this->isCsrfTokenValid(self::CSRF_INTENT, (string) $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token; please retry.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + $codeOwnerId = (string) $request->request->get('codeOwnerId', ''); + if ('' === $codeOwnerId) { + $this->addFlash('warning', 'No code owner selected.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + $codeOwner = $this->codeOwnerRepository->find($codeOwnerId); + if (!$codeOwner instanceof CodeOwner) { + $this->addFlash('error', 'Code owner not found.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + $this->addFlash('info', sprintf('Code owner: %s', $codeOwner->getName())); + + return $this->redirectToRoute('admin_repo_advisories'); + } +} diff --git a/src/Repository/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index d960d78..81dba04 100644 --- a/src/Repository/GitRepoRepository.php +++ b/src/Repository/GitRepoRepository.php @@ -22,4 +22,36 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, GitRepo::class); } + + /** + * Repos reachable from any package-version advisory, with their advisory count. + * + * Path: GitRepo → GitTag → Installation → PackageVersion → Advisory. + * + * @return list + */ + public function findReposWithAdvisoryCount(): array + { + /** @var list $rows */ + $rows = $this->createQueryBuilder('r') + ->select('r AS repo', 'COUNT(DISTINCT a.id) AS advisoryCount') + ->innerJoin('r.gitTags', 'gt') + ->innerJoin('gt.installations', 'i') + ->innerJoin('i.packageVersions', 'pv') + ->innerJoin('pv.advisories', 'a') + ->groupBy('r.id') + ->having('COUNT(DISTINCT a.id) > 0') + ->orderBy('r.organization', 'ASC') + ->addOrderBy('r.repo', 'ASC') + ->getQuery() + ->getResult(); + + return array_map( + static fn (array $row): array => [ + 'repo' => $row['repo'], + 'advisoryCount' => (int) $row['advisoryCount'], + ], + $rows, + ); + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index d5c2e25..8c62e7a 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; +use App\Entity\GitRepo; use App\Entity\Project; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -22,4 +23,26 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Project::class); } + + /** + * Projects that link to the given repo, with codeOwners and serviceAgreements eager-loaded. + * + * @return list + */ + public function findByGitRepo(GitRepo $repo): array + { + /** @var list $projects */ + $projects = $this->createQueryBuilder('p') + ->select('p', 'co', 'sc') + ->leftJoin('p.codeOwners', 'co') + ->leftJoin('p.serviceAgreements', 'sc') + ->innerJoin('p.gitRepos', 'gr') + ->where('gr = :repo') + ->setParameter('repo', $repo) + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + + return $projects; + } } diff --git a/templates/admin/repo_advisory/index.html.twig b/templates/admin/repo_advisory/index.html.twig new file mode 100644 index 0000000..64339ab --- /dev/null +++ b/templates/admin/repo_advisory/index.html.twig @@ -0,0 +1,79 @@ +{% extends '@!EasyAdmin/layout.html.twig' %} + +{% block title %}Repositories with advisories{% endblock %} + +{% block content_title %}Repositories with advisories{% endblock %} + +{% block main %} + {% if rows is empty %} +
No repositories have associated advisories.
+ {% else %} + + + + + + + + + + + + {% for row in rows %} + {% set repo = row.repo %} + {% set repo_url = ea_url() + .unsetAll() + .setController('App\\Controller\\Admin\\GitRepoCrudController') + .setAction('detail') + .setEntityId(repo.id) %} + + + + + + + + {% endfor %} + +
RepositoryAdvisoriesProjectService agreementsCode owner action
{{ repo }} + {{ row.advisoryCount }} + + {% for project in row.projects %} + {{ project.name }}{{ not loop.last ? ', ' }} + {% else %} + — + {% endfor %} + + {% set contracts = [] %} + {% for project in row.projects %} + {% for sc in project.serviceAgreements %} + {% set contracts = contracts|merge([sc]) %} + {% endfor %} + {% endfor %} + {% for sc in contracts %} +
+ {{ sc.hostingProvider ?? sc.economicsId }} + {% if sc.validTo %}(until {{ sc.validTo|date('Y-m-d') }}){% endif %} +
+ {% else %} + — + {% endfor %} +
+ {% if row.codeOwners is empty %} + No code owners + {% else %} +
+ + + +
+ {% endif %} +
+ {% endif %} +{% endblock %} From 73b2351b9a6757347efeb6037b4e37a2c1782a39 Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 10:01:32 +0200 Subject: [PATCH 3/8] Added leantime integration --- config/packages/framework.yaml | 6 + config/services.yaml | 1 + .../Admin/AdvisoryCrudController.php | 4 +- .../Admin/RepoAdvisoryController.php | 134 +++++++++- src/Repository/GitRepoRepository.php | 35 +++ src/Repository/ProjectRepository.php | 5 +- src/Service/LeantimeService.php | 235 ++++++++++++++++++ templates/admin/repo_advisory/index.html.twig | 44 +++- 8 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 src/Service/LeantimeService.php diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 1aa06cb..7f37920 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -20,6 +20,12 @@ framework: scope: '%env(APP_ECONOMICS_URI)%' headers: x-api-key: '%env(APP_ECONOMICS_API_KEY)%' + leantime.client: + base_uri: '%env(default:default_leantime_uri:APP_LEANTIME_URI)%' + scope: '%env(default:default_leantime_uri:APP_LEANTIME_URI)%' + headers: + Content-Type: 'application/json' + x-api-key: '%env(default::APP_LEANTIME_API_KEY)%' when@test: framework: diff --git a/config/services.yaml b/config/services.yaml index 71d40c9..106c945 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,7 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + default_leantime_uri: 'http://leantime.invalid' services: # default configuration for services in *this* file diff --git a/src/Controller/Admin/AdvisoryCrudController.php b/src/Controller/Admin/AdvisoryCrudController.php index 50c18ec..362085e 100644 --- a/src/Controller/Admin/AdvisoryCrudController.php +++ b/src/Controller/Admin/AdvisoryCrudController.php @@ -18,6 +18,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; +use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter; class AdvisoryCrudController extends AbstractCrudController { @@ -66,7 +67,8 @@ public function configureFields(string $pageName): iterable public function configureFilters(Filters $filters): Filters { return $filters - ->add('package') + ->add(EntityFilter::new('package')->canSelectMultiple()) + ->add(EntityFilter::new('packageVersions')->canSelectMultiple()) ->add('advisoryId') ->add('cve') ->add('reportedAt') diff --git a/src/Controller/Admin/RepoAdvisoryController.php b/src/Controller/Admin/RepoAdvisoryController.php index ea67dd2..59c9f0e 100644 --- a/src/Controller/Admin/RepoAdvisoryController.php +++ b/src/Controller/Admin/RepoAdvisoryController.php @@ -5,31 +5,60 @@ namespace App\Controller\Admin; use App\Entity\CodeOwner; -use App\Entity\GitRepo; use App\Repository\CodeOwnerRepository; use App\Repository\GitRepoRepository; use App\Repository\ProjectRepository; +use App\Service\LeantimeService; +use App\Service\ServiceAgreementSyncService; +use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute; +use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; +use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; +/** + * Repo-advisories admin page. + * + * Not an EasyAdmin CRUD controller. The page joins four entities + * (GitRepo + Project + SecurityContract + CodeOwner) with derived columns, + * augments each row with live Leantime ticket state via LeantimeService, and + * renders an inline ` + + + + {% if rows is empty %}
No repositories have associated advisories.
{% else %} @@ -12,6 +23,7 @@ Repository + Project type & version Advisories Project Service agreements @@ -28,8 +40,21 @@ .setEntityId(repo.id) %} {{ repo }} + + {% for tv in row.typesAndVersions %} + {{ tv }} + {% else %} + — + {% endfor %} + - {{ row.advisoryCount }} + {% if row.advisoriesUrl %} + + {{ row.advisoryCount }} + + {% else %} + {{ row.advisoryCount }} + {% endif %} {% for project in row.projects %} @@ -55,19 +80,30 @@ {% endfor %} - {% if row.codeOwners is empty %} + {% if row.openTicket %} +
+ + Issue assigned to {{ row.openTicket.assigneeName ?? 'Unassigned' }} + {% if row.openTicket.createdAt %} + (created {{ row.openTicket.createdAt|date('Y-m-d') }}) + {% endif %} +
+ {% elseif row.leantimeProjectId is null %} + No Leantime project linked + {% elseif row.codeOwners is empty %} No code owners {% else %}
+ - +
{% endif %} From c61a4141416cc65a4c2f812c83ce887f092c26fe Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 11:29:23 +0200 Subject: [PATCH 4/8] Moved controller content to service --- .../Admin/RepoAdvisoryController.php | 120 ++--------- src/Service/LeantimeService.php | 80 +++++++- src/Service/RepoAdvisoryService.php | 190 ++++++++++++++++++ src/Service/ServiceAgreementSyncService.php | 73 +++++-- 4 files changed, 342 insertions(+), 121 deletions(-) create mode 100644 src/Service/RepoAdvisoryService.php diff --git a/src/Controller/Admin/RepoAdvisoryController.php b/src/Controller/Admin/RepoAdvisoryController.php index 59c9f0e..1ab836b 100644 --- a/src/Controller/Admin/RepoAdvisoryController.php +++ b/src/Controller/Admin/RepoAdvisoryController.php @@ -4,15 +4,9 @@ namespace App\Controller\Admin; -use App\Entity\CodeOwner; -use App\Repository\CodeOwnerRepository; -use App\Repository\GitRepoRepository; -use App\Repository\ProjectRepository; -use App\Service\LeantimeService; +use App\Service\RepoAdvisoryService; use App\Service\ServiceAgreementSyncService; use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute; -use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -23,11 +17,14 @@ * * Not an EasyAdmin CRUD controller. The page joins four entities * (GitRepo + Project + SecurityContract + CodeOwner) with derived columns, - * augments each row with live Leantime ticket state via LeantimeService, and - * renders an inline ` + submit` form per row for the create-ticket action. EA's Action + * API only emits link-style row actions, not in-row form widgets, so a CRUD + * controller would need a custom index.html.twig override that re-implements + * the same row loops anyway. + * + * Data assembly and Leantime orchestration live in RepoAdvisoryService — this + * controller only handles request parsing, CSRF, flashes, and rendering. * * The actions still integrate with the admin shell: routes use #[AdminRoute], * so EA auto-tags this class as an admin-route controller, populates @@ -38,92 +35,22 @@ class RepoAdvisoryController extends AbstractController private const string CSRF_INTENT = 'repo_advisory_action'; public function __construct( - private readonly AdminUrlGenerator $adminUrlGenerator, - private readonly GitRepoRepository $gitRepoRepository, - private readonly ProjectRepository $projectRepository, - private readonly CodeOwnerRepository $codeOwnerRepository, + private readonly RepoAdvisoryService $repoAdvisoryService, private readonly ServiceAgreementSyncService $serviceAgreementSyncService, - private readonly LeantimeService $leantimeService, ) { } #[AdminRoute(path: '/repo-advisories', name: 'repo_advisories', options: ['methods' => ['GET']])] public function index(): Response { - $reposWithCount = $this->gitRepoRepository->findReposWithAdvisoryCount(); - $packageVersionsPerRepo = $this->gitRepoRepository->findPackageVersionsPerRepoWithAdvisories(); - - $ticketsByLeantimeId = []; - try { - $ticketsByLeantimeId = $this->leantimeService->findOpenSecurityTickets(); - } catch (\Throwable $e) { - $this->addFlash('warning', sprintf('Could not fetch Leantime tickets: %s', $e->getMessage())); - } - - $rows = []; - foreach ($reposWithCount as $entry) { - $repo = $entry['repo']; - $projects = $this->projectRepository->findByGitRepo($repo); - - $codeOwners = []; - foreach ($projects as $project) { - foreach ($project->getCodeOwners() as $codeOwner) { - $codeOwners[$codeOwner->getId()?->toRfc4122() ?? ''] = $codeOwner; - } - } + $result = $this->repoAdvisoryService->buildIndexRows(); - $typesAndVersions = []; - foreach ($repo->getGitTags() as $gitTag) { - foreach ($gitTag->getInstallations() as $installation) { - $key = trim(($installation->getType() ?? '').' '.($installation->getFrameworkVersion() ?? '')); - if ('' !== $key) { - $typesAndVersions[$key] = true; - } - } - } - ksort($typesAndVersions); - - $repoKey = (string) $repo->getId(); - $packageVersionIds = $packageVersionsPerRepo[$repoKey] ?? []; - $advisoriesUrl = [] === $packageVersionIds ? null : $this->adminUrlGenerator - ->unsetAll() - ->setController(AdvisoryCrudController::class) - ->setAction(Crud::PAGE_INDEX) - ->set('filters', ['packageVersions' => ['comparison' => '=', 'value' => $packageVersionIds]]) - ->generateUrl(); - - $openTicket = null; - $leantimeProjectId = null; - foreach ($projects as $project) { - $rawLeantimeId = $project->getLeantimeId(); - if (null === $rawLeantimeId || '' === $rawLeantimeId) { - continue; - } - $candidateId = (int) $rawLeantimeId; - if (null === $leantimeProjectId) { - $leantimeProjectId = $candidateId; - } - if (isset($ticketsByLeantimeId[$candidateId])) { - $openTicket = $ticketsByLeantimeId[$candidateId]; - $leantimeProjectId = $candidateId; - break; - } - } - - $rows[] = [ - 'repo' => $repo, - 'advisoryCount' => $entry['advisoryCount'], - 'advisoriesUrl' => $advisoriesUrl, - 'typesAndVersions' => array_keys($typesAndVersions), - 'projects' => $projects, - 'codeOwners' => array_values($codeOwners), - 'openTicket' => $openTicket, - 'leantimeProjectId' => $leantimeProjectId, - ]; + if (null !== $result['leantimeError']) { + $this->addFlash('warning', sprintf('Could not fetch Leantime tickets: %s', $result['leantimeError'])); } return $this->render('admin/repo_advisory/index.html.twig', [ - 'rows' => $rows, + 'rows' => $result['rows'], 'csrf_intent' => self::CSRF_INTENT, ]); } @@ -181,25 +108,18 @@ public function createTicket(Request $request, string $repoId): RedirectResponse return $this->redirectToRoute('admin_repo_advisories'); } - $codeOwner = $this->codeOwnerRepository->find($codeOwnerId); - if (!$codeOwner instanceof CodeOwner) { - $this->addFlash('error', 'Code owner not found.'); - - return $this->redirectToRoute('admin_repo_advisories'); - } - try { - $userId = $this->leantimeService->findUserIdByEmail($codeOwner->getEmail()); - if (null === $userId) { + $result = $this->repoAdvisoryService->createSecurityTicketForCodeOwner($codeOwnerId, $leantimeProjectId); + + if ($result['unassigned']) { $this->addFlash('warning', sprintf( 'Code owner %s has no matching Leantime user (email: %s); creating ticket unassigned.', - $codeOwner->getName(), - $codeOwner->getEmail(), + $result['codeOwner']->getName(), + $result['codeOwner']->getEmail(), )); } - $ticketId = $this->leantimeService->createSecurityTicket($leantimeProjectId, $userId); - $this->addFlash('info', sprintf('Created Leantime ticket #%d.', $ticketId)); + $this->addFlash('info', sprintf('Created Leantime ticket #%d.', $result['ticketId'])); } catch (\Throwable $e) { $this->addFlash('error', sprintf('Failed to create Leantime ticket: %s', $e->getMessage())); } diff --git a/src/Service/LeantimeService.php b/src/Service/LeantimeService.php index 708d45b..7f28c9d 100644 --- a/src/Service/LeantimeService.php +++ b/src/Service/LeantimeService.php @@ -58,11 +58,15 @@ public function __construct( /** * Find currently-open security tickets across all Leantime projects. * - * Pre-filters via searchCriteria (term + type + status), then tightens - * the LIKE match to an exact headline check, then keeps the most recent - * match per projectId. + * Pre-filters via the Leantime `searchCriteria` (term + type + status), + * then tightens the server-side LIKE match into an exact headline check, + * and finally keeps only the most recent matching ticket per Leantime + * project id. Used by the repo-advisories page to show whether a project + * already has an open security ticket. * - * @return array keyed by Leantime project id + * @return array tickets keyed by Leantime project id + * + * @throws \RuntimeException if the Leantime API rejects the request or the transport fails */ public function findOpenSecurityTickets(): array { @@ -112,6 +116,20 @@ public function findOpenSecurityTickets(): array return $byProjectId; } + /** + * Resolve a Leantime user id for an email address. + * + * Loads (and caches) the Leantime user directory on first call and looks + * the email up case-insensitively. Returns null when the email is empty + * or not present in Leantime, so callers can decide whether to fall back + * to an unassigned ticket. + * + * @param string $email free-form email — leading/trailing whitespace and case are normalized + * + * @return int|null the Leantime user id, or null when no match exists + * + * @throws \RuntimeException if the user directory fetch fails + */ public function findUserIdByEmail(string $email): ?int { $email = mb_strtolower(trim($email)); @@ -126,9 +144,18 @@ public function findUserIdByEmail(string $email): ?int /** * Create a "Sikkerhedsopdatering" task in the given Leantime project. * - * @return int the new ticket's Leantime ID + * Submits a ticket with priority `critical`, status `new`, and a one-hour + * planned/remaining estimate. Dates default to "today" — Leantime requires + * editFrom/editTo/dateToFinish on creation, so we set all three. When + * `$userId` is null the ticket is created unassigned (Leantime accepts an + * empty editorId). + * + * @param int $projectId Leantime project id the ticket belongs to + * @param int|null $userId Leantime user id to assign the ticket to, or null for unassigned + * + * @return int the new ticket's Leantime id, or 0 if Leantime returned an unexpected response shape * - * @throws \RuntimeException if the API returns an error + * @throws \RuntimeException if the API rejects the request or the transport fails */ public function createSecurityTicket(int $projectId, ?int $userId = null): int { @@ -161,11 +188,20 @@ public function createSecurityTicket(int $projectId, ?int $userId = null): int } /** - * Send a JSON-RPC 2.0 request. + * Send a JSON-RPC 2.0 request to the Leantime API. + * + * Wraps the scoped HTTP client with the JSON-RPC envelope (jsonrpc/method/ + * params/id) and unwraps the response. The HTTP client supplies the base + * URI and x-api-key header; this method just shapes the body and decodes + * the response. Both transport-level failures and API-level error + * responses are normalized into a RuntimeException. + * + * @param string $method JSON-RPC method name (e.g. `leantime.rpc.tickets.getAll`) + * @param array $params method parameters to forward verbatim to Leantime * - * @param array $params + * @return mixed the decoded `result` field from the JSON-RPC response, or null when absent * - * @throws \RuntimeException on transport error or API error response + * @throws \RuntimeException on transport error or when the API responds with an `error` object */ private function request(string $method, array $params = []): mixed { @@ -190,6 +226,21 @@ private function request(string $method, array $params = []): mixed return $data['result'] ?? null; } + /** + * Look up a Leantime user's display name by id. + * + * Loads (and caches) the user directory on first call. Accepts mixed + * input because Leantime delivers ids as either strings or ints in + * different payloads; both are coerced to int for lookup. Returns null + * when the id is empty or unknown so the caller can fall back to a + * neutral label like "Unassigned". + * + * @param mixed $userId raw user id straight from the Leantime payload (int, numeric string, or null) + * + * @return string|null the user's display name, or null when no match exists + * + * @throws \RuntimeException if the user directory fetch fails + */ private function resolveUserName(mixed $userId): ?string { if (null === $userId || '' === $userId) { @@ -200,6 +251,17 @@ private function resolveUserName(mixed $userId): ?string return $this->userNamesById[(int) $userId] ?? null; } + /** + * Populate the user id/name/email caches from Leantime. + * + * Idempotent: a non-null `$userNamesById` short-circuits the call so the + * directory is fetched at most once per service instance. Builds both an + * id → display-name map (preferring firstname+lastname, falling back to + * username) and an email → id map keyed by lowercase email. Malformed + * entries (missing id) are skipped silently. + * + * @throws \RuntimeException if the Leantime API rejects the request or the transport fails + */ private function loadUsers(): void { if (null !== $this->userNamesById) { diff --git a/src/Service/RepoAdvisoryService.php b/src/Service/RepoAdvisoryService.php new file mode 100644 index 0000000..b4be6af --- /dev/null +++ b/src/Service/RepoAdvisoryService.php @@ -0,0 +1,190 @@ +>, leantimeError: ?string} rows keyed by repo plus an optional Leantime error message for the caller to flash + */ + public function buildIndexRows(): array + { + $reposWithCount = $this->gitRepoRepository->findReposWithAdvisoryCount(); + $packageVersionsPerRepo = $this->gitRepoRepository->findPackageVersionsPerRepoWithAdvisories(); + + $ticketsByLeantimeId = []; + $leantimeError = null; + try { + $ticketsByLeantimeId = $this->leantimeService->findOpenSecurityTickets(); + } catch (\Throwable $e) { + $leantimeError = $e->getMessage(); + } + + $rows = []; + foreach ($reposWithCount as $entry) { + $rows[] = $this->buildRow($entry, $packageVersionsPerRepo, $ticketsByLeantimeId); + } + + return [ + 'rows' => $rows, + 'leantimeError' => $leantimeError, + ]; + } + + /** + * Create a Leantime "Sikkerhedsopdatering" ticket on behalf of a code owner. + * + * Resolves the code owner via the repository, asks LeantimeService to + * translate its email into a Leantime user id, then creates the security + * ticket on the given project. When the email cannot be mapped to a + * Leantime user the ticket is still created — just unassigned — and the + * caller learns about it via the `unassigned` flag in the result. + * + * @param string $codeOwnerId RFC-4122 UUID of a CodeOwner entity + * @param int $leantimeProjectId numeric Leantime project id (external + * system's id, not an ORM id) + * + * @return array{ticketId: int, codeOwner: CodeOwner, unassigned: bool} the new ticket id, the resolved code owner, and whether the ticket is unassigned + * + * @throws \RuntimeException if the code owner cannot be found or the + * Leantime API rejects the request + */ + public function createSecurityTicketForCodeOwner(string $codeOwnerId, int $leantimeProjectId): array + { + $codeOwner = $this->codeOwnerRepository->find($codeOwnerId); + if (!$codeOwner instanceof CodeOwner) { + throw new \RuntimeException('Code owner not found.'); + } + + $userId = $this->leantimeService->findUserIdByEmail($codeOwner->getEmail()); + $ticketId = $this->leantimeService->createSecurityTicket($leantimeProjectId, $userId); + + return [ + 'ticketId' => $ticketId, + 'codeOwner' => $codeOwner, + 'unassigned' => null === $userId, + ]; + } + + /** + * Build a single repo-advisory row for the admin index. + * + * Encapsulates the per-repo joins: projects, code owners (deduplicated by + * id), installation type/version labels, the AdvisoryCrudController + * deep-link, and the matching Leantime ticket (preferring the project + * whose id has an open ticket; otherwise falling back to the first + * project's Leantime id). + * + * @param array{repo: \App\Entity\GitRepo, advisoryCount: int} $entry repo + precomputed advisory count + * @param array> $packageVersionsPerRepo map of repo-id → package version ids that have advisories + * @param array $ticketsByLeantimeId open security tickets keyed by Leantime project id + * + * @return array Row data ready for the Twig template + */ + private function buildRow(array $entry, array $packageVersionsPerRepo, array $ticketsByLeantimeId): array + { + $repo = $entry['repo']; + $projects = $this->projectRepository->findByGitRepo($repo); + + $codeOwners = []; + foreach ($projects as $project) { + foreach ($project->getCodeOwners() as $codeOwner) { + $codeOwners[$codeOwner->getId()?->toRfc4122() ?? ''] = $codeOwner; + } + } + + $typesAndVersions = []; + foreach ($repo->getGitTags() as $gitTag) { + foreach ($gitTag->getInstallations() as $installation) { + $key = trim(($installation->getType() ?? '').' '.($installation->getFrameworkVersion() ?? '')); + if ('' !== $key) { + $typesAndVersions[$key] = true; + } + } + } + ksort($typesAndVersions); + + $repoKey = (string) $repo->getId(); + $packageVersionIds = $packageVersionsPerRepo[$repoKey] ?? []; + $advisoriesUrl = [] === $packageVersionIds ? null : $this->adminUrlGenerator + ->unsetAll() + ->setController(AdvisoryCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->set('filters', ['packageVersions' => ['comparison' => '=', 'value' => $packageVersionIds]]) + ->generateUrl(); + + $openTicket = null; + $leantimeProjectId = null; + foreach ($projects as $project) { + $rawLeantimeId = $project->getLeantimeId(); + if (null === $rawLeantimeId || '' === $rawLeantimeId) { + continue; + } + $candidateId = (int) $rawLeantimeId; + if (null === $leantimeProjectId) { + $leantimeProjectId = $candidateId; + } + if (isset($ticketsByLeantimeId[$candidateId])) { + $openTicket = $ticketsByLeantimeId[$candidateId]; + $leantimeProjectId = $candidateId; + break; + } + } + + return [ + 'repo' => $repo, + 'advisoryCount' => $entry['advisoryCount'], + 'advisoriesUrl' => $advisoriesUrl, + 'typesAndVersions' => array_keys($typesAndVersions), + 'projects' => $projects, + 'codeOwners' => array_values($codeOwners), + 'openTicket' => $openTicket, + 'leantimeProjectId' => $leantimeProjectId, + ]; + } +} diff --git a/src/Service/ServiceAgreementSyncService.php b/src/Service/ServiceAgreementSyncService.php index 99b8c0c..fccbea6 100644 --- a/src/Service/ServiceAgreementSyncService.php +++ b/src/Service/ServiceAgreementSyncService.php @@ -37,9 +37,17 @@ public function __construct( /** * Fetch all projects from the Economics API and sync them locally. * - * @return array{projects:int, unmatchedRepoNames:list} + * The Economics API is treated as the source of truth: every Project, + * CodeOwner and SecurityContract whose `economicsId` is not present in + * the response is removed. Nested data (codeowners, github repos and the + * service-agreement payload) is reconciled per project. GitHub repo names + * that cannot be matched against an existing GitRepo entry are collected + * and returned to the caller so they can be surfaced to the admin. * - * @throws \RuntimeException|\Exception if the API request fails + * @return array{projects: int, unmatchedRepoNames: list} count of projects processed and the list of unresolvable GitHub repo names + * + * @throws \RuntimeException if the Economics API request fails + * @throws \Exception if a date string in the payload cannot be parsed */ public function syncAll(): array { @@ -126,9 +134,20 @@ public function syncAll(): array } /** - * @param array> $codeOwnersData - * @param array $existingCodeOwners passed by reference so newly-created owners are reused within one sync run - * @param list $seenCodeOwnerIds + * Reconcile a project's code owners against the Economics payload. + * + * Upserts every code owner present in `$codeOwnersData` (creating new + * CodeOwner entities when needed), persists them, and adjusts the + * project's association so it ends up linked to exactly the desired set. + * Both `$existingCodeOwners` and `$seenCodeOwnerIds` are passed by + * reference because the caller reuses them across all projects in a + * single sync run — newly created owners must be reachable on the next + * iteration and the seen-list drives the post-loop cleanup pass. + * + * @param Project $project project being synced + * @param array> $codeOwnersData raw `codeowners` array straight from Economics + * @param array $existingCodeOwners by-reference id-keyed lookup; mutated to include newly-created owners + * @param list $seenCodeOwnerIds by-reference accumulator of every economicsId touched this run * * @param-out array $existingCodeOwners * @param-out list $seenCodeOwnerIds @@ -165,8 +184,22 @@ private function syncCodeOwners(Project $project, array $codeOwnersData, array & } /** - * @param array $existingGitReposByRepo - * @param array $unmatchedRepoNames accumulator across all projects + * Reconcile a project's GitHub repo associations against the Economics payload. + * + * Splits the multi-line `githubRepos` string into individual repo names, + * matches each one against the existing GitRepo lookup, and adjusts the + * project's association to the desired set. Repo names that have no + * matching GitRepo entry are recorded in `$unmatchedRepoNames` so the + * caller can warn the operator; GitRepo entries are NOT created on the + * fly because they are normally populated by the harvester and creating + * a half-empty one here would mask the underlying onboarding gap. + * + * @param Project $project project being synced + * @param string|null $githubReposString raw multi-line list from Economics, or null when the field is unset + * @param array $existingGitReposByRepo lookup keyed by GitRepo::getRepo() + * @param array $unmatchedRepoNames by-reference accumulator across all projects (set-like) + * + * @param-out array $unmatchedRepoNames */ private function syncGitRepos(Project $project, ?string $githubReposString, array $existingGitReposByRepo, array &$unmatchedRepoNames): void { @@ -201,9 +234,19 @@ private function syncGitRepos(Project $project, ?string $githubReposString, arra } /** - * @param array $data + * Copy a single Economics `serviceAgreement` payload onto a SecurityContract. + * + * Pure field-by-field mapping plus a project association — no persistence, + * no fetches. The contract may be either an existing entity (re-synced) + * or a brand-new one; the caller decides and persists it afterwards. + * Dates run through `parseDate()` which handles the Economics-specific + * `{date, timezone_type, timezone}` shape. + * + * @param SecurityContract $contract entity to mutate in place + * @param array $data raw `serviceAgreement` payload from Economics + * @param Project $project project the contract belongs to * - * @throws \Exception + * @throws \Exception if a date string in the payload cannot be parsed */ private function mapServiceAgreementToContract(SecurityContract $contract, array $data, Project $project): void { @@ -224,11 +267,17 @@ private function mapServiceAgreementToContract(SecurityContract $contract, array } /** - * Parse the Economics API date format ({date, timezone_type, timezone}) into a DateTimeImmutable. + * Parse the Economics API's `{date, timezone_type, timezone}` shape into a DateTimeImmutable. + * + * Returns null when the payload is null or missing the `date` key so the + * caller can leave the contract field unset. The timezone string from the + * payload is used as-is when present; otherwise UTC is assumed. + * + * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date payload from Economics, or null when absent * - * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData + * @return \DateTimeImmutable|null parsed date, or null when no date was supplied * - * @throws \Exception if the date string cannot be parsed + * @throws \Exception if the date string cannot be parsed by DateTimeImmutable */ private function parseDate(?array $dateData): ?\DateTimeImmutable { From 835450b6cd03a679d90973caf7ecf85e3d6cc87d Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 11:58:18 +0200 Subject: [PATCH 5/8] Updated documentation --- src/Service/LeantimeService.php | 61 ++++++++------------- src/Service/RepoAdvisoryService.php | 52 +++++++----------- src/Service/ServiceAgreementSyncService.php | 50 +++++++---------- 3 files changed, 64 insertions(+), 99 deletions(-) diff --git a/src/Service/LeantimeService.php b/src/Service/LeantimeService.php index 7f28c9d..ed1374a 100644 --- a/src/Service/LeantimeService.php +++ b/src/Service/LeantimeService.php @@ -10,14 +10,11 @@ /** * Minimal JSON-RPC 2.0 client for the Leantime API. * - * Only the calls this app needs: list open "Sikkerhedsopdatering" tickets, - * map codeowner emails to Leantime user IDs, and create new security tickets. - * The JSON-RPC contract (POST /api/jsonrpc/, x-api-key header) is copied - * verbatim from the reference implementation in leantime_ticket_generator. - * - * The scoped HTTP client `$leantimeClient` (configured in framework.yaml) - * provides the base URI and the x-api-key header, so this class only needs - * to assemble the JSON-RPC body and unwrap responses. + * Exposes the calls this app needs: listing open "Sikkerhedsopdatering" + * tickets, mapping codeowner emails to user IDs, and creating security + * tickets. The scoped HTTP client `$leantimeClient` supplies the base URI and + * the x-api-key header; this class only assembles the JSON-RPC body and + * unwraps responses. */ class LeantimeService { @@ -59,10 +56,8 @@ public function __construct( * Find currently-open security tickets across all Leantime projects. * * Pre-filters via the Leantime `searchCriteria` (term + type + status), - * then tightens the server-side LIKE match into an exact headline check, - * and finally keeps only the most recent matching ticket per Leantime - * project id. Used by the repo-advisories page to show whether a project - * already has an open security ticket. + * tightens the LIKE match into an exact headline check, and keeps the + * most recent matching ticket per Leantime project id. * * @return array tickets keyed by Leantime project id * @@ -119,10 +114,9 @@ public function findOpenSecurityTickets(): array /** * Resolve a Leantime user id for an email address. * - * Loads (and caches) the Leantime user directory on first call and looks - * the email up case-insensitively. Returns null when the email is empty - * or not present in Leantime, so callers can decide whether to fall back - * to an unassigned ticket. + * Lazy-loads the Leantime user directory on first call and looks the + * email up case-insensitively. Returns null when the email is empty or + * unknown so the caller can fall back to an unassigned ticket. * * @param string $email free-form email — leading/trailing whitespace and case are normalized * @@ -144,11 +138,9 @@ public function findUserIdByEmail(string $email): ?int /** * Create a "Sikkerhedsopdatering" task in the given Leantime project. * - * Submits a ticket with priority `critical`, status `new`, and a one-hour - * planned/remaining estimate. Dates default to "today" — Leantime requires - * editFrom/editTo/dateToFinish on creation, so we set all three. When - * `$userId` is null the ticket is created unassigned (Leantime accepts an - * empty editorId). + * Submits a ticket with priority `critical`, status `new`, a one-hour + * planned estimate, and editFrom/editTo/dateToFinish all set to today. + * A null `$userId` produces an unassigned ticket. * * @param int $projectId Leantime project id the ticket belongs to * @param int|null $userId Leantime user id to assign the ticket to, or null for unassigned @@ -190,11 +182,9 @@ public function createSecurityTicket(int $projectId, ?int $userId = null): int /** * Send a JSON-RPC 2.0 request to the Leantime API. * - * Wraps the scoped HTTP client with the JSON-RPC envelope (jsonrpc/method/ - * params/id) and unwraps the response. The HTTP client supplies the base - * URI and x-api-key header; this method just shapes the body and decodes - * the response. Both transport-level failures and API-level error - * responses are normalized into a RuntimeException. + * Wraps the call in the JSON-RPC envelope, decodes the response, and + * normalizes both transport failures and API-level error objects into + * RuntimeException. * * @param string $method JSON-RPC method name (e.g. `leantime.rpc.tickets.getAll`) * @param array $params method parameters to forward verbatim to Leantime @@ -229,13 +219,11 @@ private function request(string $method, array $params = []): mixed /** * Look up a Leantime user's display name by id. * - * Loads (and caches) the user directory on first call. Accepts mixed - * input because Leantime delivers ids as either strings or ints in - * different payloads; both are coerced to int for lookup. Returns null - * when the id is empty or unknown so the caller can fall back to a - * neutral label like "Unassigned". + * Lazy-loads the user directory on first call and coerces the id to int + * (Leantime delivers it as either string or int). Returns null when the + * id is empty or unknown. * - * @param mixed $userId raw user id straight from the Leantime payload (int, numeric string, or null) + * @param mixed $userId raw user id from the Leantime payload (int, numeric string, or null) * * @return string|null the user's display name, or null when no match exists * @@ -254,11 +242,10 @@ private function resolveUserName(mixed $userId): ?string /** * Populate the user id/name/email caches from Leantime. * - * Idempotent: a non-null `$userNamesById` short-circuits the call so the - * directory is fetched at most once per service instance. Builds both an - * id → display-name map (preferring firstname+lastname, falling back to - * username) and an email → id map keyed by lowercase email. Malformed - * entries (missing id) are skipped silently. + * Idempotent — fetches the directory at most once per service instance. + * Builds an id → display-name map (firstname+lastname, falling back to + * username) and an email → id map keyed by lowercase email; entries with + * no id are skipped. * * @throws \RuntimeException if the Leantime API rejects the request or the transport fails */ diff --git a/src/Service/RepoAdvisoryService.php b/src/Service/RepoAdvisoryService.php index b4be6af..82f094a 100644 --- a/src/Service/RepoAdvisoryService.php +++ b/src/Service/RepoAdvisoryService.php @@ -15,16 +15,10 @@ /** * Page-flow service backing the repo-advisories admin index. * - * Owns the data assembly that used to live in RepoAdvisoryController: joining - * GitRepo + Project + CodeOwner state, deriving installation type/version - * labels, building the deep-link URL into AdvisoryCrudController, and - * augmenting each row with live Leantime ticket state. Also orchestrates the - * inline "create security ticket" action by combining a CodeOwner lookup with - * LeantimeService calls. - * - * Kept separate from LeantimeService so the JSON-RPC client stays a thin - * Leantime API wrapper, and separate from ServiceAgreementSyncService since - * the shapes here exist only to feed the admin template. + * Joins GitRepo + Project + CodeOwner state into per-repo rows for the admin + * template, augments each row with the matching Leantime security ticket, and + * orchestrates the inline "create security ticket" action by combining a + * CodeOwner lookup with LeantimeService calls. */ class RepoAdvisoryService { @@ -40,17 +34,12 @@ public function __construct( /** * Build the rows + warnings rendered by the repo-advisories admin index. * - * Loads every GitRepo that has at least one advisory, attaches its - * projects/codeowners, derives installation-type/version labels, computes - * a filtered URL into AdvisoryCrudController for the repo's affected - * package versions, and pairs each row with the most recent matching - * Leantime "Sikkerhedsopdatering" ticket (if any). - * - * Failures while fetching Leantime tickets are caught and surfaced via the - * returned `leantimeError` so the page can still render the rows without - * ticket state — Leantime being down should not break the advisories view. + * Loads every GitRepo with at least one advisory and pairs each one with + * its matching open Leantime ticket. A Leantime fetch failure is captured + * in `leantimeError` so the page can still render the rows without ticket + * state. * - * @return array{rows: list>, leantimeError: ?string} rows keyed by repo plus an optional Leantime error message for the caller to flash + * @return array{rows: list>, leantimeError: ?string} rows plus an optional Leantime error for the caller to flash */ public function buildIndexRows(): array { @@ -79,15 +68,13 @@ public function buildIndexRows(): array /** * Create a Leantime "Sikkerhedsopdatering" ticket on behalf of a code owner. * - * Resolves the code owner via the repository, asks LeantimeService to - * translate its email into a Leantime user id, then creates the security - * ticket on the given project. When the email cannot be mapped to a - * Leantime user the ticket is still created — just unassigned — and the - * caller learns about it via the `unassigned` flag in the result. + * Resolves the code owner, maps its email to a Leantime user id, and + * creates the ticket on the given project. When no Leantime user matches + * the ticket is still created — just unassigned — signalled via the + * `unassigned` flag in the result. * * @param string $codeOwnerId RFC-4122 UUID of a CodeOwner entity - * @param int $leantimeProjectId numeric Leantime project id (external - * system's id, not an ORM id) + * @param int $leantimeProjectId numeric Leantime project id (external system's id, not an ORM id) * * @return array{ticketId: int, codeOwner: CodeOwner, unassigned: bool} the new ticket id, the resolved code owner, and whether the ticket is unassigned * @@ -114,17 +101,16 @@ public function createSecurityTicketForCodeOwner(string $codeOwnerId, int $leant /** * Build a single repo-advisory row for the admin index. * - * Encapsulates the per-repo joins: projects, code owners (deduplicated by - * id), installation type/version labels, the AdvisoryCrudController - * deep-link, and the matching Leantime ticket (preferring the project - * whose id has an open ticket; otherwise falling back to the first - * project's Leantime id). + * Resolves the repo's projects, deduplicates their code owners, derives + * installation type/version labels, builds the AdvisoryCrudController + * deep-link, and picks the Leantime project id — preferring one with an + * open ticket, otherwise the first non-empty project id. * * @param array{repo: \App\Entity\GitRepo, advisoryCount: int} $entry repo + precomputed advisory count * @param array> $packageVersionsPerRepo map of repo-id → package version ids that have advisories * @param array $ticketsByLeantimeId open security tickets keyed by Leantime project id * - * @return array Row data ready for the Twig template + * @return array row data ready for the Twig template */ private function buildRow(array $entry, array $packageVersionsPerRepo, array $ticketsByLeantimeId): array { diff --git a/src/Service/ServiceAgreementSyncService.php b/src/Service/ServiceAgreementSyncService.php index fccbea6..10007e3 100644 --- a/src/Service/ServiceAgreementSyncService.php +++ b/src/Service/ServiceAgreementSyncService.php @@ -37,12 +37,11 @@ public function __construct( /** * Fetch all projects from the Economics API and sync them locally. * - * The Economics API is treated as the source of truth: every Project, - * CodeOwner and SecurityContract whose `economicsId` is not present in - * the response is removed. Nested data (codeowners, github repos and the - * service-agreement payload) is reconciled per project. GitHub repo names - * that cannot be matched against an existing GitRepo entry are collected - * and returned to the caller so they can be surfaced to the admin. + * Treats the Economics API as the source of truth: any Project, + * CodeOwner or SecurityContract whose `economicsId` is missing from the + * response is removed. Nested codeowners, GitHub repos and service + * agreements are reconciled per project; GitHub repo names with no + * matching GitRepo entry are returned for the caller to surface. * * @return array{projects: int, unmatchedRepoNames: list} count of projects processed and the list of unresolvable GitHub repo names * @@ -136,16 +135,14 @@ public function syncAll(): array /** * Reconcile a project's code owners against the Economics payload. * - * Upserts every code owner present in `$codeOwnersData` (creating new - * CodeOwner entities when needed), persists them, and adjusts the - * project's association so it ends up linked to exactly the desired set. - * Both `$existingCodeOwners` and `$seenCodeOwnerIds` are passed by - * reference because the caller reuses them across all projects in a - * single sync run — newly created owners must be reachable on the next - * iteration and the seen-list drives the post-loop cleanup pass. + * Upserts each owner in `$codeOwnersData`, persists it, and links the + * project to exactly the desired set. The `$existingCodeOwners` and + * `$seenCodeOwnerIds` arrays are by-reference so newly created owners + * are reused across projects in the same sync run, and the seen-list + * drives the post-loop cleanup pass. * * @param Project $project project being synced - * @param array> $codeOwnersData raw `codeowners` array straight from Economics + * @param array> $codeOwnersData raw `codeowners` array from Economics * @param array $existingCodeOwners by-reference id-keyed lookup; mutated to include newly-created owners * @param list $seenCodeOwnerIds by-reference accumulator of every economicsId touched this run * @@ -186,13 +183,10 @@ private function syncCodeOwners(Project $project, array $codeOwnersData, array & /** * Reconcile a project's GitHub repo associations against the Economics payload. * - * Splits the multi-line `githubRepos` string into individual repo names, - * matches each one against the existing GitRepo lookup, and adjusts the - * project's association to the desired set. Repo names that have no - * matching GitRepo entry are recorded in `$unmatchedRepoNames` so the - * caller can warn the operator; GitRepo entries are NOT created on the - * fly because they are normally populated by the harvester and creating - * a half-empty one here would mask the underlying onboarding gap. + * Splits the multi-line `githubRepos` string and links the project to + * exactly the matching GitRepo entries. Unknown repo names are recorded + * in `$unmatchedRepoNames`; GitRepo entries are not created on the fly + * because that would hide the underlying harvester onboarding gap. * * @param Project $project project being synced * @param string|null $githubReposString raw multi-line list from Economics, or null when the field is unset @@ -236,11 +230,10 @@ private function syncGitRepos(Project $project, ?string $githubReposString, arra /** * Copy a single Economics `serviceAgreement` payload onto a SecurityContract. * - * Pure field-by-field mapping plus a project association — no persistence, - * no fetches. The contract may be either an existing entity (re-synced) - * or a brand-new one; the caller decides and persists it afterwards. - * Dates run through `parseDate()` which handles the Economics-specific - * `{date, timezone_type, timezone}` shape. + * Field-by-field mapping plus the project association. No persistence + * and no fetches — the caller persists the contract afterwards. Dates + * go through `parseDate()` to handle Economics' `{date, timezone_type, + * timezone}` shape. * * @param SecurityContract $contract entity to mutate in place * @param array $data raw `serviceAgreement` payload from Economics @@ -269,9 +262,8 @@ private function mapServiceAgreementToContract(SecurityContract $contract, array /** * Parse the Economics API's `{date, timezone_type, timezone}` shape into a DateTimeImmutable. * - * Returns null when the payload is null or missing the `date` key so the - * caller can leave the contract field unset. The timezone string from the - * payload is used as-is when present; otherwise UTC is assumed. + * Returns null when the payload is null or has no `date` key. The + * payload's timezone is honored when present; otherwise UTC is assumed. * * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date payload from Economics, or null when absent * From 62d3324e19b1cbc2dcaab3c4c343ef4c8145d8ad Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 12:25:16 +0200 Subject: [PATCH 6/8] Modified changelog --- CHANGELOG.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9031d..3414924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Adapt `/api/serviceagreements` consumer to new payload shape - - Add `Project` entity (top-level Economics project) with relations to - `CodeOwner` (ManyToMany), `GitRepo` (ManyToMany, matched by repo name), - and `SecurityContract` (OneToMany) - - Add `CodeOwner` entity (economicsId/name/email) - - Slim `SecurityContract` to the nested-agreement fields; drop - `projectName`, `clientName`, `leantimeUrl`, `projectTrackerKey`, - `gitRepos`, `quarterlyHours`, `cybersecurityPrice`, `cybersecurityNote`; - add ManyToOne to `Project` - - Rewrite `ServiceAgreementSyncService` to upsert projects, codeowners, - and contracts in one pass; warn when `githubRepos` names cannot be - linked to existing `GitRepo` rows +- [#80](https://github.com/itk-dev/devops_itksites/pull/80 ) 7523: Service agreements + - Add Project entity top-level Economics project + - Add CodeOwner entity + - Add Leantime integration - [#80](https://github.com/itk-dev/devops_itksites/pull/80 ) 5566: Service agreements - Add security contract entity with crud controller - Add Abstract full crud controller and extend on it in some cases From de1aa9b19e173ff159a26dc4d23b705e6af12c51 Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 12:46:53 +0200 Subject: [PATCH 7/8] Modified changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3414924..9539aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - [#80](https://github.com/itk-dev/devops_itksites/pull/80 ) 7523: Service agreements - - Add Project entity top-level Economics project + - Add Project entity top-level Economics project. - Add CodeOwner entity - Add Leantime integration - [#80](https://github.com/itk-dev/devops_itksites/pull/80 ) 5566: Service agreements From c40c3489b56bd268609c7821009e0c1545577ca1 Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 1 Jun 2026 14:46:48 +0200 Subject: [PATCH 8/8] Canged to use .env variables for leantime service --- .env | 5 +++++ config/packages/framework.yaml | 6 +++--- config/services.yaml | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.env b/.env index 0b6b01c..572d30d 100644 --- a/.env +++ b/.env @@ -68,3 +68,8 @@ APP_KEEP_RESULTS=5 APP_ECONOMICS_URI=https://economics.itkdev.dk APP_ECONOMICS_API_KEY=changeme ###< economics ### + +###> app/leantime ### +APP_LEANTIME_URI=http://leantime.invalid +APP_LEANTIME_API_KEY= +###< app/leantime ### diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7f37920..20230a0 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -21,11 +21,11 @@ framework: headers: x-api-key: '%env(APP_ECONOMICS_API_KEY)%' leantime.client: - base_uri: '%env(default:default_leantime_uri:APP_LEANTIME_URI)%' - scope: '%env(default:default_leantime_uri:APP_LEANTIME_URI)%' + base_uri: '%env(APP_LEANTIME_URI)%' + scope: '%env(APP_LEANTIME_URI)%' headers: Content-Type: 'application/json' - x-api-key: '%env(default::APP_LEANTIME_API_KEY)%' + x-api-key: '%env(APP_LEANTIME_API_KEY)%' when@test: framework: diff --git a/config/services.yaml b/config/services.yaml index 106c945..71d40c9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,7 +4,6 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: - default_leantime_uri: 'http://leantime.invalid' services: # default configuration for services in *this* file