├── .github
└── workflows
│ ├── .editorconfig
│ └── ci.yml
├── config
├── packages
│ ├── test
│ │ ├── twig.yaml
│ │ ├── happyr_service_mocking.yaml
│ │ ├── framework.yaml
│ │ ├── web_profiler.yaml
│ │ └── monolog.yaml
│ ├── lock.yaml
│ ├── twig.yaml
│ ├── prod
│ │ ├── routing.yaml
│ │ ├── deprecations.yaml
│ │ ├── doctrine.yaml
│ │ └── monolog.yaml
│ ├── dev
│ │ ├── web_profiler.yaml
│ │ └── monolog.yaml
│ ├── doctrine_migrations.yaml
│ ├── routing.yaml
│ ├── framework.yaml
│ ├── cache.yaml
│ ├── doctrine.yaml
│ ├── nyholm_psr7.yaml
│ └── github_api.yaml
├── routes
│ └── dev
│ │ ├── framework.yaml
│ │ └── web_profiler.yaml
├── routes.yaml
├── preload.php
├── services_test.yaml
└── bundles.php
├── .platform
├── local
│ └── project.yaml
├── routes.yaml
└── services.yaml
├── public
├── favicon.ico
├── apple-touch-icon.png
├── robots.txt
└── index.php
├── psalm.baseline.xml
├── php.ini
├── .env.test
├── tests
├── webhook_examples
│ ├── pull_request.opened.header.txt
│ ├── pull_request.draft_to_ready.header.txt
│ ├── pull_request.opened_draft.header.txt
│ ├── pull_request.opened_target_branch.header.txt
│ ├── issues.opened.header.txt
│ ├── issue_comment.created.header.txt
│ ├── pull_request.labeled.header.txt
│ └── pull_request.new_contibutor.header.txt
├── bootstrap.php
├── Service
│ ├── WipParserTest.php
│ ├── LabelNameExtractorTest.php
│ └── TaskHandler
│ │ ├── CloseDraftHandlerTest.php
│ │ ├── CloseStaleIssuesHandlerTest.php
│ │ └── InformAboutClosingStaleIssuesHandlerTest.php
├── ValidCommandProvider.php
├── Subscriber
│ ├── NeedsReviewNewPRSubscriberTest.php
│ ├── RemoveStalledLabelOnCommentSubscriberTest.php
│ ├── RewriteUnwantedPhrasesSubscriberTest.php
│ ├── UnsupportedBranchSubscriberTest.php
│ ├── MilestoneMergedPRSubscriberTest.php
│ ├── StatusChangeOnPushSubscriberTest.php
│ ├── MilestoneNewPRSubscriberTest.php
│ └── StatusChangeByCommentSubscriberTest.php
└── Controller
│ └── WebhookControllerTest.php
├── phpstan.neon.dist
├── src
├── Api
│ ├── Workflow
│ │ ├── NullWorkflowApi.php
│ │ ├── WorkflowApi.php
│ │ └── GithubWorkflowApi.php
│ ├── Issue
│ │ ├── IssueType.php
│ │ ├── NullIssueApi.php
│ │ ├── IssueApi.php
│ │ └── GithubIssueApi.php
│ ├── Milestone
│ │ ├── MilestoneApi.php
│ │ ├── NullMilestoneApi.php
│ │ ├── StaticMilestoneApi.php
│ │ └── GithubMilestoneApi.php
│ ├── Status
│ │ ├── StatusApi.php
│ │ ├── NullStatusApi.php
│ │ ├── Status.php
│ │ └── GitHubStatusApi.php
│ ├── PullRequest
│ │ ├── NullPullRequestApi.php
│ │ ├── PullRequestApi.php
│ │ └── GithubPullRequestApi.php
│ └── Label
│ │ ├── NullLabelApi.php
│ │ ├── LabelApi.php
│ │ ├── StaticLabelApi.php
│ │ └── GithubLabelApi.php
├── Service
│ ├── TaskHandler
│ │ ├── TaskHandlerInterface.php
│ │ ├── CloseDraftHandler.php
│ │ ├── CloseStaleIssuesHandler.php
│ │ └── InformAboutClosingStaleIssuesHandler.php
│ ├── WipParser.php
│ ├── TaskScheduler.php
│ ├── TaskRunner.php
│ ├── RepositoryProvider.php
│ ├── SymfonyVersionProvider.php
│ ├── StaleIssueCommentGenerator.php
│ ├── LabelNameExtractor.php
│ └── GitHubRequestHandler.php
├── Controller
│ ├── DefaultController.php
│ └── WebhookController.php
├── Event
│ ├── EventDispatcher.php
│ └── GitHubEvent.php
├── Model
│ └── Repository.php
├── Command
│ ├── RunTaskCommand.php
│ ├── ListTaskCommand.php
│ └── PingStaleIssuesCommand.php
├── Repository
│ └── TaskRepository.php
├── Subscriber
│ ├── ApproveCiForNonContributors.php
│ ├── NeedsReviewNewPRSubscriber.php
│ ├── AbstractStatusChangeSubscriber.php
│ ├── MilestoneMergedPRSubscriber.php
│ ├── BugLabelNewIssueSubscriber.php
│ ├── UpdateMilestoneWhenLabeledWaitingCodeMergeSubscriber.php
│ ├── StatusChangeByCommentSubscriber.php
│ ├── RemoveStalledLabelOnCommentSubscriber.php
│ ├── StatusChangeOnPushSubscriber.php
│ ├── AllowEditFromMaintainerSubscriber.php
│ ├── CloseDraftPRSubscriber.php
│ ├── MilestoneNewPRSubscriber.php
│ ├── UnsupportedBranchSubscriber.php
│ ├── RewriteUnwantedPhrasesSubscriber.php
│ ├── WelcomeFirstTimeContributorSubscriber.php
│ ├── MismatchBranchDescriptionSubscriber.php
│ ├── StatusChangeByReviewSubscriber.php
│ ├── AutoLabelFromContentSubscriber.php
│ └── AutoUpdateTitleWithLabelSubscriber.php
├── Kernel.php
├── Entity
│ └── Task.php
└── GitHubEvents.php
├── .php-cs-fixer.php
├── .gitignore
├── .editorconfig
├── Makefile
├── templates
├── base.html.twig
└── default
│ └── homepage.html.twig
├── bin
└── console
├── psalm.xml
├── compose.yaml
├── migrations
├── Version20201112220914.php
└── Version20201103191534.php
├── phpunit.xml.dist
├── LICENSE
├── .env
├── .platform.app.yaml
├── composer.json
└── README.md
/.github/workflows/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.yml]
2 | indent_size = 2
3 |
--------------------------------------------------------------------------------
/config/packages/test/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | strict_variables: true
3 |
--------------------------------------------------------------------------------
/.platform/local/project.yaml:
--------------------------------------------------------------------------------
1 | id: 7z7nkuqybnws6
2 | host: eu-5.platform.sh
3 |
--------------------------------------------------------------------------------
/config/packages/lock.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | lock: '%env(resolve:DATABASE_URL)%'
3 |
--------------------------------------------------------------------------------
/config/packages/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | default_path: '%kernel.project_dir%/templates'
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/symfony-tools/carsonbot/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/config/packages/prod/routing.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | router:
3 | strict_requirements: null
4 |
--------------------------------------------------------------------------------
/psalm.baseline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/symfony-tools/carsonbot/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/config/routes/dev/framework.yaml:
--------------------------------------------------------------------------------
1 | _errors:
2 | resource: '@FrameworkBundle/Resources/config/routing/errors.php'
3 | prefix: /_error
4 |
--------------------------------------------------------------------------------
/config/packages/test/happyr_service_mocking.yaml:
--------------------------------------------------------------------------------
1 | happyr_service_mocking:
2 | services:
3 | - 'App\Api\PullRequest\PullRequestApi'
4 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://developers.google.com/search/docs/crawling-indexing/robots/create-robots-txt
2 |
3 | User-agent: *
4 | Disallow:
5 |
--------------------------------------------------------------------------------
/config/packages/test/framework.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | test: true
3 | session:
4 | storage_factory_id: session.storage.factory.mock_file
5 |
--------------------------------------------------------------------------------
/config/routes.yaml:
--------------------------------------------------------------------------------
1 | controllers:
2 | resource:
3 | path: ../src/Controller/
4 | namespace: App\Controller
5 | type: attribute
6 |
--------------------------------------------------------------------------------
/.platform/routes.yaml:
--------------------------------------------------------------------------------
1 | "https://{default}/": { type: upstream, upstream: "carson-bot:http" }
2 | "http://{default}/": { type: redirect, to: "https://{default}/" }
3 |
--------------------------------------------------------------------------------
/config/packages/test/web_profiler.yaml:
--------------------------------------------------------------------------------
1 | web_profiler:
2 | toolbar: false
3 | intercept_redirects: false
4 |
5 | framework:
6 | profiler: { collect: false }
7 |
--------------------------------------------------------------------------------
/config/packages/dev/web_profiler.yaml:
--------------------------------------------------------------------------------
1 | web_profiler:
2 | toolbar: true
3 | intercept_redirects: false
4 |
5 | framework:
6 | profiler: { only_exceptions: false }
7 |
--------------------------------------------------------------------------------
/.platform/services.yaml:
--------------------------------------------------------------------------------
1 | mydatabase:
2 | # supported versions: 9.6, 10, 11, 12
3 | # 9.3 is also available but not maintained upstream
4 | type: postgresql:12
5 | disk: 1024
6 |
--------------------------------------------------------------------------------
/php.ini:
--------------------------------------------------------------------------------
1 | allow_url_include=off
2 | display_errors=off
3 | display_startup_errors=off
4 | max_execution_time=30
5 | session.use_strict_mode=On
6 | realpath_cache_ttl=3600
7 | zend.detect_unicode=Off
8 |
--------------------------------------------------------------------------------
/config/preload.php:
--------------------------------------------------------------------------------
1 | new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
8 |
--------------------------------------------------------------------------------
/.env.test:
--------------------------------------------------------------------------------
1 | # define your env variables for the test env here
2 | KERNEL_CLASS='App\Kernel'
3 | APP_SECRET='$ecretf0rt3st'
4 | SYMFONY_DOCS_SECRET=''
5 | SYMFONY_SECRET=''
6 |
7 | BOT_USERNAME='carsonbot-test'
8 | GITHUB_TOKEN=CHANGE_ME
9 |
--------------------------------------------------------------------------------
/config/routes/dev/web_profiler.yaml:
--------------------------------------------------------------------------------
1 | web_profiler_wdt:
2 | resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
3 | prefix: /_wdt
4 |
5 | web_profiler_profiler:
6 | resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
7 | prefix: /_profiler
8 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.opened.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: http://carson.knpuniversity.com/webhooks/github
2 | Request method: POST
3 | content-type: application/json
4 | Expect:
5 | User-Agent: GitHub-Hookshot/2ee22c1
6 | X-GitHub-Delivery: 4640d280-1da9-11e5-9380-bbf5a1da51b6
7 | X-GitHub-Event: pull_request
8 |
--------------------------------------------------------------------------------
/config/packages/doctrine_migrations.yaml:
--------------------------------------------------------------------------------
1 | doctrine_migrations:
2 | migrations_paths:
3 | # namespace is arbitrary but should be different from App\Migrations
4 | # as migrations classes should NOT be autoloaded
5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations'
6 | enable_profiler: false
7 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.draft_to_ready.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: http://carson.knpuniversity.com/webhooks/github
2 | Request method: POST
3 | content-type: application/json
4 | Expect:
5 | User-Agent: GitHub-Hookshot/2ee22c1
6 | X-GitHub-Delivery: 4640d280-1da9-11e5-9380-bbf5a1da51b6
7 | X-GitHub-Event: pull_request
8 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.opened_draft.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: http://carson.knpuniversity.com/webhooks/github
2 | Request method: POST
3 | content-type: application/json
4 | Expect:
5 | User-Agent: GitHub-Hookshot/2ee22c1
6 | X-GitHub-Delivery: 4640d280-1da9-11e5-9380-bbf5a1da51b6
7 | X-GitHub-Event: pull_request
8 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 8
3 | reportUnmatchedIgnoredErrors: false
4 | paths:
5 | - src
6 | ignoreErrors:
7 | - "#\\\\Entity\\\\.* is never written, only read#"
8 | - "#\\\\Entity\\\\.* is never read, only written#"
9 | - "#^Property .*\\\\Entity\\\\.* is unused#"
10 |
--------------------------------------------------------------------------------
/src/Api/Workflow/NullWorkflowApi.php:
--------------------------------------------------------------------------------
1 | in(__DIR__.'/src')
5 | ->in(__DIR__.'/tests')
6 | ;
7 |
8 | return (new PhpCsFixer\Config())
9 | ->setRules([
10 | '@PSR2' => true,
11 | '@Symfony' => true,
12 | 'array_syntax' => ['syntax' => 'short'],
13 | ])
14 | ->setFinder($finder)
15 | ;
16 |
--------------------------------------------------------------------------------
/src/Service/TaskHandler/TaskHandlerInterface.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface TaskHandlerInterface
11 | {
12 | public function handle(Task $task): void;
13 |
14 | public function supports(Task $task): bool;
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ###> symfony/framework-bundle ###
2 | /.env.local
3 | /.env.local.php
4 | /.env.*.local
5 | /config/secrets/prod/prod.decrypt.private.php
6 | /public/bundles/
7 | /var/
8 | /vendor/
9 | ###< symfony/framework-bundle ###
10 |
11 | .php-cs-fixer.cache
12 |
13 | ###> phpunit/phpunit ###
14 | /phpunit.xml
15 | .phpunit.cache/
16 | ###< phpunit/phpunit ###
17 |
--------------------------------------------------------------------------------
/config/packages/prod/deprecations.yaml:
--------------------------------------------------------------------------------
1 | # As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists
2 | #monolog:
3 | # channels: [deprecation]
4 | # handlers:
5 | # deprecation:
6 | # type: stream
7 | # channels: [deprecation]
8 | # path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"
9 |
--------------------------------------------------------------------------------
/config/packages/prod/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | orm:
3 | auto_generate_proxy_classes: false
4 |
5 | when@prod:
6 | framework:
7 | cache:
8 | pools:
9 | doctrine.result_cache_pool:
10 | adapter: cache.app
11 | doctrine.system_cache_pool:
12 | adapter: cache.system
13 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.opened_target_branch.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: http://carson.knpuniversity.com/webhooks/github
2 | Request method: POST
3 | Accept: */*
4 | content-type: application/x-www-form-urlencoded
5 | User-Agent: GitHub-Hookshot/b1c41a3
6 | X-GitHub-Delivery: d9c13b80-17ae-11eb-8ab5-293f6833841d
7 | X-GitHub-Event: pull_request
8 | X-GitHub-Hook-ID: 141546673
9 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | bootEnv(dirname(__DIR__).'/.env');
11 | }
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 4
10 | indent_style = space
11 | insert_final_newline = true
12 | trim_trailing_whitespace = true
13 |
14 | [Makefile]
15 | indent_style = tab
16 |
17 | [phpunit.xml.dist]
18 | indent_size = 2
19 |
--------------------------------------------------------------------------------
/config/packages/routing.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | router:
3 | utf8: true
4 |
5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
7 | #default_uri: http://localhost
8 |
9 | when@prod:
10 | framework:
11 | router:
12 | strict_requirements: null
13 |
--------------------------------------------------------------------------------
/tests/webhook_examples/issues.opened.header.txt:
--------------------------------------------------------------------------------
1 | Total-Route-Time: 0
2 | Content-Type: application/json
3 | Host: requestb.in
4 | X-Github-Event: issues
5 | Connection: close
6 | X-Request-Id: 41d0217e-a754-445e-bd7a-eb6ba0607f28
7 | Accept: */*
8 | Content-Length: 7038
9 | X-Github-Delivery: 20a8ce80-1d93-11e5-84ce-56c2eaece659
10 | User-Agent: GitHub-Hookshot/2ee22c1
11 | Connect-Time: 1
12 | Via: 1.1 vegur
13 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build coverage help test
2 |
3 | help:
4 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
5 |
6 | build: test ## Runs test targets
7 |
8 | test: vendor/autoload.php ## Runs tests with phpunit
9 | vendor/bin/phpunit --testsuite Unit
10 |
11 | vendor/autoload.php:
12 | composer install --no-interaction
13 |
--------------------------------------------------------------------------------
/config/packages/test/monolog.yaml:
--------------------------------------------------------------------------------
1 | monolog:
2 | handlers:
3 | main:
4 | type: fingers_crossed
5 | action_level: error
6 | handler: nested
7 | excluded_http_codes: [404, 405]
8 | channels: ["!event"]
9 | nested:
10 | type: stream
11 | path: "%kernel.logs_dir%/%kernel.environment%.log"
12 | level: debug
13 |
--------------------------------------------------------------------------------
/tests/webhook_examples/issue_comment.created.header.txt:
--------------------------------------------------------------------------------
1 | Total-Route-Time: 0
2 | Content-Type: application/json
3 | Host: requestb.in
4 | X-Github-Event: issue_comment
5 | Connection: close
6 | X-Request-Id: 44783e91-c72d-4941-988d-5d5ba59e9531
7 | Accept: */*
8 | Content-Length: 8301
9 | X-Github-Delivery: 65cd0080-1d93-11e5-92c8-651e67aa022e
10 | User-Agent: GitHub-Hookshot/2ee22c1
11 | Connect-Time: 0
12 | Via: 1.1 vegur
13 |
--------------------------------------------------------------------------------
/src/Service/WipParser.php:
--------------------------------------------------------------------------------
1 |
7 | */
8 | class WipParser
9 | {
10 | /**
11 | * Returns true if the title starts with [WIP], (WIP) or WIP:.
12 | */
13 | public static function matchTitle(string $title): bool
14 | {
15 | return (bool) preg_match('@^(\[wip\]|\(wip\)|wip:)@mi', $title);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Api/Milestone/MilestoneApi.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface MilestoneApi
11 | {
12 | public function updateMilestone(Repository $repository, int $issueNumber, string $milestoneName): void;
13 |
14 | public function exists(Repository $repository, string $milestoneName): bool;
15 | }
16 |
--------------------------------------------------------------------------------
/src/Api/Workflow/WorkflowApi.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface WorkflowApi
11 | {
12 | /**
13 | * Find workflow runs related to the PR and approve them.
14 | */
15 | public function approveWorkflowsForPullRequest(Repository $repository, string $headRepository, string $headBranch): void;
16 | }
17 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.labeled.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: https://nyholm.tech:
2 | Request method: POST
3 | Accept: */*
4 | content-type: application/x-www-form-urlencoded
5 | User-Agent: GitHub-Hookshot/888f7f5
6 | X-GitHub-Delivery: 10530500-1d1f-11eb-905c-5bab507ae18e
7 | X-GitHub-Event: pull_request
8 | X-GitHub-Hook-ID: 259877073
9 | X-GitHub-Hook-Installation-Target-ID: 309178226
10 | X-GitHub-Hook-Installation-Target-Type: repository
11 |
--------------------------------------------------------------------------------
/tests/webhook_examples/pull_request.new_contibutor.header.txt:
--------------------------------------------------------------------------------
1 | Request URL: https://nyholm.tech:
2 | Request method: POST
3 | Accept: */*
4 | content-type: application/x-www-form-urlencoded
5 | User-Agent: GitHub-Hookshot/888f7f5
6 | X-GitHub-Delivery: 47beb300-1d19-11eb-818f-ace469801400
7 | X-GitHub-Event: pull_request
8 | X-GitHub-Hook-ID: 259877073
9 | X-GitHub-Hook-Installation-Target-ID: 309178226
10 | X-GitHub-Hook-Installation-Target-Type: repository
11 |
--------------------------------------------------------------------------------
/src/Api/Status/StatusApi.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | interface StatusApi
13 | {
14 | public function getIssueStatus(int $issueNumber, Repository $repository): ?string;
15 |
16 | public function setIssueStatus(int $issueNumber, ?string $newStatus, Repository $repository): void;
17 | }
18 |
--------------------------------------------------------------------------------
/src/Api/Milestone/NullMilestoneApi.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class NullStatusApi implements StatusApi
11 | {
12 | public function getIssueStatus(int $issueNumber, Repository $repository): ?string
13 | {
14 | return null;
15 | }
16 |
17 | public function setIssueStatus(int $issueNumber, ?string $newStatus, Repository $repository): void
18 | {
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Api/Status/Status.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | final class Status
11 | {
12 | public const string NEEDS_REVIEW = 'needs_review';
13 | public const string NEEDS_WORK = 'needs_work';
14 | public const string WORKS_FOR_ME = 'works_for_me';
15 | public const string REVIEWED = 'reviewed';
16 | public const string WAITING_FEEDBACK = 'waiting_feedback';
17 | }
18 |
--------------------------------------------------------------------------------
/src/Api/Milestone/StaticMilestoneApi.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | class StaticMilestoneApi extends NullMilestoneApi
15 | {
16 | public function exists(Repository $repository, string $milestoneName): bool
17 | {
18 | return in_array($milestoneName, ['3.4', '4.4', '5.1', '5.x']);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/config/packages/framework.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | secret: '%env(APP_SECRET)%'
3 | #csrf_protection: true
4 | #http_method_override: true
5 |
6 | # Enables session support. Note that the session will ONLY be started if you read or write from it.
7 | # Remove or comment this section to explicitly disable session support.
8 | session:
9 | handler_id: null
10 | cookie_secure: auto
11 | cookie_samesite: lax
12 |
13 | #esi: true
14 | #fragments: true
15 | php_errors:
16 | log: true
17 |
--------------------------------------------------------------------------------
/templates/base.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% block title %}Carsonbot{% endblock %}
6 |
7 |
8 |
9 | {% block body %}{% endblock %}
10 | {% block javascripts %}{% endblock %}
11 |
12 |
13 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/config/bundles.php:
--------------------------------------------------------------------------------
1 | ['all' => true],
5 | Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
6 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
8 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
9 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
10 | Happyr\ServiceMocking\HappyrServiceMockingBundle::class => ['dev' => true, 'test' => true],
11 | ];
12 |
--------------------------------------------------------------------------------
/src/Api/PullRequest/NullPullRequestApi.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class NullPullRequestApi implements PullRequestApi
13 | {
14 | public function show(Repository $repository, int $number): array
15 | {
16 | return [];
17 | }
18 |
19 | public function updateTitle(Repository $repository, int $number, string $title, ?string $body = null): void
20 | {
21 | }
22 |
23 | public function getAuthorCount(Repository $repository, string $author): int
24 | {
25 | return 1;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Controller/DefaultController.php:
--------------------------------------------------------------------------------
1 | getAllRepositories();
16 |
17 | return $this->render('default/homepage.html.twig', [
18 | 'repositories' => $repositories,
19 | ]);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/config/packages/dev/monolog.yaml:
--------------------------------------------------------------------------------
1 | monolog:
2 | handlers:
3 | main:
4 | type: stream
5 | path: "%kernel.logs_dir%/%kernel.environment%.log"
6 | level: debug
7 | channels: ["!event"]
8 | # uncomment to get logging in your browser
9 | # you may have to allow bigger header sizes in your Web server configuration
10 | #firephp:
11 | # type: firephp
12 | # level: info
13 | #chromephp:
14 | # type: chromephp
15 | # level: info
16 | console:
17 | type: console
18 | process_psr_3_messages: false
19 | channels: ["!event", "!doctrine", "!console"]
20 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | database:
3 | # In production, you may want to use a managed database service
4 | image: postgres:12-alpine
5 | environment:
6 | - POSTGRES_DB=carsonbot
7 | - POSTGRES_USER=db_user
8 | # You should definitely change the password in production
9 | - POSTGRES_PASSWORD=db_password
10 | volumes:
11 | - db-data:/var/lib/postgresql/data:rw
12 | # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
13 | # - ./docker/db/data:/var/lib/postgresql/data:rw
14 | ports:
15 | - "5432:5432"
16 | volumes:
17 | db-data: { }
18 |
--------------------------------------------------------------------------------
/src/Controller/WebhookController.php:
--------------------------------------------------------------------------------
1 | handle($request);
17 |
18 | return new JsonResponse($responseData);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/migrations/Version20201112220914.php:
--------------------------------------------------------------------------------
1 | addSql(<<throwIrreversibleMigrationException();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Api/PullRequest/PullRequestApi.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | interface PullRequestApi
16 | {
17 | /**
18 | * @return array
19 | */
20 | public function show(Repository $repository, int $number): array;
21 |
22 | public function updateTitle(Repository $repository, int $number, string $title, ?string $body = null): void;
23 |
24 | public function getAuthorCount(Repository $repository, string $author): int;
25 | }
26 |
--------------------------------------------------------------------------------
/config/packages/cache.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | cache:
3 | # Unique name of your app: used to compute stable namespaces for cache keys.
4 | #prefix_seed: your_vendor_name/app_name
5 |
6 | # The "app" cache stores to the filesystem by default.
7 | # The data in this cache should persist between deploys.
8 | # Other options include:
9 |
10 | # Redis
11 | #app: cache.adapter.redis
12 | #default_redis_provider: redis://localhost
13 |
14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
15 | #app: cache.adapter.apcu
16 |
17 | # Namespaced pools use the above "app" backend by default
18 | pools:
19 | #my.dedicated.cache: null
20 |
--------------------------------------------------------------------------------
/src/Service/TaskScheduler.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class TaskScheduler
17 | {
18 | public function __construct(
19 | private readonly TaskRepository $taskRepo,
20 | ) {
21 | }
22 |
23 | public function runLater(Repository $repository, int $number, int $action, \DateTimeImmutable $checkAt): void
24 | {
25 | $task = new Task($repository->getFullName(), $number, $action, $checkAt);
26 | $this->taskRepo->persist($task);
27 | $this->taskRepo->flush();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Event/EventDispatcher.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | protected array $dispatchers = [];
13 |
14 | public function addDispatcher(string $repositoryName, EventDispatcherInterface $dispatcher): void
15 | {
16 | $this->dispatchers[$repositoryName] = $dispatcher;
17 | }
18 |
19 | public function dispatch(GitHubEvent $event, string $eventName): void
20 | {
21 | $name = $event->getRepository()->getFullName();
22 |
23 | if (isset($this->dispatchers[$name])) {
24 | $this->dispatchers[$name]->dispatch($event, $eventName);
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Api/Label/NullLabelApi.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface LabelApi
11 | {
12 | /**
13 | * @return string[]
14 | */
15 | public function getIssueLabels(int $issueNumber, Repository $repository): array;
16 |
17 | public function addIssueLabel(int $issueNumber, string $label, Repository $repository): void;
18 |
19 | public function removeIssueLabel(int $issueNumber, string $label, Repository $repository): void;
20 |
21 | /**
22 | * @param string[] $labels
23 | */
24 | public function addIssueLabels(int $issueNumber, array $labels, Repository $repository): void;
25 |
26 | /**
27 | * @return string[]
28 | */
29 | public function getAllLabelsForRepository(Repository $repository): array;
30 | }
31 |
--------------------------------------------------------------------------------
/config/packages/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | url: '%env(resolve:DATABASE_URL)%'
4 |
5 | # IMPORTANT: You MUST configure your server version,
6 | # either here or in the DATABASE_URL env var (see .env file)
7 | #server_version: '5.7'
8 | orm:
9 | identity_generation_preferences:
10 | Doctrine\DBAL\Platforms\PostgreSQLPlatform: SEQUENCE
11 | auto_generate_proxy_classes: true
12 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
13 | report_fields_where_declared: true
14 | enable_lazy_ghost_objects: true
15 | auto_mapping: true
16 | mappings:
17 | App:
18 | is_bundle: false
19 | type: attribute
20 | dir: '%kernel.project_dir%/src/Entity'
21 | prefix: 'App\Entity'
22 | alias: App
23 |
--------------------------------------------------------------------------------
/tests/Service/WipParserTest.php:
--------------------------------------------------------------------------------
1 | assertSame($expected, WipParser::matchTitle($title));
17 | }
18 |
19 | /**
20 | * @return iterable
21 | */
22 | public static function titlesProvider(): iterable
23 | {
24 | yield [true, '[WIP] foo'];
25 | yield [true, 'WIP: bar'];
26 | yield [true, '(WIP) xas'];
27 | yield [true, '[WIP]foo'];
28 | yield [true, '[wip] foo'];
29 | yield [false, 'Bar [WIP] foo'];
30 | yield [false, 'FOOWIP: foo'];
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Api/Issue/NullIssueApi.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | src
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | tests
17 | tests/Controller
18 |
19 |
20 | tests/Controller
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tests/ValidCommandProvider.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class ValidCommandProvider
13 | {
14 | /**
15 | * @return iterable
16 | */
17 | public static function get(): iterable
18 | {
19 | yield ['Status: needs review', StatusChangeByCommentSubscriber::class];
20 | yield ['Status: needs work', StatusChangeByCommentSubscriber::class];
21 | yield ['Status: reviewed', StatusChangeByCommentSubscriber::class];
22 | yield ['Status: works for me', StatusChangeByCommentSubscriber::class];
23 | yield ['@carsonbot Status: needs review', StatusChangeByCommentSubscriber::class];
24 | yield ['@carsonbot Status: reviewed', StatusChangeByCommentSubscriber::class];
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 Ryan Weaver
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is furnished
8 | to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/Api/Workflow/GithubWorkflowApi.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class GithubWorkflowApi implements WorkflowApi
13 | {
14 | public function __construct(
15 | private readonly ResultPager $resultPager,
16 | private readonly WorkflowRuns $workflowApi,
17 | ) {
18 | }
19 |
20 | public function approveWorkflowsForPullRequest(Repository $repository, string $headRepository, string $headBranch): void
21 | {
22 | $runs = $this->resultPager->fetchAllLazy($this->workflowApi, 'all', [$repository->getVendor(), $repository->getName(), [
23 | 'branch' => $headBranch,
24 | 'status' => 'action_required',
25 | ]]);
26 |
27 | foreach ($runs as $run) {
28 | if ($headRepository === $run['head_repository']['full_name']) {
29 | $this->workflowApi->approve($repository->getVendor(), $repository->getName(), $run['id']);
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/config/packages/nyholm_psr7.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | # Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)
3 | Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory'
4 | Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory'
5 | Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory'
6 | Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory'
7 | Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory'
8 | Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory'
9 |
10 | # Register nyholm/psr7 services for autowiring with HTTPlug factories
11 | Http\Message\MessageFactory: '@nyholm.psr7.httplug_factory'
12 | Http\Message\RequestFactory: '@nyholm.psr7.httplug_factory'
13 | Http\Message\ResponseFactory: '@nyholm.psr7.httplug_factory'
14 | Http\Message\StreamFactory: '@nyholm.psr7.httplug_factory'
15 | Http\Message\UriFactory: '@nyholm.psr7.httplug_factory'
16 |
17 | nyholm.psr7.psr17_factory:
18 | class: Nyholm\Psr7\Factory\Psr17Factory
19 |
20 | nyholm.psr7.httplug_factory:
21 | class: Nyholm\Psr7\Factory\HttplugFactory
22 |
--------------------------------------------------------------------------------
/migrations/Version20201103191534.php:
--------------------------------------------------------------------------------
1 | addSql('CREATE SEQUENCE task_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
15 | $this->addSql('CREATE TABLE task (id INT NOT NULL, repository_full_name VARCHAR(255) NOT NULL, number INT NOT NULL, action INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, verify_after TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
16 | $this->addSql('COMMENT ON COLUMN task.created_at IS \'(DC2Type:datetime_immutable)\'');
17 | $this->addSql('COMMENT ON COLUMN task.updated_at IS \'(DC2Type:datetime_immutable)\'');
18 | $this->addSql('COMMENT ON COLUMN task.verify_after IS \'(DC2Type:datetime_immutable)\'');
19 | }
20 |
21 | public function down(Schema $schema) : void
22 | {
23 | $this->addSql('DROP SEQUENCE task_id_seq CASCADE');
24 | $this->addSql('DROP TABLE task');
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tests/Service/LabelNameExtractorTest.php:
--------------------------------------------------------------------------------
1 | assertSame($expected, $extractor->extractLabels($title, $repo));
21 | }
22 |
23 | /**
24 | * @return iterable, string}>
25 | */
26 | public static function provideLabels(): iterable
27 | {
28 | yield [['Messenger'], '[Messenger] Foobar'];
29 | yield [['Messenger'], '[messenger] Foobar'];
30 | yield [['Messenger', 'Mime'], '[Messenger][Mime] Foobar'];
31 | yield [['Messenger', 'Mime'], '[Messenger] [Mime] Foobar'];
32 | yield [['Messenger', 'Mime'], '[Messenger] Foobar [Mime]'];
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Api/Issue/IssueApi.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | interface IssueApi
14 | {
15 | /**
16 | * Open new issue or update existing issue.
17 | *
18 | * @param array $labels
19 | */
20 | public function open(Repository $repository, string $title, string $body, array $labels): void;
21 |
22 | /**
23 | * @return array
24 | */
25 | public function show(Repository $repository, int $issueNumber): array;
26 |
27 | public function commentOnIssue(Repository $repository, int $issueNumber, string $commentBody): void;
28 |
29 | public function lastCommentWasMadeByBot(Repository $repository, int $number): bool;
30 |
31 | /**
32 | * @return iterable>
33 | */
34 | public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): iterable;
35 |
36 | /**
37 | * Close an issue and mark it as "not_planned".
38 | */
39 | public function close(Repository $repository, int $issueNumber): void;
40 | }
41 |
--------------------------------------------------------------------------------
/src/Model/Repository.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class Repository
11 | {
12 | /**
13 | * @param string|null $secret the webhook secret used by GitHub
14 | */
15 | public function __construct(
16 | private readonly string $vendor,
17 | private readonly string $name,
18 | private readonly ?string $secret = null,
19 | ) {
20 | }
21 |
22 | public function getVendor(): string
23 | {
24 | return $this->vendor;
25 | }
26 |
27 | public function getName(): string
28 | {
29 | return $this->name;
30 | }
31 |
32 | public function getSecret(): ?string
33 | {
34 | return $this->secret;
35 | }
36 |
37 | public function getNeedsReviewUrl(): string
38 | {
39 | return sprintf(
40 | 'https://github.com/%s/%s/labels/%s',
41 | $this->getVendor(),
42 | $this->getName(),
43 | rawurlencode(GitHubStatusApi::getNeedsReviewLabel())
44 | );
45 | }
46 |
47 | public function getFullName(): string
48 | {
49 | return sprintf('%s/%s', $this->getVendor(), $this->getName());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Event/GitHubEvent.php:
--------------------------------------------------------------------------------
1 |
10 | */
11 | class GitHubEvent extends Event
12 | {
13 | /** @var array */
14 | protected array $responseData = [];
15 |
16 | /**
17 | * @param array $data
18 | */
19 | public function __construct(
20 | private readonly array $data,
21 | private readonly Repository $repository,
22 | ) {
23 | }
24 |
25 | /**
26 | * @return array
27 | */
28 | public function getData(): array
29 | {
30 | return $this->data;
31 | }
32 |
33 | public function getRepository(): Repository
34 | {
35 | return $this->repository;
36 | }
37 |
38 | /**
39 | * @return array
40 | */
41 | public function getResponseData(): array
42 | {
43 | return $this->responseData;
44 | }
45 |
46 | /**
47 | * @param array $responseData
48 | */
49 | public function setResponseData(array $responseData): void
50 | {
51 | foreach ($responseData as $k => $v) {
52 | $this->responseData[$k] = $v;
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Api/PullRequest/GithubPullRequestApi.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class GithubPullRequestApi implements PullRequestApi
13 | {
14 | public function __construct(
15 | private readonly PullRequest $pullRequest,
16 | private readonly Search $search,
17 | ) {
18 | }
19 |
20 | public function show(Repository $repository, int $number): array
21 | {
22 | return (array) $this->pullRequest->show($repository->getVendor(), $repository->getName(), $number);
23 | }
24 |
25 | public function updateTitle(Repository $repository, int $number, string $title, ?string $body = null): void
26 | {
27 | $params = ['title' => $title];
28 |
29 | if (null !== $body) {
30 | $params['body'] = $body;
31 | }
32 |
33 | $this->pullRequest->update($repository->getVendor(), $repository->getName(), $number, $params);
34 | }
35 |
36 | public function getAuthorCount(Repository $repository, string $author): int
37 | {
38 | $result = $this->search->issues(sprintf('is:pr repo:%s author:%s', $repository->getFullName(), $author));
39 |
40 | return $result['total_count'];
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Service/TaskRunner.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | readonly class TaskRunner
14 | {
15 | /**
16 | * @param iterable $handlers
17 | */
18 | public function __construct(
19 | private TaskRepository $repository,
20 | private iterable $handlers,
21 | private LoggerInterface $logger,
22 | ) {
23 | }
24 |
25 | public function run(Task $task): void
26 | {
27 | try {
28 | $this->doRun($task);
29 | } finally {
30 | $this->repository->remove($task);
31 | $this->repository->flush();
32 | }
33 | }
34 |
35 | private function doRun(Task $task): void
36 | {
37 | foreach ($this->handlers as $handler) {
38 | if ($handler->supports($task)) {
39 | $handler->handle($task);
40 |
41 | return;
42 | }
43 | }
44 |
45 | $this->logger->error('Task was never handled by a TaskHandler', [
46 | 'repository' => $task->getRepositoryFullName(),
47 | 'number' => $task->getNumber(),
48 | 'action' => $task->getAction(),
49 | ]);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Command/RunTaskCommand.php:
--------------------------------------------------------------------------------
1 | repository->getTasksToVerify($limit) as $task) {
34 | try {
35 | $this->taskRunner->run($task);
36 | } catch (\Exception $e) {
37 | $this->logger->error('Failed running task', ['exception' => $e]);
38 | $output->writeln($e->getMessage());
39 | }
40 | }
41 |
42 | return Command::SUCCESS;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Repository/TaskRepository.php:
--------------------------------------------------------------------------------
1 |
11 | *
12 | * @extends ServiceEntityRepository
13 | */
14 | class TaskRepository extends ServiceEntityRepository
15 | {
16 | public function __construct(ManagerRegistry $registry)
17 | {
18 | parent::__construct($registry, Task::class);
19 | }
20 |
21 | /**
22 | * @return Task[]
23 | */
24 | public function getTasksToVerify(int $limit): array
25 | {
26 | return $this->createQueryBuilder('t')
27 | ->andWhere('t.verifyAfter <= :now')
28 | ->orderBy('t.updatedAt', 'ASC') // Yes, sort this the wrong way.
29 | ->setMaxResults($limit)
30 | ->setParameter('now', new \DateTimeImmutable())
31 | ->getQuery()
32 | ->getResult();
33 | }
34 |
35 | final public function persist(Task $entity): void
36 | {
37 | $this->getEntityManager()->persist($entity);
38 | }
39 |
40 | final public function remove(Task $entity): void
41 | {
42 | $this->getEntityManager()->remove($entity);
43 | }
44 |
45 | final public function flush(): void
46 | {
47 | $this->getEntityManager()->flush();
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Subscriber/ApproveCiForNonContributors.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class ApproveCiForNonContributors implements EventSubscriberInterface
16 | {
17 | public function __construct(
18 | private readonly WorkflowApi $workflowApi,
19 | ) {
20 | }
21 |
22 | public function onPullRequest(GitHubEvent $event): void
23 | {
24 | $data = $event->getData();
25 | if (!in_array($data['action'], ['opened', 'reopened', 'synchronize'])) {
26 | return;
27 | }
28 |
29 | $repository = $event->getRepository();
30 | $headRepository = $data['pull_request']['head']['repo']['full_name'];
31 | $headBranch = $data['pull_request']['head']['ref'];
32 | $this->workflowApi->approveWorkflowsForPullRequest($repository, $headRepository, $headBranch);
33 |
34 | $event->setResponseData(['approved_run' => true]);
35 | }
36 |
37 | /**
38 | * @return array
39 | */
40 | public static function getSubscribedEvents(): array
41 | {
42 | return [
43 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
44 | ];
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Service/TaskHandler/CloseDraftHandler.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class CloseDraftHandler implements TaskHandlerInterface
17 | {
18 | public function __construct(
19 | private readonly PullRequestApi $pullRequestApi,
20 | private readonly IssueApi $issueApi,
21 | private readonly RepositoryProvider $repositoryProvider,
22 | private readonly LoggerInterface $logger,
23 | ) {
24 | }
25 |
26 | public function handle(Task $task): void
27 | {
28 | $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName());
29 | if (!$repository) {
30 | $this->logger->error(sprintf('RepositoryProvider returned nothing for "%s" ', $task->getRepositoryFullName()));
31 |
32 | return;
33 | }
34 |
35 | $pr = $this->pullRequestApi->show($repository, $task->getNumber());
36 | if ($pr['draft'] ?? false) {
37 | $this->issueApi->close($repository, $task->getNumber());
38 | }
39 | }
40 |
41 | public function supports(Task $task): bool
42 | {
43 | return Task::ACTION_CLOSE_DRAFT === $task->getAction();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # In all environments, the following files are loaded if they exist,
2 | # the latter taking precedence over the former:
3 | #
4 | # * .env contains default values for the environment variables needed by the app
5 | # * .env.local uncommitted file with local overrides
6 | # * .env.$APP_ENV committed environment-specific defaults
7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides
8 | #
9 | # Real environment variables win over .env files.
10 | #
11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
12 | #
13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
14 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
15 | ###> symfony/framework-bundle ###
16 | APP_ENV=dev
17 | APP_SECRET=5dd8ffca252d95e8b4fb5b2d15310e92
18 | #TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
19 | #TRUSTED_HOSTS='^(localhost|example\.com)$'
20 | ###< symfony/framework-bundle ###
21 |
22 | SYMFONY_DOCS_SECRET=''
23 | SYMFONY_SECRET=''
24 | SYMFONY_AI_SECRET=''
25 | SYMFONY_UX_SECRET=''
26 | SYMFONY_WEBPACK_ENCORE_SECRET=''
27 | SYMFONY_WEBPACK_ENCORE_BUNDLE_SECRET=''
28 | BOT_USERNAME='carsonbot'
29 | ###> knplabs/github-api ###
30 | #GITHUB_TOKEN=XXX
31 | ###< knplabs/github-api ###
32 |
33 | DATABASE_URL=postgres://db_user:db_password@127.0.0.1/carsonbot?serverVersion=12&charset=utf8
34 | #DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
35 |
36 |
--------------------------------------------------------------------------------
/src/Kernel.php:
--------------------------------------------------------------------------------
1 | $repositories
22 | */
23 | $repositories = $container->getParameter('repositories');
24 | $dispatcherCollection = $container->getDefinition(EventDispatcher::class);
25 |
26 | foreach ($repositories as $name => $repository) {
27 | $ed = new Definition(SymfonyEventDispatcher::class);
28 | foreach ($repository['subscribers'] as $subscriber) {
29 | $ed->addMethodCall('addSubscriber', [new Reference($subscriber)]);
30 | }
31 | $dispatcherId = 'event_dispatcher.github.'.$name;
32 | $container->setDefinition($dispatcherId, $ed);
33 | $dispatcherCollection->addMethodCall('addDispatcher', [$name, new Reference($dispatcherId)]);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Subscriber/NeedsReviewNewPRSubscriber.php:
--------------------------------------------------------------------------------
1 | getData();
25 | $repository = $event->getRepository();
26 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
27 | return;
28 | }
29 |
30 | $newStatus = Status::NEEDS_REVIEW;
31 | if (WipParser::matchTitle($data['pull_request']['title'])) {
32 | $newStatus = Status::NEEDS_WORK;
33 | }
34 |
35 | $pullRequestNumber = $data['pull_request']['number'];
36 | $this->statusApi->setIssueStatus($pullRequestNumber, $newStatus, $repository);
37 |
38 | $event->setResponseData([
39 | 'pull_request' => $pullRequestNumber,
40 | 'status_change' => $newStatus,
41 | ]);
42 | }
43 |
44 | /**
45 | * @return array
46 | */
47 | public static function getSubscribedEvents(): array
48 | {
49 | return [
50 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
51 | ];
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Service/RepositoryProvider.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class RepositoryProvider
11 | {
12 | /**
13 | * @var Repository[]
14 | */
15 | private array $repositories = [];
16 |
17 | /**
18 | * @param array $repositories
19 | */
20 | public function __construct(array $repositories)
21 | {
22 | foreach ($repositories as $repositoryFullName => $repositoryData) {
23 | if (1 !== substr_count($repositoryFullName, '/')) {
24 | throw new \InvalidArgumentException(sprintf('The repository name %s is invalid: it must be the form "username/repo_name"', $repositoryFullName));
25 | }
26 |
27 | [$vendorName, $repositoryName] = explode('/', $repositoryFullName);
28 |
29 | $this->addRepository(new Repository(
30 | $vendorName,
31 | $repositoryName,
32 | $repositoryData['secret'] ?? null
33 | ));
34 | }
35 | }
36 |
37 | public function getRepository(string $repositoryName): ?Repository
38 | {
39 | $repository = strtolower($repositoryName);
40 |
41 | return $this->repositories[$repository] ?? null;
42 | }
43 |
44 | /**
45 | * @return Repository[]
46 | */
47 | public function getAllRepositories(): array
48 | {
49 | return array_values($this->repositories);
50 | }
51 |
52 | private function addRepository(Repository $repository): void
53 | {
54 | $this->repositories[strtolower($repository->getVendor().'/'.$repository->getName())] = $repository;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Subscriber/AbstractStatusChangeSubscriber.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | protected static array $triggerWordToStatus = [
15 | 'needs review' => Status::NEEDS_REVIEW,
16 | 'needs work' => Status::NEEDS_WORK,
17 | 'works for me' => Status::WORKS_FOR_ME,
18 | 'reviewed' => Status::REVIEWED,
19 | 'waiting feedback' => Status::WAITING_FEEDBACK,
20 | ];
21 |
22 | public function __construct(
23 | protected StatusApi $statusApi,
24 | ) {
25 | }
26 |
27 | /**
28 | * Parses the text and looks for keywords to see if this should cause any
29 | * status change.
30 | */
31 | protected function parseStatusFromText(string $body): ?string
32 | {
33 | $triggerWord = implode('|', array_keys(static::$triggerWordToStatus));
34 | $formatting = '[\\s\\*]*';
35 | // Match first character after "status:"
36 | // Case insensitive ("i"), ignores formatting with "*" before or after the ":"
37 | $pattern = "~(?=\n|^)(?:\@carsonbot)?{$formatting}status{$formatting}:{$formatting}[\"']?($triggerWord)[\"']?{$formatting}[.!]?{$formatting}(?<=\r\n|\n|$)~i";
38 |
39 | if (preg_match_all($pattern, $body, $matches)) {
40 | // Second subpattern = first status character
41 | return static::$triggerWordToStatus[strtolower(end($matches[1]) ?: '')];
42 | }
43 |
44 | return null;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Subscriber/MilestoneMergedPRSubscriber.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | class MilestoneMergedPRSubscriber implements EventSubscriberInterface
14 | {
15 | public function __construct(
16 | private readonly MilestoneApi $milestonesApi,
17 | ) {
18 | }
19 |
20 | /**
21 | * Sets milestone on merged PRs.
22 | */
23 | public function onPullRequest(GitHubEvent $event): void
24 | {
25 | $data = $event->getData();
26 | $repository = $event->getRepository();
27 |
28 | if ('closed' !== $data['action'] || !$data['merged']) {
29 | return;
30 | }
31 |
32 | $targetBranch = $data['pull_request']['base']['ref'];
33 | if ($targetBranch === $data['milestone']) {
34 | return;
35 | }
36 |
37 | if (!$this->milestonesApi->exists($repository, $targetBranch)) {
38 | return;
39 | }
40 |
41 | $pullRequestNumber = $data['pull_request']['number'];
42 | $this->milestonesApi->updateMilestone($repository, $pullRequestNumber, $targetBranch);
43 |
44 | $event->setResponseData([
45 | 'pull_request' => $pullRequestNumber,
46 | 'milestone' => $targetBranch,
47 | ]);
48 | }
49 |
50 | /**
51 | * @return array
52 | */
53 | public static function getSubscribedEvents(): array
54 | {
55 | return [
56 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
57 | ];
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Subscriber/BugLabelNewIssueSubscriber.php:
--------------------------------------------------------------------------------
1 | getData();
24 | $repository = $event->getRepository();
25 | if ('labeled' !== $data['action']) {
26 | return;
27 | }
28 |
29 | $responseData = ['issue' => $issueNumber = $data['issue']['number']];
30 | // Ignore non-bugs or issue which already has a status
31 | if ('bug' !== strtolower($data['label']['name']) || null !== $this->statusApi->getIssueStatus($issueNumber, $repository)) {
32 | $responseData['status_change'] = null;
33 | $event->setResponseData($responseData);
34 |
35 | return;
36 | }
37 |
38 | $newStatus = Status::NEEDS_REVIEW;
39 |
40 | $this->statusApi->setIssueStatus($issueNumber, $newStatus, $repository);
41 | $responseData['status_change'] = $newStatus;
42 |
43 | $event->setResponseData($responseData);
44 | }
45 |
46 | /**
47 | * @return array
48 | */
49 | public static function getSubscribedEvents(): array
50 | {
51 | return [
52 | GitHubEvents::ISSUES => 'onIssues',
53 | ];
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/templates/default/homepage.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block body %}
4 |
5 |
36 |
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/src/Subscriber/UpdateMilestoneWhenLabeledWaitingCodeMergeSubscriber.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class UpdateMilestoneWhenLabeledWaitingCodeMergeSubscriber implements EventSubscriberInterface
16 | {
17 | public function __construct(
18 | private readonly MilestoneApi $milestoneApi,
19 | ) {
20 | }
21 |
22 | public function onLabel(GitHubEvent $event): void
23 | {
24 | $data = $event->getData();
25 | $action = $data['action'];
26 | if ('labeled' !== $action) {
27 | return;
28 | }
29 |
30 | foreach ($data['pull_request']['labels'] ?? $data['issue']['labels'] ?? [] as $label) {
31 | if ('Waiting Code Merge' === $label['name']) {
32 | $repository = $event->getRepository();
33 | $number = $data['number'] ?? $data['issue']['number'];
34 | $this->milestoneApi->updateMilestone($repository, $number, 'next');
35 |
36 | $event->setResponseData([
37 | 'pull_request' => $number,
38 | 'milestone' => 'next',
39 | ]);
40 |
41 | return;
42 | }
43 | }
44 | }
45 |
46 | /**
47 | * @return array
48 | */
49 | public static function getSubscribedEvents(): array
50 | {
51 | return [
52 | GitHubEvents::PULL_REQUEST => 'onLabel',
53 | GitHubEvents::ISSUES => 'onLabel',
54 | ];
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Service/SymfonyVersionProvider.php:
--------------------------------------------------------------------------------
1 | httpClient;
22 |
23 | return $this->cache->get('symfony_version', function (ItemInterface $item) use ($httpClient) {
24 | $defaultValue = '99.99';
25 | try {
26 | $response = $httpClient->request('GET', 'https://symfony.com/releases.json');
27 | $data = $response->toArray(true);
28 | $version = $data['latest_stable_version'] ?? $defaultValue;
29 | } catch (\Throwable) {
30 | $version = $defaultValue;
31 | }
32 |
33 | $item->expiresAfter($version === $defaultValue ? 300 : 86400);
34 |
35 | return $version;
36 | });
37 | }
38 |
39 | /**
40 | * @return array
41 | *
42 | * @throws \RuntimeException
43 | */
44 | public function getMaintainedVersions(): array
45 | {
46 | $response = $this->httpClient->request('GET', 'https://symfony.com/releases.json');
47 | $data = $response->toArray(true);
48 | $versions = $data['maintained_versions'] ?? null;
49 |
50 | if (is_array($versions) && count($versions) > 0) {
51 | return $versions;
52 | }
53 |
54 | throw new \RuntimeException('Could not fetch maintained versions');
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Subscriber/StatusChangeByCommentSubscriber.php:
--------------------------------------------------------------------------------
1 | getData();
27 | $repository = $event->getRepository();
28 | $issueNumber = $data['issue']['number'];
29 | $newStatus = $this->parseStatusFromText($data['comment']['body']);
30 |
31 | $isUserAllowedToReview = ($data['issue']['user']['login'] !== $data['comment']['user']['login']);
32 |
33 | if (Status::REVIEWED === $newStatus && !$isUserAllowedToReview) {
34 | $newStatus = null;
35 | }
36 |
37 | $event->setResponseData([
38 | 'issue' => $issueNumber,
39 | 'status_change' => $newStatus,
40 | ]);
41 |
42 | if (null === $newStatus) {
43 | return;
44 | }
45 |
46 | $this->logger->debug(sprintf('Setting issue number %s to status %s', $issueNumber, $newStatus));
47 | $this->statusApi->setIssueStatus($issueNumber, $newStatus, $repository);
48 | }
49 |
50 | /**
51 | * @return array
52 | */
53 | public static function getSubscribedEvents(): array
54 | {
55 | return [
56 | GitHubEvents::ISSUE_COMMENT => 'onIssueComment',
57 | ];
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/.platform.app.yaml:
--------------------------------------------------------------------------------
1 | name: carson-bot
2 |
3 | type: php:8.4
4 |
5 | runtime:
6 | extensions:
7 | - apcu
8 | - mbstring
9 | - pdo_pgsql
10 |
11 | build:
12 | flavor: none
13 |
14 | web:
15 | locations:
16 | "/":
17 | root: "public"
18 | expires: 1h
19 | passthru: "/index.php"
20 |
21 | disk: 2048
22 |
23 | mounts:
24 | '/var': { source: local, source_path: var }
25 |
26 | hooks:
27 | build: |
28 | set -x -e
29 | curl -s https://get.symfony.com/cloud/configurator | bash
30 | symfony-build
31 | deploy: |
32 | set -x -e
33 | symfony-deploy
34 |
35 | crons:
36 | unpause:
37 | spec: '0 0 * * 0' # every Sunday at midnight
38 | cmd: croncape symfony redeploy --no-wait
39 |
40 | run_tasks:
41 | spec: '*/5 * * * *'
42 | cmd: croncape bin/console app:task:run
43 |
44 | stale_issues_symfony:
45 | spec: '58 12 * * *'
46 | cmd: croncape bin/console app:issue:ping-stale symfony/symfony --not-updated-for 6months
47 |
48 | stale_issues_docs:
49 | spec: '48 12 * * *'
50 | cmd: croncape bin/console app:issue:ping-stale symfony/symfony-docs --not-updated-for 12months
51 |
52 | stale_issues_ux:
53 | spec: '38 12 * * *'
54 | cmd: croncape bin/console app:issue:ping-stale symfony/ux --not-updated-for 6months
55 |
56 | stale_issues_webpack_encore:
57 | spec: '28 12 * * *'
58 | cmd: croncape bin/console app:issue:ping-stale symfony/webpack-encore --not-updated-for 6months
59 |
60 | stale_issues_webpack_encore_bundle:
61 | spec: '18 12 * * *'
62 | cmd: croncape bin/console app:issue:ping-stale symfony/webpack-encore-bundle --not-updated-for 6months
63 |
64 | stale_issues_ai:
65 | spec: '9 12 * * *'
66 | cmd: croncape bin/console app:issue:ping-stale symfony/ai --not-updated-for 6months
67 |
68 | relationships:
69 | database: "mydatabase:postgresql"
70 |
--------------------------------------------------------------------------------
/src/Subscriber/RemoveStalledLabelOnCommentSubscriber.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class RemoveStalledLabelOnCommentSubscriber implements EventSubscriberInterface
17 | {
18 | public function __construct(
19 | private readonly LabelApi $labelApi,
20 | private readonly string $botUsername,
21 | ) {
22 | }
23 |
24 | public function onIssueComment(GitHubEvent $event): void
25 | {
26 | $data = $event->getData();
27 | $repository = $event->getRepository();
28 |
29 | // If bot, then nothing.
30 | if ($data['comment']['user']['login'] === $this->botUsername) {
31 | return;
32 | }
33 |
34 | // If not open, then do nothing
35 | if ('open' !== $data['issue']['state']) {
36 | return;
37 | }
38 |
39 | $removed = false;
40 | $issueNumber = $data['issue']['number'];
41 | foreach ($data['issue']['labels'] as $label) {
42 | if ('Stalled' === $label['name']) {
43 | $removed = true;
44 | $this->labelApi->removeIssueLabel($issueNumber, 'Stalled', $repository);
45 | }
46 | }
47 |
48 | if ($removed) {
49 | $event->setResponseData([
50 | 'issue' => $issueNumber,
51 | 'removed_stalled_label' => true,
52 | ]);
53 | }
54 | }
55 |
56 | /**
57 | * @return array
58 | */
59 | public static function getSubscribedEvents(): array
60 | {
61 | return [
62 | GitHubEvents::ISSUE_COMMENT => 'onIssueComment',
63 | ];
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Subscriber/StatusChangeOnPushSubscriber.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | class StatusChangeOnPushSubscriber implements EventSubscriberInterface
19 | {
20 | public function __construct(
21 | private readonly StatusApi $statusApi,
22 | ) {
23 | }
24 |
25 | public function onPullRequest(GitHubEvent $event): void
26 | {
27 | $data = $event->getData();
28 | if ('synchronize' !== $data['action']) {
29 | return;
30 | }
31 |
32 | $repository = $event->getRepository();
33 | $pullRequestNumber = $data['pull_request']['number'];
34 | $responseData = ['pull_request' => $pullRequestNumber];
35 | $currentStatus = $this->statusApi->getIssueStatus($pullRequestNumber, $repository);
36 | $pullRequestTitle = $data['pull_request']['title'];
37 |
38 | if (Status::NEEDS_WORK !== $currentStatus || WipParser::matchTitle($pullRequestTitle)) {
39 | $responseData['status_change'] = null;
40 | $event->setResponseData($responseData);
41 |
42 | return;
43 | }
44 |
45 | $newStatus = Status::NEEDS_REVIEW;
46 |
47 | $this->statusApi->setIssueStatus($pullRequestNumber, $newStatus, $repository);
48 | $responseData['status_change'] = $newStatus;
49 |
50 | $event->setResponseData($responseData);
51 | }
52 |
53 | /**
54 | * @return array
55 | */
56 | public static function getSubscribedEvents(): array
57 | {
58 | return [
59 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
60 | ];
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Subscriber/AllowEditFromMaintainerSubscriber.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | class AllowEditFromMaintainerSubscriber implements EventSubscriberInterface
14 | {
15 | public function __construct(
16 | private readonly IssueApi $commentsApi,
17 | ) {
18 | }
19 |
20 | public function onPullRequest(GitHubEvent $event): void
21 | {
22 | $data = $event->getData();
23 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
24 | return;
25 | }
26 |
27 | if ($data['pull_request']['maintainer_can_modify'] ?? true) {
28 | return;
29 | }
30 |
31 | if ($data['repository']['full_name'] === $data['pull_request']['head']['repo']['full_name']) {
32 | return;
33 | }
34 |
35 | $repository = $event->getRepository();
36 | $pullRequestNumber = $data['pull_request']['number'];
37 | $this->commentsApi->commentOnIssue($repository, $pullRequestNumber, <<setResponseData([
47 | 'pull_request' => $pullRequestNumber,
48 | 'squash_comment' => true,
49 | ]);
50 | }
51 |
52 | /**
53 | * @return array
54 | */
55 | public static function getSubscribedEvents(): array
56 | {
57 | return [
58 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
59 | ];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Subscriber/CloseDraftPRSubscriber.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class CloseDraftPRSubscriber implements EventSubscriberInterface
16 | {
17 | public function __construct(
18 | private readonly IssueApi $issueApi,
19 | private readonly TaskScheduler $scheduler,
20 | ) {
21 | }
22 |
23 | public function onPullRequest(GitHubEvent $event): void
24 | {
25 | $data = $event->getData();
26 | $repository = $event->getRepository();
27 | if ('opened' !== $data['action'] || !($data['pull_request']['draft'] ?? false)) {
28 | return;
29 | }
30 |
31 | $number = (int) $data['pull_request']['number'];
32 | $this->issueApi->commentOnIssue($repository, $number, <<scheduler->runLater($repository, $number, Task::ACTION_CLOSE_DRAFT, new \DateTimeImmutable('+1hour'));
47 |
48 | $event->setResponseData([
49 | 'pull_request' => $number,
50 | 'draft_comment' => true,
51 | ]);
52 | }
53 |
54 | /**
55 | * @return array
56 | */
57 | public static function getSubscribedEvents(): array
58 | {
59 | return [
60 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
61 | ];
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Api/Label/StaticLabelApi.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | class StaticLabelApi extends NullLabelApi
15 | {
16 | private const array LABELS = [
17 | 'Asset', 'AssetMapper', 'BrowserKit', 'Cache', 'Config', 'Console',
18 | 'Contracts', 'CssSelector', 'Debug', 'DebugBundle', 'DependencyInjection',
19 | 'Doctrine', 'DoctrineBridge', 'DomCrawler', 'Dotenv', 'Emoji',
20 | 'Enhancement', 'ErrorHandler', 'EventDispatcher', 'ExpressionLanguage',
21 | 'Feature', 'Filesystem', 'Finder', 'Form', 'FrameworkBundle',
22 | 'HttpClient', 'HttpFoundation', 'HttpKernel', 'Inflector', 'Intl', 'JsonPath', 'JsonStreamer', 'Ldap',
23 | 'Locale', 'Lock', 'Mailer', 'Messenger', 'Mime', 'MonologBridge', 'Notifier', 'ObjectMapper',
24 | 'OptionsResolver', 'PasswordHasher', 'PhpUnitBridge', 'Process', 'PropertyAccess',
25 | 'PropertyInfo', 'ProxyManagerBridge', 'PsrHttpMessageBridge', 'RemoteEvent', 'Routing',
26 | 'Scheduler', 'Security', 'SecurityBundle', 'Serializer', 'Stopwatch', 'String',
27 | 'Templating', 'Translation', 'TwigBridge', 'TwigBundle', 'TypeInfo', 'Uid', 'Validator', 'VarDumper',
28 | 'VarExporter', 'Webhook', 'WebLink', 'WebProfilerBundle', 'WebServerBundle', 'Workflow',
29 | 'Yaml',
30 | ];
31 |
32 | public function getAllLabelsForRepository(Repository $repository): array
33 | {
34 | $labels = self::LABELS;
35 | $labels[] = 'BC Break';
36 | $labels[] = 'Bug';
37 | $labels[] = 'Critical';
38 | $labels[] = 'Hack Day';
39 | $labels[] = 'RFC';
40 | $labels[] = 'Performance';
41 | $labels[] = 'DX';
42 | $labels[] = 'Deprecation';
43 |
44 | return $labels;
45 | }
46 |
47 | public function getIssueLabels(int $issueNumber, Repository $repository): array
48 | {
49 | return [];
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Service/TaskHandler/CloseStaleIssuesHandler.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class CloseStaleIssuesHandler implements TaskHandlerInterface
17 | {
18 | public function __construct(
19 | private readonly LabelApi $labelApi,
20 | private readonly IssueApi $issueApi,
21 | private readonly RepositoryProvider $repositoryProvider,
22 | private readonly StaleIssueCommentGenerator $commentGenerator,
23 | ) {
24 | }
25 |
26 | /**
27 | * Close the issue if the last comment was made by the bot and if "Keep open" label does not exist.
28 | */
29 | public function handle(Task $task): void
30 | {
31 | if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) {
32 | return;
33 | }
34 |
35 | $issue = $this->issueApi->show($repository, $task->getNumber());
36 | if ('open' !== $issue['state']) {
37 | return;
38 | }
39 |
40 | $labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository);
41 | if (in_array('Keep open', $labels)) {
42 | $this->labelApi->removeIssueLabel($task->getNumber(), 'Stalled', $repository);
43 |
44 | return;
45 | }
46 |
47 | if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
48 | $this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getClosingComment());
49 | $this->issueApi->close($repository, $task->getNumber());
50 | } else {
51 | $this->labelApi->removeIssueLabel($task->getNumber(), 'Stalled', $repository);
52 | }
53 | }
54 |
55 | public function supports(Task $task): bool
56 | {
57 | return Task::ACTION_CLOSE_STALE === $task->getAction();
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/config/packages/github_api.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | Github\Client:
3 | arguments:
4 | - '@Github\HttpClient\Builder'
5 | calls:
6 | - ['authenticate', ['%env(GITHUB_TOKEN)%', 'access_token_header']]
7 |
8 | Github\ResultPager:
9 | arguments: ['@Github\Client']
10 |
11 | Github\HttpClient\Builder:
12 | arguments:
13 | - '@github.httplug_client'
14 | - '@Psr\Http\Message\RequestFactoryInterface'
15 | - '@Psr\Http\Message\StreamFactoryInterface'
16 |
17 | github.httplug_client:
18 | class: Symfony\Component\HttpClient\Psr18Client
19 | arguments:
20 | - '@github.retryable_client'
21 | - '@Psr\Http\Message\ResponseFactoryInterface'
22 | - '@Psr\Http\Message\StreamFactoryInterface'
23 |
24 | github.retryable_client:
25 | class: Symfony\Component\HttpClient\RetryableHttpClient
26 | arguments:
27 | - '@http_client'
28 | - '@github.retry_strategy'
29 | - 2
30 | - '@logger'
31 |
32 | github.retry_strategy:
33 | class: Symfony\Component\HttpClient\Retry\GenericRetryStrategy
34 | arguments:
35 | - [0, 404, 423, 425, 429, 500, 502, 503, 504, 507, 510]
36 |
37 |
38 | # Register different APIs as services
39 |
40 | Github\Api\Issue:
41 | factory: ['@Github\Client', api]
42 | arguments: [issue]
43 |
44 | Github\Api\Issue\Comments:
45 | factory: ['@Github\Api\Issue', comments]
46 |
47 | Github\Api\Issue\Labels:
48 | factory: ['@Github\Api\Issue', labels]
49 |
50 | Github\Api\Issue\Milestones:
51 | factory: ['@Github\Api\Issue', milestones]
52 |
53 | Github\Api\PullRequest:
54 | factory: ['@Github\Client', api]
55 | arguments: [pullRequest]
56 |
57 | Github\Api\Repo:
58 | factory: ['@Github\Client', api]
59 | arguments: [repo]
60 |
61 | Github\Api\Search:
62 | factory: ['@Github\Client', api]
63 | arguments: [search]
64 |
65 | Github\Api\Repository\Actions\WorkflowRuns:
66 | factory: ['@Github\Api\Repo', workflowRuns]
67 |
--------------------------------------------------------------------------------
/src/Subscriber/MilestoneNewPRSubscriber.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | class MilestoneNewPRSubscriber implements EventSubscriberInterface
15 | {
16 | public function __construct(
17 | private readonly MilestoneApi $milestonesApi,
18 | private readonly SymfonyVersionProvider $symfonyVersionProvider,
19 | private bool $ignoreCurrentVersion = false,
20 | private bool $ignoreDefaultBranch = false,
21 | ) {
22 | }
23 |
24 | /**
25 | * Sets milestone on PRs.
26 | */
27 | public function onPullRequest(GitHubEvent $event): void
28 | {
29 | $data = $event->getData();
30 | $repository = $event->getRepository();
31 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
32 | return;
33 | }
34 |
35 | $targetBranch = $data['pull_request']['base']['ref'];
36 | if ($this->ignoreDefaultBranch && $targetBranch === $data['repository']['default_branch']) {
37 | return;
38 | }
39 |
40 | if ($this->ignoreCurrentVersion && $targetBranch === $this->symfonyVersionProvider->getCurrentVersion()) {
41 | return;
42 | }
43 |
44 | if (!$this->milestonesApi->exists($repository, $targetBranch)) {
45 | return;
46 | }
47 |
48 | $pullRequestNumber = $data['pull_request']['number'];
49 | $this->milestonesApi->updateMilestone($repository, $pullRequestNumber, $targetBranch);
50 |
51 | $event->setResponseData([
52 | 'pull_request' => $pullRequestNumber,
53 | 'milestone' => $targetBranch,
54 | ]);
55 | }
56 |
57 | /**
58 | * @return array
59 | */
60 | public static function getSubscribedEvents(): array
61 | {
62 | return [
63 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
64 | ];
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/Service/TaskHandler/CloseDraftHandlerTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(NullPullRequestApi::class)
20 | ->disableOriginalConstructor()
21 | ->getMock();
22 | $prApi->expects($this->once())->method('show')->willReturn(['draft' => true]);
23 |
24 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
25 | ->disableOriginalConstructor()
26 | ->getMock();
27 | $issueApi
28 | ->expects($this->once())
29 | ->method('close')
30 | ->with($this->anything(), 4711);
31 |
32 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
33 |
34 | $handler = new CloseDraftHandler($prApi, $issueApi, $repoProvider, new NullLogger());
35 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_DRAFT, new \DateTimeImmutable()));
36 | }
37 |
38 | public function testHandleNotDraft()
39 | {
40 | $prApi = $this->getMockBuilder(NullPullRequestApi::class)
41 | ->disableOriginalConstructor()
42 | ->onlyMethods(['show'])
43 | ->getMock();
44 | $prApi->expects($this->once())->method('show')->willReturn(['draft' => false]);
45 |
46 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
47 | ->disableOriginalConstructor()
48 | ->onlyMethods(['close'])
49 | ->getMock();
50 | $issueApi->expects($this->never())->method('close');
51 |
52 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
53 |
54 | $handler = new CloseDraftHandler($prApi, $issueApi, $repoProvider, new NullLogger());
55 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_DRAFT, new \DateTimeImmutable()));
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Command/ListTaskCommand.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | #[AsCommand(
18 | name: 'app:task:list',
19 | description: 'List scheduled tasks',
20 | )]
21 | final class ListTaskCommand
22 | {
23 | public function __construct(
24 | private readonly TaskRepository $repository,
25 | ) {
26 | }
27 |
28 | public function __invoke(
29 | SymfonyStyle $io,
30 | #[Option(description: 'The issue number we are interested in')]
31 | ?int $number = null,
32 | ): int {
33 | if (null === $number) {
34 | $criteria = [];
35 | } else {
36 | $criteria = ['number' => $number];
37 | }
38 |
39 | $limit = 100;
40 | /** @var Task[] $tasks */
41 | $tasks = $this->repository->findBy($criteria, ['verifyAfter' => 'ASC'], $limit);
42 | $rows = [];
43 | foreach ($tasks as $task) {
44 | $rows[] = [
45 | $task->getRepositoryFullName(),
46 | $task->getNumber(),
47 | $this->actionToString($task->getAction()),
48 | $task->getVerifyAfter()->format('Y-m-d H:i:s'),
49 | ];
50 | }
51 |
52 | $io->table(['Repo', 'Number', 'Action', 'Verify After'], $rows);
53 | $io->write(sprintf('Total of %d items in the table. ', $taskCount = count($tasks)));
54 | if ($limit === $taskCount) {
55 | $io->write('There might be more tasks in the database.');
56 | }
57 | $io->newLine();
58 |
59 | return Command::SUCCESS;
60 | }
61 |
62 | private function actionToString(int $action): string
63 | {
64 | $data = [
65 | Task::ACTION_CLOSE_STALE => 'Close stale',
66 | Task::ACTION_CLOSE_DRAFT => 'Close draft',
67 | Task::ACTION_INFORM_CLOSE_STALE => 'Inform about close',
68 | ];
69 |
70 | return $data[$action] ?? 'Unknown action';
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/Service/TaskHandler/InformAboutClosingStaleIssuesHandler.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | class InformAboutClosingStaleIssuesHandler implements TaskHandlerInterface
19 | {
20 | public function __construct(
21 | private readonly LabelApi $labelApi,
22 | private readonly IssueApi $issueApi,
23 | private readonly RepositoryProvider $repositoryProvider,
24 | private readonly StaleIssueCommentGenerator $commentGenerator,
25 | private readonly TaskScheduler $scheduler,
26 | ) {
27 | }
28 |
29 | /**
30 | * Close the issue if the last comment was made by the bot and "Keep open" label does not exist.
31 | */
32 | public function handle(Task $task): void
33 | {
34 | if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) {
35 | return;
36 | }
37 |
38 | $issue = $this->issueApi->show($repository, $task->getNumber());
39 | if ('open' !== $issue['state']) {
40 | return;
41 | }
42 |
43 | $labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository);
44 | if (in_array('Keep open', $labels)) {
45 | $this->labelApi->removeIssueLabel($task->getNumber(), 'Stalled', $repository);
46 |
47 | return;
48 | }
49 |
50 | if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
51 | $this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getInformAboutClosingComment());
52 | $this->scheduler->runLater($repository, $task->getNumber(), Task::ACTION_CLOSE_STALE, new \DateTimeImmutable(PingStaleIssuesCommand::MESSAGE_THREE_AND_CLOSE_AFTER));
53 | } else {
54 | $this->labelApi->removeIssueLabel($task->getNumber(), 'Stalled', $repository);
55 | }
56 | }
57 |
58 | public function supports(Task $task): bool
59 | {
60 | return Task::ACTION_INFORM_CLOSE_STALE === $task->getAction();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Entity/Task.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | #[ORM\Entity(repositoryClass: TaskRepository::class)]
14 | #[ORM\HasLifecycleCallbacks]
15 | #[ORM\Table]
16 | class Task
17 | {
18 | public const int ACTION_CLOSE_STALE = 1;
19 | public const int ACTION_CLOSE_DRAFT = 2;
20 | public const int ACTION_INFORM_CLOSE_STALE = 3;
21 |
22 | #[ORM\Column]
23 | #[ORM\Id]
24 | #[ORM\GeneratedValue(strategy: 'AUTO')]
25 | private int $id;
26 |
27 | #[ORM\Column]
28 | private string $repositoryFullName;
29 |
30 | #[ORM\Column]
31 | private int $number;
32 |
33 | #[ORM\Column]
34 | private int $action;
35 |
36 | #[ORM\Column]
37 | private \DateTimeImmutable $verifyAfter;
38 |
39 | #[ORM\Column]
40 | private \DateTimeImmutable $createdAt;
41 |
42 | #[ORM\Column]
43 | private \DateTimeImmutable $updatedAt;
44 |
45 | public function __construct(
46 | string $repositoryFullName,
47 | int $number,
48 | int $action,
49 | \DateTimeImmutable $verifyAfter,
50 | ) {
51 | $this->repositoryFullName = $repositoryFullName;
52 | $this->number = $number;
53 | $this->action = $action;
54 | $this->verifyAfter = $verifyAfter;
55 | $this->createdAt = new \DateTimeImmutable();
56 | $this->updatedAt = new \DateTimeImmutable();
57 | }
58 |
59 | public function getId(): int
60 | {
61 | return $this->id;
62 | }
63 |
64 | public function getRepositoryFullName(): string
65 | {
66 | return $this->repositoryFullName;
67 | }
68 |
69 | public function getNumber(): int
70 | {
71 | return $this->number;
72 | }
73 |
74 | public function getAction(): int
75 | {
76 | return $this->action;
77 | }
78 |
79 | public function getVerifyAfter(): \DateTimeImmutable
80 | {
81 | return $this->verifyAfter;
82 | }
83 |
84 | public function setVerifyAfter(\DateTimeImmutable $verifyAfter): void
85 | {
86 | $this->verifyAfter = $verifyAfter;
87 | }
88 |
89 | #[ORM\PrePersist]
90 | #[ORM\PreUpdate]
91 | public function updateUpdatedAt(): void
92 | {
93 | $this->updatedAt = new \DateTimeImmutable();
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Api/Milestone/GithubMilestoneApi.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class GithubMilestoneApi implements MilestoneApi
13 | {
14 | /**
15 | * @var array>
16 | */
17 | private array $cache = [];
18 |
19 | public function __construct(
20 | private readonly Milestones $milestonesApi,
21 | private readonly Issue $issuesApi,
22 | ) {
23 | }
24 |
25 | /**
26 | * @return array
27 | */
28 | private function getMilestones(Repository $repository): array
29 | {
30 | $key = $this->getCacheKey($repository);
31 | if (!isset($this->cache[$key])) {
32 | $this->cache[$key] = [];
33 |
34 | $milestones = $this->milestonesApi->all($repository->getVendor(), $repository->getName(), ['per_page' => 100]);
35 |
36 | foreach ($milestones as $milestone) {
37 | $this->cache[$key][] = $milestone;
38 | }
39 | }
40 |
41 | return $this->cache[$key];
42 | }
43 |
44 | public function updateMilestone(Repository $repository, int $issueNumber, string $milestoneName): void
45 | {
46 | $milestoneNumber = null;
47 | foreach ($this->getMilestones($repository) as $milestone) {
48 | if ($milestone['title'] === $milestoneName) {
49 | $milestoneNumber = $milestone['number'];
50 | }
51 | }
52 |
53 | if (null === $milestoneNumber) {
54 | throw new \LogicException(\sprintf('Milestone "%s" does not exist', $milestoneName));
55 | }
56 |
57 | $this->issuesApi->update($repository->getVendor(), $repository->getName(), $issueNumber, [
58 | 'milestone' => $milestoneNumber,
59 | ]);
60 | }
61 |
62 | public function exists(Repository $repository, string $milestoneName): bool
63 | {
64 | foreach ($this->getMilestones($repository) as $milestone) {
65 | if ($milestone['title'] === $milestoneName) {
66 | return true;
67 | }
68 | }
69 |
70 | return false;
71 | }
72 |
73 | private function getCacheKey(Repository $repository): string
74 | {
75 | return sprintf('%s_%s', $repository->getVendor(), $repository->getName());
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Subscriber/UnsupportedBranchSubscriber.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class UnsupportedBranchSubscriber implements EventSubscriberInterface
16 | {
17 | public function __construct(
18 | private readonly SymfonyVersionProvider $symfonyVersionProvider,
19 | private readonly IssueApi $issueApi,
20 | private readonly LoggerInterface $logger,
21 | ) {
22 | }
23 |
24 | /**
25 | * Sets milestone on PRs that target non-default branch.
26 | */
27 | public function onPullRequest(GitHubEvent $event): void
28 | {
29 | $data = $event->getData();
30 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
31 | return;
32 | }
33 |
34 | $targetBranch = $data['pull_request']['base']['ref'];
35 | if ($targetBranch === $data['repository']['default_branch']) {
36 | return;
37 | }
38 |
39 | try {
40 | $validBranches = $this->symfonyVersionProvider->getMaintainedVersions();
41 | } catch (\Throwable $e) {
42 | $this->logger->error('Failed to get valid branches', ['exception' => $e]);
43 |
44 | return;
45 | }
46 |
47 | if (in_array($targetBranch, $validBranches)) {
48 | return;
49 | }
50 |
51 | $number = $data['pull_request']['number'];
52 | $validBranchesString = implode(', ', $validBranches);
53 | $this->issueApi->commentOnIssue($event->getRepository(), $number, <<setResponseData([
66 | 'pull_request' => $number,
67 | 'unsupported_branch' => $targetBranch,
68 | ]);
69 | }
70 |
71 | /**
72 | * @return array
73 | */
74 | public static function getSubscribedEvents(): array
75 | {
76 | return [
77 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
78 | ];
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/GitHubEvents.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | final class GitHubEvents
13 | {
14 | /** @Event("\App\Event\GitHubEvent") */
15 | public const string COMMIT_COMMENT = 'github.commit_comment';
16 |
17 | /** @Event("\App\Event\GitHubEvent") */
18 | public const string CREATE = 'github.create';
19 |
20 | /** @Event("\App\Event\GitHubEvent") */
21 | public const string DELETE = 'github.delete';
22 |
23 | /** @Event("\App\Event\GitHubEvent") */
24 | public const string DEPLOYMENT = 'github.deployment';
25 |
26 | /** @Event("\App\Event\GitHubEvent") */
27 | public const string DEPLOYMENT_STATUS = 'github.deployment_status';
28 |
29 | /** @Event("\App\Event\GitHubEvent") */
30 | public const string FORK = 'github.fork';
31 |
32 | /** @Event("\App\Event\GitHubEvent") */
33 | public const string GOLLUM = 'github.gollum';
34 |
35 | /** @Event("\App\Event\GitHubEvent") */
36 | public const string ISSUE_COMMENT = 'github.issue_comment';
37 |
38 | /** @Event("\App\Event\GitHubEvent") */
39 | public const string ISSUES = 'github.issues';
40 |
41 | /** @Event("\App\Event\GitHubEvent") */
42 | public const string MEMBER = 'github.member';
43 |
44 | /** @Event("\App\Event\GitHubEvent") */
45 | public const string MEMBERSHIP = 'github.membership';
46 |
47 | /** @Event("\App\Event\GitHubEvent") */
48 | public const string PAGE_BUILD = 'github.page_build';
49 |
50 | /** @Event("\App\Event\GitHubEvent") */
51 | public const string IS_PUBLIC = 'github.public';
52 |
53 | /** @Event("\App\Event\GitHubEvent") */
54 | public const string PR_REVIEW_COMMENT = 'github.pull_request_review_comment';
55 |
56 | /** @Event("\App\Event\GitHubEvent") */
57 | public const string PULL_REQUEST_REVIEW = 'github.pull_request_review';
58 |
59 | /** @Event("\App\Event\GitHubEvent") */
60 | public const string PULL_REQUEST = 'github.pull_request';
61 |
62 | /** @Event("\App\Event\GitHubEvent") */
63 | public const string PUSH = 'github.push';
64 |
65 | /** @Event("\App\Event\GitHubEvent") */
66 | public const string REPOSITORY = 'github.repository';
67 |
68 | /** @Event("\App\Event\GitHubEvent") */
69 | public const string RELEASE = 'github.release';
70 |
71 | /** @Event("\App\Event\GitHubEvent") */
72 | public const string STATUS = 'github.status';
73 |
74 | /** @Event("\App\Event\GitHubEvent") */
75 | public const string TEAM_ADD = 'github.team_add';
76 |
77 | /** @Event("\App\Event\GitHubEvent") */
78 | public const string WATCH = 'github.watch';
79 | }
80 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request: ~
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 |
11 | test:
12 | name: Test
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Set up PHP
16 | uses: shivammathur/setup-php@v2
17 | with:
18 | php-version: 8.4
19 | coverage: none
20 |
21 | - name: Checkout code
22 | uses: actions/checkout@v6
23 |
24 | - name: Download dependencies
25 | uses: ramsey/composer-install@v3
26 |
27 | - name: Docker
28 | run: docker compose up -d
29 |
30 | - name: Download dependencies
31 | run: composer install --no-interaction --optimize-autoloader
32 |
33 | - name: Setup database
34 | run: bin/console doctrine:migrations:migrate --no-interaction
35 |
36 | - name: Run tests
37 | run: ./vendor/bin/phpunit
38 |
39 | - name: Upload log file
40 | if: ${{ failure() }}
41 | uses: actions/upload-artifact@v4
42 | with:
43 | name: test.log
44 | path: var/log/test.log
45 | retention-days: 5
46 |
47 | phpstan:
48 | name: PHPStan
49 | runs-on: ubuntu-latest
50 |
51 | steps:
52 | - name: Setup PHP
53 | uses: shivammathur/setup-php@v2
54 | with:
55 | php-version: 8.4
56 | coverage: none
57 | tools: phpstan:2.1, cs2pr
58 |
59 | - name: Checkout code
60 | uses: actions/checkout@v6
61 |
62 | - name: Download dependencies
63 | uses: ramsey/composer-install@v3
64 |
65 | - name: PHPStan
66 | run: phpstan analyze --no-progress --error-format=checkstyle | cs2pr
67 |
68 | php-cs-fixer:
69 | name: PHP-CS-Fixer
70 | runs-on: ubuntu-latest
71 |
72 | steps:
73 | - name: Setup PHP
74 | uses: shivammathur/setup-php@v2
75 | with:
76 | php-version: 8.4
77 | coverage: none
78 | tools: php-cs-fixer:3.90, cs2pr
79 |
80 | - name: Checkout code
81 | uses: actions/checkout@v6
82 |
83 | - name: PHP-CS-Fixer
84 | run: php-cs-fixer fix --dry-run --format=checkstyle | cs2pr
85 |
86 | psalm:
87 | name: Psalm
88 | runs-on: ubuntu-latest
89 | steps:
90 | - name: Setup PHP
91 | uses: shivammathur/setup-php@v2
92 | with:
93 | php-version: 8.4
94 | coverage: none
95 | tools: vimeo/psalm:6.13.1
96 |
97 | - name: Checkout code
98 | uses: actions/checkout@v6
99 |
100 | - name: Download dependencies
101 | uses: ramsey/composer-install@v3
102 |
103 | - name: Psalm
104 | run: psalm --no-progress --output-format=github
105 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "carsonbot/carsonbot",
3 | "description": "The Carson Issue Butler",
4 | "type": "project",
5 | "license": "MIT",
6 | "require": {
7 | "php": ">=8.4",
8 | "doctrine/dbal": "^4.2",
9 | "doctrine/doctrine-bundle": "^2.13",
10 | "doctrine/doctrine-migrations-bundle": "^3.4",
11 | "doctrine/orm": "^3.3",
12 | "knplabs/github-api": "^3.3",
13 | "nyholm/psr7": "^1.3",
14 | "symfony/console": "^7.4",
15 | "symfony/dotenv": "^7.4",
16 | "symfony/flex": "^2.0",
17 | "symfony/framework-bundle": "^7.4",
18 | "symfony/http-client": "^7.4",
19 | "symfony/lock": "^7.4",
20 | "symfony/monolog-bundle": "^3.5",
21 | "symfony/runtime": "^7.4",
22 | "symfony/security-core": "^7.4",
23 | "symfony/translation-contracts": "*",
24 | "symfony/twig-bundle": "^7.4",
25 | "symfony/yaml": "^7.4",
26 | "twig/twig": "^3.3.8"
27 | },
28 | "conflict": {
29 | "symfony/symfony": "*",
30 | "symfony/var-exporter": ">=8.0"
31 | },
32 | "replace": {
33 | "symfony/polyfill-php72": "*",
34 | "symfony/polyfill-php80": "*",
35 | "symfony/polyfill-php81": "*",
36 | "symfony/polyfill-php82": "*",
37 | "symfony/polyfill-php83": "*",
38 | "symfony/polyfill-php84": "*"
39 | },
40 | "require-dev": {
41 | "happyr/service-mocking": "^1.0",
42 | "symfony/browser-kit": "^7.4",
43 | "phpunit/phpunit": "^12.4",
44 | "symfony/web-profiler-bundle": "^7.4"
45 | },
46 | "config": {
47 | "platform": {
48 | "php": "8.4"
49 | },
50 | "preferred-install": {
51 | "*": "dist"
52 | },
53 | "sort-packages": true,
54 | "allow-plugins": {
55 | "symfony/flex": true,
56 | "php-http/discovery": true,
57 | "symfony/runtime": true
58 | }
59 | },
60 | "extra": {
61 | "symfony": {
62 | "allow-contrib": true
63 | }
64 | },
65 | "autoload": {
66 | "psr-4": {
67 | "App\\": "src"
68 | }
69 | },
70 | "autoload-dev": {
71 | "psr-4": {
72 | "App\\Tests\\": "tests"
73 | }
74 | },
75 | "scripts": {
76 | "post-install-cmd": [
77 | "@auto-scripts"
78 | ],
79 | "post-update-cmd": [
80 | "@auto-scripts"
81 | ],
82 | "auto-scripts": {
83 | "cache:clear": "symfony-cmd",
84 | "assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd",
85 | "assets:install %PUBLIC_DIR%": "symfony-cmd"
86 | }
87 | },
88 | "minimum-stability": "RC",
89 | "prefer-stable": true
90 | }
91 |
--------------------------------------------------------------------------------
/src/Subscriber/RewriteUnwantedPhrasesSubscriber.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | class RewriteUnwantedPhrasesSubscriber implements EventSubscriberInterface
18 | {
19 | public function __construct(
20 | private readonly PullRequestApi $pullRequestApi,
21 | private readonly LockFactory $lockFactory,
22 | ) {
23 | }
24 |
25 | public function onPullRequest(GitHubEvent $event): void
26 | {
27 | $data = $event->getData();
28 | $action = $data['action'];
29 | if (!in_array($action, ['opened', 'ready_for_review', 'edited'])) {
30 | return;
31 | }
32 |
33 | if (!isset($data['pull_request'])) {
34 | // Only update PullRequests
35 | return;
36 | }
37 |
38 | $repository = $event->getRepository();
39 | $number = $data['number'];
40 |
41 | sleep(1); // Wait for github API to be updated
42 | $lock = $this->lockFactory->createLock($repository->getFullName().'#'.$number);
43 | $lock->acquire(true); // blocking. Lock will be released at __destruct
44 |
45 | // Fetch the current PR just to make sure we are working with all available information
46 | $githubPullRequest = $this->pullRequestApi->show($repository, $number);
47 | $title = $this->replaceUnwantedPhrases($githubPullRequest['title'] ?? '', $a);
48 | $body = $this->replaceUnwantedPhrases($githubPullRequest['body'] ?? '', $b);
49 |
50 | if (0 === $a + $b) {
51 | // No changes
52 | return;
53 | }
54 |
55 | $this->pullRequestApi->updateTitle($repository, $number, $title, $body);
56 | $event->setResponseData([
57 | 'pull_request' => $number,
58 | 'unwanted_phrases' => 'rewritten',
59 | ]);
60 | }
61 |
62 | /**
63 | * @param int<0, max>|null &$count
64 | *
65 | * @param-out int $count
66 | */
67 | private function replaceUnwantedPhrases(string $text, &$count): string
68 | {
69 | $replace = [
70 | 'dead code' => 'unused code',
71 | ];
72 |
73 | return str_ireplace(array_keys($replace), array_values($replace), $text, $count);
74 | }
75 |
76 | /**
77 | * @return array
78 | */
79 | public static function getSubscribedEvents(): array
80 | {
81 | return [
82 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
83 | ];
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Service/StaleIssueCommentGenerator.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class StaleIssueCommentGenerator
13 | {
14 | /**
15 | * Get a comment to say: "I will close this soon".
16 | */
17 | public function getInformAboutClosingComment(): string
18 | {
19 | $messages = [
20 | 'Hello? This issue is about to be closed if nobody replies.',
21 | 'Friendly ping? Should this still be open? I will close if I don\'t hear anything.',
22 | 'Could I get a reply or should I close this?',
23 | 'Just a quick reminder to make a comment on this. If I don\'t hear anything I\'ll close this.',
24 | 'Friendly reminder that this issue exists. If I don\'t hear anything I\'ll close this.',
25 | 'Could I get an answer? If I do not hear anything I will assume this issue is resolved or abandoned. Please get back to me <3',
26 | ];
27 |
28 | return $messages[array_rand($messages)];
29 | }
30 |
31 | /**
32 | * Get a comment to say: "I'm closing this now".
33 | */
34 | public function getClosingComment(): string
35 | {
36 | return << $this->bug(),
52 | IssueType::FEATURE, IssueType::RFC => $this->feature(),
53 | default => $this->unknown(),
54 | };
55 | }
56 |
57 | private function bug(): string
58 | {
59 | return <<
13 | */
14 | class WelcomeFirstTimeContributorSubscriber implements EventSubscriberInterface
15 | {
16 | public function __construct(
17 | private readonly IssueApi $commentsApi,
18 | private readonly PullRequestApi $pullRequestApi,
19 | ) {
20 | }
21 |
22 | public function onPullRequest(GitHubEvent $event): void
23 | {
24 | $data = $event->getData();
25 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
26 | return;
27 | }
28 |
29 | $association = $data['pull_request']['author_association'] ?? '';
30 | if (!in_array($association, ['NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR'])) {
31 | return;
32 | }
33 |
34 | $repository = $event->getRepository();
35 | if ($this->pullRequestApi->getAuthorCount($repository, $data['sender']['login']) > 1) {
36 | // This users has made pull requests before
37 | return;
38 | }
39 |
40 | $pullRequestNumber = $data['pull_request']['number'];
41 | $defaultBranch = $data['repository']['default_branch'];
42 | $this->commentsApi->commentOnIssue($repository, $pullRequestNumber, <<setResponseData([
69 | 'pull_request' => $pullRequestNumber,
70 | 'new_contributor' => true,
71 | ]);
72 | }
73 |
74 | /**
75 | * @return array
76 | */
77 | public static function getSubscribedEvents(): array
78 | {
79 | return [
80 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
81 | ];
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Subscriber/NeedsReviewNewPRSubscriberTest.php:
--------------------------------------------------------------------------------
1 | statusApi = $this->createMock(StatusApi::class);
30 | $this->needsReviewSubscriber = new NeedsReviewNewPRSubscriber($this->statusApi);
31 | $this->repository = new Repository('weaverryan', 'symfony', null);
32 |
33 | $this->dispatcher = new EventDispatcher();
34 | $this->dispatcher->addSubscriber($this->needsReviewSubscriber);
35 | }
36 |
37 | public function testOnPullRequestOpen()
38 | {
39 | $this->statusApi->expects($this->once())
40 | ->method('setIssueStatus')
41 | ->with(1234, Status::NEEDS_REVIEW);
42 |
43 | $event = new GitHubEvent([
44 | 'action' => 'opened',
45 | 'pull_request' => ['number' => 1234, 'title' => 'foobar'],
46 | ], $this->repository);
47 |
48 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
49 |
50 | $responseData = $event->getResponseData();
51 |
52 | $this->assertCount(2, $responseData);
53 | $this->assertSame(1234, $responseData['pull_request']);
54 | $this->assertSame(Status::NEEDS_REVIEW, $responseData['status_change']);
55 | }
56 |
57 | public function testOnPullRequestOpenWIP()
58 | {
59 | $this->statusApi->expects($this->once())
60 | ->method('setIssueStatus')
61 | ->with(1234, Status::NEEDS_WORK);
62 |
63 | $event = new GitHubEvent([
64 | 'action' => 'opened',
65 | 'pull_request' => ['number' => 1234, 'title' => '[WIP] foobar'],
66 | ], $this->repository);
67 |
68 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
69 |
70 | $responseData = $event->getResponseData();
71 | $this->assertCount(2, $responseData);
72 | $this->assertSame(1234, $responseData['pull_request']);
73 | $this->assertSame(Status::NEEDS_WORK, $responseData['status_change']);
74 | }
75 |
76 | public function testOnPullRequestNotOpen()
77 | {
78 | $this->statusApi->expects($this->never())
79 | ->method('setIssueStatus');
80 |
81 | $event = new GitHubEvent([
82 | 'action' => 'close',
83 | ], $this->repository);
84 |
85 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
86 |
87 | $responseData = $event->getResponseData();
88 |
89 | $this->assertEmpty($responseData);
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/tests/Subscriber/RemoveStalledLabelOnCommentSubscriberTest.php:
--------------------------------------------------------------------------------
1 | subscriber = new RemoveStalledLabelOnCommentSubscriber(new NullLabelApi(), 'carsonbot');
29 | $this->repository = new Repository('carsonbot-playground', 'symfony', null);
30 |
31 | $this->dispatcher = new EventDispatcher();
32 | $this->dispatcher->addSubscriber($this->subscriber);
33 | }
34 |
35 | public function testOnComment()
36 | {
37 | $event = new GitHubEvent([
38 | 'issue' => ['number' => 1234, 'state' => 'open', 'labels' => []], 'comment' => ['user' => ['login' => 'nyholm']],
39 | ], $this->repository);
40 |
41 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
42 |
43 | $responseData = $event->getResponseData();
44 | $this->assertEmpty($responseData);
45 | }
46 |
47 | public function testOnCommentOnStale()
48 | {
49 | $event = new GitHubEvent([
50 | 'issue' => ['number' => 1234, 'state' => 'open', 'labels' => [['name' => 'Foo'], ['name' => 'Stalled']]], 'comment' => ['user' => ['login' => 'nyholm']],
51 | ], $this->repository);
52 |
53 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
54 |
55 | $responseData = $event->getResponseData();
56 | $this->assertCount(2, $responseData);
57 | $this->assertSame(1234, $responseData['issue']);
58 | $this->assertSame(true, $responseData['removed_stalled_label']);
59 | }
60 |
61 | public function testOnBotCommentOnStale()
62 | {
63 | $event = new GitHubEvent([
64 | 'issue' => ['number' => 1234, 'state' => 'open', 'labels' => [['name' => 'Foo'], ['name' => 'Stalled']]], 'comment' => ['user' => ['login' => 'carsonbot']],
65 | ], $this->repository);
66 |
67 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
68 |
69 | $responseData = $event->getResponseData();
70 | $this->assertEmpty($responseData);
71 | }
72 |
73 | public function testCommentOnClosed()
74 | {
75 | $event = new GitHubEvent([
76 | 'issue' => ['number' => 1234, 'state' => 'closed', 'labels' => [['name' => 'Foo'], ['name' => 'Stalled']]], 'comment' => ['user' => ['login' => 'nyholm']],
77 | ], $this->repository);
78 |
79 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
80 |
81 | $responseData = $event->getResponseData();
82 | $this->assertEmpty($responseData);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/Service/LabelNameExtractor.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | class LabelNameExtractor
15 | {
16 | private const array LABEL_ALIASES = [
17 | 'bridge/doctrine' => 'DoctrineBridge',
18 | 'bridge/monolog' => 'MonologBridge',
19 | 'bridge/phpunit' => 'PhpUnitBridge',
20 | 'bridge/proxymanager' => 'ProxyManagerBridge',
21 | 'bridge/twig' => 'TwigBridge',
22 | 'di' => 'DependencyInjection',
23 | 'fwb' => 'FrameworkBundle',
24 | 'profiler' => 'WebProfilerBundle',
25 | 'router' => 'Routing',
26 | 'translator' => 'Translation',
27 | 'wdt' => 'WebProfilerBundle',
28 | ];
29 |
30 | public function __construct(
31 | private readonly LabelApi $labelsApi,
32 | private readonly LoggerInterface $logger,
33 | ) {
34 | }
35 |
36 | /**
37 | * Get labels from title string.
38 | * Example title: "[PropertyAccess] [RFC] [WIP] Allow custom methods on property accesses".
39 | *
40 | * @return string[]
41 | */
42 | public function extractLabels(string $title, Repository $repository): array
43 | {
44 | $labels = [];
45 | if (preg_match_all('/\[(?P.+)\]/U', $title, $matches)) {
46 | $validLabels = $this->getLabels($repository);
47 | foreach ($matches['labels'] as $label) {
48 | $label = $this->fixLabelName($label);
49 |
50 | // check case-insensitively, but then apply the correctly-cased label
51 | if (isset($validLabels[strtolower($label)])) {
52 | $labels[] = $validLabels[strtolower($label)];
53 | }
54 | }
55 | }
56 |
57 | $this->logger->debug('Searched for labels in title', ['title' => $title, 'labels' => \json_encode($labels, \JSON_THROW_ON_ERROR)]);
58 |
59 | return $labels;
60 | }
61 |
62 | /**
63 | * @return \Generator
64 | */
65 | public function getAliasesForLabel(string $label): \Generator
66 | {
67 | foreach (self::LABEL_ALIASES as $alias => $name) {
68 | if ($name === $label) {
69 | yield $alias;
70 | }
71 | }
72 | }
73 |
74 | /**
75 | * Creates a key=>val array, but the key is lowercased.
76 | *
77 | * @return array
78 | */
79 | private function getLabels(Repository $repository): array
80 | {
81 | $allLabels = $this->labelsApi->getAllLabelsForRepository($repository);
82 |
83 | return array_combine(array_map('strtolower', $allLabels), $allLabels);
84 | }
85 |
86 | /**
87 | * It fixes common misspellings and aliases commonly used for label names
88 | * (e.g. DI -> DependencyInjection).
89 | */
90 | private function fixLabelName(string $label): string
91 | {
92 | return self::LABEL_ALIASES[strtr($label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ\\', 'abcdefghijklmnopqrstuvwxyz/')] ?? $label;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/Subscriber/MismatchBranchDescriptionSubscriber.php:
--------------------------------------------------------------------------------
1 |
14 | * @author Antoine Lamirault
15 | */
16 | class MismatchBranchDescriptionSubscriber implements EventSubscriberInterface
17 | {
18 | public function __construct(
19 | private readonly IssueApi $issueApi,
20 | private readonly LoggerInterface $logger,
21 | ) {
22 | }
23 |
24 | public function onPullRequest(GitHubEvent $event): void
25 | {
26 | $data = $event->getData();
27 | if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) {
28 | return;
29 | }
30 |
31 | $number = $data['pull_request']['number'];
32 |
33 | $descriptionBranch = $this->extractDescriptionBranchFromBody($data['pull_request']['body']);
34 | if (null === $descriptionBranch) {
35 | $this->logger->notice('Pull Request without default template.', ['pull_request_number' => $number]);
36 |
37 | return;
38 | }
39 |
40 | $targetBranch = $data['pull_request']['base']['ref'];
41 | if ($targetBranch === $descriptionBranch) {
42 | return;
43 | }
44 |
45 | $this->issueApi->commentOnIssue($event->getRepository(), $number, <<setResponseData([
58 | 'pull_request' => $number,
59 | ]);
60 | }
61 |
62 | /**
63 | * @return array
64 | */
65 | public static function getSubscribedEvents(): array
66 | {
67 | return [
68 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
69 | ];
70 | }
71 |
72 | private function extractDescriptionBranchFromBody(string $body): ?string
73 | {
74 | $s = new UnicodeString($body);
75 | $bodyWithoutComment = $s->replaceMatches('//', '');
76 |
77 | // @see symfony/symfony/.github/PULL_REQUEST_TEMPLATE.md
78 | if (!$bodyWithoutComment->containsAny('Branch?')) {
79 | return null;
80 | }
81 |
82 | $rowsDescriptionBranch = $bodyWithoutComment->match('/.*Branch.*/');
83 |
84 | $rowDescriptionBranch = $rowsDescriptionBranch[0]; // row matching
85 |
86 | $descriptionBranchParts = \explode('|', $rowDescriptionBranch);
87 | if (false === array_key_exists(2, $descriptionBranchParts)) { // Branch description is in second Markdown table column
88 | return null;
89 | }
90 |
91 | $descriptionBranch = $descriptionBranchParts[2]; // get the version
92 |
93 | return \trim($descriptionBranch);
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Subscriber/StatusChangeByReviewSubscriber.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class StatusChangeByReviewSubscriber extends AbstractStatusChangeSubscriber
17 | {
18 | public function __construct(
19 | StatusApi $statusApi,
20 | private readonly LoggerInterface $logger,
21 | ) {
22 | parent::__construct($statusApi);
23 | }
24 |
25 | /**
26 | * Sets the status based on the review state (approved/changes requested)
27 | * or the review body (using the Status: keyword).
28 | */
29 | public function onReview(GitHubEvent $event): void
30 | {
31 | $data = $event->getData();
32 | if ('submitted' !== $data['action']) {
33 | return;
34 | }
35 |
36 | $repository = $event->getRepository();
37 | $pullRequestNumber = $data['pull_request']['number'];
38 |
39 | // Set status based on review state
40 | $newStatus = match (strtolower($data['review']['state'])) {
41 | 'approved' => Status::REVIEWED,
42 | 'changes_requested' => Status::NEEDS_WORK,
43 | default => $this->parseStatusFromText($data['review']['body'] ?? ''),
44 | };
45 |
46 | if (
47 | Status::REVIEWED === $newStatus
48 | && ($data['pull_request']['user']['login'] === $data['review']['user']['login'])
49 | ) {
50 | $newStatus = null;
51 | }
52 |
53 | $event->setResponseData([
54 | 'pull_request' => $pullRequestNumber,
55 | 'status_change' => $newStatus,
56 | ]);
57 |
58 | if (null === $newStatus) {
59 | return;
60 | }
61 |
62 | $this->logger->debug(sprintf('Setting issue number %s to status %s', $pullRequestNumber, $newStatus));
63 | $this->statusApi->setIssueStatus($pullRequestNumber, $newStatus, $repository);
64 | }
65 |
66 | /**
67 | * Sets the status to needs review when a review is requested.
68 | */
69 | public function onReviewRequested(GitHubEvent $event): void
70 | {
71 | $data = $event->getData();
72 | if ('review_requested' !== $data['action']) {
73 | return;
74 | }
75 |
76 | $repository = $event->getRepository();
77 | $pullRequestNumber = $data['pull_request']['number'];
78 | $newStatus = Status::NEEDS_REVIEW;
79 |
80 | $this->logger->debug(sprintf('Setting issue number %s to status %s', $pullRequestNumber, $newStatus));
81 | $this->statusApi->setIssueStatus($pullRequestNumber, $newStatus, $repository);
82 |
83 | $event->setResponseData([
84 | 'pull_request' => $pullRequestNumber,
85 | 'status_change' => $newStatus,
86 | ]);
87 | }
88 |
89 | /**
90 | * @return array
91 | */
92 | public static function getSubscribedEvents(): array
93 | {
94 | return [
95 | GitHubEvents::PULL_REQUEST_REVIEW => 'onReview',
96 | GitHubEvents::PULL_REQUEST => 'onReviewRequested',
97 | ];
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/Api/Issue/GithubIssueApi.php:
--------------------------------------------------------------------------------
1 | $title,
26 | 'labels' => $labels,
27 | 'body' => $body,
28 | ];
29 |
30 | $issueNumber = null;
31 | $existingIssues = $this->resultPager->fetchAllLazy($this->searchApi, 'issues', [sprintf('repo:%s "%s" is:open author:%s', $repository->getFullName(), $title, $this->botUsername), 'updated', 'desc']);
32 | foreach ($existingIssues as $issue) {
33 | $issueNumber = $issue['number'];
34 | }
35 |
36 | if (null === $issueNumber) {
37 | $this->issueApi->create($repository->getVendor(), $repository->getName(), $params);
38 | } else {
39 | unset($params['labels']);
40 | $this->issueApi->update($repository->getVendor(), $repository->getName(), $issueNumber, $params);
41 | }
42 | }
43 |
44 | public function lastCommentWasMadeByBot(Repository $repository, int $number): bool
45 | {
46 | $allComments = $this->issueCommentApi->all($repository->getVendor(), $repository->getName(), $number, ['per_page' => 100]);
47 | $lastComment = $allComments[count($allComments) - 1] ?? [];
48 |
49 | return $this->botUsername === ($lastComment['user']['login'] ?? null);
50 | }
51 |
52 | public function show(Repository $repository, int $issueNumber): array
53 | {
54 | return $this->issueApi->show($repository->getVendor(), $repository->getName(), $issueNumber);
55 | }
56 |
57 | public function close(Repository $repository, int $issueNumber): void
58 | {
59 | $this->issueApi->update(
60 | $repository->getVendor(),
61 | $repository->getName(),
62 | $issueNumber,
63 | [
64 | 'state' => 'closed',
65 | 'state_reason' => 'not_planned',
66 | ],
67 | );
68 | }
69 |
70 | /**
71 | * This will comment on both Issues and Pull Requests.
72 | */
73 | public function commentOnIssue(Repository $repository, int $issueNumber, string $commentBody): void
74 | {
75 | $this->issueCommentApi->create(
76 | $repository->getVendor(),
77 | $repository->getName(),
78 | $issueNumber,
79 | ['body' => $commentBody]
80 | );
81 | }
82 |
83 | public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): iterable
84 | {
85 | return $this->resultPager->fetchAllLazy($this->searchApi, 'issues', [
86 | sprintf('repo:%s is:issue -label:"Keep open" -label:"Missing translations" is:open -linked:pr updated:<%s', $repository->getFullName(), $noUpdateAfter->format('Y-m-d')),
87 | 'updated',
88 | 'desc',
89 | ]);
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/tests/Subscriber/RewriteUnwantedPhrasesSubscriberTest.php:
--------------------------------------------------------------------------------
1 | pullRequestApi = $this->getMockBuilder(NullPullRequestApi::class)
28 | ->disableOriginalConstructor()
29 | ->onlyMethods(['show', 'updateTitle'])
30 | ->getMock();
31 |
32 | $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock();
33 | $store->method('exists')->willReturn(false);
34 |
35 | $this->subscriber = new RewriteUnwantedPhrasesSubscriber($this->pullRequestApi, new LockFactory($store));
36 | $this->repository = new Repository('carsonbot-playground', 'symfony', null);
37 |
38 | $this->dispatcher = new EventDispatcher();
39 | $this->dispatcher->addSubscriber($this->subscriber);
40 | }
41 |
42 | public function testReplaceUnwantedInTitle()
43 | {
44 | $event = new GitHubEvent(['action' => 'opened', 'number' => 1234, 'pull_request' => []], $this->repository);
45 | $this->pullRequestApi->method('show')->willReturn([
46 | 'title' => 'Remove dead code',
47 | 'body' => 'foobar',
48 | ]);
49 |
50 | $this->pullRequestApi->expects($this->once())->method('updateTitle')->with($this->repository, 1234, 'Remove unused code', 'foobar');
51 |
52 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
53 | $responseData = $event->getResponseData();
54 | $this->assertCount(2, $responseData);
55 | }
56 |
57 | public function testReplaceUnwantedInBody()
58 | {
59 | $event = new GitHubEvent(['action' => 'opened', 'number' => 1234, 'pull_request' => []], $this->repository);
60 | $this->pullRequestApi->method('show')->willReturn([
61 | 'title' => 'Foobar',
62 | 'body' => 'I want to remove dead code.',
63 | ]);
64 |
65 | $this->pullRequestApi->expects($this->once())->method('updateTitle')->with($this->repository, 1234, 'Foobar', 'I want to remove unused code.');
66 |
67 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
68 | $responseData = $event->getResponseData();
69 | $this->assertCount(2, $responseData);
70 | }
71 |
72 | public function testDoNothingOnAllowedContent()
73 | {
74 | $event = new GitHubEvent(['action' => 'opened', 'number' => 1234, 'pull_request' => []], $this->repository);
75 | $this->pullRequestApi->method('show')->willReturn([
76 | 'title' => 'Hello world',
77 | 'body' => 'foobar',
78 | ]);
79 |
80 | $this->pullRequestApi->expects($this->never())->method('updateTitle');
81 |
82 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
83 | $responseData = $event->getResponseData();
84 | $this->assertCount(0, $responseData);
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Subscriber/AutoLabelFromContentSubscriber.php:
--------------------------------------------------------------------------------
1 | getData();
25 | if (!in_array($data['action'], ['opened', 'ready_for_review'])
26 | || ($data['pull_request']['draft'] ?? false)
27 | ) {
28 | return;
29 | }
30 |
31 | $repository = $event->getRepository();
32 |
33 | $prNumber = $data['pull_request']['number'];
34 | $prTitle = $data['pull_request']['title'];
35 | $prBody = $data['pull_request']['body'];
36 | $prLabels = [];
37 |
38 | // the PR title usually contains one or more labels
39 | foreach ($this->labelExtractor->extractLabels($prTitle, $repository) as $label) {
40 | $prLabels[] = $label;
41 | }
42 |
43 | // the PR body usually indicates if this is a Bug, Feature, BC Break, Deprecation or Documentation
44 | if (preg_match('/\|\s*Bug fix\?\s*\|\s*yes\s*/i', $prBody, $matches)) {
45 | $prLabels[] = 'Bug';
46 | }
47 | if (preg_match('/\|\s*New feature\?\s*\|\s*yes\s*/i', $prBody, $matches)) {
48 | $prLabels[] = 'Feature';
49 | }
50 | if (preg_match('/\|\s*BC breaks\?\s*\|\s*yes\s*/i', $prBody, $matches)) {
51 | $prLabels[] = 'BC Break';
52 | }
53 | if (preg_match('/\|\s*Deprecations\?\s*\|\s*yes\s*/i', $prBody, $matches)) {
54 | $prLabels[] = 'Deprecation';
55 | }
56 | if (preg_match('/\|\s*Documentation\?\s*\|\s*yes\s*/i', $prBody, $matches)) {
57 | $prLabels[] = 'Documentation';
58 | }
59 |
60 | $this->labelsApi->addIssueLabels($prNumber, $prLabels, $repository);
61 |
62 | $event->setResponseData([
63 | 'pull_request' => $prNumber,
64 | 'pr_labels' => $prLabels,
65 | ]);
66 | }
67 |
68 | public function onIssue(GitHubEvent $event): void
69 | {
70 | $data = $event->getData();
71 | if ('opened' !== $data['action']) {
72 | return;
73 | }
74 | $repository = $event->getRepository();
75 |
76 | $issueNumber = $data['issue']['number'];
77 | $prTitle = $data['issue']['title'];
78 | $labels = [];
79 |
80 | // the issue title usually contains one or more labels
81 | foreach ($this->labelExtractor->extractLabels($prTitle, $repository) as $label) {
82 | $labels[] = $label;
83 | }
84 |
85 | $this->labelsApi->addIssueLabels($issueNumber, $labels, $repository);
86 |
87 | $event->setResponseData([
88 | 'issue' => $issueNumber,
89 | 'issue_labels' => $labels,
90 | ]);
91 | }
92 |
93 | /**
94 | * @return array
95 | */
96 | public static function getSubscribedEvents(): array
97 | {
98 | return [
99 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
100 | GitHubEvents::ISSUES => 'onIssue',
101 | ];
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/tests/Subscriber/UnsupportedBranchSubscriberTest.php:
--------------------------------------------------------------------------------
1 | issueApi = $this->createMock(NullIssueApi::class);
36 | $symfonyVersionProvider = $this->getMockBuilder(SymfonyVersionProvider::class)
37 | ->disableOriginalConstructor()
38 | ->onlyMethods(['getMaintainedVersions'])
39 | ->getMock();
40 | $symfonyVersionProvider->method('getMaintainedVersions')->willReturn(['4.4', '5.1']);
41 |
42 | $subscriber = new UnsupportedBranchSubscriber($symfonyVersionProvider, $this->issueApi, new NullLogger());
43 | $this->repository = new Repository('carsonbot-playground', 'symfony', null);
44 |
45 | $this->dispatcher = new EventDispatcher();
46 | $this->dispatcher->addSubscriber($subscriber);
47 | }
48 |
49 | public function testOnPullRequestOpen()
50 | {
51 | $this->issueApi->expects($this->once())
52 | ->method('commentOnIssue');
53 |
54 | $event = new GitHubEvent([
55 | 'action' => 'opened',
56 | 'pull_request' => [
57 | 'number' => 1234,
58 | 'base' => ['ref' => '4.3'],
59 | ],
60 | 'repository' => [
61 | 'default_branch' => '5.x',
62 | ],
63 | ], $this->repository);
64 |
65 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
66 | $responseData = $event->getResponseData();
67 |
68 | $this->assertCount(2, $responseData);
69 | $this->assertSame(1234, $responseData['pull_request']);
70 | $this->assertSame('4.3', $responseData['unsupported_branch']);
71 | }
72 |
73 | public function testOnPullRequestOpenDefaultBranch()
74 | {
75 | $this->issueApi->expects($this->never())
76 | ->method('commentOnIssue');
77 |
78 | $event = new GitHubEvent([
79 | 'action' => 'opened',
80 | 'pull_request' => [
81 | 'number' => 1234,
82 | 'base' => ['ref' => '4.4'],
83 | ],
84 | 'repository' => [
85 | 'default_branch' => '5.x',
86 | ],
87 | ], $this->repository);
88 |
89 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
90 | $responseData = $event->getResponseData();
91 | $this->assertEmpty($responseData);
92 | }
93 |
94 | public function testOnPullRequestNotOpen()
95 | {
96 | $this->issueApi->expects($this->never())
97 | ->method('commentOnIssue');
98 |
99 | $event = new GitHubEvent([
100 | 'action' => 'close',
101 | ], $this->repository);
102 |
103 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
104 | $responseData = $event->getResponseData();
105 | $this->assertEmpty($responseData);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Api/Status/GitHubStatusApi.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | private const array STATUS_TO_LABEL = [
15 | Status::NEEDS_REVIEW => 'Status: Needs Review',
16 | Status::NEEDS_WORK => 'Status: Needs Work',
17 | Status::WORKS_FOR_ME => 'Status: Works for me',
18 | Status::REVIEWED => 'Status: Reviewed',
19 | Status::WAITING_FEEDBACK => 'Status: Waiting feedback',
20 | ];
21 |
22 | /**
23 | * @var array
24 | */
25 | private array $labelToStatus;
26 |
27 | public function __construct(
28 | private readonly LabelApi $labelsApi,
29 | private readonly LoggerInterface $logger,
30 | ) {
31 | $this->labelToStatus = array_flip(self::STATUS_TO_LABEL);
32 | }
33 |
34 | /**
35 | * @param int $issueNumber The GitHub issue number
36 | * @param string|null $newStatus A Status::* constant
37 | */
38 | public function setIssueStatus(int $issueNumber, ?string $newStatus, Repository $repository): void
39 | {
40 | if (null !== $newStatus && !isset(self::STATUS_TO_LABEL[$newStatus])) {
41 | throw new \InvalidArgumentException(sprintf('Invalid status "%s"', $newStatus));
42 | }
43 |
44 | $newLabel = null === $newStatus ? null : self::STATUS_TO_LABEL[$newStatus];
45 | $this->logger->info(sprintf('Fetching issue labels for issue %s, repository %s', $issueNumber, $repository->getFullName()));
46 | $currentLabels = $this->labelsApi->getIssueLabels($issueNumber, $repository);
47 |
48 | $this->logger->info(sprintf('Fetched the following labels: %s', implode(', ', $currentLabels)));
49 |
50 | $addLabel = true;
51 | foreach ($currentLabels as $label) {
52 | // Ignore non-status, except when the bug is reviewed
53 | // but still marked as unconfirmed.
54 | if (
55 | !isset($this->labelToStatus[$label])
56 | && !(Status::REVIEWED === $newStatus && 'Unconfirmed' === $label)
57 | ) {
58 | continue;
59 | }
60 |
61 | if ($newLabel === $label) {
62 | $addLabel = false;
63 | continue;
64 | }
65 |
66 | // Remove other statuses
67 | $this->logger->debug(sprintf('Removing label %s from issue %s on repository %s', $label, $issueNumber, $repository->getFullName()));
68 | $this->labelsApi->removeIssueLabel($issueNumber, $label, $repository);
69 | }
70 |
71 | // Ignored if the label is already set
72 | if ($addLabel && $newLabel) {
73 | $this->logger->debug(sprintf('Adding label "%s" to issue %s on repository %s', $newLabel, $issueNumber, $repository->getFullName()));
74 | $this->labelsApi->addIssueLabel($issueNumber, $newLabel, $repository);
75 | $this->logger->debug('Label added!');
76 | }
77 | }
78 |
79 | public function getIssueStatus(int $issueNumber, Repository $repository): ?string
80 | {
81 | $currentLabels = $this->labelsApi->getIssueLabels($issueNumber, $repository);
82 |
83 | foreach ($currentLabels as $label) {
84 | if (isset($this->labelToStatus[$label])) {
85 | return $this->labelToStatus[$label];
86 | }
87 | }
88 |
89 | // No status set
90 | return null;
91 | }
92 |
93 | public static function getNeedsReviewLabel(): string
94 | {
95 | return self::STATUS_TO_LABEL[Status::NEEDS_REVIEW];
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/tests/Subscriber/MilestoneMergedPRSubscriberTest.php:
--------------------------------------------------------------------------------
1 | milestonesApi = $this->createMock(GithubMilestoneApi::class);
29 | $this->subscriber = new MilestoneMergedPRSubscriber($this->milestonesApi);
30 | $this->repository = new Repository('nyholm', 'symfony', null);
31 |
32 | $this->dispatcher = new EventDispatcher();
33 | $this->dispatcher->addSubscriber($this->subscriber);
34 | }
35 |
36 | public function testOnPullRequestMerged()
37 | {
38 | $this->milestonesApi->expects($this->once())
39 | ->method('exists')
40 | ->with($this->repository, '4.4')
41 | ->willReturn(true);
42 |
43 | $this->milestonesApi->expects($this->once())
44 | ->method('updateMilestone')
45 | ->with($this->repository, 1234, '4.4');
46 |
47 | $event = new GitHubEvent([
48 | 'action' => 'closed',
49 | 'merged' => true,
50 | 'milestone' => '1.2',
51 | 'pull_request' => [
52 | 'number' => 1234,
53 | 'base' => ['ref' => '4.4'],
54 | ],
55 | 'repository' => [
56 | 'default_branch' => 'master',
57 | ],
58 | ], $this->repository);
59 |
60 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
61 | $responseData = $event->getResponseData();
62 |
63 | $this->assertCount(2, $responseData);
64 | $this->assertSame(1234, $responseData['pull_request']);
65 | $this->assertSame('4.4', $responseData['milestone']);
66 | }
67 |
68 | public function testMilestoneNotExist()
69 | {
70 | $this->milestonesApi->expects($this->once())
71 | ->method('exists')
72 | ->with($this->repository, '4.4')
73 | ->willReturn(false);
74 |
75 | $this->milestonesApi->expects($this->never())
76 | ->method('updateMilestone');
77 |
78 | $event = new GitHubEvent([
79 | 'action' => 'closed',
80 | 'merged' => true,
81 | 'milestone' => null,
82 | 'pull_request' => [
83 | 'number' => 1234,
84 | 'base' => ['ref' => '4.4'],
85 | ],
86 | 'repository' => [
87 | 'default_branch' => 'master',
88 | ],
89 | ], $this->repository);
90 |
91 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
92 | $responseData = $event->getResponseData();
93 | $this->assertEmpty($responseData);
94 | }
95 |
96 | public function testOnPullRequestNotMerged()
97 | {
98 | $this->milestonesApi->expects($this->never())
99 | ->method('updateMilestone');
100 |
101 | $event = new GitHubEvent([
102 | 'action' => 'closed',
103 | 'merged' => false,
104 | ], $this->repository);
105 |
106 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
107 | $responseData = $event->getResponseData();
108 | $this->assertEmpty($responseData);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(NullLabelApi::class)
20 | ->disableOriginalConstructor()
21 | ->onlyMethods(['getIssueLabels'])
22 | ->getMock();
23 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']);
24 |
25 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
26 | ->disableOriginalConstructor()
27 | ->onlyMethods(['close', 'lastCommentWasMadeByBot', 'show'])
28 | ->getMock();
29 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
30 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
31 | $issueApi->expects($this->never())->method('close');
32 |
33 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
34 |
35 | $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
36 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
37 | }
38 |
39 | public function testHandleComments()
40 | {
41 | $labelApi = $this->getMockBuilder(NullLabelApi::class)
42 | ->disableOriginalConstructor()
43 | ->onlyMethods(['getIssueLabels'])
44 | ->getMock();
45 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
46 |
47 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
48 | ->disableOriginalConstructor()
49 | ->onlyMethods(['close', 'lastCommentWasMadeByBot', 'show'])
50 | ->getMock();
51 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
52 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false);
53 | $issueApi->expects($this->never())->method('close');
54 |
55 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
56 |
57 | $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
58 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
59 | }
60 |
61 | public function testHandleStale()
62 | {
63 | $labelApi = $this->getMockBuilder(NullLabelApi::class)
64 | ->disableOriginalConstructor()
65 | ->getMock();
66 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
67 |
68 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
69 | ->disableOriginalConstructor()
70 | ->getMock();
71 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
72 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
73 | $issueApi->expects($this->once())->method('close')->with($this->anything(), 4711);
74 |
75 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
76 |
77 | $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
78 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Service/GitHubRequestHandler.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | class GitHubRequestHandler
19 | {
20 | public function __construct(
21 | private readonly EventDispatcher $dispatcher,
22 | private readonly RepositoryProvider $repositoryProvider,
23 | private readonly LoggerInterface $logger,
24 | ) {
25 | }
26 |
27 | /**
28 | * @return array The response data
29 | */
30 | public function handle(Request $request): array
31 | {
32 | $data = json_decode($request->getContent(), true);
33 | if (null === $data) {
34 | throw new BadRequestHttpException('Invalid JSON body!');
35 | }
36 |
37 | $repositoryFullName = $data['repository']['full_name'] ?? null;
38 | if (empty($repositoryFullName)) {
39 | throw new BadRequestHttpException('No repository name!');
40 | }
41 |
42 | $this->logger->debug(sprintf('Handling from repository %s', $repositoryFullName));
43 |
44 | $repository = $this->repositoryProvider->getRepository($repositoryFullName);
45 | if (!$repository) {
46 | throw new PreconditionFailedHttpException(sprintf('Unsupported repository "%s".', $repositoryFullName));
47 | }
48 |
49 | $secret = $repository->getSecret();
50 | if (is_string($secret) && '' !== trim($secret)) {
51 | if (!$request->headers->has('X-Hub-Signature')) {
52 | throw new AccessDeniedHttpException('The request is not secured.');
53 | }
54 |
55 | $content = $request->getContent();
56 | if (!$content) {
57 | throw new BadRequestHttpException('Empty request body!');
58 | }
59 |
60 | $signature = $request->headers->get('X-Hub-Signature');
61 | if (!$signature) {
62 | throw new BadRequestHttpException('Invalid signature!');
63 | }
64 |
65 | if (!$this->authenticate($signature, $secret, $content)) {
66 | throw new AccessDeniedHttpException('Invalid signature.');
67 | }
68 | }
69 |
70 | $event = new GitHubEvent($data, $repository);
71 | $eventName = $request->headers->get('X-Github-Event');
72 |
73 | try {
74 | $this->dispatcher->dispatch($event, 'github.'.$eventName);
75 | } catch (\Exception $e) {
76 | $message = sprintf('Failed dispatching "%s" event for "%s" repository.', (string) $eventName, $repository->getFullName());
77 | $this->logger->error($message, ['exception' => $e]);
78 |
79 | throw new \RuntimeException($message, 0, $e);
80 | }
81 |
82 | $responseData = $event->getResponseData();
83 |
84 | if (empty($responseData)) {
85 | $responseData['unsupported_action'] = $eventName;
86 | }
87 |
88 | $this->logger->info('Done handling request', [
89 | 'event' => $eventName,
90 | 'action' => $data['action'] ?? 'unknown',
91 | 'response-data' => json_encode($responseData),
92 | 'repository' => $repositoryFullName,
93 | 'issue-number' => $data['number'] ?? $data['issue']['number'] ?? $data['pull_request']['number'] ?? 'unknown',
94 | ]);
95 |
96 | return $responseData;
97 | }
98 |
99 | private function authenticate(string $hash, string $key, string $data): bool
100 | {
101 | if (!extension_loaded('hash')) {
102 | throw new \RuntimeException('"hash" extension is needed to check request signature.');
103 | }
104 |
105 | return hash_equals($hash, 'sha1='.hash_hmac('sha1', $data, $key));
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/tests/Subscriber/StatusChangeOnPushSubscriberTest.php:
--------------------------------------------------------------------------------
1 | statusApi = $this->createMock(StatusApi::class);
31 | $this->statusChangeSubscriber = new StatusChangeOnPushSubscriber($this->statusApi);
32 | $this->repository = new Repository('weaverryan', 'symfony', null);
33 |
34 | $this->dispatcher = new EventDispatcher();
35 | $this->dispatcher->addSubscriber($this->statusChangeSubscriber);
36 | }
37 |
38 | #[DataProvider('getStatuses')]
39 | public function testOnPushingCommits($currentStatus, $statusChange)
40 | {
41 | $this->statusApi->expects($this->any())
42 | ->method('getIssueStatus')
43 | ->with(1234, $this->repository)
44 | ->willReturn($currentStatus);
45 |
46 | if (null !== $statusChange) {
47 | $this->statusApi->expects($this->once())
48 | ->method('setIssueStatus')
49 | ->with(1234, Status::NEEDS_REVIEW, $this->repository);
50 | }
51 |
52 | $event = new GitHubEvent([
53 | 'action' => 'synchronize',
54 | 'pull_request' => $this->getPullRequestData(),
55 | ], $this->repository);
56 |
57 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
58 |
59 | $responseData = $event->getResponseData();
60 |
61 | $this->assertCount(2, $responseData);
62 | $this->assertSame(1234, $responseData['pull_request']);
63 | $this->assertSame($statusChange, $responseData['status_change']);
64 | }
65 |
66 | /**
67 | * @return iterable
68 | */
69 | public static function getStatuses(): iterable
70 | {
71 | yield [Status::NEEDS_WORK, Status::NEEDS_REVIEW];
72 | yield [Status::REVIEWED, null];
73 | yield [Status::WORKS_FOR_ME, null];
74 | yield [Status::NEEDS_REVIEW, null];
75 | }
76 |
77 | public function testOnNonPushPullRequestEvent()
78 | {
79 | $this->statusApi->expects($this->any())
80 | ->method('getIssueStatus')
81 | ->with(1234, $this->repository)
82 | ->willReturn(Status::NEEDS_WORK);
83 |
84 | $event = new GitHubEvent([
85 | 'action' => 'labeled',
86 | 'pull_request' => $this->getPullRequestData(),
87 | ], $this->repository);
88 |
89 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
90 |
91 | $responseData = $event->getResponseData();
92 |
93 | $this->assertEmpty($responseData);
94 | }
95 |
96 | public function testWipPullRequest()
97 | {
98 | $this->statusApi->expects($this->any())
99 | ->method('getIssueStatus')
100 | ->with(1234, $this->repository)
101 | ->willReturn(Status::NEEDS_WORK);
102 |
103 | $event = new GitHubEvent([
104 | 'action' => 'synchronize',
105 | 'pull_request' => $this->getPullRequestData('[wip] needs some more work.'),
106 | ], $this->repository);
107 |
108 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
109 |
110 | $responseData = $event->getResponseData();
111 |
112 | $this->assertCount(2, $responseData);
113 | $this->assertSame(1234, $responseData['pull_request']);
114 | $this->assertSame(null, $responseData['status_change']);
115 | }
116 |
117 | private function getPullRequestData($title = 'Default title.')
118 | {
119 | return [
120 | 'number' => 1234,
121 | 'title' => $title,
122 | ];
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/Command/PingStaleIssuesCommand.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | #[AsCommand(
26 | name: 'app:issue:ping-stale',
27 | description: 'Ping stale issues and schedule them for closing',
28 | )]
29 | final class PingStaleIssuesCommand
30 | {
31 | public const string MESSAGE_TWO_AFTER = '+2weeks';
32 | public const string MESSAGE_THREE_AND_CLOSE_AFTER = '+2weeks';
33 |
34 | public function __construct(
35 | private readonly RepositoryProvider $repositoryProvider,
36 | private readonly IssueApi $issueApi,
37 | private readonly TaskScheduler $scheduler,
38 | private readonly StaleIssueCommentGenerator $commentGenerator,
39 | private readonly LabelApi $labelApi,
40 | ) {
41 | }
42 |
43 | public function __invoke(
44 | OutputInterface $output,
45 | #[Argument(description: 'The full name to the repository, eg symfony/symfony-docs')]
46 | string $repository,
47 | #[Option(description: 'A string representing a time period to for how long the issue has been stalled.')]
48 | string $notUpdatedFor = '12months',
49 | #[Option(description: 'Do a test search without making any comments or changes')]
50 | bool $dryRun = false,
51 | ): int {
52 | $repo = $this->repositoryProvider->getRepository($repository);
53 | if (null === $repo) {
54 | $output->writeln('Repository not configured');
55 |
56 | return Command::FAILURE;
57 | }
58 |
59 | $notUpdatedAfter = new \DateTimeImmutable('-'.ltrim($notUpdatedFor, '-'));
60 | $issues = $this->issueApi->findStaleIssues($repo, $notUpdatedAfter);
61 |
62 | if ($dryRun) {
63 | foreach ($issues as $issue) {
64 | $output->writeln(sprintf('Marking issue #%s as "Stalled". Link https://github.com/%s/issues/%s', $issue['number'], $repo->getFullName(), $issue['number']));
65 | }
66 |
67 | return Command::SUCCESS;
68 | }
69 |
70 | foreach ($issues as $issue) {
71 | /**
72 | * @var array{number: int, name: string, labels: array} $issue
73 | */
74 | $comment = $this->commentGenerator->getComment($this->extractType($issue));
75 | $this->issueApi->commentOnIssue($repo, $issue['number'], $comment);
76 | $this->labelApi->addIssueLabel($issue['number'], 'Stalled', $repo);
77 |
78 | // add a scheduled task to process this issue again after 2 weeks
79 | $this->scheduler->runLater($repo, $issue['number'], Task::ACTION_INFORM_CLOSE_STALE, new \DateTimeImmutable(self::MESSAGE_TWO_AFTER));
80 | }
81 |
82 | return Command::SUCCESS;
83 | }
84 |
85 | /**
86 | * Extract type from issue array. Make sure we prioritize labels if there are
87 | * more than one type defined.
88 | *
89 | * @param array{number: int, name: string, labels: array} $issue
90 | */
91 | private function extractType(array $issue): string
92 | {
93 | $types = [
94 | IssueType::FEATURE => false,
95 | IssueType::BUG => false,
96 | IssueType::RFC => false,
97 | IssueType::DOCUMENTATION => false,
98 | ];
99 |
100 | foreach ($issue['labels'] as $label) {
101 | if (isset($types[$label['name']])) {
102 | $types[$label['name']] = true;
103 | }
104 | }
105 |
106 | foreach ($types as $type => $exists) {
107 | if ($exists) {
108 | return $type;
109 | }
110 | }
111 |
112 | return IssueType::UNKNOWN;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | The Carson Issue Butler
2 | =======================
3 |
4 | 
5 |
6 | Carson is a bot that currently runs on the [symfony/symfony](https://github.com/symfony/symfony)
7 | repository. Its job is to help automate different issue and pull request
8 | workflows.
9 |
10 | For an introduction, read: http://symfony.com/blog/calling-for-issue-triagers-a-new-workflow-and-the-carson-butler
11 |
12 | Currently, Carson's super powers are used to automatically label issues based
13 | on comments from people in the community. This gives anyone the power to review
14 | an issue or pull request and comment to update its status.
15 |
16 | For details on how this review / label process works, see http://symfony.com/doc/current/contributing/community/reviews.html
17 |
18 | ## Feature scope
19 |
20 | Carson is excellent to look at issues and pull requests to make sure they are correctly
21 | labeled, uses a supported branch or to say hi to new contributors. Carson also likes
22 | cloning a repository and run checks on it time to time, ie making sure all translations
23 | are up to date.
24 |
25 | Carson will never read the contents of a pull requests, ie it never parses the code
26 | to check for typos or other automated fixes.
27 |
28 | ## Features
29 |
30 | Here is all the things Carson can help you with. Not all features are enabled. A
31 | list of enabled features for a specific repository is defined in `config/services.yaml`.
32 |
33 | ### Automatic labeling from content
34 |
35 | Using the issue template, Carson adds labels "Bug", "Feature", "BC Break"
36 | and "Deprecation" on issues and pull requests.
37 |
38 | ### Add "Needs Review" label on pull requests
39 |
40 | When a PR is opened, then Carson will add "Needs Review".
41 |
42 | ### Add "Needs Review" label on bugs issues
43 |
44 | If an issue is labeled with "Bug", then Carson will add "Needs Review".
45 |
46 | ### Update pull request title with component labels
47 |
48 | When a PR is labeled, Carson looks for component labels (i.e. labels with color #dddddd).
49 | These labels' names will be added to the PR title (e.g. `[HttpKernel]`).
50 |
51 | ### Close draft pull requests
52 |
53 | When a PR is open as "draft", Carson adds a comment to encourage people to mark it as
54 | "ready to review". If no action is taken, Carson will close the PR in one hour.
55 |
56 | ### Add milestone to PRs
57 |
58 | When a new PR is opened and it does not target the default branch or current version, then
59 | Carson will update the milestone of the PR to a existing milestone that matches the target branch.
60 |
61 | ### Manage pull request status
62 |
63 | The "status" of a pull request defined by one of the labels: "Needs review", "Needs work",
64 | "Works for me" and "Reviewed". The status can be changed by adding a comment like:
65 | "Status: Needs work".
66 |
67 | The status will also be changed if someone adds a review that requests changes or
68 | approves the PR. Finally, the status will be "Needs review" after the author pushes
69 | changes to the PR.
70 |
71 | ### Welcome new contributors
72 |
73 | When a user opens their first PR, they will get a welcome message explaining the
74 | review process and how to increase the chances to get the PR merged.
75 |
76 | ### Comment on stale issues
77 |
78 | Carson will look for old inactive issues and start a process with them.
79 |
80 | 1. Bot will make a comment to encourage activity and add label "Stalled".
81 | 1. Bot will make a comment to inform the issue will be closed
82 | 1. Bot will close the issue.
83 |
84 | The process can be interrupted with anyone making a comment on the issue or the
85 | "Keep open" label is added.
86 |
87 | ### Add a warning if pull request target unsupported branch
88 |
89 | If a PR is opened towards a branch that is not maintained anymore, Carson will
90 | kindly explain to the author what to do.
91 |
92 | ### Add a warning if pull request description mismatch the targeted branch.
93 |
94 | If a PR is opened towards a branch but the description does not match, Carson will
95 | post a nice comment to explain to the author what to do.
96 |
97 | ### Open issues when docs for config reference is incomplete
98 |
99 | The Symfony documentation includes some pages with "configuration reference", to
100 | make sure these are always up to date, Carson will look at the bundles' defined
101 | config and compare that to what is documented. If Carson finds something that is
102 | missing, it will open an issue in the symfony/symfony-docs repository.
103 |
--------------------------------------------------------------------------------
/src/Subscriber/AutoUpdateTitleWithLabelSubscriber.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | class AutoUpdateTitleWithLabelSubscriber implements EventSubscriberInterface
18 | {
19 | public function __construct(
20 | private readonly LabelNameExtractor $labelExtractor,
21 | private readonly PullRequestApi $pullRequestApi,
22 | private readonly LockFactory $lockFactory,
23 | ) {
24 | }
25 |
26 | public function onPullRequest(GitHubEvent $event): void
27 | {
28 | $data = $event->getData();
29 | $action = $data['action'];
30 | if (!in_array($action, ['labeled', 'unlabeled'])) {
31 | return;
32 | }
33 |
34 | if (!isset($data['pull_request'])) {
35 | // Only update PullRequests
36 | return;
37 | }
38 |
39 | $repository = $event->getRepository();
40 | $number = $data['number'];
41 |
42 | sleep(1); // Wait for github API to be updated
43 | $lock = $this->lockFactory->createLock($repository->getFullName().'#'.$number);
44 | $lock->acquire(true); // blocking. Lock will be released at __destruct
45 |
46 | // Fetch the current PR just to make sure we are working with all available information
47 | $githubPullRequest = $this->pullRequestApi->show($repository, $number);
48 | $originalTitle = $prTitle = trim($githubPullRequest['title'] ?? '');
49 | $validLabels = [];
50 |
51 | foreach ($githubPullRequest['labels'] ?? [] as $label) {
52 | if ('dddddd' === strtolower($label['color'])) {
53 | $validLabels[] = $label['name'];
54 | // Remove label name from title
55 | $prTitle = str_ireplace('['.$label['name'].']', '', $prTitle);
56 |
57 | // Remove label aliases from title
58 | foreach ($this->labelExtractor->getAliasesForLabel($label['name']) as $alias) {
59 | $prTitle = str_ireplace('['.$alias.']', '', $prTitle);
60 | }
61 | }
62 | }
63 |
64 | // Remove any other labels in the title.
65 | foreach ($this->labelExtractor->extractLabels($prTitle, $repository) as $label) {
66 | $prTitle = str_ireplace('['.$label.']', '', $prTitle);
67 | }
68 |
69 | sort($validLabels);
70 | $prPrefix = '';
71 | foreach ($validLabels as $label) {
72 | $prPrefix .= '['.$label.']';
73 | }
74 |
75 | // Clean string from all HTML chars and remove whitespace at the beginning
76 | $prTitle = (string) preg_replace('@^[\h\s]+@u', '', html_entity_decode($prTitle));
77 |
78 | // Extract any bracketed text at the beginning of the title
79 | $leadingBrackets = '';
80 | $remainingTitle = $prTitle;
81 |
82 | // Match all consecutive bracketed items at the start of the title
83 | while (preg_match('/^\[([^]]+)]\s*/', $remainingTitle, $matches)) {
84 | $leadingBrackets .= '['.$matches[1].']';
85 | $remainingTitle = substr($remainingTitle, strlen($matches[0]));
86 | }
87 |
88 | // Combine: valid labels + any unrecognized brackets + remaining title
89 | if ('' !== trim($remainingTitle)) {
90 | $prTitle = $prPrefix.$leadingBrackets.' '.trim($remainingTitle);
91 | } else {
92 | $prTitle = $prPrefix.$leadingBrackets;
93 | }
94 |
95 | if ('symfony/ai' === $repository->getFullName()) {
96 | $prTitle = preg_replace('/\[ai[\s\-]*bundle\]/i', '[AI Bundle]', $prTitle) ?? $prTitle;
97 | $prTitle = preg_replace('/\[mcp[\s\-]*bundle\]/i', '[MCP Bundle]', $prTitle) ?? $prTitle;
98 | }
99 |
100 | if ($originalTitle === $prTitle) {
101 | return;
102 | }
103 |
104 | $this->pullRequestApi->updateTitle($repository, $number, $prTitle);
105 | $event->setResponseData([
106 | 'pull_request' => $number,
107 | 'new_title' => $prTitle,
108 | ]);
109 | }
110 |
111 | /**
112 | * @return array
113 | */
114 | public static function getSubscribedEvents(): array
115 | {
116 | return [
117 | GitHubEvents::PULL_REQUEST => 'onPullRequest',
118 | ];
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/Api/Label/GithubLabelApi.php:
--------------------------------------------------------------------------------
1 |
15 | */
16 | class GithubLabelApi implements LabelApi
17 | {
18 | /**
19 | * In memory cache for specific issues.
20 | *
21 | * @var array>
22 | */
23 | private array $labelCache = [];
24 |
25 | public function __construct(
26 | private readonly Labels $labelsApi,
27 | private readonly ResultPager $resultPager,
28 | private readonly CacheInterface $cache,
29 | private readonly LoggerInterface $logger,
30 | ) {
31 | }
32 |
33 | public function getIssueLabels(int $issueNumber, Repository $repository): array
34 | {
35 | $key = $this->getCacheKey($issueNumber, $repository);
36 | if (!isset($this->labelCache[$key])) {
37 | $this->labelCache[$key] = [];
38 |
39 | $labelsData = $this->labelsApi->all(
40 | $repository->getVendor(),
41 | $repository->getName(),
42 | $issueNumber
43 | );
44 |
45 | // Load labels, keep only the first status label
46 | foreach ($labelsData as $labelData) {
47 | $this->labelCache[$key][$labelData['name']] = true;
48 | }
49 | }
50 |
51 | $labels = array_keys($this->labelCache[$key]);
52 | $this->logger->debug('Returning labels for {repo}#{issue}', ['repo' => $repository->getFullName(), 'issue' => $issueNumber]);
53 |
54 | return $labels;
55 | }
56 |
57 | public function addIssueLabel(int $issueNumber, string $label, Repository $repository): void
58 | {
59 | $this->addIssueLabels($issueNumber, [$label], $repository);
60 | }
61 |
62 | public function removeIssueLabel(int $issueNumber, string $label, Repository $repository): void
63 | {
64 | $key = $this->getCacheKey($issueNumber, $repository);
65 | if (isset($this->labelCache[$key]) && !isset($this->labelCache[$key][$label])) {
66 | return;
67 | }
68 |
69 | try {
70 | $this->labelsApi->remove($repository->getVendor(), $repository->getName(), $issueNumber, $label);
71 | } catch (RuntimeException $e) {
72 | // We can just ignore 404 exceptions.
73 | if (404 !== $e->getCode()) {
74 | throw $e;
75 | }
76 | }
77 |
78 | // Update cache if already loaded
79 | if (isset($this->labelCache[$key])) {
80 | unset($this->labelCache[$key][$label]);
81 | }
82 | }
83 |
84 | public function addIssueLabels(int $issueNumber, array $labels, Repository $repository): void
85 | {
86 | $key = $this->getCacheKey($issueNumber, $repository);
87 | $labelsToAdd = [];
88 |
89 | foreach ($labels as $label) {
90 | if (!isset($this->labelCache[$key][$label])) {
91 | $labelsToAdd[] = $label;
92 | }
93 | }
94 |
95 | if ([] !== $labelsToAdd) {
96 | $this->labelsApi->add($repository->getVendor(), $repository->getName(), $issueNumber, $labelsToAdd);
97 | }
98 |
99 | // Update cache if already loaded
100 | foreach ($labels as $label) {
101 | if (isset($this->labelCache[$key])) {
102 | $this->labelCache[$key][$label] = true;
103 | }
104 | }
105 | }
106 |
107 | /**
108 | * @return string[]
109 | */
110 | public function getAllLabelsForRepository(Repository $repository): array
111 | {
112 | $allLabels = $this->getAllLabels($repository);
113 |
114 | return array_column($allLabels, 'name');
115 | }
116 |
117 | /**
118 | * @return array
119 | */
120 | private function getAllLabels(Repository $repository): array
121 | {
122 | $key = 'labels_'.sha1($repository->getFullName());
123 |
124 | return $this->cache->get($key, function (ItemInterface $item) use ($repository): array {
125 | $labels = $this->resultPager->fetchAll($this->labelsApi, 'all', [$repository->getVendor(), $repository->getName()]);
126 | $item->expiresAfter(604800);
127 |
128 | return $labels;
129 | });
130 | }
131 |
132 | private function getCacheKey(int $issueNumber, Repository $repository): string
133 | {
134 | return sprintf('%s_%s_%s', $issueNumber, $repository->getVendor(), $repository->getName());
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/tests/Subscriber/MilestoneNewPRSubscriberTest.php:
--------------------------------------------------------------------------------
1 | milestonesApi = $this->createMock(GithubMilestoneApi::class);
32 | $this->symfonyVersionProvider = $this->getMockBuilder(SymfonyVersionProvider::class)
33 | ->disableOriginalConstructor()
34 | ->onlyMethods(['getCurrentVersion'])
35 | ->getMock();
36 | $this->symfonyVersionProvider->method('getCurrentVersion')->willReturn('5.1');
37 | $this->subscriber = new MilestoneNewPRSubscriber($this->milestonesApi, $this->symfonyVersionProvider);
38 | $this->repository = new Repository('nyholm', 'symfony', null);
39 |
40 | $this->dispatcher = new EventDispatcher();
41 | $this->dispatcher->addSubscriber($this->subscriber);
42 | }
43 |
44 | public function testOnPullRequestOpen()
45 | {
46 | $this->milestonesApi->expects($this->once())
47 | ->method('exists')
48 | ->with($this->repository, '4.4')
49 | ->willReturn(true);
50 |
51 | $this->milestonesApi->expects($this->once())
52 | ->method('updateMilestone')
53 | ->with($this->repository, 1234, '4.4');
54 |
55 | $event = new GitHubEvent([
56 | 'action' => 'opened',
57 | 'pull_request' => [
58 | 'number' => 1234,
59 | 'base' => ['ref' => '4.4'],
60 | ],
61 | 'repository' => [
62 | 'default_branch' => 'master',
63 | ],
64 | ], $this->repository);
65 |
66 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
67 | $responseData = $event->getResponseData();
68 |
69 | $this->assertCount(2, $responseData);
70 | $this->assertSame(1234, $responseData['pull_request']);
71 | $this->assertSame('4.4', $responseData['milestone']);
72 | }
73 |
74 | public function testOnPullRequestOpenDefaultBranch()
75 | {
76 | $this->subscriber = new MilestoneNewPRSubscriber($this->milestonesApi, $this->symfonyVersionProvider, true);
77 |
78 | $this->milestonesApi->expects($this->never())
79 | ->method('updateMilestone');
80 |
81 | $event = new GitHubEvent([
82 | 'action' => 'opened',
83 | 'pull_request' => [
84 | 'number' => 1234,
85 | 'base' => ['ref' => 'master'],
86 | ],
87 | 'repository' => [
88 | 'default_branch' => 'master',
89 | ],
90 | ], $this->repository);
91 |
92 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
93 | $responseData = $event->getResponseData();
94 | $this->assertEmpty($responseData);
95 | }
96 |
97 | public function testOnPullRequestOpenMilestoneNotExist()
98 | {
99 | $this->milestonesApi->expects($this->once())
100 | ->method('exists')
101 | ->with($this->repository, '4.4')
102 | ->willReturn(false);
103 |
104 | $this->milestonesApi->expects($this->never())
105 | ->method('updateMilestone');
106 |
107 | $event = new GitHubEvent([
108 | 'action' => 'opened',
109 | 'pull_request' => [
110 | 'number' => 1234,
111 | 'base' => ['ref' => '4.4'],
112 | ],
113 | 'repository' => [
114 | 'default_branch' => 'master',
115 | ],
116 | ], $this->repository);
117 |
118 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
119 | $responseData = $event->getResponseData();
120 | $this->assertEmpty($responseData);
121 | }
122 |
123 | public function testOnPullRequestNotOpen()
124 | {
125 | $this->milestonesApi->expects($this->never())
126 | ->method('updateMilestone');
127 |
128 | $event = new GitHubEvent([
129 | 'action' => 'close',
130 | ], $this->repository);
131 |
132 | $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST);
133 | $responseData = $event->getResponseData();
134 | $this->assertEmpty($responseData);
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/tests/Service/TaskHandler/InformAboutClosingStaleIssuesHandlerTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(NullLabelApi::class)
21 | ->disableOriginalConstructor()
22 | ->onlyMethods(['getIssueLabels'])
23 | ->getMock();
24 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']);
25 |
26 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
27 | ->disableOriginalConstructor()
28 | ->onlyMethods(['close', 'lastCommentWasMadeByBot', 'show'])
29 | ->getMock();
30 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
31 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
32 | $issueApi->expects($this->never())->method('close');
33 |
34 | $scheduler = $this->getMockBuilder(TaskScheduler::class)
35 | ->disableOriginalConstructor()
36 | ->onlyMethods(['runLater'])
37 | ->getMock();
38 | $scheduler->expects($this->never())->method('runLater');
39 |
40 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
41 |
42 | $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler);
43 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
44 | }
45 |
46 | public function testHandleComments()
47 | {
48 | $labelApi = $this->getMockBuilder(NullLabelApi::class)
49 | ->disableOriginalConstructor()
50 | ->onlyMethods(['getIssueLabels'])
51 | ->getMock();
52 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
53 |
54 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
55 | ->disableOriginalConstructor()
56 | ->onlyMethods(['close', 'lastCommentWasMadeByBot', 'show'])
57 | ->getMock();
58 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
59 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false);
60 | $issueApi->expects($this->never())->method('close');
61 |
62 | $scheduler = $this->getMockBuilder(TaskScheduler::class)
63 | ->disableOriginalConstructor()
64 | ->onlyMethods(['runLater'])
65 | ->getMock();
66 | $scheduler->expects($this->never())->method('runLater');
67 |
68 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
69 |
70 | $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler);
71 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
72 | }
73 |
74 | public function testHandleStale()
75 | {
76 | $labelApi = $this->getMockBuilder(NullLabelApi::class)
77 | ->disableOriginalConstructor()
78 | ->onlyMethods(['getIssueLabels'])
79 | ->getMock();
80 | $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
81 |
82 | $issueApi = $this->getMockBuilder(NullIssueApi::class)
83 | ->disableOriginalConstructor()
84 | ->onlyMethods(['close', 'lastCommentWasMadeByBot', 'show'])
85 | ->getMock();
86 | $issueApi->expects($this->any())->method('show')->willReturn(['state' => 'open']);
87 | $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
88 | $issueApi->expects($this->never())->method('close');
89 |
90 | $scheduler = $this->getMockBuilder(TaskScheduler::class)
91 | ->disableOriginalConstructor()
92 | ->onlyMethods(['runLater'])
93 | ->getMock();
94 | $scheduler->expects($this->once())->method('runLater');
95 |
96 | $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
97 |
98 | $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler);
99 | $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tests/Controller/WebhookControllerTest.php:
--------------------------------------------------------------------------------
1 | client) {
21 | return;
22 | }
23 |
24 | $this->client = $this->createClient();
25 | $repository = self::getContainer()->get(RepositoryProvider::class);
26 | $statusApi = self::getContainer()->get(StatusApi::class);
27 | $pullRequestApi = self::getContainer()->get(PullRequestApi::class);
28 |
29 | ServiceMock::all($pullRequestApi, 'show', function ($repository, $id) {
30 | if (4711 !== $id) {
31 | return [];
32 | }
33 |
34 | return ['title' => 'Readme update', 'labels' => [
35 | ['name' => 'Messenger', 'color' => 'dddddd'],
36 | ]];
37 | });
38 |
39 | // the labels need to be off this issue for one test to pass
40 | $statusApi->setIssueStatus(2, null, $repository->getRepository('carsonbot-playground/symfony'));
41 | }
42 |
43 | #[DataProvider('getTests')]
44 | public function testIssueComment($eventHeader, $payloadFilename, $expectedResponse)
45 | {
46 | $client = $this->client;
47 | $body = file_get_contents(__DIR__.'/../webhook_examples/'.$payloadFilename);
48 | $client->request('POST', '/webhooks/github', [], [], ['HTTP_X-Github-Event' => $eventHeader], $body);
49 | $response = $client->getResponse();
50 |
51 | $responseData = json_decode($response->getContent(), true);
52 | $this->assertResponseIsSuccessful($responseData['error'] ?? 'An error occurred.');
53 |
54 | // a weak sanity check that we went down "the right path" in the controller
55 | $this->assertSame($expectedResponse, $responseData);
56 | }
57 |
58 | /**
59 | * @return array}>
60 | */
61 | public static function getTests(): array
62 | {
63 | return [
64 | 'On issue commented' => [
65 | 'issue_comment',
66 | 'issue_comment.created.json',
67 | ['issue' => 1, 'status_change' => 'needs_review'],
68 | ],
69 | 'On normal pull request opened' => [
70 | 'pull_request',
71 | 'pull_request.opened.json',
72 | ['pull_request' => 3, 'status_change' => 'needs_review', 'pr_labels' => ['Console', 'Bug'], 'unsupported_branch' => '2.5', 'approved_run' => true],
73 | ],
74 | 'On draft pull request opened' => [
75 | 'pull_request',
76 | 'pull_request.opened_draft.json',
77 | ['pull_request' => 3, 'draft_comment' => true, 'approved_run' => true],
78 | ],
79 | 'On pull request draft to ready' => [
80 | 'pull_request',
81 | 'pull_request.draft_to_ready.json',
82 | ['pull_request' => 3, 'status_change' => 'needs_review', 'pr_labels' => ['Console', 'Bug']],
83 | ],
84 | 'On pull request labeled' => [
85 | 'pull_request',
86 | 'pull_request.labeled.json',
87 | ['pull_request' => 4711, 'new_title' => '[Messenger] Readme update'],
88 | ],
89 | 'On pull request opened with target branch' => [
90 | 'pull_request',
91 | 'pull_request.opened_target_branch.json',
92 | ['pull_request' => 3, 'status_change' => 'needs_review', 'pr_labels' => ['Bug'], 'milestone' => '4.4', 'unsupported_branch' => '4.4', 'approved_run' => true],
93 | ],
94 | 'On issue labeled bug' => [
95 | 'issues',
96 | 'issues.labeled.bug.json',
97 | ['issue' => 2, 'status_change' => 'needs_review'],
98 | ],
99 | 'On issue labeled "feature"' => [
100 | 'issues',
101 | 'issues.labeled.feature.json',
102 | ['issue' => 2, 'status_change' => null],
103 | ],
104 | 'Welcome first users' => [
105 | 'pull_request',
106 | 'pull_request.new_contributor.json',
107 | ['pull_request' => 4, 'status_change' => 'needs_review', 'pr_labels' => [], 'new_contributor' => true, 'squash_comment' => true, 'approved_run' => true],
108 | ],
109 | 'Waiting Code Merge' => [
110 | 'pull_request',
111 | 'issues.labeled.waitingCodeMerge.json',
112 | ['pull_request' => 2, 'milestone' => 'next'],
113 | ],
114 | ];
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/tests/Subscriber/StatusChangeByCommentSubscriberTest.php:
--------------------------------------------------------------------------------
1 | statusApi = $this->createMock(StatusApi::class);
32 | $logger = $this->createMock(LoggerInterface::class);
33 | $this->statusChangeSubscriber = new StatusChangeByCommentSubscriber($this->statusApi, $logger);
34 | $this->repository = new Repository('weaverryan', 'symfony', null);
35 |
36 | $this->dispatcher = new EventDispatcher();
37 | $this->dispatcher->addSubscriber($this->statusChangeSubscriber);
38 | }
39 |
40 | #[DataProvider('getCommentsForStatusChange')]
41 | public function testOnIssueComment($comment, $expectedStatus)
42 | {
43 | if (null !== $expectedStatus) {
44 | $this->statusApi->expects($this->once())
45 | ->method('setIssueStatus')
46 | ->with(1234, $expectedStatus);
47 | }
48 |
49 | $event = new GitHubEvent([
50 | 'issue' => ['number' => 1234, 'user' => ['login' => 'weaverryan']],
51 | 'comment' => ['body' => $comment, 'user' => ['login' => 'leannapelham']],
52 | ], $this->repository);
53 |
54 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
55 |
56 | $responseData = $event->getResponseData();
57 |
58 | $this->assertCount(2, $responseData);
59 | $this->assertSame(1234, $responseData['issue']);
60 | $this->assertSame($expectedStatus, $responseData['status_change']);
61 | }
62 |
63 | /**
64 | * @return iterable
65 | */
66 | public static function getCommentsForStatusChange(): iterable
67 | {
68 | yield ['Have a great day!', null];
69 | // basic tests for status change
70 | yield ['Status: needs review', Status::NEEDS_REVIEW];
71 | yield ['Status: needs work', Status::NEEDS_WORK];
72 | yield ['Status: reviewed', Status::REVIEWED];
73 | // accept quotes
74 | yield ['Status: "reviewed"', Status::REVIEWED];
75 | yield ["Status: 'reviewed'", Status::REVIEWED];
76 | // accept trailing punctuation
77 | yield ['Status: works for me!', Status::WORKS_FOR_ME];
78 | yield ['Status: works for me.', Status::WORKS_FOR_ME];
79 | // play with different formatting
80 | yield ['STATUS: REVIEWED', Status::REVIEWED];
81 | yield ['**Status**: reviewed', Status::REVIEWED];
82 | yield ['**Status:** reviewed', Status::REVIEWED];
83 | yield ['**Status: reviewed**', Status::REVIEWED];
84 | yield ['**Status: reviewed!**', Status::REVIEWED];
85 | yield ['**Status: reviewed**.', Status::REVIEWED];
86 | yield ['Status:reviewed', Status::REVIEWED];
87 | yield ['Status: reviewed', Status::REVIEWED];
88 | // reject missing colon
89 | yield ['Status reviewed', null];
90 | // multiple matches - use the last one
91 | yield ["Status: needs review \r\n that is what the issue *was* marked as.\r\n Status: reviewed", Status::REVIEWED];
92 | // "needs review" does not come directly after status: , so there is no status change
93 | yield ['Here is my status: I\'m really happy! I realize this needs review, but I\'m, having too much fun Googling cats!', null];
94 | // reject if the status is not on a line of its own
95 | // use case: someone posts instructions about how to change a status
96 | // in a comment
97 | yield ['You should include e.g. the line `Status: needs review` in your comment', null];
98 | yield ['Before the ticket was in state "Status: reviewed", but then the status was changed', null];
99 | }
100 |
101 | public function testOnIssueCommentAuthorSelfReview(): void
102 | {
103 | $this->statusApi->expects($this->never())
104 | ->method('setIssueStatus')
105 | ;
106 |
107 | $user = ['login' => 'weaverryan'];
108 |
109 | $event = new GitHubEvent([
110 | 'issue' => [
111 | 'number' => 1234,
112 | 'user' => $user,
113 | ],
114 | 'comment' => [
115 | 'body' => 'Status: reviewed',
116 | 'user' => $user,
117 | ],
118 | ], $this->repository);
119 |
120 | $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT);
121 |
122 | $responseData = $event->getResponseData();
123 |
124 | $this->assertCount(2, $responseData);
125 | $this->assertSame(1234, $responseData['issue']);
126 | $this->assertNull($responseData['status_change']);
127 | }
128 | }
129 |
--------------------------------------------------------------------------------