diff --git a/.env b/.env index 0b6b01cf..572d30d8 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/CHANGELOG.md b/CHANGELOG.md index 095afd30..2e596ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,17 @@ 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 +- [#83](https://github.com/itk-dev/devops_itksites/pull/83) 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 - Add economics service and sync action/command for service agreement synchronization - [#81](https://github.com/itk-dev/devops_itksites/pull/81) 5564: Asset Mapper migration - Add Symfony Asset Mapper bundle and importmap +- Add Renovate auto-patch + auto-release pipeline (Phase 1 fork validation) ## [1.11.2] - 2026-06-02 diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 1aa06cb0..20230a0f 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(APP_LEANTIME_URI)%' + scope: '%env(APP_LEANTIME_URI)%' + headers: + Content-Type: 'application/json' + x-api-key: '%env(APP_LEANTIME_API_KEY)%' when@test: framework: diff --git a/migrations/Version20260527103011.php b/migrations/Version20260527103011.php new file mode 100644 index 00000000..b1f8b2d9 --- /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 55fcb64f..8c4ee44f 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/AdvisoryCrudController.php b/src/Controller/Admin/AdvisoryCrudController.php index 50c18ec2..362085ef 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/DashboardController.php b/src/Controller/Admin/DashboardController.php index 7044546d..5ca9619d 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 00000000..1ab836bd --- /dev/null +++ b/src/Controller/Admin/RepoAdvisoryController.php @@ -0,0 +1,129 @@ + + 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 + * AdminContext, and the templates extend the EasyAdmin layout normally. + */ +class RepoAdvisoryController extends AbstractController +{ + private const string CSRF_INTENT = 'repo_advisory_action'; + + public function __construct( + private readonly RepoAdvisoryService $repoAdvisoryService, + private readonly ServiceAgreementSyncService $serviceAgreementSyncService, + ) { + } + + #[AdminRoute(path: '/repo-advisories', name: 'repo_advisories', options: ['methods' => ['GET']])] + public function index(): Response + { + $result = $this->repoAdvisoryService->buildIndexRows(); + + 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' => $result['rows'], + 'csrf_intent' => self::CSRF_INTENT, + ]); + } + + #[AdminRoute(path: '/repo-advisories/sync', name: 'repo_advisories_sync', options: ['methods' => ['POST']])] + public function sync(Request $request): 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'); + } + + try { + $result = $this->serviceAgreementSyncService->syncAll(); + + $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())); + } + + return $this->redirectToRoute('admin_repo_advisories'); + } + + #[AdminRoute(path: '/repo-advisories/{repoId}/create-ticket', name: 'repo_advisories_create_ticket', options: ['methods' => ['POST']])] + public function createTicket(Request $request, string $repoId): RedirectResponse + { + unset($repoId); // route param kept for future per-repo context + + 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', ''); + $leantimeProjectId = (int) $request->request->get('leantimeProjectId', 0); + + if ('' === $codeOwnerId) { + $this->addFlash('warning', 'No code owner selected.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + if (0 === $leantimeProjectId) { + $this->addFlash('warning', 'No Leantime project linked to this repo.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + try { + $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.', + $result['codeOwner']->getName(), + $result['codeOwner']->getEmail(), + )); + } + + $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())); + } + + return $this->redirectToRoute('admin_repo_advisories'); + } +} diff --git a/src/Controller/Admin/SecurityContractCrudController.php b/src/Controller/Admin/SecurityContractCrudController.php index 758851d0..1cac8143 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 00000000..4d3e641a --- /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 00000000..acebcb9c --- /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 390cdd7f..c03cdefe 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 00000000..9e85b430 --- /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/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index d960d78d..6303083e 100644 --- a/src/Repository/GitRepoRepository.php +++ b/src/Repository/GitRepoRepository.php @@ -22,4 +22,71 @@ 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, + ); + } + + /** + * Distinct PackageVersion IDs reachable from each repo via + * gitTags → installations → packageVersions, restricted to versions that + * have at least one Advisory. This is the same chain used by + * findReposWithAdvisoryCount(), so a filter on Advisory.packageVersions + * with these IDs returns exactly the advisories counted on the repo list. + * + * Both keys and values are the Ulid's compact base32 form (i.e. its + * default __toString) so they match the choice keys Symfony's EntityType + * generates for filter URLs. + * + * @return array> repoUlid (base32) => list of PackageVersion Ulid (base32) + */ + public function findPackageVersionsPerRepoWithAdvisories(): array + { + /** @var list $rows */ + $rows = $this->createQueryBuilder('r') + ->select('r.id AS repoId', 'pv.id AS pvId') + ->distinct() + ->innerJoin('r.gitTags', 'gt') + ->innerJoin('gt.installations', 'i') + ->innerJoin('i.packageVersions', 'pv') + ->innerJoin('pv.advisories', 'a') + ->getQuery() + ->getResult(); + + $map = []; + foreach ($rows as $row) { + $repoKey = (string) $row['repoId']; + $map[$repoKey][] = (string) $row['pvId']; + } + + return $map; + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 00000000..0bd869f6 --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,47 @@ + + * + * @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); + } + + /** + * 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') + ->where(':repoId MEMBER OF p.gitRepos') + ->setParameter('repoId', $repo->getId(), 'ulid') + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + + return $projects; + } +} diff --git a/src/Service/LeantimeService.php b/src/Service/LeantimeService.php new file mode 100644 index 00000000..ed1374a0 --- /dev/null +++ b/src/Service/LeantimeService.php @@ -0,0 +1,284 @@ + + */ + private const array OPEN_STATUS_IDS = ['1', '2', '3', '4']; + + private const float DEFAULT_HOURS = 1.0; + + /** + * @var array|null cached display-name lookup keyed by user id + */ + private ?array $userNamesById = null; + + /** + * @var array|null cached email → user id lookup (lowercase email) + */ + private ?array $userIdsByEmail = null; + + public function __construct( + private readonly HttpClientInterface $leantimeClient, + ) { + } + + /** + * Find currently-open security tickets across all Leantime projects. + * + * Pre-filters via the Leantime `searchCriteria` (term + type + status), + * 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 + * + * @throws \RuntimeException if the Leantime API rejects the request or the transport fails + */ + public function findOpenSecurityTickets(): array + { + $result = $this->request('leantime.rpc.tickets.getAll', [ + 'searchCriteria' => [ + 'term' => self::SECURITY_TICKET_TITLE, + 'type' => self::TICKET_TYPE_TASK, + 'status' => self::OPEN_STATUS_IDS, + ], + ]); + + if (!is_array($result)) { + return []; + } + + $needle = mb_strtolower(self::SECURITY_TICKET_TITLE); + $exact = []; + foreach ($result as $ticket) { + if (!is_array($ticket) || !isset($ticket['projectId'])) { + continue; + } + $headline = isset($ticket['headline']) ? mb_strtolower(trim((string) $ticket['headline'])) : ''; + if ($headline !== $needle) { + continue; + } + $exact[] = $ticket; + } + + usort( + $exact, + static fn (array $a, array $b): int => strcmp((string) ($b['dateCreated'] ?? ''), (string) ($a['dateCreated'] ?? '')), + ); + + $byProjectId = []; + foreach ($exact as $ticket) { + $projectId = (int) $ticket['projectId']; + if (isset($byProjectId[$projectId])) { + continue; + } + $byProjectId[$projectId] = [ + 'id' => (int) ($ticket['id'] ?? 0), + 'assigneeName' => $this->resolveUserName($ticket['editorId'] ?? null), + 'createdAt' => isset($ticket['dateCreated']) ? (string) $ticket['dateCreated'] : null, + ]; + } + + return $byProjectId; + } + + /** + * Resolve a Leantime user id for an email address. + * + * 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 + * + * @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)); + if ('' === $email) { + return null; + } + $this->loadUsers(); + + return $this->userIdsByEmail[$email] ?? null; + } + + /** + * Create a "Sikkerhedsopdatering" task in the given Leantime project. + * + * 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 + * + * @return int the new ticket's Leantime id, or 0 if Leantime returned an unexpected response shape + * + * @throws \RuntimeException if the API rejects the request or the transport fails + */ + public function createSecurityTicket(int $projectId, ?int $userId = null): int + { + $date = date(self::DATE_FORMAT); + + $result = $this->request('leantime.rpc.tickets.addTicket', [ + 'values' => [ + 'headline' => self::SECURITY_TICKET_TITLE, + 'description' => '', + 'projectId' => $projectId, + 'type' => self::TICKET_TYPE_TASK, + 'status' => self::TICKET_STATUS_NEW, + 'priority' => self::TICKET_PRIORITY_CRITICAL, + 'dateToFinish' => $date, + 'editFrom' => $date, + 'editTo' => $date, + 'planHours' => (string) self::DEFAULT_HOURS, + 'hourRemaining' => (string) self::DEFAULT_HOURS, + 'tags' => '', + 'milestoneid' => '', + 'editorId' => $userId ?? '', + ], + ]); + + if (is_array($result) && isset($result[0])) { + return (int) $result[0]; + } + + return is_numeric($result) ? (int) $result : 0; + } + + /** + * Send a JSON-RPC 2.0 request to the Leantime API. + * + * 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 + * + * @return mixed the decoded `result` field from the JSON-RPC response, or null when absent + * + * @throws \RuntimeException on transport error or when the API responds with an `error` object + */ + private function request(string $method, array $params = []): mixed + { + try { + $response = $this->leantimeClient->request('POST', self::API_PATH, [ + 'json' => [ + 'jsonrpc' => self::JSONRPC_VERSION, + 'method' => $method, + 'params' => $params, + 'id' => uniqid('', true), + ], + ]); + $data = $response->toArray(false); + } catch (ExceptionInterface $e) { + throw new \RuntimeException('Leantime request failed: '.$e->getMessage(), 0, $e); + } + + if (isset($data['error'])) { + throw new \RuntimeException(sprintf('Leantime API error (%s): %s', $data['error']['code'] ?? '?', $data['error']['message'] ?? 'Unknown error')); + } + + return $data['result'] ?? null; + } + + /** + * Look up a Leantime user's display name by id. + * + * 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 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) { + return null; + } + $this->loadUsers(); + + return $this->userNamesById[(int) $userId] ?? null; + } + + /** + * Populate the user id/name/email caches from Leantime. + * + * 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 + */ + private function loadUsers(): void + { + if (null !== $this->userNamesById) { + return; + } + + $this->userNamesById = []; + $this->userIdsByEmail = []; + + $result = $this->request('leantime.rpc.users.getAll'); + if (!is_array($result)) { + return; + } + + foreach ($result as $user) { + if (!is_array($user) || !isset($user['id'])) { + continue; + } + $id = (int) $user['id']; + + $name = trim(((string) ($user['firstname'] ?? '')).' '.((string) ($user['lastname'] ?? ''))); + if ('' === $name) { + $name = (string) ($user['username'] ?? 'Unknown'); + } + $this->userNamesById[$id] = $name; + + $email = mb_strtolower(trim((string) ($user['email'] ?? ''))); + if ('' !== $email) { + $this->userIdsByEmail[$email] = $id; + } + } + } +} diff --git a/src/Service/RepoAdvisoryService.php b/src/Service/RepoAdvisoryService.php new file mode 100644 index 00000000..82f094ad --- /dev/null +++ b/src/Service/RepoAdvisoryService.php @@ -0,0 +1,176 @@ +>, leantimeError: ?string} rows plus an optional Leantime error 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, 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) + * + * @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. + * + * 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 + */ + 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 4c7be95a..10007e39 100644 --- a/src/Service/ServiceAgreementSyncService.php +++ b/src/Service/ServiceAgreementSyncService.php @@ -2,92 +2,249 @@ 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. + * 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 int the number of agreements synced + * @return array{projects: int, unmatchedRepoNames: list} count of projects processed and the list of unresolvable GitHub repo names * - * @throws \RuntimeException|\Exception if the API request fails + * @throws \RuntimeException if the Economics API request fails + * @throws \Exception if a date string in the payload cannot be parsed */ - 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 = []; - if (null === $contract->getEconomicsId()) { - $contract->setEconomicsId($economicsId); - } + 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->entityManager->persist($contract); + $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']; + } } - foreach ($existingContracts as $contract) { - if (!in_array($contract->getEconomicsId(), $seenIds, true)) { + foreach ($existingContracts as $economicsId => $contract) { + if (!in_array($economicsId, $seenContractIds, true)) { $this->entityManager->remove($contract); } } + foreach ($existingProjects as $economicsId => $project) { + if (!in_array($economicsId, $seenProjectIds, true)) { + $this->entityManager->remove($project); + } + } + + 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), + ]; + } + + /** + * Reconcile a project's code owners against the Economics payload. + * + * 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 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 + */ + 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); + } } /** - * Map API response data onto a SecurityContract entity. + * Reconcile a project's GitHub repo associations against the Economics payload. + * + * 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 array $data a single service agreement from the API + * @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) * - * @throws \Exception + * @param-out array $unmatchedRepoNames */ - private function mapDataToContract(SecurityContract $contract, array $data): void + 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); + } + } + + /** + * Copy a single Economics `serviceAgreement` payload onto a SecurityContract. + * + * 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 + * @param Project $project project the contract belongs to + * + * @throws \Exception if a date string in the payload cannot be parsed + */ + 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,31 +252,24 @@ 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. + * Parse the Economics API's `{date, timezone_type, timezone}` shape into a DateTimeImmutable. + * + * 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 object from the API + * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date payload from Economics, or null when absent * - * @return \DateTimeImmutable|null the parsed date, or null if no valid date data was provided + * @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 { @@ -127,7 +277,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); } diff --git a/templates/admin/repo_advisory/index.html.twig b/templates/admin/repo_advisory/index.html.twig new file mode 100644 index 00000000..164a257d --- /dev/null +++ b/templates/admin/repo_advisory/index.html.twig @@ -0,0 +1,115 @@ +{% 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 %} + +
RepositoryProject type & versionAdvisoriesProjectService agreementsCode owner action
{{ repo }} + {% for tv in row.typesAndVersions %} + {{ tv }} + {% else %} + — + {% endfor %} + + {% if row.advisoriesUrl %} + + {{ row.advisoryCount }} + + {% else %} + {{ row.advisoryCount }} + {% endif %} + + {% 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.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 %} +
+ {% endif %} +{% endblock %}