Skip to content

Commit 7eb2355

Browse files
authored
Add UI for webhooks (#269)
1 parent 5377bb6 commit 7eb2355

35 files changed

+1123
-47
lines changed

docker/php-fpm/cron/notifications

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ HOME=/app
99
*/5 * * * * www-data php bin/console revisions:fetch 2>&1
1010
*/5 * * * * www-data php -dmax_execution_time=0 bin/console revisions:validate 2>&1
1111
15 4 * * * www-data php bin/console code-inspection:cleanup 2>&1
12+
30 5 * * * www-data php bin/console webhook:cleanup 2>&1

migrations/Version20230506160659.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20230506160659 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return '';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql(
24+
'CREATE TABLE webhook_repository (webhook_id INT NOT NULL, repository_id INT NOT NULL, INDEX IDX_57821885C9BA60B (webhook_id), ' .
25+
'INDEX IDX_578218850C9D4F7 (repository_id), ' .
26+
'PRIMARY KEY(webhook_id, repository_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'
27+
);
28+
$this->addSql(
29+
'ALTER TABLE webhook_repository ADD CONSTRAINT FK_57821885C9BA60B FOREIGN KEY (webhook_id) REFERENCES webhook (id) ON DELETE CASCADE'
30+
);
31+
$this->addSql(
32+
'ALTER TABLE webhook_repository ' .
33+
'ADD CONSTRAINT FK_578218850C9D4F7 FOREIGN KEY (repository_id) REFERENCES repository (id) ON DELETE CASCADE'
34+
);
35+
$this->addSql('ALTER TABLE webhook DROP FOREIGN KEY FK_8A74175650C9D4F7');
36+
$this->addSql('DROP INDEX IDX_8A74175650C9D4F7 ON webhook');
37+
$this->addSql('INSERT INTO webhook_repository SELECT `id` AS webhook_id, repository_id FROM webhook');
38+
$this->addSql('ALTER TABLE webhook DROP repository_id');
39+
}
40+
41+
public function down(Schema $schema): void
42+
{
43+
// this down() migration is auto-generated, please modify it to your needs
44+
$this->addSql('ALTER TABLE webhook_repository DROP FOREIGN KEY FK_57821885C9BA60B');
45+
$this->addSql('ALTER TABLE webhook_repository DROP FOREIGN KEY FK_578218850C9D4F7');
46+
$this->addSql('DROP TABLE webhook_repository');
47+
$this->addSql('ALTER TABLE webhook ADD repository_id INT DEFAULT NULL');
48+
$this->addSql(
49+
'ALTER TABLE webhook ' .
50+
'ADD CONSTRAINT FK_8A74175650C9D4F7 FOREIGN KEY (repository_id) REFERENCES repository (id) ON UPDATE NO ACTION ON DELETE NO ACTION'
51+
);
52+
$this->addSql('CREATE INDEX IDX_8A74175650C9D4F7 ON webhook (repository_id)');
53+
}
54+
}

migrations/Version20230506201821.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20230506201821 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return '';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('CREATE INDEX create_timestamp_idx ON webhook_activity (create_timestamp)');
24+
}
25+
26+
public function down(Schema $schema): void
27+
{
28+
// this down() migration is auto-generated, please modify it to your needs
29+
$this->addSql('DROP INDEX create_timestamp_idx ON webhook_activity');
30+
}
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace DR\Review\Command\Webhook;
5+
6+
use DR\Review\Repository\Webhook\WebhookActivityRepository;
7+
use Symfony\Component\Console\Attribute\AsCommand;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
12+
#[AsCommand('webhook:cleanup', "Clean up all the webhook activity from 2 weeks and older.")]
13+
class WebhookCleanUpCommand extends Command
14+
{
15+
public function __construct(private readonly WebhookActivityRepository $activityRepository)
16+
{
17+
parent::__construct();
18+
}
19+
20+
/**
21+
* @inheritDoc
22+
*/
23+
protected function execute(InputInterface $input, OutputInterface $output): int
24+
{
25+
$removed = $this->activityRepository->cleanUp(strtotime("-2 weeks"));
26+
27+
$output->writeln("Removed " . $removed . " webhook activities");
28+
29+
return self::SUCCESS;
30+
}
31+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace DR\Review\Controller\App\Admin;
5+
6+
use DR\Review\Controller\AbstractController;
7+
use DR\Review\Entity\Webhook\Webhook;
8+
use DR\Review\Repository\Webhook\WebhookRepository;
9+
use DR\Review\Security\Role\Roles;
10+
use DR\Review\ViewModel\App\Admin\EditWebhookViewModel;
11+
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
12+
use Symfony\Component\HttpFoundation\RedirectResponse;
13+
use Symfony\Component\Routing\Annotation\Route;
14+
use Symfony\Component\Security\Http\Attribute\IsGranted;
15+
16+
class DeleteWebhookController extends AbstractController
17+
{
18+
public function __construct(private WebhookRepository $webhookRepository)
19+
{
20+
}
21+
22+
/**
23+
* @return array<string, EditWebhookViewModel>|RedirectResponse
24+
*/
25+
#[Route('/app/admin/webhook/{id<\d+>}', self::class, methods: ['DELETE'])]
26+
#[IsGranted(Roles::ROLE_ADMIN)]
27+
public function __invoke(#[MapEntity] Webhook $webhook): array|RedirectResponse
28+
{
29+
$this->webhookRepository->remove($webhook, true);
30+
31+
$this->addFlash('success', 'webhook.successful.removed');
32+
33+
return $this->refererRedirect(WebhooksController::class);
34+
}
35+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace DR\Review\Controller\App\Admin;
5+
6+
use DR\Review\Controller\AbstractController;
7+
use DR\Review\Entity\Webhook\Webhook;
8+
use DR\Review\Form\Webhook\EditWebhookFormType;
9+
use DR\Review\Repository\Webhook\WebhookRepository;
10+
use DR\Review\Security\Role\Roles;
11+
use DR\Review\ViewModel\App\Admin\EditWebhookViewModel;
12+
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
13+
use Symfony\Bridge\Twig\Attribute\Template;
14+
use Symfony\Component\HttpFoundation\RedirectResponse;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
17+
use Symfony\Component\Routing\Annotation\Route;
18+
use Symfony\Component\Security\Http\Attribute\IsGranted;
19+
20+
class WebhookController extends AbstractController
21+
{
22+
public function __construct(private WebhookRepository $webhookRepository)
23+
{
24+
}
25+
26+
/**
27+
* @return array<string, EditWebhookViewModel>|RedirectResponse
28+
*/
29+
#[Route('/app/admin/webhook/{id<\d+>?}', self::class, methods: ['GET', 'POST'])]
30+
#[Template('app/admin/edit_webhook.html.twig')]
31+
#[IsGranted(Roles::ROLE_ADMIN)]
32+
public function __invoke(Request $request, #[MapEntity] ?Webhook $webhook): array|RedirectResponse
33+
{
34+
if ($webhook === null && $request->attributes->get('id') !== null) {
35+
throw new NotFoundHttpException('Webhook not found');
36+
}
37+
38+
$webhook ??= (new Webhook())->setEnabled(true)->setRetries(3)->setVerifySsl(true);
39+
40+
$form = $this->createForm(EditWebhookFormType::class, ['webhook' => $webhook]);
41+
$form->handleRequest($request);
42+
if ($form->isSubmitted() === false || $form->isValid() === false) {
43+
return ['editWebhookModel' => new EditWebhookViewModel($webhook, $form->createView())];
44+
}
45+
46+
$this->webhookRepository->save($webhook, true);
47+
48+
$this->addFlash('success', 'webhook.successful.saved');
49+
50+
return $this->redirectToRoute(WebhooksController::class);
51+
}
52+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace DR\Review\Controller\App\Admin;
5+
6+
use DR\Review\Controller\AbstractController;
7+
use DR\Review\Security\Role\Roles;
8+
use DR\Review\ViewModel\App\Admin\WebhooksViewModel;
9+
use DR\Review\ViewModelProvider\WebhooksViewModelProvider;
10+
use Symfony\Bridge\Twig\Attribute\Template;
11+
use Symfony\Component\Routing\Annotation\Route;
12+
use Symfony\Component\Security\Http\Attribute\IsGranted;
13+
14+
class WebhooksController extends AbstractController
15+
{
16+
public function __construct(private readonly WebhooksViewModelProvider $viewModelProvider)
17+
{
18+
}
19+
20+
/**
21+
* @return array<string, WebhooksViewModel>
22+
*/
23+
#[Route('/app/admin/webhooks', self::class, methods: 'GET')]
24+
#[Template('app/admin/webhooks.html.twig')]
25+
#[IsGranted(Roles::ROLE_ADMIN)]
26+
public function __invoke(): array
27+
{
28+
return ['webhooksViewModel' => $this->viewModelProvider->getWebhooksViewModel()];
29+
}
30+
}

src/Entity/Webhook/Webhook.php

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,18 @@ class Webhook
3333
#[ORM\Column(type: 'json', nullable: true)]
3434
private array $headers = [];
3535

36-
#[ORM\ManyToOne(targetEntity: Repository::class)]
37-
private ?Repository $repository = null;
36+
/** @phpstan-var Collection<int, Repository> */
37+
#[ORM\ManyToMany(targetEntity: Repository::class)]
38+
private Collection $repositories;
3839

3940
/** @phpstan-var Collection<int, WebhookActivity> */
4041
#[ORM\OneToMany(mappedBy: 'webhook', targetEntity: WebhookActivity::class, cascade: ['persist', 'remove'], orphanRemoval: false)]
4142
private Collection $activities;
4243

4344
public function __construct()
4445
{
45-
$this->activities = new ArrayCollection();
46+
$this->repositories = new ArrayCollection();
47+
$this->activities = new ArrayCollection();
4648
}
4749

4850
public function setId(int $id): self
@@ -123,14 +125,37 @@ public function setHeaders(array $headers): self
123125
return $this;
124126
}
125127

126-
public function getRepository(): ?Repository
128+
public function setHeader(string $key, ?string $value): self
127129
{
128-
return $this->repository;
130+
if ($value === null) {
131+
unset($this->headers[$key]);
132+
} else {
133+
$this->headers[$key] = $value;
134+
}
135+
136+
return $this;
137+
}
138+
139+
/**
140+
* @return Collection<int, Repository>
141+
*/
142+
public function getRepositories(): Collection
143+
{
144+
return $this->repositories;
145+
}
146+
147+
public function addRepository(Repository $repository): self
148+
{
149+
if (!$this->repositories->contains($repository)) {
150+
$this->repositories->add($repository);
151+
}
152+
153+
return $this;
129154
}
130155

131-
public function setRepository(?Repository $repository): self
156+
public function removeRepository(Repository $repository): self
132157
{
133-
$this->repository = $repository;
158+
$this->repositories->removeElement($repository);
134159

135160
return $this;
136161
}

src/Entity/Webhook/WebhookActivity.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use DR\Review\Repository\Webhook\WebhookActivityRepository;
88

99
#[ORM\Entity(repositoryClass: WebhookActivityRepository::class)]
10+
#[ORM\Index(columns: ['create_timestamp'], name: 'create_timestamp_idx')]
1011
class WebhookActivity
1112
{
1213
#[ORM\Id]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace DR\Review\Form\Webhook;
5+
6+
use DR\Review\Controller\App\Admin\WebhookController;
7+
use DR\Review\Entity\Webhook\Webhook;
8+
use Symfony\Component\Form\AbstractType;
9+
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
10+
use Symfony\Component\Form\FormBuilderInterface;
11+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
12+
13+
class EditWebhookFormType extends AbstractType
14+
{
15+
public function __construct(private UrlGeneratorInterface $urlGenerator)
16+
{
17+
}
18+
19+
/**
20+
* @inheritDoc
21+
*/
22+
public function buildForm(FormBuilderInterface $builder, array $options): void
23+
{
24+
/** @var array{webhook: Webhook|null} $data */
25+
$data = $options['data'];
26+
27+
$builder->setAction($this->urlGenerator->generate(WebhookController::class, ['id' => $data['webhook']?->getId()]));
28+
$builder->setMethod('POST');
29+
$builder->add('webhook', WebhookType::class, ['label' => false]);
30+
$builder->add('save', SubmitType::class, ['label' => 'save']);
31+
}
32+
}

0 commit comments

Comments
 (0)