Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions migrations/Version20260527103011.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260527103011 extends AbstractMigration
{
public function getDescription(): string
{
return 'Introduce Project + CodeOwner entities; slim SecurityContract to nested-agreement fields.';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
12 changes: 10 additions & 2 deletions src/Command/SyncServiceAgreementsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
4 changes: 3 additions & 1 deletion src/Controller/Admin/AdvisoryCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/Controller/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
129 changes: 129 additions & 0 deletions src/Controller/Admin/RepoAdvisoryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Service\RepoAdvisoryService;
use App\Service\ServiceAgreementSyncService;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* 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, and renders an inline
* `<select> + 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');
}
}
29 changes: 16 additions & 13 deletions src/Controller/Admin/SecurityContractCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.');
Expand All @@ -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');
Expand All @@ -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);
Expand All @@ -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()));
}
Expand Down
Loading
Loading