[
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\n\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[{compose.yaml,compose.*.yaml}]\nindent_size = 2\n\n[.github/**.yml]\nindent_size = 2\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: j0k3r\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: composer\n  directory: \"/\"\n  schedule:\n    interval: weekly\n  open-pull-requests-limit: 10\n  groups:\n    symfony-dependencies:\n      patterns:\n        - \"symfony/*\"\n    twig-dependencies:\n      patterns:\n        - \"twig/*\"\n    phpstan-dependencies:\n      patterns:\n        - \"phpstan/*\"\n- package-ecosystem: github-actions\n  directory: \"/\"\n  schedule:\n    interval: weekly\n  open-pull-requests-limit: 10\n"
  },
  {
    "path": ".github/release.yml",
    "content": "changelog:\n  exclude:\n    labels:\n      - dependencies\n    authors:\n      - dependabot\n"
  },
  {
    "path": ".github/workflows/coding-standards.yml",
    "content": "name: CS\n\non:\n  pull_request:\n    branches:\n      - master\n  push:\n    branches:\n      - master\n\njobs:\n  coding-standards:\n    name: CS Fixer & PHPStan\n    runs-on: \"ubuntu-latest\"\n\n    strategy:\n      matrix:\n        php:\n          - \"8.2\"\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Install PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          coverage: \"none\"\n          php-version: \"${{ matrix.php }}\"\n          tools: composer:v2\n          ini-values: \"date.timezone=Europe/Paris\"\n        env:\n          COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Install dependencies with Composer\n        uses: ramsey/composer-install@v4\n\n      - name: Run PHP CS Fixer\n        run: bin/php-cs-fixer fix --verbose --dry-run\n\n      - name: Generate test cache for PHPStan\n        run: php bin/console cache:clear --env=test\n\n      - name: Install PHPUnit for PHPStan\n        run: php bin/simple-phpunit install\n\n      - name: Run PHPStan\n        run: php bin/phpstan analyse --no-progress\n"
  },
  {
    "path": ".github/workflows/continuous-integration.yml",
    "content": "name: CI\n\non:\n  pull_request:\n    branches:\n      - \"master\"\n  push:\n    branches:\n      - \"master\"\n\nenv:\n  fail-fast: true\n  APP_ENV: test\n\njobs:\n  phpunit:\n    name: PHPUnit (PHP ${{ matrix.php }})\n    runs-on: \"ubuntu-latest\"\n    services:\n      mysql:\n        image: mysql:5.7\n        env:\n          MYSQL_ALLOW_EMPTY_PASSWORD: yes\n        ports:\n          - 3306:3306\n      rabbitmq:\n        image: rabbitmq:3-alpine\n        ports:\n          - 5672:5672\n      redis:\n        image: redis:6-alpine\n        ports:\n          - 6379:6379\n\n    strategy:\n      matrix:\n        php:\n          - \"8.2\"\n          - \"8.3\"\n          - \"8.4\"\n          - \"8.5\"\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 2\n\n      - name: Install PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: \"${{ matrix.php }}\"\n          coverage: \"none\"\n          tools: composer:v2\n          extensions: pdo, pdo_mysql, curl, redis, amqp\n          ini-values: \"date.timezone=Europe/Paris\"\n        env:\n          COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Install dependencies with Composer\n        uses: ramsey/composer-install@v4\n\n      - name: Create database\n        run: php bin/console doctrine:database:create --env=test\n\n      - name: Create schema\n        run: php bin/console doctrine:schema:create --env=test\n\n      - name: Load fixtures\n        run: php bin/console doctrine:fixtures:load --env=test -n\n\n      - name: Setup messenger queue\n        run: php bin/console messenger:setup-transports --env=dev\n\n      - name: Run PHPUnit\n        run: php bin/phpunit\n"
  },
  {
    "path": ".github/workflows/coverage.yml",
    "content": "name: Coverage\n\non:\n  pull_request:\n    branches:\n      - \"master\"\n  push:\n    branches:\n      - \"master\"\n\nenv:\n  APP_ENV: test\n\njobs:\n  phpunit:\n    name: PHPUnit\n    runs-on: \"ubuntu-latest\"\n    services:\n      mysql:\n        image: mysql:5.7\n        env:\n          MYSQL_ALLOW_EMPTY_PASSWORD: yes\n        ports:\n          - 3306:3306\n      rabbitmq:\n        image: rabbitmq:3-alpine\n        ports:\n          - 5672:5672\n      redis:\n        image: redis:6-alpine\n        ports:\n          - 6379:6379\n\n    strategy:\n      matrix:\n        php:\n          - \"8.2\"\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 2\n\n      - name: Install PHP with PCOV\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: \"${{ matrix.php }}\"\n          coverage: \"pcov\"\n          tools: composer:v2\n          extensions: pdo, pdo_mysql, curl, redis, amqp\n          ini-values: \"date.timezone=Europe/Paris\"\n        env:\n          COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Install dependencies with Composer\n        uses: ramsey/composer-install@v4\n\n      - name: Create database\n        run: php bin/console doctrine:database:create --env=test\n\n      - name: Create schema\n        run: php bin/console doctrine:schema:create --env=test\n\n      - name: Load fixtures\n        run: php bin/console doctrine:fixtures:load --env=test -n\n\n      - name: Setup messenger queue\n        run: php bin/console messenger:setup-transports --env=dev\n\n      - name: Run PHPUnit (with coverage)\n        run: php bin/phpunit --coverage-clover=coverage.xml\n\n      - name: Upload coverage to Codecov\n        uses: codecov/codecov-action@v6\n        with:\n          files: ./coverage.xml\n          token: ${{ secrets.CODECOV_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/build/\n/coverage/\n/var/*\n!/var/cache\n/var/cache/*\n!var/cache/.gitkeep\n!/var/log\n/var/log/*\n!var/log/.gitkeep\n!/var/sessions\n/var/sessions/*\n!var/sessions/.gitkeep\n/bin/*\n!/bin/console\n\n###> symfony/framework-bundle ###\n/.env.local\n/.env.local.php\n/.env.*.local\n/config/secrets/prod/prod.decrypt.private.php\n/public/bundles/\n/var/\n/vendor/\n###< symfony/framework-bundle ###\n\n###> friendsofphp/php-cs-fixer ###\n/.php-cs-fixer.php\n/.php-cs-fixer.cache\n###< friendsofphp/php-cs-fixer ###\n\n###> symfony/phpunit-bridge ###\n.phpunit\n.phpunit.result.cache\n/.phpunit.cache\n/phpunit.xml\n###< symfony/phpunit-bridge ###\n\n###> phpunit/phpunit ###\n/phpunit.xml\n.phpunit.result.cache\n###< phpunit/phpunit ###\n\n###> phpstan/phpstan ###\nphpstan.neon\n###< phpstan/phpstan ###\n"
  },
  {
    "path": ".nvmrc",
    "content": "22\n"
  },
  {
    "path": ".php-cs-fixer.dist.php",
    "content": "<?php\n\nuse PhpCsFixer\\Finder;\nuse PhpCsFixer\\Config;\n\n$finder = (new Finder())\n    ->in(__DIR__)\n    ->exclude(['vendor', 'var', 'web'])\n    ->notPath([\n        'config/reference.php',\n    ])\n;\n\nreturn (new Config())\n    ->setRiskyAllowed(true)\n    ->setRules([\n        '@Symfony' => true,\n        '@Symfony:risky' => true,\n        'array_syntax' => ['syntax' => 'short'],\n        'combine_consecutive_unsets' => true,\n        'heredoc_to_nowdoc' => true,\n        'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block']],\n        'no_unreachable_default_argument_value' => true,\n        'no_useless_else' => true,\n        'no_useless_return' => true,\n        'ordered_class_elements' => true,\n        'ordered_imports' => true,\n        'php_unit_strict' => true,\n        'phpdoc_order' => true,\n        // 'psr4' => true,\n        'strict_comparison' => true,\n        'strict_param' => true,\n        'concat_space' => ['spacing' => 'one'],\n    ])\n    ->setFinder($finder)\n;\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nContributions are welcome, of course.\n\n## Setting up an Environment\n\nYou locally need:\n\n - PHP >= 8.2 (with `pdo_mysql`) with [Composer](https://getcomposer.org/) installed\n - Docker\n\nInstall deps:\n\n```\ncomposer i\n```\n\nThe application serves its frontend assets directly from `public/`, so there is no Node/Yarn setup step.\n\nThen you can use Docker (used for test or dev):\n\n```\ndocker run -d --name banditore-mysql -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 mysql:latest\ndocker run -d --name banditore-redis -p 6379:6379 redis:latest\ndocker run -d --name banditore-rabbit -p 5672:5672 -p 15672:15672 rabbitmq:4-management\n```\n\n## Running Tests\n\nYou can setup the database and the project using:\n\n```\nmake prepare\n```\n\nOnce it's ok, launch tests:\n\n```\nphp bin/phpunit\n```\n\nTest environment defaults live in `.env.test`.\n\n## Linting\n\nLinter is used only on PHP files:\n\n```\nphp bin/php-cs-fixer fix\nphp bin/phpstan analyse\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017-present Jérémy Benoist\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: build local prepare test\n\nbuild: prepare test\n\nlocal:\n\tphp bin/console doctrine:database:create --if-not-exists --env=test\n\tphp bin/console doctrine:schema:create --env=test\n\tphp bin/console doctrine:fixtures:load --env=test -n\n\tphp bin/console cache:clear --env=test\n\nprepare:\n\tcomposer install --no-interaction -o --prefer-dist\n\tphp bin/console doctrine:database:create --if-not-exists --env=test\n\tphp bin/console doctrine:schema:drop --force --env=test\n\tphp bin/console doctrine:schema:create --env=test\n\tphp bin/console doctrine:fixtures:load --env=test -n\n\tphp bin/console cache:clear --env=test\n\ntest:\n\tphp bin/phpunit --coverage-html build/coverage\n\nreset:\n\tphp bin/console doctrine:schema:drop --force --env=test\n\tphp bin/console doctrine:schema:create --env=test\n\tphp bin/console doctrine:fixtures:load --env=test -n\n"
  },
  {
    "path": "README.md",
    "content": "<img src=\"https://i.imgur.com/kAvg4w9.png\" align=\"right\" />\n\n# Banditore\n\n![CI](https://github.com/j0k3r/banditore/workflows/CI/badge.svg)\n[![codecov](https://codecov.io/github/j0k3r/banditore/graph/badge.svg?token=vGOatWHRG8)](https://codecov.io/github/j0k3r/banditore)\n![PHPStan level max](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg?style=flat)\n\nBanditore retrieves new releases from your GitHub starred repositories and put them in a RSS feed, just for you.\n\n![](https://i.imgur.com/XDCWLJV.png)\n\n## Requirements\n\n - PHP >= 8.2 (with `pdo_mysql`)\n - MySQL >= 5.7\n - Redis (to cache requests to the GitHub API)\n - [RabbitMQ](https://www.rabbitmq.com/), which is optional (see below)\n - [Supervisor](http://supervisord.org/) (only if you use RabbitMQ)\n\n## Installation\n\n1. Clone the project\n\n    ```bash\n    git clone https://github.com/j0k3r/banditore.git\n    ```\n\n2. [Register a new OAuth GitHub application](https://github.com/settings/applications/new) and get the _Client ID_ & _Client Secret_ for the next step (for the _Authorization callback URL_ put `http://127.0.0.1:8000/callback`)\n\n3. Install dependencies using [Composer](https://getcomposer.org/download/) and define your parameter during the installation\n\n    ```bash\n    APP_ENV=prod composer install -o --no-dev\n    ```\n\n    If you want to use:\n     - **Sentry** to retrieve all errors, [register here](https://sentry.io/signup/) and get your dsn (in Project Settings > DSN).\n\n4. Setup the database\n\n    ```bash\n    php bin/console doctrine:database:create -e prod\n    php bin/console doctrine:schema:create -e prod\n    ```\n\n5. The application serves its frontend assets directly from `public/`, so no Node/Yarn install step is required (it's locked on `font-awesome@4.7.0` & `purecss@3.0.0`).\n\n6. You can now launch the website:\n\n    ```bash\n    php -S localhost:8000 -t public/\n    ```\n\n    And access it at this address: `http://127.0.0.1:8000`\n\n## Running the instance\n\nOnce the website is up, you now have to setup few things to retrieve new releases.\nYou have two choices:\n- using crontab command (very simple and ok if you are alone)\n- using RabbitMQ (might be better if you plan to have more than few persons but it's more complex) :call_me_hand:\n\n### Without RabbitMQ\n\nYou just need to define these 2 cronjobs (replace all `/path/to/banditore` with real value):\n\n```bash\n# retrieve new release of each repo every 10 minutes\n*/10  *   *   *   *   php /path/to/banditore/bin/console -e prod banditore:sync:versions >> /path/to/banditore/var/logs/command-sync-versions.log 2>&1\n# sync starred repos of each user every 5 minutes\n*/5   *   *   *   *   php /path/to/banditore/bin/console -e prod banditore:sync:starred-repos >> /path/banditore/to/var/logs/command-sync-repos.log 2>&1\n```\n\n### With RabbitMQ\n\n1. You'll need to declare exchanges and queues. Replace `guest` by the user of your RabbitMQ instance (`guest` is the default one):\n\n ```bash\n php bin/console messenger:setup-transports -vvv sync_starred_repos\n php bin/console messenger:setup-transports -vvv sync_versions\n ```\n\n2. You now have two queues and two exchanges defined:\n - `banditore.sync_starred_repos`: will receive messages to sync starred repos of all users\n - `banditore.sync_versions`: will receive message to retrieve new release for repos\n\n3. Enable these 2 cronjobs which will periodically push messages in queues (replace all `/path/to/banditore` with real value):\n\n ```bash\n # retrieve new release of each repo every 10 minutes\n */10  *   *   *   *   php /path/to/banditore/bin/console -e prod banditore:sync:versions --use_queue >> /path/to/banditore/var/logs/command-sync-versions.log 2>&1\n # sync starred repos of each user every 5 minutes\n */5   *   *   *   *   php /path/to/banditore/bin/console -e prod banditore:sync:starred-repos --use_queue >> /path/banditore/to/var/logs/command-sync-repos.log 2>&1\n```\n\n4. Setup Supervisor using the [sample file](data/supervisor.conf) from the repo. You can copy/paste it into `/etc/supervisor/conf.d/` and adjust path. The default file will launch:\n  - 2 workers for sync starred repos\n  - 4 workers to fetch new releases\n\n Once you've put the file in the supervisor conf repo, run `supervisorctl update && supervisorctl start all` (`update` will read your conf, `start all` will start all workers)\n\n### Monitoring\n\nThere is a status page available at `/status`, it returns a json with some information about the freshness of fetched versions:\n\n```json\n{\n    \"latest\": {\n        \"date\": \"2019-09-17 19:50:50.000000\",\n        \"timezone_type\": 3,\n        \"timezone\": \"Europe\\/Berlin\"\n    },\n    \"diff\": 1736,\n    \"is_fresh\": true\n}\n```\n\n- `latest`: the latest created version as a DateTime\n- `diff`: the difference between now and the latest created version (in seconds)\n- `is_fresh`: indicate if everything is fine by comparing the `diff` above with the `status_minute_interval_before_alert` parameter\n\nFor example, I've setup a check on [updown.io](https://updown.io/r/P7qer) to check that status page and if the page contains `\"is_fresh\":true`. So I receive an alert when `is_fresh` is false: which means there is a potential issue on the server.\n\n## Running the test suite\n\nIf you plan to contribute (you're awesome, I know that :v:), you'll need to install the project in a different way (for example, to retrieve dev packages):\n\n```bash\ngit clone https://github.com/j0k3r/banditore.git\ncomposer install -o\nphp bin/console doctrine:database:create -e=test\nphp bin/console doctrine:schema:create -e=test\nphp bin/console doctrine:fixtures:load --env=test -n\nphp bin/phpunit\n```\n\nTest environment defaults, including the database connection, are defined in `.env.test`.\n\n## How it works\n\nOk, if you goes that deeper in the readme, it means you're a bit more than interested, I like that.\n\n### Retrieving new release / tag\n\nThis is the complex part of the app. Here is a simplified solution to achieve it.\n\n#### New release\n\nIt's not as easy as using the `/repos/:owner/:repo/releases` API endpoint to retrieve latest release for a given repo. Because not all repo owner use that feature (which is a shame in my case).\n\nAll information for a release are available on that endpoint:\n- name of the tag (ie: v1.0.0)\n- name of the release (ie: yay first release)\n- published date\n- description of the release\n\n> Check a new release of that repo as example: https://api.github.com/repos/j0k3r/banditore/releases/5770680\n\n#### New tag\n\nSome owners also use tag which is a bit more complex to retrieve all information because a tag only contains information about the SHA-1 of the commit which was used to make the tag.\nWe only have these information:\n- name of the tag (ie: v1.4.2)\n- name of the release will be the name of the tag, in that case\n\n> Check tag list of swarrot/SwarrotBundle as example: https://api.github.com/repos/swarrot/SwarrotBundle/tags\n\nAfter retrieving the tag, we need to retrieve the commit to get these information:\n- date of the commit\n- message of the commit\n\n> Check a commit from the previous tag list as example: https://api.github.com/repos/swarrot/SwarrotBundle/commits/84c7c57622e4666ae5706f33cd71842639b78755\n\n### GitHub Client Discovery\n\nThis is the most important piece of the app. One thing that I ran though is hitting the rate limit on GitHub.\nThe rate limit for a given authenticated client is 5.000 calls per hour. This limit is **never** reached when looking for new release (thanks to the [conditional requests](https://developer.github.com/v3/#conditional-requests) of the GitHub API) on a daily basis.\n\nBut when new user sign in, we need to sync all its starred repositories and also all their releases / tags. And here come the gourmand part:\n- one call for the list of release\n- one call to retrieve information of each tag (if the repo doesn't have release)\n- one call for each release to convert markdown text to html\n\nLet's say the repo:\n- has 50 tags: 1 (get tag list) + 50 (get commit information) + 50 (convert markdown) = 101 calls.\n- has 50 releases: 1 (get tag list) + 50 (get each release) + 50 (convert markdown) = 101 calls.\n\nAnd keep in mind that some repos got also 1.000+ tags (!!).\n\nTo avoid hitting the limit in such case and wait 1 hour to be able to make requests again I created the [GitHub Client Discovery class](src/AppBundle/Github/ClientDiscovery.php).\nIt aims to find the best client with enough rate limit remain (defined as 50).\n- it first checks using the GitHub OAuth app\n- then it checks using all user GitHub token\n\nWhich means, if you have 5 users on the app, you'll be able to make (1 + 5) x 5.000 = 30.000 calls per hour\n"
  },
  {
    "path": "bin/console",
    "content": "#!/usr/bin/env php\n<?php\n\nuse App\\Kernel;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\n\nif (!is_dir(dirname(__DIR__).'/vendor')) {\n    throw new LogicException('Dependencies are missing. Try running \"composer install\".');\n}\n\nif (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {\n    throw new LogicException('Symfony Runtime is missing. Try running \"composer require symfony/runtime\".');\n}\n\nrequire_once dirname(__DIR__).'/vendor/autoload_runtime.php';\n\nreturn function (array $context) {\n    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);\n\n    return new Application($kernel);\n};\n"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"j0k3r/banditore\",\n    \"description\": \"Banditore retrieve all new releases from your starred repositories and put them in a RSS feed, just for you.\",\n    \"license\": \"MIT\",\n    \"type\": \"project\",\n    \"autoload\": {\n        \"psr-4\": {\n            \"App\\\\\": \"src/\"\n        }\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"App\\\\Tests\\\\\": \"tests/\"\n        }\n    },\n    \"require\": {\n        \"php\": \">=8.2\",\n        \"cache/adapter-common\": \"dev-psr-cache-v3\",\n        \"cache/tag-interop\": \"dev-psr-cache-v3\",\n        \"doctrine/doctrine-bundle\": \"^2.0\",\n        \"doctrine/doctrine-migrations-bundle\": \"^3.0\",\n        \"doctrine/orm\": \"^3.5\",\n        \"knplabs/github-api\": \"^3.0\",\n        \"knplabs/knp-time-bundle\": \"^2.4\",\n        \"knpuniversity/oauth2-client-bundle\": \"^2.1\",\n        \"laminas/laminas-code\": \"^4.5\",\n        \"league/oauth2-github\": \"^3.0\",\n        \"marcw/rss-writer\": \"dev-fix/symfony-6\",\n        \"php-http/guzzle7-adapter\": \"^1.1\",\n        \"predis/predis\": \"^3.2\",\n        \"ramsey/uuid\": \"^4.0\",\n        \"sentry/sentry-symfony\": \"^5.0\",\n        \"snc/redis-bundle\": \"^4.10\",\n        \"symfony/amqp-messenger\": \"*\",\n        \"symfony/asset\": \"*\",\n        \"symfony/dotenv\": \"*\",\n        \"symfony/expression-language\": \"*\",\n        \"symfony/flex\": \"^2.5\",\n        \"symfony/monolog-bundle\": \"^4.0\",\n        \"symfony/polyfill-apcu\": \"^1.0\",\n        \"symfony/polyfill-php80\": \"^1.27\",\n        \"symfony/runtime\": \"*\",\n        \"symfony/security-bundle\": \"*\",\n        \"symfony/translation\": \"*\",\n        \"symfony/twig-bundle\": \"*\",\n        \"symfony/validator\": \"*\",\n        \"symfony/yaml\": \"*\",\n        \"twig/extra-bundle\": \"^2.12|^3.0\",\n        \"twig/twig\": \"^2.0|^3.0\"\n    },\n    \"require-dev\": {\n        \"doctrine/doctrine-fixtures-bundle\": \"^4.1\",\n        \"friendsofphp/php-cs-fixer\": \"~3.0\",\n        \"phpstan/extension-installer\": \"^1.0\",\n        \"phpstan/phpstan\": \"^2.0\",\n        \"phpstan/phpstan-deprecation-rules\": \"^2.0\",\n        \"phpstan/phpstan-doctrine\": \"^2.0\",\n        \"phpstan/phpstan-phpunit\": \"^2.0\",\n        \"phpstan/phpstan-symfony\": \"^2.0\",\n        \"phpunit/phpunit\": \"^11\",\n        \"rector/rector\": \"^2.1\",\n        \"symfony/browser-kit\": \"*\",\n        \"symfony/css-selector\": \"*\",\n        \"symfony/debug-bundle\": \"*\",\n        \"symfony/phpunit-bridge\": \"*\",\n        \"symfony/web-profiler-bundle\": \"*\"\n    },\n    \"conflict\": {\n        \"symfony/symfony\": \"*\"\n    },\n    \"scripts\": {\n        \"auto-scripts\": {\n            \"cache:clear\": \"symfony-cmd\",\n            \"assets:install %PUBLIC_DIR%\": \"symfony-cmd\"\n        },\n        \"post-install-cmd\": [\n            \"@auto-scripts\"\n        ],\n        \"post-update-cmd\": [\n            \"@auto-scripts\"\n        ]\n    },\n    \"config\": {\n        \"bin-dir\": \"bin\",\n        \"sort-packages\": true,\n        \"allow-plugins\": {\n            \"phpstan/extension-installer\": true,\n            \"symfony/flex\": true,\n            \"symfony/runtime\": true,\n            \"php-http/discovery\": true\n        }\n    },\n    \"repositories\": [\n        {\n            \"type\": \"vcs\",\n            \"url\": \"https://github.com/j0k3r/rss-writer\"\n        },\n        {\n            \"type\": \"vcs\",\n            \"url\": \"https://github.com/j0k3r/adapter-common\"\n        },\n        {\n            \"type\": \"vcs\",\n            \"url\": \"https://github.com/j0k3r/tag-interop\"\n        }\n    ],\n    \"extra\": {\n        \"symfony\": {\n            \"allow-contrib\": true,\n            \"require\": \"7.4.*\"\n        }\n    }\n}\n"
  },
  {
    "path": "config/bundles.php",
    "content": "<?php\n\nuse Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle;\nuse Doctrine\\Bundle\\FixturesBundle\\DoctrineFixturesBundle;\nuse Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle;\nuse Knp\\Bundle\\TimeBundle\\KnpTimeBundle;\nuse KnpU\\OAuth2ClientBundle\\KnpUOAuth2ClientBundle;\nuse Sentry\\SentryBundle\\SentryBundle;\nuse Snc\\RedisBundle\\SncRedisBundle;\nuse Symfony\\Bundle\\DebugBundle\\DebugBundle;\nuse Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle;\nuse Symfony\\Bundle\\MonologBundle\\MonologBundle;\nuse Symfony\\Bundle\\SecurityBundle\\SecurityBundle;\nuse Symfony\\Bundle\\TwigBundle\\TwigBundle;\nuse Symfony\\Bundle\\WebProfilerBundle\\WebProfilerBundle;\nuse Twig\\Extra\\TwigExtraBundle\\TwigExtraBundle;\n\nreturn [\n    FrameworkBundle::class => ['all' => true],\n    DoctrineBundle::class => ['all' => true],\n    DoctrineMigrationsBundle::class => ['all' => true],\n    KnpTimeBundle::class => ['all' => true],\n    KnpUOAuth2ClientBundle::class => ['all' => true],\n    SentryBundle::class => ['prod' => true],\n    SncRedisBundle::class => ['all' => true],\n    MonologBundle::class => ['all' => true],\n    DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],\n    TwigBundle::class => ['all' => true],\n    TwigExtraBundle::class => ['all' => true],\n    SecurityBundle::class => ['all' => true],\n    DebugBundle::class => ['dev' => true, 'test' => true],\n    WebProfilerBundle::class => ['dev' => true, 'test' => true],\n];\n"
  },
  {
    "path": "config/packages/cache.yaml",
    "content": "framework:\n    cache:\n        # Unique name of your app: used to compute stable namespaces for cache keys.\n        #prefix_seed: your_vendor_name/app_name\n\n        # The \"app\" cache stores to the filesystem by default.\n        # The data in this cache should persist between deploys.\n        # Other options include:\n\n        # Redis\n        #app: cache.adapter.redis\n        #default_redis_provider: redis://localhost\n\n        # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)\n        #app: cache.adapter.apcu\n\n        # Namespaced pools use the above \"app\" backend by default\n        #pools:\n            #my.dedicated.cache: null\n"
  },
  {
    "path": "config/packages/debug.yaml",
    "content": "when@dev:\n    debug:\n        # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.\n        # See the \"server:dump\" command to start a new server.\n        dump_destination: \"tcp://%env(VAR_DUMPER_SERVER)%\"\n"
  },
  {
    "path": "config/packages/doctrine.yaml",
    "content": "doctrine:\n    dbal:\n        url: '%env(resolve:DATABASE_URL)%'\n        # driver: pdo_mysql\n        # host: \"%database_host%\"\n        # port: \"%database_port%\"\n        # dbname: \"%database_name%\"\n        # user: \"%database_user%\"\n        # password: \"%database_password%\"\n        charset: utf8mb4\n        server_version: 5.7\n        default_table_options:\n            charset: utf8mb4\n            collate: utf8mb4_unicode_ci\n\n        # backtrace queries in profiler (increases memory usage per request)\n        profiling_collect_backtrace: '%kernel.debug%'\n\n        use_savepoints: true\n    orm:\n        auto_generate_proxy_classes: true\n        enable_lazy_ghost_objects: true\n        report_fields_where_declared: true\n        validate_xml_mapping: true\n        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware\n        identity_generation_preferences:\n            Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform: identity\n        auto_mapping: true\n        mappings:\n            App:\n                type: attribute\n                is_bundle: false\n                dir: '%kernel.project_dir%/src/Entity'\n                prefix: 'App\\Entity'\n                alias: App\n        controller_resolver:\n            auto_mapping: false\n\nwhen@test:\n    doctrine:\n        dbal:\n            logging: false\n            # \"TEST_TOKEN\" is typically set by ParaTest\n            # dbname_suffix: '_test%env(default::TEST_TOKEN)%'\n\nwhen@prod:\n    doctrine:\n        orm:\n            auto_generate_proxy_classes: false\n            proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'\n            metadata_cache_driver:\n                type: pool\n                pool: doctrine.system_cache_pool\n            query_cache_driver:\n                type: pool\n                pool: doctrine.system_cache_pool\n            result_cache_driver:\n                type: pool\n                pool: doctrine.result_cache_pool\n\n    framework:\n        cache:\n            pools:\n                doctrine.result_cache_pool:\n                    adapter: cache.app\n                doctrine.system_cache_pool:\n                    adapter: cache.system\n"
  },
  {
    "path": "config/packages/doctrine_migrations.yaml",
    "content": "doctrine_migrations:\n    migrations_paths:\n        # namespace is arbitrary but should be different from App\\Migrations\n        # as migrations classes should NOT be autoloaded\n        'DoctrineMigrations': '%kernel.project_dir%/migrations'\n    enable_profiler: false\n"
  },
  {
    "path": "config/packages/framework.yaml",
    "content": "# see https://symfony.com/doc/current/reference/configuration/framework.html\nframework:\n    secret: '%env(APP_SECRET)%'\n    annotations: false\n    http_method_override: true\n    handle_all_throwables: true\n\n    # Enables session support. Note that the session will ONLY be started if you read or write from it.\n    # Remove or comment this section to explicitly disable session support.\n    session:\n        name: banditore\n        storage_factory_id: session.storage.factory.native\n        save_path: \"%kernel.project_dir%/var/sessions/%kernel.environment%\"\n        cookie_secure: auto\n        cookie_samesite: lax\n\n    #esi: true\n    #fragments: true\n\nwhen@test:\n    framework:\n        test: true\n        session:\n            storage_factory_id: session.storage.factory.mock_file\n"
  },
  {
    "path": "config/packages/github_api.yaml",
    "content": "services:\n    Github\\Client:\n        arguments:\n            - '@Github\\HttpClient\\Builder'\n        # Uncomment to enable authentication\n        #calls:\n        #    - ['authenticate', ['%env(GITHUB_USERNAME)%', '%env(GITHUB_SECRET)%', '%env(GITHUB_AUTH_METHOD)%']]\n\n    Github\\HttpClient\\Builder:\n        arguments:\n            - '@?Http\\Client\\HttpClient'\n            - '@?Http\\Message\\RequestFactory'\n            - '@?Http\\Message\\StreamFactory'\n"
  },
  {
    "path": "config/packages/http_discovery.yaml",
    "content": "services:\n    Psr\\Http\\Message\\RequestFactoryInterface: '@http_discovery.psr17_factory'\n    Psr\\Http\\Message\\ResponseFactoryInterface: '@http_discovery.psr17_factory'\n    Psr\\Http\\Message\\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'\n    Psr\\Http\\Message\\StreamFactoryInterface: '@http_discovery.psr17_factory'\n    Psr\\Http\\Message\\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'\n    Psr\\Http\\Message\\UriFactoryInterface: '@http_discovery.psr17_factory'\n\n    http_discovery.psr17_factory:\n        class: Http\\Discovery\\Psr17Factory\n"
  },
  {
    "path": "config/packages/knpu_oauth2_client.yaml",
    "content": "knpu_oauth2_client:\n    clients:\n        # will create service: \"knpu.oauth2.client.github\"\n        # an instance of: KnpU\\OAuth2ClientBundle\\Client\\Provider\\GithubClient\n        github:\n            type: github\n            client_id: \"%env(GITHUB_CLIENT_ID)%\"\n            client_secret: \"%env(GITHUB_CLIENT_SECRET)%\"\n            # a route name you'll create\n            redirect_route: github_callback\n            redirect_params: {}\n"
  },
  {
    "path": "config/packages/messenger.yaml",
    "content": "framework:\n    messenger:\n        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.\n        # failure_transport: failed\n\n        transports:\n            sync_starred_repos:\n                dsn: '%env(MESSENGER_TRANSPORT_DSN)%/sync_starred_repos'\n                options:\n                    exchange:\n                        name: banditore.sync_starred_repos\n                        type: direct\n                    queues:\n                        banditore.sync_starred_repos: ~\n\n            sync_versions:\n                dsn: '%env(MESSENGER_TRANSPORT_DSN)%/sync_versions'\n                options:\n                    exchange:\n                        name: banditore.sync_versions\n                        type: direct\n                    queues:\n                        banditore.sync_versions: ~\n\n            # https://symfony.com/doc/current/messenger.html#transport-configuration\n            # async: '%env(MESSENGER_TRANSPORT_DSN)%'\n            # failed: 'doctrine://default?queue_name=failed'\n            # sync: 'sync://'\n\n        routing:\n            'App\\Message\\StarredReposSync': sync_starred_repos\n            'App\\Message\\VersionsSync': sync_versions\n\n        buses:\n            command_bus:\n                middleware:\n                    - doctrine_ping_connection\n                    - doctrine_close_connection\n\nwhen@test:\n    framework:\n        messenger:\n            transports:\n                sync_starred_repos: 'in-memory://'\n                sync_versions: 'in-memory://'\n"
  },
  {
    "path": "config/packages/monolog.yaml",
    "content": "monolog:\n    channels:\n        - deprecation # Deprecations are logged in the dedicated \"deprecation\" channel when it exists\n\nwhen@dev:\n    monolog:\n        handlers:\n            main:\n                type: stream\n                path: \"%kernel.logs_dir%/%kernel.environment%.log\"\n                level: debug\n                channels: [\"!event\"]\n            console:\n                type: console\n                process_psr_3_messages: false\n                channels: [\"!event\", \"!doctrine\", \"!console\"]\n\nwhen@test:\n    monolog:\n        handlers:\n            main:\n                type: fingers_crossed\n                action_level: error\n                handler: nested\n                excluded_http_codes: [404, 405]\n                channels: [\"!event\"]\n            nested:\n                type: stream\n                path: \"%kernel.logs_dir%/%kernel.environment%.log\"\n                level: debug\n\nwhen@prod:\n    monolog:\n        handlers:\n            main:\n                type: fingers_crossed\n                action_level: error\n                handler: nested\n                excluded_http_codes: [404, 405]\n                channels: [\"!deprecation\"]\n                buffer_size: 50 # How many messages should be saved? Prevent memory leaks\n            nested:\n                type: rotating_file\n                path: \"%kernel.logs_dir%/%kernel.environment%.log\"\n                level: debug\n                max_files: 10\n            console:\n                type: console\n                process_psr_3_messages: false\n                channels: [\"!event\", \"!doctrine\"]\n            deprecation:\n                type: stream\n                channels: [deprecation]\n                path: \"%kernel.logs_dir%/deprecation.log\"\n"
  },
  {
    "path": "config/packages/routing.yaml",
    "content": "framework:\n    router:\n        # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.\n        # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands\n        default_uri: '%env(DEFAULT_URI)%'\n\nwhen@prod:\n    framework:\n        router:\n            strict_requirements: null\n"
  },
  {
    "path": "config/packages/security.yaml",
    "content": "security:\n    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords\n    password_hashers:\n        Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface: 'auto'\n\n    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider\n    providers:\n        github_provider:\n            entity:\n                class: App\\Entity\\User\n                property: githubId\n\n    firewalls:\n        dev:\n            # Ensure dev tools and static assets are always allowed\n            pattern: ^/(_profiler|_wdt|assets|build|css|images|js)/\n            security: false\n\n        main:\n            lazy: true\n\n            custom_authenticators:\n                - App\\Security\\GithubAuthenticator\n\n            logout:\n                path: logout\n\n            # Activate different ways to authenticate:\n            # https://symfony.com/doc/current/security.html#the-firewall\n\n            # https://symfony.com/doc/current/security/impersonating_user.html\n            # switch_user: true\n\n    # Note: Only the *first* matching rule is applied\n    access_control:\n        # - { path: ^/admin, roles: ROLE_ADMIN }\n        # - { path: ^/profile, roles: ROLE_USER }\n\nwhen@test:\n    security:\n        password_hashers:\n            # Password hashers are resource-intensive by design to ensure security.\n            # In tests, it's safe to reduce their cost to improve performance.\n            Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface:\n                algorithm: auto\n                cost: 4 # Lowest possible value for bcrypt\n                time_cost: 3 # Lowest possible value for argon\n                memory_cost: 10 # Lowest possible value for argon\n"
  },
  {
    "path": "config/packages/sentry.yaml",
    "content": "when@prod:\n    sentry:\n        dsn: '%env(SENTRY_DSN)%'\n        options:\n            integrations:\n                - 'Sentry\\Integration\\IgnoreErrorsIntegration'\n                - 'Symfony\\Component\\ErrorHandler\\Error\\FatalError'\n                - 'Symfony\\Component\\Debug\\Exception\\FatalErrorException'\n        # If you are using Monolog, you also need these additional configuration and services to log the errors correctly:\n        # https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration\n        register_error_listener: false\n        register_error_handler: false\n\n        # this hooks into critical paths of the framework (and vendors) to perform\n        # automatic instrumentation (there might be some performance penalty)\n        # https://docs.sentry.io/platforms/php/guides/symfony/performance/instrumentation/automatic-instrumentation/\n        tracing:\n            enabled: false\n\n    monolog:\n        handlers:\n            sentry:\n                type: service\n                id: Sentry\\Monolog\\Handler\n                level: !php/const Monolog\\Logger::ERROR\n\n    services:\n        Sentry\\Integration\\IgnoreErrorsIntegration:\n            arguments:\n                $options:\n                    ignore_exceptions:\n                        - Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException\n        Sentry\\Monolog\\Handler:\n            arguments:\n                $hub: '@Sentry\\State\\HubInterface'\n                $level: !php/const Monolog\\Logger::ERROR\n                $bubble: false\n"
  },
  {
    "path": "config/packages/snc_redis.yaml",
    "content": "# Define your clients here. The example below connects to database 0 of the default Redis server.\n#\n# See https://github.com/snc/SncRedisBundle/blob/master/docs/README.md for instructions on\n# how to configure the bundle.\nsnc_redis:\n    clients:\n        guzzle_cache:\n            type: predis\n            alias: guzzle_cache\n            dsn: \"%env(REDIS_URL_GUZZLE_CACHE)%\"\n        app_cache:\n            type: predis\n            alias: app_cache\n            dsn: \"%env(REDIS_URL_APP_CACHE)%\"\n"
  },
  {
    "path": "config/packages/twig.yaml",
    "content": "twig:\n    file_name_pattern: '*.twig'\n\nwhen@test:\n    twig:\n        strict_variables: true\n"
  },
  {
    "path": "config/packages/validator.yaml",
    "content": "framework:\n    validation:\n        enable_attributes: true\n        email_validation_mode: html5\n\n        # Enables validator auto-mapping support.\n        # For instance, basic validation constraints will be inferred from Doctrine's metadata.\n        auto_mapping:\n           App\\Entity\\: []\n\nwhen@test:\n    framework:\n        validation:\n            not_compromised_password: false\n"
  },
  {
    "path": "config/packages/web_profiler.yaml",
    "content": "when@dev:\n    web_profiler:\n        toolbar: true\n\n    framework:\n        profiler:\n            collect_serializer_data: true\n\nwhen@test:\n    web_profiler:\n        toolbar: false\n        intercept_redirects: false\n\n    framework:\n        profiler:\n            enabled: false\n            collect: false\n            collect_serializer_data: true\n"
  },
  {
    "path": "config/preload.php",
    "content": "<?php\n\nif (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {\n    require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';\n}\n"
  },
  {
    "path": "config/reference.php",
    "content": "<?php\n\n// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.\n\nnamespace Symfony\\Component\\DependencyInjection\\Loader\\Configurator;\n\nuse Symfony\\Component\\Config\\Loader\\ParamConfigurator as Param;\n\n/**\n * This class provides array-shapes for configuring the services and bundles of an application.\n *\n * Services declared with the config() method below are autowired and autoconfigured by default.\n *\n * This is for apps only. Bundles SHOULD NOT use it.\n *\n * Example:\n *\n *     ```php\n *     // config/services.php\n *     namespace Symfony\\Component\\DependencyInjection\\Loader\\Configurator;\n *\n *     return App::config([\n *         'services' => [\n *             'App\\\\' => [\n *                 'resource' => '../src/',\n *             ],\n *         ],\n *     ]);\n *     ```\n *\n * @psalm-type ImportsConfig = list<string|array{\n *     resource: string,\n *     type?: string|null,\n *     ignore_errors?: bool,\n * }>\n * @psalm-type ParametersConfig = array<string, scalar|\\UnitEnum|array<scalar|\\UnitEnum|array<mixed>|Param|null>|Param|null>\n * @psalm-type ArgumentsType = list<mixed>|array<string, mixed>\n * @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}\n * @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key\n * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\\Closure|ReferenceConfigurator|ExpressionConfigurator\n * @psalm-type DeprecationType = array{package: string, version: string, message?: string}\n * @psalm-type DefaultsType = array{\n *     public?: bool,\n *     tags?: TagsType,\n *     resource_tags?: TagsType,\n *     autowire?: bool,\n *     autoconfigure?: bool,\n *     bind?: array<string, mixed>,\n * }\n * @psalm-type InstanceofType = array{\n *     shared?: bool,\n *     lazy?: bool|string,\n *     public?: bool,\n *     properties?: array<string, mixed>,\n *     configurator?: CallbackType,\n *     calls?: list<CallType>,\n *     tags?: TagsType,\n *     resource_tags?: TagsType,\n *     autowire?: bool,\n *     bind?: array<string, mixed>,\n *     constructor?: string,\n * }\n * @psalm-type DefinitionType = array{\n *     class?: string,\n *     file?: string,\n *     parent?: string,\n *     shared?: bool,\n *     synthetic?: bool,\n *     lazy?: bool|string,\n *     public?: bool,\n *     abstract?: bool,\n *     deprecated?: DeprecationType,\n *     factory?: CallbackType,\n *     configurator?: CallbackType,\n *     arguments?: ArgumentsType,\n *     properties?: array<string, mixed>,\n *     calls?: list<CallType>,\n *     tags?: TagsType,\n *     resource_tags?: TagsType,\n *     decorates?: string,\n *     decoration_inner_name?: string,\n *     decoration_priority?: int,\n *     decoration_on_invalid?: 'exception'|'ignore'|null,\n *     autowire?: bool,\n *     autoconfigure?: bool,\n *     bind?: array<string, mixed>,\n *     constructor?: string,\n *     from_callable?: CallbackType,\n * }\n * @psalm-type AliasType = string|array{\n *     alias: string,\n *     public?: bool,\n *     deprecated?: DeprecationType,\n * }\n * @psalm-type PrototypeType = array{\n *     resource: string,\n *     namespace?: string,\n *     exclude?: string|list<string>,\n *     parent?: string,\n *     shared?: bool,\n *     lazy?: bool|string,\n *     public?: bool,\n *     abstract?: bool,\n *     deprecated?: DeprecationType,\n *     factory?: CallbackType,\n *     arguments?: ArgumentsType,\n *     properties?: array<string, mixed>,\n *     configurator?: CallbackType,\n *     calls?: list<CallType>,\n *     tags?: TagsType,\n *     resource_tags?: TagsType,\n *     autowire?: bool,\n *     autoconfigure?: bool,\n *     bind?: array<string, mixed>,\n *     constructor?: string,\n * }\n * @psalm-type StackType = array{\n *     stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,\n *     public?: bool,\n *     deprecated?: DeprecationType,\n * }\n * @psalm-type ServicesConfig = array{\n *     _defaults?: DefaultsType,\n *     _instanceof?: InstanceofType,\n *     ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>\n * }\n * @psalm-type ExtensionType = array<string, mixed>\n * @psalm-type FrameworkConfig = array{\n *     secret?: scalar|Param|null,\n *     http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false\n *     allowed_http_method_override?: list<string|Param>|null,\n *     trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: \"%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%\"\n *     ide?: scalar|Param|null, // Default: \"%env(default::SYMFONY_IDE)%\"\n *     test?: bool|Param,\n *     default_locale?: scalar|Param|null, // Default: \"en\"\n *     set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the \"_locale\" request attribute is not passed). // Default: false\n *     set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false\n *     enabled_locales?: list<scalar|Param|null>,\n *     trusted_hosts?: list<scalar|Param|null>,\n *     trusted_proxies?: mixed, // Default: [\"%env(default::SYMFONY_TRUSTED_PROXIES)%\"]\n *     trusted_headers?: list<scalar|Param|null>,\n *     error_controller?: scalar|Param|null, // Default: \"error_controller\"\n *     handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \\Throwable. // Default: true\n *     csrf_protection?: bool|array{\n *         enabled?: scalar|Param|null, // Default: null\n *         stateless_token_ids?: list<scalar|Param|null>,\n *         check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false\n *         cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: \"csrf-token\"\n *     },\n *     form?: bool|array{ // Form configuration\n *         enabled?: bool|Param, // Default: false\n *         csrf_protection?: bool|array{\n *             enabled?: scalar|Param|null, // Default: null\n *             token_id?: scalar|Param|null, // Default: null\n *             field_name?: scalar|Param|null, // Default: \"_token\"\n *             field_attr?: array<string, scalar|Param|null>,\n *         },\n *     },\n *     http_cache?: bool|array{ // HTTP cache configuration\n *         enabled?: bool|Param, // Default: false\n *         debug?: bool|Param, // Default: \"%kernel.debug%\"\n *         trace_level?: \"none\"|\"short\"|\"full\"|Param,\n *         trace_header?: scalar|Param|null,\n *         default_ttl?: int|Param,\n *         private_headers?: list<scalar|Param|null>,\n *         skip_response_headers?: list<scalar|Param|null>,\n *         allow_reload?: bool|Param,\n *         allow_revalidate?: bool|Param,\n *         stale_while_revalidate?: int|Param,\n *         stale_if_error?: int|Param,\n *         terminate_on_cache_hit?: bool|Param,\n *     },\n *     esi?: bool|array{ // ESI configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n *     ssi?: bool|array{ // SSI configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n *     fragments?: bool|array{ // Fragments configuration\n *         enabled?: bool|Param, // Default: false\n *         hinclude_default_template?: scalar|Param|null, // Default: null\n *         path?: scalar|Param|null, // Default: \"/_fragment\"\n *     },\n *     profiler?: bool|array{ // Profiler configuration\n *         enabled?: bool|Param, // Default: false\n *         collect?: bool|Param, // Default: true\n *         collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null\n *         only_exceptions?: bool|Param, // Default: false\n *         only_main_requests?: bool|Param, // Default: false\n *         dsn?: scalar|Param|null, // Default: \"file:%kernel.cache_dir%/profiler\"\n *         collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false\n *     },\n *     workflows?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *         workflows?: array<string, array{ // Default: []\n *             audit_trail?: bool|array{\n *                 enabled?: bool|Param, // Default: false\n *             },\n *             type?: \"workflow\"|\"state_machine\"|Param, // Default: \"state_machine\"\n *             marking_store?: array{\n *                 type?: \"method\"|Param,\n *                 property?: scalar|Param|null,\n *                 service?: scalar|Param|null,\n *             },\n *             supports?: list<scalar|Param|null>,\n *             definition_validators?: list<scalar|Param|null>,\n *             support_strategy?: scalar|Param|null,\n *             initial_marking?: list<scalar|Param|null>,\n *             events_to_dispatch?: list<string|Param>|null,\n *             places?: list<array{ // Default: []\n *                 name?: scalar|Param|null,\n *                 metadata?: array<string, mixed>,\n *             }>,\n *             transitions?: list<array{ // Default: []\n *                 name?: string|Param,\n *                 guard?: string|Param, // An expression to block the transition.\n *                 from?: list<array{ // Default: []\n *                     place?: string|Param,\n *                     weight?: int|Param, // Default: 1\n *                 }>,\n *                 to?: list<array{ // Default: []\n *                     place?: string|Param,\n *                     weight?: int|Param, // Default: 1\n *                 }>,\n *                 weight?: int|Param, // Default: 1\n *                 metadata?: array<string, mixed>,\n *             }>,\n *             metadata?: array<string, mixed>,\n *         }>,\n *     },\n *     router?: bool|array{ // Router configuration\n *         enabled?: bool|Param, // Default: false\n *         resource?: scalar|Param|null,\n *         type?: scalar|Param|null,\n *         cache_dir?: scalar|Param|null, // Deprecated: Setting the \"framework.router.cache_dir.cache_dir\" configuration option is deprecated. It will be removed in version 8.0. // Default: \"%kernel.build_dir%\"\n *         default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null\n *         http_port?: scalar|Param|null, // Default: 80\n *         https_port?: scalar|Param|null, // Default: 443\n *         strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true\n *         utf8?: bool|Param, // Default: true\n *     },\n *     session?: bool|array{ // Session configuration\n *         enabled?: bool|Param, // Default: false\n *         storage_factory_id?: scalar|Param|null, // Default: \"session.storage.factory.native\"\n *         handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if \"save_path\" is not null.\n *         name?: scalar|Param|null,\n *         cookie_lifetime?: scalar|Param|null,\n *         cookie_path?: scalar|Param|null,\n *         cookie_domain?: scalar|Param|null,\n *         cookie_secure?: true|false|\"auto\"|Param, // Default: \"auto\"\n *         cookie_httponly?: bool|Param, // Default: true\n *         cookie_samesite?: null|\"lax\"|\"strict\"|\"none\"|Param, // Default: \"lax\"\n *         use_cookies?: bool|Param,\n *         gc_divisor?: scalar|Param|null,\n *         gc_probability?: scalar|Param|null,\n *         gc_maxlifetime?: scalar|Param|null,\n *         save_path?: scalar|Param|null, // Defaults to \"%kernel.cache_dir%/sessions\" if the \"handler_id\" option is not null.\n *         metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0\n *         sid_length?: int|Param, // Deprecated: Setting the \"framework.session.sid_length.sid_length\" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.\n *         sid_bits_per_character?: int|Param, // Deprecated: Setting the \"framework.session.sid_bits_per_character.sid_bits_per_character\" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.\n *     },\n *     request?: bool|array{ // Request configuration\n *         enabled?: bool|Param, // Default: false\n *         formats?: array<string, string|list<scalar|Param|null>>,\n *     },\n *     assets?: bool|array{ // Assets configuration\n *         enabled?: bool|Param, // Default: true\n *         strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false\n *         version_strategy?: scalar|Param|null, // Default: null\n *         version?: scalar|Param|null, // Default: null\n *         version_format?: scalar|Param|null, // Default: \"%%s?%%s\"\n *         json_manifest_path?: scalar|Param|null, // Default: null\n *         base_path?: scalar|Param|null, // Default: \"\"\n *         base_urls?: list<scalar|Param|null>,\n *         packages?: array<string, array{ // Default: []\n *             strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false\n *             version_strategy?: scalar|Param|null, // Default: null\n *             version?: scalar|Param|null,\n *             version_format?: scalar|Param|null, // Default: null\n *             json_manifest_path?: scalar|Param|null, // Default: null\n *             base_path?: scalar|Param|null, // Default: \"\"\n *             base_urls?: list<scalar|Param|null>,\n *         }>,\n *     },\n *     asset_mapper?: bool|array{ // Asset Mapper configuration\n *         enabled?: bool|Param, // Default: false\n *         paths?: array<string, scalar|Param|null>,\n *         excluded_patterns?: list<scalar|Param|null>,\n *         exclude_dotfiles?: bool|Param, // If true, any files starting with \".\" will be excluded from the asset mapper. // Default: true\n *         server?: bool|Param, // If true, a \"dev server\" will return the assets from the public directory (true in \"debug\" mode only by default). // Default: true\n *         public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when \"server\" is true). // Default: \"/assets/\"\n *         missing_import_mode?: \"strict\"|\"warn\"|\"ignore\"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. \"import './non-existent.js'\". \"strict\" means an exception is thrown, \"warn\" means a warning is logged, \"ignore\" means the import is left as-is. // Default: \"warn\"\n *         extensions?: array<string, scalar|Param|null>,\n *         importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: \"%kernel.project_dir%/importmap.php\"\n *         importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: \"es-module-shims\"\n *         importmap_script_attributes?: array<string, scalar|Param|null>,\n *         vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: \"%kernel.project_dir%/assets/vendor\"\n *         precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.\n *             enabled?: bool|Param, // Default: false\n *             formats?: list<scalar|Param|null>,\n *             extensions?: list<scalar|Param|null>,\n *         },\n *     },\n *     translator?: bool|array{ // Translator configuration\n *         enabled?: bool|Param, // Default: true\n *         fallbacks?: list<scalar|Param|null>,\n *         logging?: bool|Param, // Default: false\n *         formatter?: scalar|Param|null, // Default: \"translator.formatter.default\"\n *         cache_dir?: scalar|Param|null, // Default: \"%kernel.cache_dir%/translations\"\n *         default_path?: scalar|Param|null, // The default path used to load translations. // Default: \"%kernel.project_dir%/translations\"\n *         paths?: list<scalar|Param|null>,\n *         pseudo_localization?: bool|array{\n *             enabled?: bool|Param, // Default: false\n *             accents?: bool|Param, // Default: true\n *             expansion_factor?: float|Param, // Default: 1.0\n *             brackets?: bool|Param, // Default: true\n *             parse_html?: bool|Param, // Default: false\n *             localizable_html_attributes?: list<scalar|Param|null>,\n *         },\n *         providers?: array<string, array{ // Default: []\n *             dsn?: scalar|Param|null,\n *             domains?: list<scalar|Param|null>,\n *             locales?: list<scalar|Param|null>,\n *         }>,\n *         globals?: array<string, string|array{ // Default: []\n *             value?: mixed,\n *             message?: string|Param,\n *             parameters?: array<string, scalar|Param|null>,\n *             domain?: string|Param,\n *         }>,\n *     },\n *     validation?: bool|array{ // Validation configuration\n *         enabled?: bool|Param, // Default: true\n *         cache?: scalar|Param|null, // Deprecated: Setting the \"framework.validation.cache.cache\" configuration option is deprecated. It will be removed in version 8.0.\n *         enable_attributes?: bool|Param, // Default: true\n *         static_method?: list<scalar|Param|null>,\n *         translation_domain?: scalar|Param|null, // Default: \"validators\"\n *         email_validation_mode?: \"html5\"|\"html5-allow-no-tld\"|\"strict\"|\"loose\"|Param, // Default: \"html5\"\n *         mapping?: array{\n *             paths?: list<scalar|Param|null>,\n *         },\n *         not_compromised_password?: bool|array{\n *             enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true\n *             endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null\n *         },\n *         disable_translation?: bool|Param, // Default: false\n *         auto_mapping?: array<string, array{ // Default: []\n *             services?: list<scalar|Param|null>,\n *         }>,\n *     },\n *     annotations?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     serializer?: bool|array{ // Serializer configuration\n *         enabled?: bool|Param, // Default: false\n *         enable_attributes?: bool|Param, // Default: true\n *         name_converter?: scalar|Param|null,\n *         circular_reference_handler?: scalar|Param|null,\n *         max_depth_handler?: scalar|Param|null,\n *         mapping?: array{\n *             paths?: list<scalar|Param|null>,\n *         },\n *         default_context?: array<string, mixed>,\n *         named_serializers?: array<string, array{ // Default: []\n *             name_converter?: scalar|Param|null,\n *             default_context?: array<string, mixed>,\n *             include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true\n *             include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true\n *         }>,\n *     },\n *     property_access?: bool|array{ // Property access configuration\n *         enabled?: bool|Param, // Default: true\n *         magic_call?: bool|Param, // Default: false\n *         magic_get?: bool|Param, // Default: true\n *         magic_set?: bool|Param, // Default: true\n *         throw_exception_on_invalid_index?: bool|Param, // Default: false\n *         throw_exception_on_invalid_property_path?: bool|Param, // Default: true\n *     },\n *     type_info?: bool|array{ // Type info configuration\n *         enabled?: bool|Param, // Default: true\n *         aliases?: array<string, scalar|Param|null>,\n *     },\n *     property_info?: bool|array{ // Property info configuration\n *         enabled?: bool|Param, // Default: true\n *         with_constructor_extractor?: bool|Param, // Registers the constructor extractor.\n *     },\n *     cache?: array{ // Cache configuration\n *         prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: \"_%kernel.project_dir%.%kernel.container_class%\"\n *         app?: scalar|Param|null, // App related cache pools configuration. // Default: \"cache.adapter.filesystem\"\n *         system?: scalar|Param|null, // System related cache pools configuration. // Default: \"cache.adapter.system\"\n *         directory?: scalar|Param|null, // Default: \"%kernel.share_dir%/pools/app\"\n *         default_psr6_provider?: scalar|Param|null,\n *         default_redis_provider?: scalar|Param|null, // Default: \"redis://localhost\"\n *         default_valkey_provider?: scalar|Param|null, // Default: \"valkey://localhost\"\n *         default_memcached_provider?: scalar|Param|null, // Default: \"memcached://localhost\"\n *         default_doctrine_dbal_provider?: scalar|Param|null, // Default: \"database_connection\"\n *         default_pdo_provider?: scalar|Param|null, // Default: null\n *         pools?: array<string, array{ // Default: []\n *             adapters?: list<scalar|Param|null>,\n *             tags?: scalar|Param|null, // Default: null\n *             public?: bool|Param, // Default: false\n *             default_lifetime?: scalar|Param|null, // Default lifetime of the pool.\n *             provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.\n *             early_expiration_message_bus?: scalar|Param|null,\n *             clearer?: scalar|Param|null,\n *         }>,\n *     },\n *     php_errors?: array{ // PHP errors handling configuration\n *         log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true\n *         throw?: bool|Param, // Throw PHP errors as \\ErrorException instances. // Default: true\n *     },\n *     exceptions?: array<string, array{ // Default: []\n *         log_level?: scalar|Param|null, // The level of log message. Null to let Symfony decide. // Default: null\n *         status_code?: scalar|Param|null, // The status code of the response. Null or 0 to let Symfony decide. // Default: null\n *         log_channel?: scalar|Param|null, // The channel of log message. Null to let Symfony decide. // Default: null\n *     }>,\n *     web_link?: bool|array{ // Web links configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n *     lock?: bool|string|array{ // Lock configuration\n *         enabled?: bool|Param, // Default: false\n *         resources?: array<string, string|list<scalar|Param|null>>,\n *     },\n *     semaphore?: bool|string|array{ // Semaphore configuration\n *         enabled?: bool|Param, // Default: false\n *         resources?: array<string, scalar|Param|null>,\n *     },\n *     messenger?: bool|array{ // Messenger configuration\n *         enabled?: bool|Param, // Default: true\n *         routing?: array<string, string|array{ // Default: []\n *             senders?: list<scalar|Param|null>,\n *         }>,\n *         serializer?: array{\n *             default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: \"messenger.transport.native_php_serializer\"\n *             symfony_serializer?: array{\n *                 format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: \"json\"\n *                 context?: array<string, mixed>,\n *             },\n *         },\n *         transports?: array<string, string|array{ // Default: []\n *             dsn?: scalar|Param|null,\n *             serializer?: scalar|Param|null, // Service id of a custom serializer to use. // Default: null\n *             options?: array<string, mixed>,\n *             failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null\n *             retry_strategy?: string|array{\n *                 service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null\n *                 max_retries?: int|Param, // Default: 3\n *                 delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000\n *                 multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2\n *                 max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0\n *                 jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1\n *             },\n *             rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null\n *         }>,\n *         failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null\n *         stop_worker_on_signals?: list<scalar|Param|null>,\n *         default_bus?: scalar|Param|null, // Default: null\n *         buses?: array<string, array{ // Default: {\"messenger.bus.default\":{\"default_middleware\":{\"enabled\":true,\"allow_no_handlers\":false,\"allow_no_senders\":true},\"middleware\":[]}}\n *             default_middleware?: bool|string|array{\n *                 enabled?: bool|Param, // Default: true\n *                 allow_no_handlers?: bool|Param, // Default: false\n *                 allow_no_senders?: bool|Param, // Default: true\n *             },\n *             middleware?: list<string|array{ // Default: []\n *                 id?: scalar|Param|null,\n *                 arguments?: list<mixed>,\n *             }>,\n *         }>,\n *     },\n *     scheduler?: bool|array{ // Scheduler configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n *     disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true\n *     http_client?: bool|array{ // HTTP Client configuration\n *         enabled?: bool|Param, // Default: false\n *         max_host_connections?: int|Param, // The maximum number of connections to a single host.\n *         default_options?: array{\n *             headers?: array<string, mixed>,\n *             vars?: array<string, mixed>,\n *             max_redirects?: int|Param, // The maximum number of redirects to follow.\n *             http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.\n *             resolve?: array<string, scalar|Param|null>,\n *             proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.\n *             no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.\n *             timeout?: float|Param, // The idle timeout, defaults to the \"default_socket_timeout\" ini parameter.\n *             max_duration?: float|Param, // The maximum execution time for the request+response as a whole.\n *             bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.\n *             verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.\n *             verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.\n *             cafile?: scalar|Param|null, // A certificate authority file.\n *             capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.\n *             local_cert?: scalar|Param|null, // A PEM formatted certificate file.\n *             local_pk?: scalar|Param|null, // A private key file.\n *             passphrase?: scalar|Param|null, // The passphrase used to encrypt the \"local_pk\" file.\n *             ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. \"RC3-SHA:TLS13-AES-128-GCM-SHA256\"...)\n *             peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).\n *                 sha1?: mixed,\n *                 pin-sha256?: mixed,\n *                 md5?: mixed,\n *             },\n *             crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.\n *             extra?: array<string, mixed>,\n *             rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null\n *             caching?: bool|array{ // Caching configuration.\n *                 enabled?: bool|Param, // Default: false\n *                 cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: \"cache.http_client\"\n *                 shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true\n *                 max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null\n *             },\n *             retry_failed?: bool|array{\n *                 enabled?: bool|Param, // Default: false\n *                 retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null\n *                 http_codes?: array<string, array{ // Default: []\n *                     code?: int|Param,\n *                     methods?: list<string|Param>,\n *                 }>,\n *                 max_retries?: int|Param, // Default: 3\n *                 delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000\n *                 multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2\n *                 max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0\n *                 jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1\n *             },\n *         },\n *         mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.\n *         scoped_clients?: array<string, string|array{ // Default: []\n *             scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.\n *             base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.\n *             auth_basic?: scalar|Param|null, // An HTTP Basic authentication \"username:password\".\n *             auth_bearer?: scalar|Param|null, // A token enabling HTTP Bearer authorization.\n *             auth_ntlm?: scalar|Param|null, // A \"username:password\" pair to use Microsoft NTLM authentication (requires the cURL extension).\n *             query?: array<string, scalar|Param|null>,\n *             headers?: array<string, mixed>,\n *             max_redirects?: int|Param, // The maximum number of redirects to follow.\n *             http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.\n *             resolve?: array<string, scalar|Param|null>,\n *             proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection.\n *             no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached.\n *             timeout?: float|Param, // The idle timeout, defaults to the \"default_socket_timeout\" ini parameter.\n *             max_duration?: float|Param, // The maximum execution time for the request+response as a whole.\n *             bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to.\n *             verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.\n *             verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.\n *             cafile?: scalar|Param|null, // A certificate authority file.\n *             capath?: scalar|Param|null, // A directory that contains multiple certificate authority files.\n *             local_cert?: scalar|Param|null, // A PEM formatted certificate file.\n *             local_pk?: scalar|Param|null, // A private key file.\n *             passphrase?: scalar|Param|null, // The passphrase used to encrypt the \"local_pk\" file.\n *             ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. \"RC3-SHA:TLS13-AES-128-GCM-SHA256\"...).\n *             peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).\n *                 sha1?: mixed,\n *                 pin-sha256?: mixed,\n *                 md5?: mixed,\n *             },\n *             crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.\n *             extra?: array<string, mixed>,\n *             rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null\n *             caching?: bool|array{ // Caching configuration.\n *                 enabled?: bool|Param, // Default: false\n *                 cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: \"cache.http_client\"\n *                 shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true\n *                 max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null\n *             },\n *             retry_failed?: bool|array{\n *                 enabled?: bool|Param, // Default: false\n *                 retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null\n *                 http_codes?: array<string, array{ // Default: []\n *                     code?: int|Param,\n *                     methods?: list<string|Param>,\n *                 }>,\n *                 max_retries?: int|Param, // Default: 3\n *                 delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000\n *                 multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2\n *                 max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0\n *                 jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1\n *             },\n *         }>,\n *     },\n *     mailer?: bool|array{ // Mailer configuration\n *         enabled?: bool|Param, // Default: false\n *         message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null\n *         dsn?: scalar|Param|null, // Default: null\n *         transports?: array<string, scalar|Param|null>,\n *         envelope?: array{ // Mailer Envelope configuration\n *             sender?: scalar|Param|null,\n *             recipients?: list<scalar|Param|null>,\n *             allowed_recipients?: list<scalar|Param|null>,\n *         },\n *         headers?: array<string, string|array{ // Default: []\n *             value?: mixed,\n *         }>,\n *         dkim_signer?: bool|array{ // DKIM signer configuration\n *             enabled?: bool|Param, // Default: false\n *             key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: \"\"\n *             domain?: scalar|Param|null, // Default: \"\"\n *             select?: scalar|Param|null, // Default: \"\"\n *             passphrase?: scalar|Param|null, // The private key passphrase // Default: \"\"\n *             options?: array<string, mixed>,\n *         },\n *         smime_signer?: bool|array{ // S/MIME signer configuration\n *             enabled?: bool|Param, // Default: false\n *             key?: scalar|Param|null, // Path to key (in PEM format) // Default: \"\"\n *             certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: \"\"\n *             passphrase?: scalar|Param|null, // The private key passphrase // Default: null\n *             extra_certificates?: scalar|Param|null, // Default: null\n *             sign_options?: int|Param, // Default: null\n *         },\n *         smime_encrypter?: bool|array{ // S/MIME encrypter configuration\n *             enabled?: bool|Param, // Default: false\n *             repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\\Component\\Mailer\\EventListener\\SmimeCertificateRepositoryInterface`. // Default: \"\"\n *             cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null\n *         },\n *     },\n *     secrets?: bool|array{\n *         enabled?: bool|Param, // Default: true\n *         vault_directory?: scalar|Param|null, // Default: \"%kernel.project_dir%/config/secrets/%kernel.runtime_environment%\"\n *         local_dotenv_file?: scalar|Param|null, // Default: \"%kernel.project_dir%/.env.%kernel.environment%.local\"\n *         decryption_env_var?: scalar|Param|null, // Default: \"base64:default::SYMFONY_DECRYPTION_SECRET\"\n *     },\n *     notifier?: bool|array{ // Notifier configuration\n *         enabled?: bool|Param, // Default: false\n *         message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null\n *         chatter_transports?: array<string, scalar|Param|null>,\n *         texter_transports?: array<string, scalar|Param|null>,\n *         notification_on_failed_messages?: bool|Param, // Default: false\n *         channel_policy?: array<string, string|list<scalar|Param|null>>,\n *         admin_recipients?: list<array{ // Default: []\n *             email?: scalar|Param|null,\n *             phone?: scalar|Param|null, // Default: \"\"\n *         }>,\n *     },\n *     rate_limiter?: bool|array{ // Rate limiter configuration\n *         enabled?: bool|Param, // Default: false\n *         limiters?: array<string, array{ // Default: []\n *             lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: \"auto\"\n *             cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: \"cache.rate_limiter\"\n *             storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured \"cache_pool\". // Default: null\n *             policy?: \"fixed_window\"|\"token_bucket\"|\"sliding_window\"|\"compound\"|\"no_limit\"|Param, // The algorithm to be used by this limiter.\n *             limiters?: list<scalar|Param|null>,\n *             limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.\n *             interval?: scalar|Param|null, // Configures the fixed interval if \"policy\" is set to \"fixed_window\" or \"sliding_window\". The value must be a number followed by \"second\", \"minute\", \"hour\", \"day\", \"week\" or \"month\" (or their plural equivalent).\n *             rate?: array{ // Configures the fill rate if \"policy\" is set to \"token_bucket\".\n *                 interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by \"second\", \"minute\", \"hour\", \"day\", \"week\" or \"month\" (or their plural equivalent).\n *                 amount?: int|Param, // Amount of tokens to add each interval. // Default: 1\n *             },\n *         }>,\n *     },\n *     uid?: bool|array{ // Uid configuration\n *         enabled?: bool|Param, // Default: false\n *         default_uuid_version?: 7|6|4|1|Param, // Default: 7\n *         name_based_uuid_version?: 5|3|Param, // Default: 5\n *         name_based_uuid_namespace?: scalar|Param|null,\n *         time_based_uuid_version?: 7|6|1|Param, // Default: 7\n *         time_based_uuid_node?: scalar|Param|null,\n *     },\n *     html_sanitizer?: bool|array{ // HtmlSanitizer configuration\n *         enabled?: bool|Param, // Default: false\n *         sanitizers?: array<string, array{ // Default: []\n *             allow_safe_elements?: bool|Param, // Allows \"safe\" elements and attributes. // Default: false\n *             allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false\n *             allow_elements?: array<string, mixed>,\n *             block_elements?: list<string|Param>,\n *             drop_elements?: list<string|Param>,\n *             allow_attributes?: array<string, mixed>,\n *             drop_attributes?: array<string, mixed>,\n *             force_attributes?: array<string, array<string, string|Param>>,\n *             force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false\n *             allowed_link_schemes?: list<string|Param>,\n *             allowed_link_hosts?: list<string|Param>|null,\n *             allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false\n *             allowed_media_schemes?: list<string|Param>,\n *             allowed_media_hosts?: list<string|Param>|null,\n *             allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false\n *             with_attribute_sanitizers?: list<string|Param>,\n *             without_attribute_sanitizers?: list<string|Param>,\n *             max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0\n *         }>,\n *     },\n *     webhook?: bool|array{ // Webhook configuration\n *         enabled?: bool|Param, // Default: false\n *         message_bus?: scalar|Param|null, // The message bus to use. // Default: \"messenger.default_bus\"\n *         routing?: array<string, array{ // Default: []\n *             service?: scalar|Param|null,\n *             secret?: scalar|Param|null, // Default: \"\"\n *         }>,\n *     },\n *     remote-event?: bool|array{ // RemoteEvent configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n *     json_streamer?: bool|array{ // JSON streamer configuration\n *         enabled?: bool|Param, // Default: false\n *     },\n * }\n * @psalm-type DoctrineConfig = array{\n *     dbal?: array{\n *         default_connection?: scalar|Param|null,\n *         types?: array<string, string|array{ // Default: []\n *             class?: scalar|Param|null,\n *             commented?: bool|Param, // Deprecated: The doctrine-bundle type commenting features were removed; the corresponding config parameter was deprecated in 2.0 and will be dropped in 3.0.\n *         }>,\n *         driver_schemes?: array<string, scalar|Param|null>,\n *         connections?: array<string, array{ // Default: []\n *             url?: scalar|Param|null, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters\n *             dbname?: scalar|Param|null,\n *             host?: scalar|Param|null, // Defaults to \"localhost\" at runtime.\n *             port?: scalar|Param|null, // Defaults to null at runtime.\n *             user?: scalar|Param|null, // Defaults to \"root\" at runtime.\n *             password?: scalar|Param|null, // Defaults to null at runtime.\n *             override_url?: bool|Param, // Deprecated: The \"doctrine.dbal.override_url\" configuration key is deprecated.\n *             dbname_suffix?: scalar|Param|null, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform\n *             application_name?: scalar|Param|null,\n *             charset?: scalar|Param|null,\n *             path?: scalar|Param|null,\n *             memory?: bool|Param,\n *             unix_socket?: scalar|Param|null, // The unix socket to use for MySQL\n *             persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver\n *             protocol?: scalar|Param|null, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)\n *             service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle\n *             servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.\n *             sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver\n *             server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.\n *             default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.\n *             sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.\n *             sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.\n *             sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.\n *             sslkey?: scalar|Param|null, // The path to the SSL client key file for PostgreSQL.\n *             sslcrl?: scalar|Param|null, // The file name of the SSL certificate revocation list for PostgreSQL.\n *             pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver\n *             MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver\n *             use_savepoints?: bool|Param, // Use savepoints for nested transactions\n *             instancename?: scalar|Param|null, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.\n *             connectstring?: scalar|Param|null, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\\DBAL\\Connection will no longer function as expected.\n *             driver?: scalar|Param|null, // Default: \"pdo_mysql\"\n *             platform_service?: scalar|Param|null, // Deprecated: The \"platform_service\" configuration key is deprecated since doctrine-bundle 2.9. DBAL 4 will not support setting a custom platform via connection params anymore.\n *             auto_commit?: bool|Param,\n *             schema_filter?: scalar|Param|null,\n *             logging?: bool|Param, // Default: true\n *             profiling?: bool|Param, // Default: true\n *             profiling_collect_backtrace?: bool|Param, // Enables collecting backtraces when profiling is enabled // Default: false\n *             profiling_collect_schema_errors?: bool|Param, // Enables collecting schema errors when profiling is enabled // Default: true\n *             disable_type_comments?: bool|Param,\n *             server_version?: scalar|Param|null,\n *             idle_connection_ttl?: int|Param, // Default: 600\n *             driver_class?: scalar|Param|null,\n *             wrapper_class?: scalar|Param|null,\n *             keep_slave?: bool|Param, // Deprecated: The \"keep_slave\" configuration key is deprecated since doctrine-bundle 2.2. Use the \"keep_replica\" configuration key instead.\n *             keep_replica?: bool|Param,\n *             options?: array<string, mixed>,\n *             mapping_types?: array<string, scalar|Param|null>,\n *             default_table_options?: array<string, scalar|Param|null>,\n *             schema_manager_factory?: scalar|Param|null, // Default: \"doctrine.dbal.default_schema_manager_factory\"\n *             result_cache?: scalar|Param|null,\n *             slaves?: array<string, array{ // Default: []\n *                 url?: scalar|Param|null, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters\n *                 dbname?: scalar|Param|null,\n *                 host?: scalar|Param|null, // Defaults to \"localhost\" at runtime.\n *                 port?: scalar|Param|null, // Defaults to null at runtime.\n *                 user?: scalar|Param|null, // Defaults to \"root\" at runtime.\n *                 password?: scalar|Param|null, // Defaults to null at runtime.\n *                 override_url?: bool|Param, // Deprecated: The \"doctrine.dbal.override_url\" configuration key is deprecated.\n *                 dbname_suffix?: scalar|Param|null, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform\n *                 application_name?: scalar|Param|null,\n *                 charset?: scalar|Param|null,\n *                 path?: scalar|Param|null,\n *                 memory?: bool|Param,\n *                 unix_socket?: scalar|Param|null, // The unix socket to use for MySQL\n *                 persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver\n *                 protocol?: scalar|Param|null, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)\n *                 service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle\n *                 servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.\n *                 sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver\n *                 server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.\n *                 default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.\n *                 sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.\n *                 sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.\n *                 sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.\n *                 sslkey?: scalar|Param|null, // The path to the SSL client key file for PostgreSQL.\n *                 sslcrl?: scalar|Param|null, // The file name of the SSL certificate revocation list for PostgreSQL.\n *                 pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver\n *                 MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver\n *                 use_savepoints?: bool|Param, // Use savepoints for nested transactions\n *                 instancename?: scalar|Param|null, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.\n *                 connectstring?: scalar|Param|null, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\\DBAL\\Connection will no longer function as expected.\n *             }>,\n *             replicas?: array<string, array{ // Default: []\n *                 url?: scalar|Param|null, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters\n *                 dbname?: scalar|Param|null,\n *                 host?: scalar|Param|null, // Defaults to \"localhost\" at runtime.\n *                 port?: scalar|Param|null, // Defaults to null at runtime.\n *                 user?: scalar|Param|null, // Defaults to \"root\" at runtime.\n *                 password?: scalar|Param|null, // Defaults to null at runtime.\n *                 override_url?: bool|Param, // Deprecated: The \"doctrine.dbal.override_url\" configuration key is deprecated.\n *                 dbname_suffix?: scalar|Param|null, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform\n *                 application_name?: scalar|Param|null,\n *                 charset?: scalar|Param|null,\n *                 path?: scalar|Param|null,\n *                 memory?: bool|Param,\n *                 unix_socket?: scalar|Param|null, // The unix socket to use for MySQL\n *                 persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver\n *                 protocol?: scalar|Param|null, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)\n *                 service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle\n *                 servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.\n *                 sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver\n *                 server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.\n *                 default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.\n *                 sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.\n *                 sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.\n *                 sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.\n *                 sslkey?: scalar|Param|null, // The path to the SSL client key file for PostgreSQL.\n *                 sslcrl?: scalar|Param|null, // The file name of the SSL certificate revocation list for PostgreSQL.\n *                 pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver\n *                 MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver\n *                 use_savepoints?: bool|Param, // Use savepoints for nested transactions\n *                 instancename?: scalar|Param|null, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.\n *                 connectstring?: scalar|Param|null, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\\DBAL\\Connection will no longer function as expected.\n *             }>,\n *         }>,\n *     },\n *     orm?: array{\n *         default_entity_manager?: scalar|Param|null,\n *         auto_generate_proxy_classes?: scalar|Param|null, // Auto generate mode possible values are: \"NEVER\", \"ALWAYS\", \"FILE_NOT_EXISTS\", \"EVAL\", \"FILE_NOT_EXISTS_OR_CHANGED\", this option is ignored when the \"enable_native_lazy_objects\" option is true // Default: false\n *         enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true\n *         enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false\n *         proxy_dir?: scalar|Param|null, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the \"enable_native_lazy_objects\" option is true // Default: \"%kernel.build_dir%/doctrine/orm/Proxies\"\n *         proxy_namespace?: scalar|Param|null, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the \"enable_native_lazy_objects\" option is true // Default: \"Proxies\"\n *         controller_resolver?: bool|array{\n *             enabled?: bool|Param, // Default: true\n *             auto_mapping?: bool|Param|null, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null\n *             evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false\n *         },\n *         entity_managers?: array<string, array{ // Default: []\n *             query_cache_driver?: string|array{\n *                 type?: scalar|Param|null, // Default: null\n *                 id?: scalar|Param|null,\n *                 pool?: scalar|Param|null,\n *             },\n *             metadata_cache_driver?: string|array{\n *                 type?: scalar|Param|null, // Default: null\n *                 id?: scalar|Param|null,\n *                 pool?: scalar|Param|null,\n *             },\n *             result_cache_driver?: string|array{\n *                 type?: scalar|Param|null, // Default: null\n *                 id?: scalar|Param|null,\n *                 pool?: scalar|Param|null,\n *             },\n *             entity_listeners?: array{\n *                 entities?: array<string, array{ // Default: []\n *                     listeners?: array<string, array{ // Default: []\n *                         events?: list<array{ // Default: []\n *                             type?: scalar|Param|null,\n *                             method?: scalar|Param|null, // Default: null\n *                         }>,\n *                     }>,\n *                 }>,\n *             },\n *             connection?: scalar|Param|null,\n *             class_metadata_factory_name?: scalar|Param|null, // Default: \"Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadataFactory\"\n *             default_repository_class?: scalar|Param|null, // Default: \"Doctrine\\\\ORM\\\\EntityRepository\"\n *             auto_mapping?: scalar|Param|null, // Default: false\n *             naming_strategy?: scalar|Param|null, // Default: \"doctrine.orm.naming_strategy.default\"\n *             quote_strategy?: scalar|Param|null, // Default: \"doctrine.orm.quote_strategy.default\"\n *             typed_field_mapper?: scalar|Param|null, // Default: \"doctrine.orm.typed_field_mapper.default\"\n *             entity_listener_resolver?: scalar|Param|null, // Default: null\n *             fetch_mode_subselect_batch_size?: scalar|Param|null,\n *             repository_factory?: scalar|Param|null, // Default: \"doctrine.orm.container_repository_factory\"\n *             schema_ignore_classes?: list<scalar|Param|null>,\n *             report_fields_where_declared?: bool|Param, // Set to \"true\" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true\n *             validate_xml_mapping?: bool|Param, // Set to \"true\" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false\n *             second_level_cache?: array{\n *                 region_cache_driver?: string|array{\n *                     type?: scalar|Param|null, // Default: null\n *                     id?: scalar|Param|null,\n *                     pool?: scalar|Param|null,\n *                 },\n *                 region_lock_lifetime?: scalar|Param|null, // Default: 60\n *                 log_enabled?: bool|Param, // Default: true\n *                 region_lifetime?: scalar|Param|null, // Default: 3600\n *                 enabled?: bool|Param, // Default: true\n *                 factory?: scalar|Param|null,\n *                 regions?: array<string, array{ // Default: []\n *                     cache_driver?: string|array{\n *                         type?: scalar|Param|null, // Default: null\n *                         id?: scalar|Param|null,\n *                         pool?: scalar|Param|null,\n *                     },\n *                     lock_path?: scalar|Param|null, // Default: \"%kernel.cache_dir%/doctrine/orm/slc/filelock\"\n *                     lock_lifetime?: scalar|Param|null, // Default: 60\n *                     type?: scalar|Param|null, // Default: \"default\"\n *                     lifetime?: scalar|Param|null, // Default: 0\n *                     service?: scalar|Param|null,\n *                     name?: scalar|Param|null,\n *                 }>,\n *                 loggers?: array<string, array{ // Default: []\n *                     name?: scalar|Param|null,\n *                     service?: scalar|Param|null,\n *                 }>,\n *             },\n *             hydrators?: array<string, scalar|Param|null>,\n *             mappings?: array<string, bool|string|array{ // Default: []\n *                 mapping?: scalar|Param|null, // Default: true\n *                 type?: scalar|Param|null,\n *                 dir?: scalar|Param|null,\n *                 alias?: scalar|Param|null,\n *                 prefix?: scalar|Param|null,\n *                 is_bundle?: bool|Param,\n *             }>,\n *             dql?: array{\n *                 string_functions?: array<string, scalar|Param|null>,\n *                 numeric_functions?: array<string, scalar|Param|null>,\n *                 datetime_functions?: array<string, scalar|Param|null>,\n *             },\n *             filters?: array<string, string|array{ // Default: []\n *                 class?: scalar|Param|null,\n *                 enabled?: bool|Param, // Default: false\n *                 parameters?: array<string, mixed>,\n *             }>,\n *             identity_generation_preferences?: array<string, scalar|Param|null>,\n *         }>,\n *         resolve_target_entities?: array<string, scalar|Param|null>,\n *     },\n * }\n * @psalm-type DoctrineMigrationsConfig = array{\n *     enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false\n *     migrations_paths?: array<string, scalar|Param|null>,\n *     services?: array<string, scalar|Param|null>,\n *     factories?: array<string, scalar|Param|null>,\n *     storage?: array{ // Storage to use for migration status metadata.\n *         table_storage?: array{ // The default metadata storage, implemented as a table in the database.\n *             table_name?: scalar|Param|null, // Default: null\n *             version_column_name?: scalar|Param|null, // Default: null\n *             version_column_length?: scalar|Param|null, // Default: null\n *             executed_at_column_name?: scalar|Param|null, // Default: null\n *             execution_time_column_name?: scalar|Param|null, // Default: null\n *         },\n *     },\n *     migrations?: list<scalar|Param|null>,\n *     connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null\n *     em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null\n *     all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false\n *     check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true\n *     custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null\n *     organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: \"BY_YEAR\", \"BY_YEAR_AND_MONTH\", false // Default: false\n *     enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false\n *     transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true\n * }\n * @psalm-type KnpuOauth2ClientConfig = array{\n *     http_client?: scalar|Param|null, // Service id of HTTP client to use (must implement GuzzleHttp\\ClientInterface) // Default: null\n *     http_client_options?: array{\n *         timeout?: int|Param,\n *         proxy?: scalar|Param|null,\n *         verify?: bool|Param, // Use only with proxy option set\n *     },\n *     clients?: array<string, array<string, mixed>>,\n * }\n * @psalm-type SentryConfig = array{\n *     dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events.\n *     register_error_listener?: bool|Param, // Default: true\n *     register_error_handler?: bool|Param, // Default: true\n *     logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null\n *     options?: array{\n *         integrations?: mixed, // Default: []\n *         default_integrations?: bool|Param,\n *         prefixes?: list<scalar|Param|null>,\n *         sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events.\n *         enable_tracing?: bool|Param,\n *         traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions.\n *         traces_sampler?: scalar|Param|null,\n *         profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate\n *         enable_logs?: bool|Param,\n *         log_flush_threshold?: mixed, // Default: null\n *         enable_metrics?: bool|Param, // Default: true\n *         attach_stacktrace?: bool|Param,\n *         attach_metric_code_locations?: bool|Param,\n *         context_lines?: int|Param,\n *         environment?: scalar|Param|null, // Default: \"%kernel.environment%\"\n *         logger?: scalar|Param|null,\n *         spotlight?: bool|Param,\n *         spotlight_url?: scalar|Param|null,\n *         release?: scalar|Param|null, // Default: \"%env(default::SENTRY_RELEASE)%\"\n *         org_id?: int|Param,\n *         server_name?: scalar|Param|null,\n *         ignore_exceptions?: list<scalar|Param|null>,\n *         ignore_transactions?: list<scalar|Param|null>,\n *         before_send?: scalar|Param|null,\n *         before_send_transaction?: scalar|Param|null,\n *         before_send_check_in?: scalar|Param|null,\n *         before_send_metrics?: scalar|Param|null,\n *         before_send_log?: scalar|Param|null,\n *         before_send_metric?: scalar|Param|null,\n *         trace_propagation_targets?: mixed,\n *         strict_trace_continuation?: bool|Param,\n *         tags?: array<string, scalar|Param|null>,\n *         error_types?: scalar|Param|null,\n *         max_breadcrumbs?: int|Param,\n *         before_breadcrumb?: mixed,\n *         in_app_exclude?: list<scalar|Param|null>,\n *         in_app_include?: list<scalar|Param|null>,\n *         send_default_pii?: bool|Param,\n *         max_value_length?: int|Param,\n *         transport?: scalar|Param|null,\n *         http_client?: scalar|Param|null,\n *         http_proxy?: scalar|Param|null,\n *         http_proxy_authentication?: scalar|Param|null,\n *         http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport.\n *         http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport.\n *         http_ssl_verify_peer?: bool|Param,\n *         http_compression?: bool|Param,\n *         capture_silenced_errors?: bool|Param,\n *         max_request_body_size?: \"none\"|\"never\"|\"small\"|\"medium\"|\"always\"|Param,\n *         class_serializers?: array<string, scalar|Param|null>,\n *     },\n *     messenger?: bool|array{\n *         enabled?: bool|Param, // Default: true\n *         capture_soft_fails?: bool|Param, // Default: true\n *         isolate_breadcrumbs_by_message?: bool|Param, // Default: false\n *         isolate_context_by_message?: bool|Param, // Default: false\n *     },\n *     tracing?: bool|array{\n *         enabled?: bool|Param, // Default: true\n *         dbal?: bool|array{\n *             enabled?: bool|Param, // Default: true\n *             ignore_prepare_spans?: bool|Param, // Default: false\n *             connections?: list<scalar|Param|null>,\n *         },\n *         twig?: bool|array{\n *             enabled?: bool|Param, // Default: true\n *         },\n *         cache?: bool|array{\n *             enabled?: bool|Param, // Default: true\n *         },\n *         http_client?: bool|array{\n *             enabled?: bool|Param, // Default: false\n *         },\n *         console?: array{\n *             excluded_commands?: list<scalar|Param|null>,\n *         },\n *     },\n * }\n * @psalm-type SncRedisConfig = array{\n *     class?: array{\n *         client?: scalar|Param|null, // Default: \"Predis\\\\Client\"\n *         client_options?: scalar|Param|null, // Default: \"Predis\\\\Configuration\\\\Options\"\n *         connection_parameters?: scalar|Param|null, // Default: \"Predis\\\\Connection\\\\Parameters\"\n *         connection_factory?: scalar|Param|null, // Default: \"Snc\\\\RedisBundle\\\\Client\\\\Predis\\\\Connection\\\\ConnectionFactory\"\n *         connection_wrapper?: scalar|Param|null, // Default: \"Snc\\\\RedisBundle\\\\Client\\\\Predis\\\\Connection\\\\ConnectionWrapper\"\n *         phpredis_client?: scalar|Param|null, // Default: \"Redis\"\n *         relay_client?: scalar|Param|null, // Default: \"Relay\\\\Relay\"\n *         phpredis_clusterclient?: scalar|Param|null, // Default: \"RedisCluster\"\n *         logger?: scalar|Param|null, // Default: \"Snc\\\\RedisBundle\\\\Logger\\\\RedisLogger\"\n *         data_collector?: scalar|Param|null, // Default: \"Snc\\\\RedisBundle\\\\DataCollector\\\\RedisDataCollector\"\n *         monolog_handler?: scalar|Param|null, // Default: \"Monolog\\\\Handler\\\\RedisHandler\"\n *     },\n *     clients?: array<string, array{ // Default: []\n *         type?: scalar|Param|null,\n *         alias?: scalar|Param|null,\n *         logging?: bool|Param, // Default: true\n *         dsns?: list<mixed>,\n *         options?: array{\n *             commands?: array<string, scalar|Param|null>,\n *             connection_async?: bool|Param, // Default: false\n *             connection_persistent?: mixed, // Default: false\n *             connection_timeout?: scalar|Param|null, // Default: 5\n *             scan?: int|Param, // Default: null\n *             read_write_timeout?: scalar|Param|null, // Default: null\n *             iterable_multibulk?: bool|Param, // Default: false\n *             throw_errors?: bool|Param, // Default: true\n *             serialization?: scalar|Param|null, // Default: \"default\"\n *             cluster?: scalar|Param|null, // Default: null\n *             prefix?: scalar|Param|null, // Default: null\n *             replication?: true|\"predis\"|\"sentinel\"|Param,\n *             service?: scalar|Param|null, // Default: null\n *             slave_failover?: \"none\"|\"error\"|\"distribute\"|\"distribute_slaves\"|Param,\n *             parameters?: array{\n *                 database?: scalar|Param|null, // Default: null\n *                 username?: scalar|Param|null, // Default: null\n *                 password?: scalar|Param|null, // Default: null\n *                 sentinel_username?: scalar|Param|null, // Default: null\n *                 sentinel_password?: scalar|Param|null, // Default: null\n *                 logging?: bool|Param, // Default: true\n *                 ssl_context?: mixed, // Default: null\n *             },\n *         },\n *     }>,\n *     monolog?: array{\n *         client?: scalar|Param|null,\n *         key?: scalar|Param|null,\n *         formatter?: scalar|Param|null,\n *     },\n * }\n * @psalm-type MonologConfig = array{\n *     use_microseconds?: scalar|Param|null, // Default: true\n *     channels?: list<scalar|Param|null>,\n *     handlers?: array<string, array{ // Default: []\n *         type?: scalar|Param|null,\n *         id?: scalar|Param|null,\n *         enabled?: bool|Param, // Default: true\n *         priority?: scalar|Param|null, // Default: 0\n *         level?: scalar|Param|null, // Default: \"DEBUG\"\n *         bubble?: bool|Param, // Default: true\n *         interactive_only?: bool|Param, // Default: false\n *         app_name?: scalar|Param|null, // Default: null\n *         include_stacktraces?: bool|Param, // Default: false\n *         process_psr_3_messages?: array{\n *             enabled?: bool|Param|null, // Default: null\n *             date_format?: scalar|Param|null,\n *             remove_used_context_fields?: bool|Param,\n *         },\n *         path?: scalar|Param|null, // Default: \"%kernel.logs_dir%/%kernel.environment%.log\"\n *         file_permission?: scalar|Param|null, // Default: null\n *         use_locking?: bool|Param, // Default: false\n *         filename_format?: scalar|Param|null, // Default: \"{filename}-{date}\"\n *         date_format?: scalar|Param|null, // Default: \"Y-m-d\"\n *         ident?: scalar|Param|null, // Default: false\n *         logopts?: scalar|Param|null, // Default: 1\n *         facility?: scalar|Param|null, // Default: \"user\"\n *         max_files?: scalar|Param|null, // Default: 0\n *         action_level?: scalar|Param|null, // Default: \"WARNING\"\n *         activation_strategy?: scalar|Param|null, // Default: null\n *         stop_buffering?: bool|Param, // Default: true\n *         passthru_level?: scalar|Param|null, // Default: null\n *         excluded_http_codes?: list<array{ // Default: []\n *             code?: scalar|Param|null,\n *             urls?: list<scalar|Param|null>,\n *         }>,\n *         accepted_levels?: list<scalar|Param|null>,\n *         min_level?: scalar|Param|null, // Default: \"DEBUG\"\n *         max_level?: scalar|Param|null, // Default: \"EMERGENCY\"\n *         buffer_size?: scalar|Param|null, // Default: 0\n *         flush_on_overflow?: bool|Param, // Default: false\n *         handler?: scalar|Param|null,\n *         url?: scalar|Param|null,\n *         exchange?: scalar|Param|null,\n *         exchange_name?: scalar|Param|null, // Default: \"log\"\n *         channel?: scalar|Param|null, // Default: null\n *         bot_name?: scalar|Param|null, // Default: \"Monolog\"\n *         use_attachment?: scalar|Param|null, // Default: true\n *         use_short_attachment?: scalar|Param|null, // Default: false\n *         include_extra?: scalar|Param|null, // Default: false\n *         icon_emoji?: scalar|Param|null, // Default: null\n *         webhook_url?: scalar|Param|null,\n *         exclude_fields?: list<scalar|Param|null>,\n *         token?: scalar|Param|null,\n *         region?: scalar|Param|null,\n *         source?: scalar|Param|null,\n *         use_ssl?: bool|Param, // Default: true\n *         user?: mixed,\n *         title?: scalar|Param|null, // Default: null\n *         host?: scalar|Param|null, // Default: null\n *         port?: scalar|Param|null, // Default: 514\n *         config?: list<scalar|Param|null>,\n *         members?: list<scalar|Param|null>,\n *         connection_string?: scalar|Param|null,\n *         timeout?: scalar|Param|null,\n *         time?: scalar|Param|null, // Default: 60\n *         deduplication_level?: scalar|Param|null, // Default: 400\n *         store?: scalar|Param|null, // Default: null\n *         connection_timeout?: scalar|Param|null,\n *         persistent?: bool|Param,\n *         message_type?: scalar|Param|null, // Default: 0\n *         parse_mode?: scalar|Param|null, // Default: null\n *         disable_webpage_preview?: bool|Param|null, // Default: null\n *         disable_notification?: bool|Param|null, // Default: null\n *         split_long_messages?: bool|Param, // Default: false\n *         delay_between_messages?: bool|Param, // Default: false\n *         topic?: int|Param, // Default: null\n *         factor?: int|Param, // Default: 1\n *         tags?: list<scalar|Param|null>,\n *         console_formatter_options?: mixed, // Default: []\n *         formatter?: scalar|Param|null,\n *         nested?: bool|Param, // Default: false\n *         publisher?: string|array{\n *             id?: scalar|Param|null,\n *             hostname?: scalar|Param|null,\n *             port?: scalar|Param|null, // Default: 12201\n *             chunk_size?: scalar|Param|null, // Default: 1420\n *             encoder?: \"json\"|\"compressed_json\"|Param,\n *         },\n *         mongodb?: string|array{\n *             id?: scalar|Param|null, // ID of a MongoDB\\Client service\n *             uri?: scalar|Param|null,\n *             username?: scalar|Param|null,\n *             password?: scalar|Param|null,\n *             database?: scalar|Param|null, // Default: \"monolog\"\n *             collection?: scalar|Param|null, // Default: \"logs\"\n *         },\n *         elasticsearch?: string|array{\n *             id?: scalar|Param|null,\n *             hosts?: list<scalar|Param|null>,\n *             host?: scalar|Param|null,\n *             port?: scalar|Param|null, // Default: 9200\n *             transport?: scalar|Param|null, // Default: \"Http\"\n *             user?: scalar|Param|null, // Default: null\n *             password?: scalar|Param|null, // Default: null\n *         },\n *         index?: scalar|Param|null, // Default: \"monolog\"\n *         document_type?: scalar|Param|null, // Default: \"logs\"\n *         ignore_error?: scalar|Param|null, // Default: false\n *         redis?: string|array{\n *             id?: scalar|Param|null,\n *             host?: scalar|Param|null,\n *             password?: scalar|Param|null, // Default: null\n *             port?: scalar|Param|null, // Default: 6379\n *             database?: scalar|Param|null, // Default: 0\n *             key_name?: scalar|Param|null, // Default: \"monolog_redis\"\n *         },\n *         predis?: string|array{\n *             id?: scalar|Param|null,\n *             host?: scalar|Param|null,\n *         },\n *         from_email?: scalar|Param|null,\n *         to_email?: list<scalar|Param|null>,\n *         subject?: scalar|Param|null,\n *         content_type?: scalar|Param|null, // Default: null\n *         headers?: list<scalar|Param|null>,\n *         mailer?: scalar|Param|null, // Default: null\n *         email_prototype?: string|array{\n *             id?: scalar|Param|null,\n *             method?: scalar|Param|null, // Default: null\n *         },\n *         verbosity_levels?: array{\n *             VERBOSITY_QUIET?: scalar|Param|null, // Default: \"ERROR\"\n *             VERBOSITY_NORMAL?: scalar|Param|null, // Default: \"WARNING\"\n *             VERBOSITY_VERBOSE?: scalar|Param|null, // Default: \"NOTICE\"\n *             VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: \"INFO\"\n *             VERBOSITY_DEBUG?: scalar|Param|null, // Default: \"DEBUG\"\n *         },\n *         channels?: string|array{\n *             type?: scalar|Param|null,\n *             elements?: list<scalar|Param|null>,\n *         },\n *     }>,\n * }\n * @psalm-type TwigConfig = array{\n *     form_themes?: list<scalar|Param|null>,\n *     globals?: array<string, array{ // Default: []\n *         id?: scalar|Param|null,\n *         type?: scalar|Param|null,\n *         value?: mixed,\n *     }>,\n *     autoescape_service?: scalar|Param|null, // Default: null\n *     autoescape_service_method?: scalar|Param|null, // Default: null\n *     base_template_class?: scalar|Param|null, // Deprecated: The child node \"base_template_class\" at path \"twig.base_template_class\" is deprecated.\n *     cache?: scalar|Param|null, // Default: true\n *     charset?: scalar|Param|null, // Default: \"%kernel.charset%\"\n *     debug?: bool|Param, // Default: \"%kernel.debug%\"\n *     strict_variables?: bool|Param, // Default: \"%kernel.debug%\"\n *     auto_reload?: scalar|Param|null,\n *     optimizations?: int|Param,\n *     default_path?: scalar|Param|null, // The default path used to load templates. // Default: \"%kernel.project_dir%/templates\"\n *     file_name_pattern?: list<scalar|Param|null>,\n *     paths?: array<string, mixed>,\n *     date?: array{ // The default format options used by the date filter.\n *         format?: scalar|Param|null, // Default: \"F j, Y H:i\"\n *         interval_format?: scalar|Param|null, // Default: \"%d days\"\n *         timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null\n *     },\n *     number_format?: array{ // The default format options for the number_format filter.\n *         decimals?: int|Param, // Default: 0\n *         decimal_point?: scalar|Param|null, // Default: \".\"\n *         thousands_separator?: scalar|Param|null, // Default: \",\"\n *     },\n *     mailer?: array{\n *         html_to_text_converter?: scalar|Param|null, // A service implementing the \"Symfony\\Component\\Mime\\HtmlToTextConverter\\HtmlToTextConverterInterface\". // Default: null\n *     },\n * }\n * @psalm-type TwigExtraConfig = array{\n *     cache?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     html?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     markdown?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     intl?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     cssinliner?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     inky?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     string?: bool|array{\n *         enabled?: bool|Param, // Default: false\n *     },\n *     commonmark?: array{\n *         renderer?: array{ // Array of options for rendering HTML.\n *             block_separator?: scalar|Param|null,\n *             inner_separator?: scalar|Param|null,\n *             soft_break?: scalar|Param|null,\n *         },\n *         html_input?: \"strip\"|\"allow\"|\"escape\"|Param, // How to handle HTML input.\n *         allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true\n *         max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807\n *         max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807\n *         slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created.\n *             instance?: mixed,\n *             max_length?: int|Param, // Default: 255\n *             unique?: mixed,\n *         },\n *         commonmark?: array{ // Array of options for configuring the CommonMark core extension.\n *             enable_em?: bool|Param, // Default: true\n *             enable_strong?: bool|Param, // Default: true\n *             use_asterisk?: bool|Param, // Default: true\n *             use_underscore?: bool|Param, // Default: true\n *             unordered_list_markers?: list<scalar|Param|null>,\n *         },\n *         ...<string, mixed>\n *     },\n * }\n * @psalm-type SecurityConfig = array{\n *     access_denied_url?: scalar|Param|null, // Default: null\n *     session_fixation_strategy?: \"none\"|\"migrate\"|\"invalidate\"|Param, // Default: \"migrate\"\n *     hide_user_not_found?: bool|Param, // Deprecated: The \"hide_user_not_found\" option is deprecated and will be removed in 8.0. Use the \"expose_security_errors\" option instead.\n *     expose_security_errors?: \\Symfony\\Component\\Security\\Http\\Authentication\\ExposeSecurityLevel::None|\\Symfony\\Component\\Security\\Http\\Authentication\\ExposeSecurityLevel::AccountStatus|\\Symfony\\Component\\Security\\Http\\Authentication\\ExposeSecurityLevel::All|Param, // Default: \"none\"\n *     erase_credentials?: bool|Param, // Default: true\n *     access_decision_manager?: array{\n *         strategy?: \"affirmative\"|\"consensus\"|\"unanimous\"|\"priority\"|Param,\n *         service?: scalar|Param|null,\n *         strategy_service?: scalar|Param|null,\n *         allow_if_all_abstain?: bool|Param, // Default: false\n *         allow_if_equal_granted_denied?: bool|Param, // Default: true\n *     },\n *     password_hashers?: array<string, string|array{ // Default: []\n *         algorithm?: scalar|Param|null,\n *         migrate_from?: list<scalar|Param|null>,\n *         hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: \"sha512\"\n *         key_length?: scalar|Param|null, // Default: 40\n *         ignore_case?: bool|Param, // Default: false\n *         encode_as_base64?: bool|Param, // Default: true\n *         iterations?: scalar|Param|null, // Default: 5000\n *         cost?: int|Param, // Default: null\n *         memory_cost?: scalar|Param|null, // Default: null\n *         time_cost?: scalar|Param|null, // Default: null\n *         id?: scalar|Param|null,\n *     }>,\n *     providers?: array<string, array{ // Default: []\n *         id?: scalar|Param|null,\n *         chain?: array{\n *             providers?: list<scalar|Param|null>,\n *         },\n *         entity?: array{\n *             class?: scalar|Param|null, // The full entity class name of your user class.\n *             property?: scalar|Param|null, // Default: null\n *             manager_name?: scalar|Param|null, // Default: null\n *         },\n *         memory?: array{\n *             users?: array<string, array{ // Default: []\n *                 password?: scalar|Param|null, // Default: null\n *                 roles?: list<scalar|Param|null>,\n *             }>,\n *         },\n *         ldap?: array{\n *             service?: scalar|Param|null,\n *             base_dn?: scalar|Param|null,\n *             search_dn?: scalar|Param|null, // Default: null\n *             search_password?: scalar|Param|null, // Default: null\n *             extra_fields?: list<scalar|Param|null>,\n *             default_roles?: list<scalar|Param|null>,\n *             role_fetcher?: scalar|Param|null, // Default: null\n *             uid_key?: scalar|Param|null, // Default: \"sAMAccountName\"\n *             filter?: scalar|Param|null, // Default: \"({uid_key}={user_identifier})\"\n *             password_attribute?: scalar|Param|null, // Default: null\n *         },\n *     }>,\n *     firewalls?: array<string, array{ // Default: []\n *         pattern?: scalar|Param|null,\n *         host?: scalar|Param|null,\n *         methods?: list<scalar|Param|null>,\n *         security?: bool|Param, // Default: true\n *         user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: \"security.user_checker\"\n *         request_matcher?: scalar|Param|null,\n *         access_denied_url?: scalar|Param|null,\n *         access_denied_handler?: scalar|Param|null,\n *         entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements \"Symfony\\Component\\Security\\Http\\EntryPoint\\AuthenticationEntryPointInterface\".\n *         provider?: scalar|Param|null,\n *         stateless?: bool|Param, // Default: false\n *         lazy?: bool|Param, // Default: false\n *         context?: scalar|Param|null,\n *         logout?: array{\n *             enable_csrf?: bool|Param|null, // Default: null\n *             csrf_token_id?: scalar|Param|null, // Default: \"logout\"\n *             csrf_parameter?: scalar|Param|null, // Default: \"_csrf_token\"\n *             csrf_token_manager?: scalar|Param|null,\n *             path?: scalar|Param|null, // Default: \"/logout\"\n *             target?: scalar|Param|null, // Default: \"/\"\n *             invalidate_session?: bool|Param, // Default: true\n *             clear_site_data?: list<\"*\"|\"cache\"|\"cookies\"|\"storage\"|\"executionContexts\"|Param>,\n *             delete_cookies?: array<string, array{ // Default: []\n *                 path?: scalar|Param|null, // Default: null\n *                 domain?: scalar|Param|null, // Default: null\n *                 secure?: scalar|Param|null, // Default: false\n *                 samesite?: scalar|Param|null, // Default: null\n *                 partitioned?: scalar|Param|null, // Default: false\n *             }>,\n *         },\n *         switch_user?: array{\n *             provider?: scalar|Param|null,\n *             parameter?: scalar|Param|null, // Default: \"_switch_user\"\n *             role?: scalar|Param|null, // Default: \"ROLE_ALLOWED_TO_SWITCH\"\n *             target_route?: scalar|Param|null, // Default: null\n *         },\n *         required_badges?: list<scalar|Param|null>,\n *         custom_authenticators?: list<scalar|Param|null>,\n *         login_throttling?: array{\n *             limiter?: scalar|Param|null, // A service id implementing \"Symfony\\Component\\HttpFoundation\\RateLimiter\\RequestRateLimiterInterface\".\n *             max_attempts?: int|Param, // Default: 5\n *             interval?: scalar|Param|null, // Default: \"1 minute\"\n *             lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null\n *             cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: \"cache.rate_limiter\"\n *             storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured \"cache_pool\" // Default: null\n *         },\n *         x509?: array{\n *             provider?: scalar|Param|null,\n *             user?: scalar|Param|null, // Default: \"SSL_CLIENT_S_DN_Email\"\n *             credentials?: scalar|Param|null, // Default: \"SSL_CLIENT_S_DN\"\n *             user_identifier?: scalar|Param|null, // Default: \"emailAddress\"\n *         },\n *         remote_user?: array{\n *             provider?: scalar|Param|null,\n *             user?: scalar|Param|null, // Default: \"REMOTE_USER\"\n *         },\n *         login_link?: array{\n *             check_route?: scalar|Param|null, // Route that will validate the login link - e.g. \"app_login_link_verify\".\n *             check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to \"check_route\" will be handled by the authenticator. // Default: false\n *             signature_properties?: list<scalar|Param|null>,\n *             lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600\n *             max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null\n *             used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.\n *             success_handler?: scalar|Param|null, // A service id that implements Symfony\\Component\\Security\\Http\\Authentication\\AuthenticationSuccessHandlerInterface.\n *             failure_handler?: scalar|Param|null, // A service id that implements Symfony\\Component\\Security\\Http\\Authentication\\AuthenticationFailureHandlerInterface.\n *             provider?: scalar|Param|null, // The user provider to load users from.\n *             secret?: scalar|Param|null, // Default: \"%kernel.secret%\"\n *             always_use_default_target_path?: bool|Param, // Default: false\n *             default_target_path?: scalar|Param|null, // Default: \"/\"\n *             login_path?: scalar|Param|null, // Default: \"/login\"\n *             target_path_parameter?: scalar|Param|null, // Default: \"_target_path\"\n *             use_referer?: bool|Param, // Default: false\n *             failure_path?: scalar|Param|null, // Default: null\n *             failure_forward?: bool|Param, // Default: false\n *             failure_path_parameter?: scalar|Param|null, // Default: \"_failure_path\"\n *         },\n *         form_login?: array{\n *             provider?: scalar|Param|null,\n *             remember_me?: bool|Param, // Default: true\n *             success_handler?: scalar|Param|null,\n *             failure_handler?: scalar|Param|null,\n *             check_path?: scalar|Param|null, // Default: \"/login_check\"\n *             use_forward?: bool|Param, // Default: false\n *             login_path?: scalar|Param|null, // Default: \"/login\"\n *             username_parameter?: scalar|Param|null, // Default: \"_username\"\n *             password_parameter?: scalar|Param|null, // Default: \"_password\"\n *             csrf_parameter?: scalar|Param|null, // Default: \"_csrf_token\"\n *             csrf_token_id?: scalar|Param|null, // Default: \"authenticate\"\n *             enable_csrf?: bool|Param, // Default: false\n *             post_only?: bool|Param, // Default: true\n *             form_only?: bool|Param, // Default: false\n *             always_use_default_target_path?: bool|Param, // Default: false\n *             default_target_path?: scalar|Param|null, // Default: \"/\"\n *             target_path_parameter?: scalar|Param|null, // Default: \"_target_path\"\n *             use_referer?: bool|Param, // Default: false\n *             failure_path?: scalar|Param|null, // Default: null\n *             failure_forward?: bool|Param, // Default: false\n *             failure_path_parameter?: scalar|Param|null, // Default: \"_failure_path\"\n *         },\n *         form_login_ldap?: array{\n *             provider?: scalar|Param|null,\n *             remember_me?: bool|Param, // Default: true\n *             success_handler?: scalar|Param|null,\n *             failure_handler?: scalar|Param|null,\n *             check_path?: scalar|Param|null, // Default: \"/login_check\"\n *             use_forward?: bool|Param, // Default: false\n *             login_path?: scalar|Param|null, // Default: \"/login\"\n *             username_parameter?: scalar|Param|null, // Default: \"_username\"\n *             password_parameter?: scalar|Param|null, // Default: \"_password\"\n *             csrf_parameter?: scalar|Param|null, // Default: \"_csrf_token\"\n *             csrf_token_id?: scalar|Param|null, // Default: \"authenticate\"\n *             enable_csrf?: bool|Param, // Default: false\n *             post_only?: bool|Param, // Default: true\n *             form_only?: bool|Param, // Default: false\n *             always_use_default_target_path?: bool|Param, // Default: false\n *             default_target_path?: scalar|Param|null, // Default: \"/\"\n *             target_path_parameter?: scalar|Param|null, // Default: \"_target_path\"\n *             use_referer?: bool|Param, // Default: false\n *             failure_path?: scalar|Param|null, // Default: null\n *             failure_forward?: bool|Param, // Default: false\n *             failure_path_parameter?: scalar|Param|null, // Default: \"_failure_path\"\n *             service?: scalar|Param|null, // Default: \"ldap\"\n *             dn_string?: scalar|Param|null, // Default: \"{user_identifier}\"\n *             query_string?: scalar|Param|null,\n *             search_dn?: scalar|Param|null, // Default: \"\"\n *             search_password?: scalar|Param|null, // Default: \"\"\n *         },\n *         json_login?: array{\n *             provider?: scalar|Param|null,\n *             remember_me?: bool|Param, // Default: true\n *             success_handler?: scalar|Param|null,\n *             failure_handler?: scalar|Param|null,\n *             check_path?: scalar|Param|null, // Default: \"/login_check\"\n *             use_forward?: bool|Param, // Default: false\n *             login_path?: scalar|Param|null, // Default: \"/login\"\n *             username_path?: scalar|Param|null, // Default: \"username\"\n *             password_path?: scalar|Param|null, // Default: \"password\"\n *         },\n *         json_login_ldap?: array{\n *             provider?: scalar|Param|null,\n *             remember_me?: bool|Param, // Default: true\n *             success_handler?: scalar|Param|null,\n *             failure_handler?: scalar|Param|null,\n *             check_path?: scalar|Param|null, // Default: \"/login_check\"\n *             use_forward?: bool|Param, // Default: false\n *             login_path?: scalar|Param|null, // Default: \"/login\"\n *             username_path?: scalar|Param|null, // Default: \"username\"\n *             password_path?: scalar|Param|null, // Default: \"password\"\n *             service?: scalar|Param|null, // Default: \"ldap\"\n *             dn_string?: scalar|Param|null, // Default: \"{user_identifier}\"\n *             query_string?: scalar|Param|null,\n *             search_dn?: scalar|Param|null, // Default: \"\"\n *             search_password?: scalar|Param|null, // Default: \"\"\n *         },\n *         access_token?: array{\n *             provider?: scalar|Param|null,\n *             remember_me?: bool|Param, // Default: true\n *             success_handler?: scalar|Param|null,\n *             failure_handler?: scalar|Param|null,\n *             realm?: scalar|Param|null, // Default: null\n *             token_extractors?: list<scalar|Param|null>,\n *             token_handler?: string|array{\n *                 id?: scalar|Param|null,\n *                 oidc_user_info?: string|array{\n *                     base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require \"discovery\" to be configured).\n *                     discovery?: array{ // Enable the OIDC discovery.\n *                         cache?: array{\n *                             id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.\n *                         },\n *                     },\n *                     claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: \"sub\"\n *                     client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server.\n *                 },\n *                 oidc?: array{\n *                     discovery?: array{ // Enable the OIDC discovery.\n *                         base_uri?: list<scalar|Param|null>,\n *                         cache?: array{\n *                             id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.\n *                         },\n *                     },\n *                     claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: \"sub\"\n *                     audience?: scalar|Param|null, // Audience set in the token, for validation purpose.\n *                     issuers?: list<scalar|Param|null>,\n *                     algorithm?: array<mixed>,\n *                     algorithms?: list<scalar|Param|null>,\n *                     key?: scalar|Param|null, // Deprecated: The \"key\" option is deprecated and will be removed in 8.0. Use the \"keyset\" option instead. // JSON-encoded JWK used to sign the token (must contain a \"kty\" key).\n *                     keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).\n *                     encryption?: bool|array{\n *                         enabled?: bool|Param, // Default: false\n *                         enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false\n *                         algorithms?: list<scalar|Param|null>,\n *                         keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).\n *                     },\n *                 },\n *                 cas?: array{\n *                     validation_url?: scalar|Param|null, // CAS server validation URL\n *                     prefix?: scalar|Param|null, // CAS prefix // Default: \"cas\"\n *                     http_client?: scalar|Param|null, // HTTP Client service // Default: null\n *                 },\n *                 oauth2?: scalar|Param|null,\n *             },\n *         },\n *         http_basic?: array{\n *             provider?: scalar|Param|null,\n *             realm?: scalar|Param|null, // Default: \"Secured Area\"\n *         },\n *         http_basic_ldap?: array{\n *             provider?: scalar|Param|null,\n *             realm?: scalar|Param|null, // Default: \"Secured Area\"\n *             service?: scalar|Param|null, // Default: \"ldap\"\n *             dn_string?: scalar|Param|null, // Default: \"{user_identifier}\"\n *             query_string?: scalar|Param|null,\n *             search_dn?: scalar|Param|null, // Default: \"\"\n *             search_password?: scalar|Param|null, // Default: \"\"\n *         },\n *         remember_me?: array{\n *             secret?: scalar|Param|null, // Default: \"%kernel.secret%\"\n *             service?: scalar|Param|null,\n *             user_providers?: list<scalar|Param|null>,\n *             catch_exceptions?: bool|Param, // Default: true\n *             signature_properties?: list<scalar|Param|null>,\n *             token_provider?: string|array{\n *                 service?: scalar|Param|null, // The service ID of a custom remember-me token provider.\n *                 doctrine?: bool|array{\n *                     enabled?: bool|Param, // Default: false\n *                     connection?: scalar|Param|null, // Default: null\n *                 },\n *             },\n *             token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier.\n *             name?: scalar|Param|null, // Default: \"REMEMBERME\"\n *             lifetime?: int|Param, // Default: 31536000\n *             path?: scalar|Param|null, // Default: \"/\"\n *             domain?: scalar|Param|null, // Default: null\n *             secure?: true|false|\"auto\"|Param, // Default: null\n *             httponly?: bool|Param, // Default: true\n *             samesite?: null|\"lax\"|\"strict\"|\"none\"|Param, // Default: \"lax\"\n *             always_remember_me?: bool|Param, // Default: false\n *             remember_me_parameter?: scalar|Param|null, // Default: \"_remember_me\"\n *         },\n *     }>,\n *     access_control?: list<array{ // Default: []\n *         request_matcher?: scalar|Param|null, // Default: null\n *         requires_channel?: scalar|Param|null, // Default: null\n *         path?: scalar|Param|null, // Use the urldecoded format. // Default: null\n *         host?: scalar|Param|null, // Default: null\n *         port?: int|Param, // Default: null\n *         ips?: list<scalar|Param|null>,\n *         attributes?: array<string, scalar|Param|null>,\n *         route?: scalar|Param|null, // Default: null\n *         methods?: list<scalar|Param|null>,\n *         allow_if?: scalar|Param|null, // Default: null\n *         roles?: list<scalar|Param|null>,\n *     }>,\n *     role_hierarchy?: array<string, string|list<scalar|Param|null>>,\n * }\n * @psalm-type DebugConfig = array{\n *     max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500\n *     min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1\n *     max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1\n *     dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null\n *     theme?: \"dark\"|\"light\"|Param, // Changes the color of the dump() output when rendered directly on the templating. \"dark\" (default) or \"light\". // Default: \"dark\"\n * }\n * @psalm-type WebProfilerConfig = array{\n *     toolbar?: bool|array{ // Profiler toolbar configuration\n *         enabled?: bool|Param, // Default: false\n *         ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false\n *     },\n *     intercept_redirects?: bool|Param, // Default: false\n *     excluded_ajax_paths?: scalar|Param|null, // Default: \"^/((index|app(_[\\\\w]+)?)\\\\.php/)?_wdt\"\n * }\n * @psalm-type ConfigType = array{\n *     imports?: ImportsConfig,\n *     parameters?: ParametersConfig,\n *     services?: ServicesConfig,\n *     framework?: FrameworkConfig,\n *     doctrine?: DoctrineConfig,\n *     doctrine_migrations?: DoctrineMigrationsConfig,\n *     knpu_oauth2_client?: KnpuOauth2ClientConfig,\n *     snc_redis?: SncRedisConfig,\n *     monolog?: MonologConfig,\n *     twig?: TwigConfig,\n *     twig_extra?: TwigExtraConfig,\n *     security?: SecurityConfig,\n *     \"when@dev\"?: array{\n *         imports?: ImportsConfig,\n *         parameters?: ParametersConfig,\n *         services?: ServicesConfig,\n *         framework?: FrameworkConfig,\n *         doctrine?: DoctrineConfig,\n *         doctrine_migrations?: DoctrineMigrationsConfig,\n *         knpu_oauth2_client?: KnpuOauth2ClientConfig,\n *         snc_redis?: SncRedisConfig,\n *         monolog?: MonologConfig,\n *         twig?: TwigConfig,\n *         twig_extra?: TwigExtraConfig,\n *         security?: SecurityConfig,\n *         debug?: DebugConfig,\n *         web_profiler?: WebProfilerConfig,\n *     },\n *     \"when@prod\"?: array{\n *         imports?: ImportsConfig,\n *         parameters?: ParametersConfig,\n *         services?: ServicesConfig,\n *         framework?: FrameworkConfig,\n *         doctrine?: DoctrineConfig,\n *         doctrine_migrations?: DoctrineMigrationsConfig,\n *         knpu_oauth2_client?: KnpuOauth2ClientConfig,\n *         sentry?: SentryConfig,\n *         snc_redis?: SncRedisConfig,\n *         monolog?: MonologConfig,\n *         twig?: TwigConfig,\n *         twig_extra?: TwigExtraConfig,\n *         security?: SecurityConfig,\n *     },\n *     \"when@test\"?: array{\n *         imports?: ImportsConfig,\n *         parameters?: ParametersConfig,\n *         services?: ServicesConfig,\n *         framework?: FrameworkConfig,\n *         doctrine?: DoctrineConfig,\n *         doctrine_migrations?: DoctrineMigrationsConfig,\n *         knpu_oauth2_client?: KnpuOauth2ClientConfig,\n *         snc_redis?: SncRedisConfig,\n *         monolog?: MonologConfig,\n *         twig?: TwigConfig,\n *         twig_extra?: TwigExtraConfig,\n *         security?: SecurityConfig,\n *         debug?: DebugConfig,\n *         web_profiler?: WebProfilerConfig,\n *     },\n *     ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias\n *         imports?: ImportsConfig,\n *         parameters?: ParametersConfig,\n *         services?: ServicesConfig,\n *         ...<string, ExtensionType>,\n *     }>\n * }\n */\nfinal class App\n{\n    /**\n     * @param ConfigType $config\n     *\n     * @psalm-return ConfigType\n     */\n    public static function config(array $config): array\n    {\n        /** @var ConfigType $config */\n        $config = AppReference::config($config);\n\n        return $config;\n    }\n}\n\nnamespace Symfony\\Component\\Routing\\Loader\\Configurator;\n\n/**\n * This class provides array-shapes for configuring the routes of an application.\n *\n * Example:\n *\n *     ```php\n *     // config/routes.php\n *     namespace Symfony\\Component\\Routing\\Loader\\Configurator;\n *\n *     return Routes::config([\n *         'controllers' => [\n *             'resource' => 'routing.controllers',\n *         ],\n *     ]);\n *     ```\n *\n * @psalm-type RouteConfig = array{\n *     path: string|array<string,string>,\n *     controller?: string,\n *     methods?: string|list<string>,\n *     requirements?: array<string,string>,\n *     defaults?: array<string,mixed>,\n *     options?: array<string,mixed>,\n *     host?: string|array<string,string>,\n *     schemes?: string|list<string>,\n *     condition?: string,\n *     locale?: string,\n *     format?: string,\n *     utf8?: bool,\n *     stateless?: bool,\n * }\n * @psalm-type ImportConfig = array{\n *     resource: string,\n *     type?: string,\n *     exclude?: string|list<string>,\n *     prefix?: string|array<string,string>,\n *     name_prefix?: string,\n *     trailing_slash_on_root?: bool,\n *     controller?: string,\n *     methods?: string|list<string>,\n *     requirements?: array<string,string>,\n *     defaults?: array<string,mixed>,\n *     options?: array<string,mixed>,\n *     host?: string|array<string,string>,\n *     schemes?: string|list<string>,\n *     condition?: string,\n *     locale?: string,\n *     format?: string,\n *     utf8?: bool,\n *     stateless?: bool,\n * }\n * @psalm-type AliasConfig = array{\n *     alias: string,\n *     deprecated?: array{package:string, version:string, message?:string},\n * }\n * @psalm-type RoutesConfig = array{\n *     \"when@dev\"?: array<string, RouteConfig|ImportConfig|AliasConfig>,\n *     \"when@prod\"?: array<string, RouteConfig|ImportConfig|AliasConfig>,\n *     \"when@test\"?: array<string, RouteConfig|ImportConfig|AliasConfig>,\n *     ...<string, RouteConfig|ImportConfig|AliasConfig>\n * }\n */\nfinal class Routes\n{\n    /**\n     * @param RoutesConfig $config\n     *\n     * @psalm-return RoutesConfig\n     */\n    public static function config(array $config): array\n    {\n        return $config;\n    }\n}\n"
  },
  {
    "path": "config/routes/framework.yaml",
    "content": "when@dev:\n    _errors:\n        resource: '@FrameworkBundle/Resources/config/routing/errors.php'\n        prefix: /_error\n"
  },
  {
    "path": "config/routes/security.yaml",
    "content": "_security_logout:\n    resource: security.route_loader.logout\n    type: service\n"
  },
  {
    "path": "config/routes/web_profiler.yaml",
    "content": "when@dev:\n    web_profiler_wdt:\n        resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'\n        prefix: /_wdt\n\n    web_profiler_profiler:\n        resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'\n        prefix: /_profiler\n"
  },
  {
    "path": "config/routes.yaml",
    "content": "# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json\n\n# This file is the entry point to configure the routes of your app.\n# Methods with the #[Route] attribute are automatically imported.\n# See also https://symfony.com/doc/current/routing.html\n\n# To list all registered routes, run the following command:\n#   bin/console debug:router\nlogout:\n    path: /logout\n\ncontrollers:\n    resource:\n        path: ../src/Controller/\n        namespace: App\\Controller\n    type: attribute\n"
  },
  {
    "path": "config/services.yaml",
    "content": "# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json\n\n# This file is the entry point to configure your own services.\n# Files in the packages/ subdirectory configure your dependencies.\n# See also https://symfony.com/doc/current/service_container/import.html\n\n# Put parameters here that don't need to change on each machine where the app is deployed\n# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration\nparameters:\n\nservices:\n    # default configuration for services in *this* file\n    _defaults:\n        autowire: true      # Automatically injects dependencies in your services.\n        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.\n\n    # makes classes in src/ available to be used as services\n    # this creates a service per class whose id is the fully-qualified class name\n    App\\:\n        resource: '../src/'\n        exclude:\n            - '../src/DependencyInjection/'\n            - '../src/Kernel.php'\n\n    # controllers are imported separately to make sure services can be injected\n    # as action arguments even if you don't extend any base controller class\n    App\\Controller\\:\n        resource: '../src/Controller/'\n        tags: ['controller.service_arguments']\n\n    # autorwire parameters\n    App\\Github\\ClientDiscovery:\n        arguments:\n            $clientId: \"%env(GITHUB_CLIENT_ID)%\"\n            $clientSecret: \"%env(GITHUB_CLIENT_SECRET)%\"\n\n    App\\Controller\\DefaultController:\n        arguments:\n            $diffInterval: \"%env(STATUS_MINUTE_INTERVAL_BEFORE_ALERT)%\"\n            $redis: \"@snc_redis.app_cache\"\n\n    App\\PubSubHubbub\\Publisher:\n        arguments:\n            $hub: \"http://pubsubhubbub.appspot.com\"\n            $host: \"%env(PROJECT_HOST)%\"\n            $scheme: \"%env(PROJECT_SCHEME)%\"\n\n    App\\Command\\SyncStarredReposCommand:\n        arguments:\n            $transport: \"@messenger.transport.sync_starred_repos\"\n\n    App\\Command\\SyncVersionsCommand:\n        arguments:\n            $transport: \"@messenger.transport.sync_versions\"\n\n    App\\Webfeeds\\WebfeedsWriter:\n        tags:\n            - { name: marcw_rss_writer.extension.writer }\n\n    # lazy consumer\n    # to avoid triggering Github Client Discovery\n    # which will make a doctrine query on Travis because the default limit to Github will be reached\n    App\\MessageHandler\\StarredReposSyncHandler:\n        lazy: true\n        arguments:\n            $redis: \"@snc_redis.app_cache\"\n\n    App\\MessageHandler\\VersionsSyncHandler:\n        lazy: true\n\n    # github stuff\n    banditore.client.guzzle:\n        class: GuzzleHttp\\Client\n\n    banditore.client.github:\n        class: Github\\Client\n        factory: [ \"@App\\\\Github\\\\ClientDiscovery\", find ]\n\n    # feed stuff\n    banditore.writer.rss:\n        class: MarcW\\RssWriter\\RssWriter\n        arguments:\n            - ~\n            -\n                core: \"@marcw_rss_writer.writer.core\"\n                webfeeds: \"@App\\\\Webfeeds\\\\WebfeedsWriter\"\n                atom: \"@marcw_rss_writer.writer.atom\"\n            - true\n            - \"    \"\n\n    # force this service to be injected at first instead of the default one (from marcw)\n    MarcW\\RssWriter\\RssWriter: '@banditore.writer.rss'\n\n    App\\Pagination\\Paginator:\n        arguments:\n            -\n                itemsPerPage: 30\n                pagesInRange: 5\n\n    Predis\\Client:\n        alias: snc_redis.guzzle_cache\n\n    GuzzleHttp\\Client:\n        alias: banditore.client.guzzle\n\n    Github\\Client:\n        alias: banditore.client.github\n\n    # bundle not using recipe OR not compatible with Symfony > 3\n    marcw_rss_writer.rss_writer:\n        class: MarcW\\RssWriter\\RssWriter\n\n    marcw_rss_writer.writer.core:\n        class: MarcW\\RssWriter\\Extension\\Core\\CoreWriter\n        tags:\n            - { name: marcw_rss_writer.extension.writer }\n\n    marcw_rss_writer.writer.atom:\n        class: MarcW\\RssWriter\\Extension\\Atom\\AtomWriter\n        tags:\n            - { name: marcw_rss_writer.extension.writer }\n"
  },
  {
    "path": "config/services_test.yaml",
    "content": "services:\n    # see https://github.com/symfony/symfony/issues/24543\n    banditore.client.github.test:\n        alias: banditore.client.github\n        public: true\n\n    banditore.client.guzzle.test:\n        alias: banditore.client.guzzle\n        public: true\n"
  },
  {
    "path": "data/supervisor.conf",
    "content": "[group:sync_repo]\nprograms=sync_repo_1,sync_repo_2\n\n[program:sync_repo_1]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php bin/console messenger:consume --limit=5 sync_starred_repos -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_starred_repos_1.err\nstdout_logfile=/path/to/banditore/var/log/sync_starred_repos_1.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n\n[program:sync_repo_2]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php bin/console messenger:consume --limit=5 sync_starred_repos -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_starred_repos_2.err\nstdout_logfile=/path/to/banditore/var/log/sync_starred_repos_2.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n\n[group:sync_version]\nprograms=sync_version_1,sync_version_2,sync_version_3,sync_version_4\n\n[program:sync_version_1]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php php bin/console messenger:consume --limit=50 sync_versions -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_versions_1.err\nstdout_logfile=/path/to/banditore/var/log/sync_versions_1.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n\n[program:sync_version_2]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php php bin/console messenger:consume --limit=50 sync_versions -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_versions_2.err\nstdout_logfile=/path/to/banditore/var/log/sync_versions_2.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n\n[program:sync_version_3]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php php bin/console messenger:consume --limit=50 sync_versions -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_versions_3.err\nstdout_logfile=/path/to/banditore/var/log/sync_versions_3.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n\n[program:sync_version_4]\ndirectory=/path/to/banditore\ncommand=/usr/bin/php php bin/console messenger:consume --limit=50 sync_versions -e prod\nautostart=true\nautorestart=true\nstderr_logfile=/path/to/banditore/var/log/sync_versions_4.err\nstdout_logfile=/path/to/banditore/var/log/sync_versions_4.log\nuser=www-data\nenvironment = http_proxy=\"\",https_proxy=\"\"\n"
  },
  {
    "path": "migrations/.gitignore",
    "content": ""
  },
  {
    "path": "migrations/Version20170222055642.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Add homepage & language to the repo entity.\n */\nfinal class Version20170222055642 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add homepage & language to the repo entity.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $repoTable = $schema->getTable('repo');\n        $this->skipIf($repoTable->hasColumn('homepage') || $repoTable->hasColumn('language'), 'It seems that you already played this migration.');\n\n        $this->addSql('ALTER TABLE repo ADD homepage VARCHAR(255) DEFAULT NULL, ADD language VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE repo DROP homepage, DROP language');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20170329095349.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * GitHub name can be null.\n */\nfinal class Version20170329095349 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'GitHub name can be null.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE user CHANGE name name VARCHAR(191) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE user CHANGE name name VARCHAR(191) NOT NULL COLLATE utf8mb4_unicode_ci');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20180827105910.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Mark a repo as removed to avoid checking for new version in the future.\n */\nfinal class Version20180827105910 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Mark a repo as removed to avoid checking for new version in the future.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE repo ADD removed_at DATETIME DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE repo DROP removed_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20200511062812.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Mark a user as removed to avoid checking for new starred repos in the future.\n */\nfinal class Version20200511062812 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Mark a user as removed to avoid checking for new starred repos in the future.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE user ADD removed_at DATETIME DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE user DROP removed_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20200613153754.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Enforce some relations to not be null.\n */\nfinal class Version20200613153754 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Enforce some relations to not be null';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE star CHANGE user_id user_id INT NOT NULL, CHANGE repo_id repo_id INT NOT NULL');\n        $this->addSql('ALTER TABLE version CHANGE repo_id repo_id INT NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE star CHANGE user_id user_id INT DEFAULT NULL, CHANGE repo_id repo_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE version CHANGE repo_id repo_id INT DEFAULT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260408120000.php",
    "content": "<?php\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260408120000 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add per-user repo flag to ignore releases in RSS feeds';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE star ADD ignored_in_feed TINYINT(1) DEFAULT 0 NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->abortIf(!$this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform, 'Migration can only be executed safely on MySQL.');\n\n        $this->addSql('ALTER TABLE star DROP ignored_in_feed');\n    }\n}\n"
  },
  {
    "path": "phpstan.dist.neon",
    "content": "parameters:\n    level: max\n    paths:\n        - src\n        - tests\n\n    symfony:\n        containerXmlPath: %rootDir%/../../../var/cache/test/App_KernelTestDebugContainer.xml\n        consoleApplicationLoader: tests/console-application.php\n\n    doctrine:\n        objectManagerLoader: tests/object-manager.php\n\n    ignoreErrors:\n        -\n            identifier: missingType.iterableValue\n        -\n            identifier: offsetAccess.nonOffsetAccessible\n        -\n            identifier: argument.type\n        -\n            identifier: cast.int\n        -\n            identifier: cast.string\n        -\n            identifier: foreach.nonIterable\n        -\n            identifier: binaryOp.invalid\n        -\n            identifier: method.nonObject\n        -\n            path: src/Pagination/PaginatorInterface.php\n            identifier: throws.notThrowable\n        -\n            path: src/Repository/VersionRepository.php\n            identifier: return.type\n        -\n            path: tests/Security/GithubAuthenticatorTest.php\n            identifier: deadCode.unreachable\n            count: 2\n        -\n            path: tests/bootstrap.php\n            identifier: function.alreadyNarrowedType\n            count: 1\n        -\n            path: src/Cache/PredisCachePool.php\n            identifier: return.type\n            count: 2\n        -\n            path: src/Entity/User.php\n            identifier: return.type\n            count: 1\n\n    inferPrivatePropertyTypeFromConstructor: true\n    treatPhpDocTypesAsCertain: false\n"
  },
  {
    "path": "phpunit.xml.dist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"https://schema.phpunit.de/11.5/phpunit.xsd\" backupGlobals=\"false\" colors=\"true\" bootstrap=\"tests/bootstrap.php\" cacheDirectory=\".phpunit.cache\">\n  <php>\n    <ini name=\"error_reporting\" value=\"-1\"/>\n    <server name=\"APP_ENV\" value=\"test\" force=\"true\"/>\n    <server name=\"SHELL_VERBOSITY\" value=\"-1\"/>\n    <server name=\"APP_DEBUG\" value=\"0\"/>\n    <env name=\"SYMFONY_DEPRECATIONS_HELPER\" value=\"weak\"/>\n  </php>\n  <testsuites>\n    <testsuite name=\"Project Test Suite\">\n      <directory>tests</directory>\n    </testsuite>\n  </testsuites>\n  <source>\n    <include>\n      <directory suffix=\".php\">src</directory>\n    </include>\n    <exclude>\n      <directory>src/DataFixtures</directory>\n      <directory>src/Migrations</directory>\n    </exclude>\n  </source>\n</phpunit>\n"
  },
  {
    "path": "public/css/banditore.css",
    "content": "* {\n    -webkit-box-sizing: border-box;\n    -moz-box-sizing: border-box;\n    box-sizing: border-box;\n}\n\n/*\n * -- BASE STYLES --\n * Most of these are inherited from Base, but I want to change a few.\n */\nbody {\n    line-height: 1.7em;\n    color: #7f8c8d;\n    font-size: 13px;\n    background: #10556B;\n}\n\na {\n    color: #7f8c8d;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nlabel {\n    color: #34495e;\n}\n\n.pure-img-responsive {\n    max-width: 100%;\n    height: auto;\n}\n\n/*\n * -- LAYOUT STYLES --\n * These are some useful classes which I will need\n */\n.l-box {\n    padding: 0 1em;\n}\n\n.l-box-lrg {\n    padding: 2em;\n    border-bottom: 1px solid rgba(0,0,0,0.1);\n}\n\n.is-center {\n    text-align: center;\n}\n\n/*\n * -- PURE FORM STYLES --\n * Style the form inputs and labels\n */\n.pure-form label {\n    margin: 1em 0 0;\n    font-weight: bold;\n    font-size: 100%;\n}\n\n.pure-form input[type] {\n    border: 2px solid #ddd;\n    box-shadow: none;\n    font-size: 100%;\n    width: 100%;\n    margin-bottom: 1em;\n}\n\n/*\n * -- PURE BUTTON STYLES --\n * I want my pure-button elements to look a little different\n */\n.pure-button {\n    background-color: #2B687F;\n    color: white;\n    padding: 0.5em 2em;\n    border-radius: 5px;\n}\n\na.pure-button-primary {\n    background: #FFAB80;\n    color: white;\n    border-radius: 5px;\n    font-size: 120%;\n}\n\na.pure-button-primary:hover {\n    color: #2B687F;\n}\n\n/*\n * -- MENU STYLES --\n * I want to customize how my .pure-menu looks at the top of the page\n */\n.menu-wrapper {\n    background-color: #10556B;\n    /*margin-bottom: 1em;*/\n    /*-webkit-font-smoothing: antialiased;*/\n    height: 3.8em;\n    padding: 8px 0;\n    overflow: hidden;\n    -webkit-transition: height 0.5s;\n    -moz-transition: height 0.5s;\n    -ms-transition: height 0.5s;\n    transition: height 0.5s;\n    /*box-shadow: 0 1px 1px rgba(0,0,0, 0.10);*/\n}\n\n.pure-menu li a {\n    color: #6FBEF3;\n}\n\n.pure-menu li a:hover,\n.pure-menu li a:focus {\n    color: white;\n    background-color: #10556B;\n}\n\n.pure-menu-heading {\n    color: #FFAB80;\n    font-weight: 400;\n    font-size: 120%;\n}\n\n.menu-wrapper.open {\n    height: 12.5em;\n}\n\n.menu-can-transform {\n    text-align: left;\n}\n\n.menu-toggle {\n    width: 34px;\n    height: 34px;\n    display: block;\n    position: absolute;\n    top: 0;\n    right: 0;\n    margin: 7px;\n}\n\n.menu-toggle .bar {\n    background-color: #777;\n    display: block;\n    width: 20px;\n    height: 2px;\n    border-radius: 100px;\n    position: absolute;\n    top: 18px;\n    right: 7px;\n    -webkit-transition: all 0.5s;\n    -moz-transition: all 0.5s;\n    -ms-transition: all 0.5s;\n    transition: all 0.5s;\n}\n\n.menu-toggle .bar:first-child {\n    -webkit-transform: translateY(-6px);\n    -moz-transform: translateY(-6px);\n    -ms-transform: translateY(-6px);\n    transform: translateY(-6px);\n}\n\n.menu-toggle.x .bar {\n    -webkit-transform: rotate(45deg);\n    -moz-transform: rotate(45deg);\n    -ms-transform: rotate(45deg);\n    transform: rotate(45deg);\n}\n\n.menu-toggle.x .bar:first-child {\n    -webkit-transform: rotate(-45deg);\n    -moz-transform: rotate(-45deg);\n    -ms-transform: rotate(-45deg);\n    transform: rotate(-45deg);\n}\n\n/*\n * -- SPLASH STYLES --\n * This is the blue top section that appears on the page.\n */\n.splash-container {\n    background: #2B687F;\n    padding: 80px 0 70px;\n}\n\n.splash {\n    /* absolute center .splash within .splash-container */\n    width: 80%;\n    height: 50%;\n    margin: 0 auto;\n    /*position: absolute;*/\n    top: 0;\n    left: 0;\n    bottom: 0;\n    right: 0;\n    text-align: center;\n    text-transform: uppercase;\n}\n\n.splash p {\n    color: #D5E1E5;\n    letter-spacing: 0.05em;\n}\n\n/* This is the main heading that appears on the blue section */\n.splash-head {\n    font-size: 20px;\n    color: white;\n    border: 3px solid #FFAB80;\n    padding: 1em 1.6em;\n    font-weight: 100;\n    border-radius: 5px;\n    line-height: 1.3em;\n}\n\n/*\n * -- CONTENT STYLES --\n * This represents the content area (everything below the blue section)\n */\n.content-wrapper {\n    /* These styles are required for the \"scroll-over\" effect */\n    /*position: absolute;*/\n    /*top: 69%;*/\n    /*width: 100%;*/\n    /*min-height: 12%;*/\n    /*z-index: 2;*/\n    background: #2B687F;\n    margin: 0 auto;\n    /*padding-top: 51px;*/\n}\n\n/* We want to give the content area some more padding */\n.content {\n    padding: 1em;\n    background: white;\n}\n\n/* This is the class used for the main content headers (<h2>) */\n.content-head {\n    font-weight: 400;\n    text-transform: uppercase;\n    letter-spacing: 0.1em;\n    margin: 2em 0 1em;\n}\n\n/* This is a modifier class used when the content-head is inside a ribbon */\n.content-head-ribbon {\n    color: white;\n}\n\n/* This is the class used for the content sub-headers (<h3>) */\n.content-subhead {\n    color: #2B687F;\n}\n.content-subhead i {\n    margin-right: 7px;\n}\n\n/* This is the class used for the dark-background areas. */\n.ribbon {\n    background: #10556B;\n    color: #aaa;\n}\n\nimg.repo-avatar {\n    vertical-align: middle;\n    width: 25px;\n}\n\n.image-productivity,\n.image-megaphone {\n    display: none;\n}\n\naside.feed {\n    background: #2B687F;\n    margin: 1em auto;\n    padding: 0.3em 1em;\n    border-radius: 3px;\n    color: #fff;\n    display: block;\n}\n\naside.feed a {\n    color: #FFAB80;\n    text-decoration: none;\n}\n\naside.feed a:hover {\n    text-decoration: underline;\n}\n\naside.feed b:after {\n    content: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACGklEQVQ4T53TX0hTURzA8e/d3XbnxLUlpaOkFZW0IQqJkQ8t6GHuodAX3YOi/aGQeqgnrd4iCOrVpIdhovUWIiQsoX9oPkSFPdjIbGrDJ3Pq5pzc/bk3NuWWNkI7T4dzzu9zfr8f5wg/b5a6VUV4jMBBdjZmUWkV5jvs0/8RvH6Vyoww32lXs3PDgWr0ZcdRFn+QCn9EiS9sKx8NMJ++RqHn1oaskpoeIzH6iOTk639CGmAsP4Op2oe+pBxxz2EtKPn1JSvPbqCsRvJCGvDnrljsoKD2EgUnWkA0kFmeI+pvJBOZ/QvRAKmqAaniHOm5ceTxgVyQ3u7C0tKDaCsjsxRmucuLkljahOTvgZJmbcxPfPgeusLdWNufI1r3I38JEHtyMT8g7j2K8YgbyenBcKg2dyj57S3Rvlb0die29iHQiSz7G0mF3mlI3h5IzjqKmroQjGYSI92sBu5S1HAfU00zyclXRHtb8gCCgFRxFjJp5GAAyenF0uyHTIrIg5PoTEXYrr8BJc3CHReqvJJDfjexsh6Lrzu3GOs/jxwcxnplEIOjJpdBNpPijg/orPuI9vhITo1sAaoasDQ93AAuIAdfYHZfpbDuNvLEELGnl9nV1k/2vcQHO1l737cZIFtCZf16CRNDoKroS49hdHrILIaRPw8gubyIJeWkvo+SCn/SgBnAsa2Hv/WQIISyv/EU0LvjHykIIRTafgEZntfprlNojgAAAABJRU5ErkJggg==');\n    vertical-align: text-top;\n    margin-left: 5px;\n}\n\n/*\n * -- DASHBOARD TABLE --\n *\n * From https://codepen.io/geoffyuen/pen/FCBEg?editors=1100\n */\n.pure-table-rwd {\n    margin: 0 auto;\n    min-width: 300px;\n}\n\n.pure-table-rwd tr {\n    border-top: 1px solid #ddd;\n    border-bottom: 1px solid #ddd;\n}\n\n.pure-table-rwd th {\n    display: none;\n}\n\n.pure-table-rwd td {\n    display: block;\n}\n\n.pure-table-rwd td:first-child {\n    padding-top: .5em;\n}\n\n.pure-table-rwd td:last-child {\n    padding-bottom: .5em;\n}\n\n.pure-table-rwd td:before {\n    content: attr(data-th) \": \";\n    font-weight: bold;\n    width: 3.5em;\n    display: inline-block;\n}\n\n.pure-table-rwd th, .pure-table-rwd td {\n    text-align: left;\n    border-left-width: 0;\n}\n\n/*\n * Pagination\n *\n * From https://www.bypeople.com/quick-and-simple-pagination-cssdeck/\n */\n.pagination {\n    text-align: center;\n    margin: 20px\n}\n\n.pagination a.previous,\n.pagination a.next {\n    display: none;\n}\n\n.pagination a, .pagination strong {\n    color: #4A4A4A;\n    border: 0;\n    outline: 0;\n    background: #fff;\n    display: inline-block;\n    margin-right: 3px;\n    padding: 4px 12px;\n    text-decoration: none;\n    line-height: 1.5em;\n    -webkit-border-radius: 3px;\n    -moz-border-radius: 3px;\n    border-radius: 3px;\n}\n.pagination a:hover {\n    background-color: #10556B;\n    color: #fff;\n}\n\n.pagination a:active {\n    background: rgba(190, 190, 190, 0.75);\n}\n\n.pagination strong {\n    color: #fff;\n    background-color: #FFAB80;\n}\n\n/*\n * -- ALERTS --\n *\n * From https://isabelcastillo.com/error-info-messages-css\n */\n.alert {\n    margin: 10px 0;\n    padding: 12px;\n    border-radius: .5em;\n}\n.alert span {\n    float: right;\n    margin-top: -10px;\n    padding-left: 5px;\n    cursor: pointer;\n    text-transform: uppercase;\n}\n.alert.info {\n    color: #00529B;\n    background-color: #BDE5F8;\n}\n.alert.success {\n    color: #4F8A10;\n    background-color: #DFF2BF;\n}\n.alert.warning {\n    color: #9F6000;\n    background-color: #FEEFB3;\n}\n.alert.error {\n    color: #D8000C;\n    background-color: #FFBABA;\n}\n\n/*\n * -- LABELS --\n *\n * From http://www.gumbyframework.com/docs/ui-kit/#!/indicators\n */\n.label_info, .label_success, .label_warning, .label_error, .label_prerelease {\n    padding: 0 5px;\n    font-size: 12px;\n    border-radius: 2px;\n    height: 20px;\n    display: inline-block;\n    font-weight: bold;\n    line-height: 20px;\n    text-align: center;\n    color: #fff;\n}\n.label_info {\n    background: #4a4d50;\n}\n.label_success {\n    background: #58c026;\n}\n.label_warning {\n    background: #f6b83f;\n    color: #644405;\n}\n.label_error {\n    background: #ca3838;\n}\n.label_prerelease {\n    background: #ffab80;\n}\n\n.feed-status {\n    display: inline-block;\n    min-width: 5.5em;\n    padding: 0.15em 0.55em;\n    border-radius: 999px;\n    background: #EAF2F5;\n    color: #10556B;\n    font-size: 12px;\n    font-weight: bold;\n    line-height: 1.5;\n    text-align: center;\n}\n\n.feed-status-muted {\n    background: #FFF0E8;\n    color: #AF5D37;\n}\n\n.feed-toggle-form {\n    display: inline;\n    margin-left: 0.45em;\n}\n\n.feed-toggle-button {\n    -webkit-appearance: none;\n    appearance: none;\n    background: none;\n    background-color: transparent;\n    border: 0;\n    border-radius: 0;\n    box-shadow: none;\n    color: #10556B;\n    cursor: pointer;\n    display: inline;\n    font: inherit;\n    font-size: 14px;\n    font-weight: 600;\n    line-height: inherit;\n    margin: 0;\n    outline: none;\n    padding: 0;\n    text-decoration: underline;\n    text-underline-offset: 2px;\n    vertical-align: baseline;\n}\n\n.feed-toggle-button:hover,\n.feed-toggle-button:focus {\n    color: #2B687F;\n}\n\n.included-indicator {\n    color: #58c026;\n    display: inline-block;\n    font-size: 15px;\n    line-height: 1;\n    vertical-align: middle;\n}\n\n.excluded-indicator {\n    color: #ca3838;\n    display: inline-block;\n    font-size: 15px;\n    line-height: 1;\n    vertical-align: middle;\n}\n\n/* This is the class used for the footer */\n.footer {\n    background: #10556B;\n    color: #E6E6E6;\n    padding: 1em;\n}\n\n.footer a {\n    color: #FFAB80;\n    text-decoration: none;\n}\n\n.footer a:hover {\n    text-decoration: underline\n}\n\n/*\n * -- TABLET (AND UP) MEDIA QUERIES --\n */\n@media (min-width: 48em) {\n    /* We increase the body font size */\n    body {\n        font-size: 16px;\n    }\n\n    /* We can align the menu header to the left, but float the menu items to the right. */\n    .home-menu {\n        text-align: left;\n    }\n\n    .home-menu ul {\n        float: right;\n    }\n\n    .pure-menu-item.item-logged-in,\n    .pure-menu-item.item-github {\n        display: inline-block;\n    }\n\n    .menu-can-transform {\n        text-align: right;\n    }\n\n    .menu-toggle {\n        display: none;\n    }\n\n    .splash-head {\n        font-size: 250%;\n    }\n\n    /* We remove the border-separator assigned to .l-box-lrg */\n    .l-box-lrg {\n        border: none;\n    }\n\n    .content {\n        padding: 1em 1em 3em;\n    }\n\n    .image-productivity,\n    .image-megaphone {\n        display: initial;\n    }\n\n    .pure-table td:before {\n        display: none;\n    }\n\n    .pure-table tr {\n        border-top: 0;\n        border-bottom: 0;\n    }\n\n    .pure-table th, .pure-table td {\n        display: table-cell;\n        border-left: 1px solid #cbcbcb;\n    }\n\n    .pagination a.previous,\n    .pagination a.next {\n        display: inline-block;\n    }\n}\n\n/*\n * -- DESKTOP (AND UP) MEDIA QUERIES --\n */\n@media (min-width: 78em) {\n    /* We increase the header font size even more */\n    .splash-head {\n        font-size: 300%;\n    }\n\n    .middle-content {\n        width: 980px;\n        margin: 0 auto;\n    }\n\n    .l-box {\n        padding: 1em;\n    }\n}\n"
  },
  {
    "path": "public/css/grids-responsive-min.css",
    "content": "/*!\nPure v3.0.0\nCopyright 2013 Yahoo!\nLicensed under the BSD License.\nhttps://github.com/pure-css/pure/blob/master/LICENSE\n*/\n@media screen and (min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-12,.pure-u-sm-1-2,.pure-u-sm-1-24,.pure-u-sm-1-3,.pure-u-sm-1-4,.pure-u-sm-1-5,.pure-u-sm-1-6,.pure-u-sm-1-8,.pure-u-sm-10-24,.pure-u-sm-11-12,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-2-24,.pure-u-sm-2-3,.pure-u-sm-2-5,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24,.pure-u-sm-3-24,.pure-u-sm-3-4,.pure-u-sm-3-5,.pure-u-sm-3-8,.pure-u-sm-4-24,.pure-u-sm-4-5,.pure-u-sm-5-12,.pure-u-sm-5-24,.pure-u-sm-5-5,.pure-u-sm-5-6,.pure-u-sm-5-8,.pure-u-sm-6-24,.pure-u-sm-7-12,.pure-u-sm-7-24,.pure-u-sm-7-8,.pure-u-sm-8-24,.pure-u-sm-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-sm-1-24{width:4.1667%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%}.pure-u-sm-1-5{width:20%}.pure-u-sm-5-24{width:20.8333%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%}.pure-u-sm-7-24{width:29.1667%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%}.pure-u-sm-2-5{width:40%}.pure-u-sm-10-24,.pure-u-sm-5-12{width:41.6667%}.pure-u-sm-11-24{width:45.8333%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%}.pure-u-sm-13-24{width:54.1667%}.pure-u-sm-14-24,.pure-u-sm-7-12{width:58.3333%}.pure-u-sm-3-5{width:60%}.pure-u-sm-15-24,.pure-u-sm-5-8{width:62.5%}.pure-u-sm-16-24,.pure-u-sm-2-3{width:66.6667%}.pure-u-sm-17-24{width:70.8333%}.pure-u-sm-18-24,.pure-u-sm-3-4{width:75%}.pure-u-sm-19-24{width:79.1667%}.pure-u-sm-4-5{width:80%}.pure-u-sm-20-24,.pure-u-sm-5-6{width:83.3333%}.pure-u-sm-21-24,.pure-u-sm-7-8{width:87.5%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%}.pure-u-sm-23-24{width:95.8333%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-24-24,.pure-u-sm-5-5{width:100%}}@media screen and (min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-12,.pure-u-md-1-2,.pure-u-md-1-24,.pure-u-md-1-3,.pure-u-md-1-4,.pure-u-md-1-5,.pure-u-md-1-6,.pure-u-md-1-8,.pure-u-md-10-24,.pure-u-md-11-12,.pure-u-md-11-24,.pure-u-md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-2-24,.pure-u-md-2-3,.pure-u-md-2-5,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24,.pure-u-md-3-24,.pure-u-md-3-4,.pure-u-md-3-5,.pure-u-md-3-8,.pure-u-md-4-24,.pure-u-md-4-5,.pure-u-md-5-12,.pure-u-md-5-24,.pure-u-md-5-5,.pure-u-md-5-6,.pure-u-md-5-8,.pure-u-md-6-24,.pure-u-md-7-12,.pure-u-md-7-24,.pure-u-md-7-8,.pure-u-md-8-24,.pure-u-md-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-md-1-24{width:4.1667%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%}.pure-u-md-1-5{width:20%}.pure-u-md-5-24{width:20.8333%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%}.pure-u-md-7-24{width:29.1667%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%}.pure-u-md-2-5{width:40%}.pure-u-md-10-24,.pure-u-md-5-12{width:41.6667%}.pure-u-md-11-24{width:45.8333%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%}.pure-u-md-13-24{width:54.1667%}.pure-u-md-14-24,.pure-u-md-7-12{width:58.3333%}.pure-u-md-3-5{width:60%}.pure-u-md-15-24,.pure-u-md-5-8{width:62.5%}.pure-u-md-16-24,.pure-u-md-2-3{width:66.6667%}.pure-u-md-17-24{width:70.8333%}.pure-u-md-18-24,.pure-u-md-3-4{width:75%}.pure-u-md-19-24{width:79.1667%}.pure-u-md-4-5{width:80%}.pure-u-md-20-24,.pure-u-md-5-6{width:83.3333%}.pure-u-md-21-24,.pure-u-md-7-8{width:87.5%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%}.pure-u-md-23-24{width:95.8333%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-24-24,.pure-u-md-5-5{width:100%}}@media screen and (min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-12,.pure-u-lg-1-2,.pure-u-lg-1-24,.pure-u-lg-1-3,.pure-u-lg-1-4,.pure-u-lg-1-5,.pure-u-lg-1-6,.pure-u-lg-1-8,.pure-u-lg-10-24,.pure-u-lg-11-12,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-2-24,.pure-u-lg-2-3,.pure-u-lg-2-5,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24,.pure-u-lg-3-24,.pure-u-lg-3-4,.pure-u-lg-3-5,.pure-u-lg-3-8,.pure-u-lg-4-24,.pure-u-lg-4-5,.pure-u-lg-5-12,.pure-u-lg-5-24,.pure-u-lg-5-5,.pure-u-lg-5-6,.pure-u-lg-5-8,.pure-u-lg-6-24,.pure-u-lg-7-12,.pure-u-lg-7-24,.pure-u-lg-7-8,.pure-u-lg-8-24,.pure-u-lg-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-lg-1-24{width:4.1667%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%}.pure-u-lg-1-5{width:20%}.pure-u-lg-5-24{width:20.8333%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%}.pure-u-lg-7-24{width:29.1667%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%}.pure-u-lg-2-5{width:40%}.pure-u-lg-10-24,.pure-u-lg-5-12{width:41.6667%}.pure-u-lg-11-24{width:45.8333%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%}.pure-u-lg-13-24{width:54.1667%}.pure-u-lg-14-24,.pure-u-lg-7-12{width:58.3333%}.pure-u-lg-3-5{width:60%}.pure-u-lg-15-24,.pure-u-lg-5-8{width:62.5%}.pure-u-lg-16-24,.pure-u-lg-2-3{width:66.6667%}.pure-u-lg-17-24{width:70.8333%}.pure-u-lg-18-24,.pure-u-lg-3-4{width:75%}.pure-u-lg-19-24{width:79.1667%}.pure-u-lg-4-5{width:80%}.pure-u-lg-20-24,.pure-u-lg-5-6{width:83.3333%}.pure-u-lg-21-24,.pure-u-lg-7-8{width:87.5%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%}.pure-u-lg-23-24{width:95.8333%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-24-24,.pure-u-lg-5-5{width:100%}}@media screen and (min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-12,.pure-u-xl-1-2,.pure-u-xl-1-24,.pure-u-xl-1-3,.pure-u-xl-1-4,.pure-u-xl-1-5,.pure-u-xl-1-6,.pure-u-xl-1-8,.pure-u-xl-10-24,.pure-u-xl-11-12,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-19-24,.pure-u-xl-2-24,.pure-u-xl-2-3,.pure-u-xl-2-5,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24,.pure-u-xl-3-24,.pure-u-xl-3-4,.pure-u-xl-3-5,.pure-u-xl-3-8,.pure-u-xl-4-24,.pure-u-xl-4-5,.pure-u-xl-5-12,.pure-u-xl-5-24,.pure-u-xl-5-5,.pure-u-xl-5-6,.pure-u-xl-5-8,.pure-u-xl-6-24,.pure-u-xl-7-12,.pure-u-xl-7-24,.pure-u-xl-7-8,.pure-u-xl-8-24,.pure-u-xl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xl-1-24{width:4.1667%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%}.pure-u-xl-1-5{width:20%}.pure-u-xl-5-24{width:20.8333%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%}.pure-u-xl-7-24{width:29.1667%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%}.pure-u-xl-2-5{width:40%}.pure-u-xl-10-24,.pure-u-xl-5-12{width:41.6667%}.pure-u-xl-11-24{width:45.8333%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%}.pure-u-xl-13-24{width:54.1667%}.pure-u-xl-14-24,.pure-u-xl-7-12{width:58.3333%}.pure-u-xl-3-5{width:60%}.pure-u-xl-15-24,.pure-u-xl-5-8{width:62.5%}.pure-u-xl-16-24,.pure-u-xl-2-3{width:66.6667%}.pure-u-xl-17-24{width:70.8333%}.pure-u-xl-18-24,.pure-u-xl-3-4{width:75%}.pure-u-xl-19-24{width:79.1667%}.pure-u-xl-4-5{width:80%}.pure-u-xl-20-24,.pure-u-xl-5-6{width:83.3333%}.pure-u-xl-21-24,.pure-u-xl-7-8{width:87.5%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%}.pure-u-xl-23-24{width:95.8333%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-24-24,.pure-u-xl-5-5{width:100%}}@media screen and (min-width:120em){.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-1-12,.pure-u-xxl-1-2,.pure-u-xxl-1-24,.pure-u-xxl-1-3,.pure-u-xxl-1-4,.pure-u-xxl-1-5,.pure-u-xxl-1-6,.pure-u-xxl-1-8,.pure-u-xxl-10-24,.pure-u-xxl-11-12,.pure-u-xxl-11-24,.pure-u-xxl-12-24,.pure-u-xxl-13-24,.pure-u-xxl-14-24,.pure-u-xxl-15-24,.pure-u-xxl-16-24,.pure-u-xxl-17-24,.pure-u-xxl-18-24,.pure-u-xxl-19-24,.pure-u-xxl-2-24,.pure-u-xxl-2-3,.pure-u-xxl-2-5,.pure-u-xxl-20-24,.pure-u-xxl-21-24,.pure-u-xxl-22-24,.pure-u-xxl-23-24,.pure-u-xxl-24-24,.pure-u-xxl-3-24,.pure-u-xxl-3-4,.pure-u-xxl-3-5,.pure-u-xxl-3-8,.pure-u-xxl-4-24,.pure-u-xxl-4-5,.pure-u-xxl-5-12,.pure-u-xxl-5-24,.pure-u-xxl-5-5,.pure-u-xxl-5-6,.pure-u-xxl-5-8,.pure-u-xxl-6-24,.pure-u-xxl-7-12,.pure-u-xxl-7-24,.pure-u-xxl-7-8,.pure-u-xxl-8-24,.pure-u-xxl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xxl-1-24{width:4.1667%}.pure-u-xxl-1-12,.pure-u-xxl-2-24{width:8.3333%}.pure-u-xxl-1-8,.pure-u-xxl-3-24{width:12.5%}.pure-u-xxl-1-6,.pure-u-xxl-4-24{width:16.6667%}.pure-u-xxl-1-5{width:20%}.pure-u-xxl-5-24{width:20.8333%}.pure-u-xxl-1-4,.pure-u-xxl-6-24{width:25%}.pure-u-xxl-7-24{width:29.1667%}.pure-u-xxl-1-3,.pure-u-xxl-8-24{width:33.3333%}.pure-u-xxl-3-8,.pure-u-xxl-9-24{width:37.5%}.pure-u-xxl-2-5{width:40%}.pure-u-xxl-10-24,.pure-u-xxl-5-12{width:41.6667%}.pure-u-xxl-11-24{width:45.8333%}.pure-u-xxl-1-2,.pure-u-xxl-12-24{width:50%}.pure-u-xxl-13-24{width:54.1667%}.pure-u-xxl-14-24,.pure-u-xxl-7-12{width:58.3333%}.pure-u-xxl-3-5{width:60%}.pure-u-xxl-15-24,.pure-u-xxl-5-8{width:62.5%}.pure-u-xxl-16-24,.pure-u-xxl-2-3{width:66.6667%}.pure-u-xxl-17-24{width:70.8333%}.pure-u-xxl-18-24,.pure-u-xxl-3-4{width:75%}.pure-u-xxl-19-24{width:79.1667%}.pure-u-xxl-4-5{width:80%}.pure-u-xxl-20-24,.pure-u-xxl-5-6{width:83.3333%}.pure-u-xxl-21-24,.pure-u-xxl-7-8{width:87.5%}.pure-u-xxl-11-12,.pure-u-xxl-22-24{width:91.6667%}.pure-u-xxl-23-24{width:95.8333%}.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-24-24,.pure-u-xxl-5-5{width:100%}}@media screen and (min-width:160em){.pure-u-xxxl-1,.pure-u-xxxl-1-1,.pure-u-xxxl-1-12,.pure-u-xxxl-1-2,.pure-u-xxxl-1-24,.pure-u-xxxl-1-3,.pure-u-xxxl-1-4,.pure-u-xxxl-1-5,.pure-u-xxxl-1-6,.pure-u-xxxl-1-8,.pure-u-xxxl-10-24,.pure-u-xxxl-11-12,.pure-u-xxxl-11-24,.pure-u-xxxl-12-24,.pure-u-xxxl-13-24,.pure-u-xxxl-14-24,.pure-u-xxxl-15-24,.pure-u-xxxl-16-24,.pure-u-xxxl-17-24,.pure-u-xxxl-18-24,.pure-u-xxxl-19-24,.pure-u-xxxl-2-24,.pure-u-xxxl-2-3,.pure-u-xxxl-2-5,.pure-u-xxxl-20-24,.pure-u-xxxl-21-24,.pure-u-xxxl-22-24,.pure-u-xxxl-23-24,.pure-u-xxxl-24-24,.pure-u-xxxl-3-24,.pure-u-xxxl-3-4,.pure-u-xxxl-3-5,.pure-u-xxxl-3-8,.pure-u-xxxl-4-24,.pure-u-xxxl-4-5,.pure-u-xxxl-5-12,.pure-u-xxxl-5-24,.pure-u-xxxl-5-5,.pure-u-xxxl-5-6,.pure-u-xxxl-5-8,.pure-u-xxxl-6-24,.pure-u-xxxl-7-12,.pure-u-xxxl-7-24,.pure-u-xxxl-7-8,.pure-u-xxxl-8-24,.pure-u-xxxl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xxxl-1-24{width:4.1667%}.pure-u-xxxl-1-12,.pure-u-xxxl-2-24{width:8.3333%}.pure-u-xxxl-1-8,.pure-u-xxxl-3-24{width:12.5%}.pure-u-xxxl-1-6,.pure-u-xxxl-4-24{width:16.6667%}.pure-u-xxxl-1-5{width:20%}.pure-u-xxxl-5-24{width:20.8333%}.pure-u-xxxl-1-4,.pure-u-xxxl-6-24{width:25%}.pure-u-xxxl-7-24{width:29.1667%}.pure-u-xxxl-1-3,.pure-u-xxxl-8-24{width:33.3333%}.pure-u-xxxl-3-8,.pure-u-xxxl-9-24{width:37.5%}.pure-u-xxxl-2-5{width:40%}.pure-u-xxxl-10-24,.pure-u-xxxl-5-12{width:41.6667%}.pure-u-xxxl-11-24{width:45.8333%}.pure-u-xxxl-1-2,.pure-u-xxxl-12-24{width:50%}.pure-u-xxxl-13-24{width:54.1667%}.pure-u-xxxl-14-24,.pure-u-xxxl-7-12{width:58.3333%}.pure-u-xxxl-3-5{width:60%}.pure-u-xxxl-15-24,.pure-u-xxxl-5-8{width:62.5%}.pure-u-xxxl-16-24,.pure-u-xxxl-2-3{width:66.6667%}.pure-u-xxxl-17-24{width:70.8333%}.pure-u-xxxl-18-24,.pure-u-xxxl-3-4{width:75%}.pure-u-xxxl-19-24{width:79.1667%}.pure-u-xxxl-4-5{width:80%}.pure-u-xxxl-20-24,.pure-u-xxxl-5-6{width:83.3333%}.pure-u-xxxl-21-24,.pure-u-xxxl-7-8{width:87.5%}.pure-u-xxxl-11-12,.pure-u-xxxl-22-24{width:91.6667%}.pure-u-xxxl-23-24{width:95.8333%}.pure-u-xxxl-1,.pure-u-xxxl-1-1,.pure-u-xxxl-24-24,.pure-u-xxxl-5-5{width:100%}}@media screen and (min-width:240em){.pure-u-x4k-1,.pure-u-x4k-1-1,.pure-u-x4k-1-12,.pure-u-x4k-1-2,.pure-u-x4k-1-24,.pure-u-x4k-1-3,.pure-u-x4k-1-4,.pure-u-x4k-1-5,.pure-u-x4k-1-6,.pure-u-x4k-1-8,.pure-u-x4k-10-24,.pure-u-x4k-11-12,.pure-u-x4k-11-24,.pure-u-x4k-12-24,.pure-u-x4k-13-24,.pure-u-x4k-14-24,.pure-u-x4k-15-24,.pure-u-x4k-16-24,.pure-u-x4k-17-24,.pure-u-x4k-18-24,.pure-u-x4k-19-24,.pure-u-x4k-2-24,.pure-u-x4k-2-3,.pure-u-x4k-2-5,.pure-u-x4k-20-24,.pure-u-x4k-21-24,.pure-u-x4k-22-24,.pure-u-x4k-23-24,.pure-u-x4k-24-24,.pure-u-x4k-3-24,.pure-u-x4k-3-4,.pure-u-x4k-3-5,.pure-u-x4k-3-8,.pure-u-x4k-4-24,.pure-u-x4k-4-5,.pure-u-x4k-5-12,.pure-u-x4k-5-24,.pure-u-x4k-5-5,.pure-u-x4k-5-6,.pure-u-x4k-5-8,.pure-u-x4k-6-24,.pure-u-x4k-7-12,.pure-u-x4k-7-24,.pure-u-x4k-7-8,.pure-u-x4k-8-24,.pure-u-x4k-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-x4k-1-24{width:4.1667%}.pure-u-x4k-1-12,.pure-u-x4k-2-24{width:8.3333%}.pure-u-x4k-1-8,.pure-u-x4k-3-24{width:12.5%}.pure-u-x4k-1-6,.pure-u-x4k-4-24{width:16.6667%}.pure-u-x4k-1-5{width:20%}.pure-u-x4k-5-24{width:20.8333%}.pure-u-x4k-1-4,.pure-u-x4k-6-24{width:25%}.pure-u-x4k-7-24{width:29.1667%}.pure-u-x4k-1-3,.pure-u-x4k-8-24{width:33.3333%}.pure-u-x4k-3-8,.pure-u-x4k-9-24{width:37.5%}.pure-u-x4k-2-5{width:40%}.pure-u-x4k-10-24,.pure-u-x4k-5-12{width:41.6667%}.pure-u-x4k-11-24{width:45.8333%}.pure-u-x4k-1-2,.pure-u-x4k-12-24{width:50%}.pure-u-x4k-13-24{width:54.1667%}.pure-u-x4k-14-24,.pure-u-x4k-7-12{width:58.3333%}.pure-u-x4k-3-5{width:60%}.pure-u-x4k-15-24,.pure-u-x4k-5-8{width:62.5%}.pure-u-x4k-16-24,.pure-u-x4k-2-3{width:66.6667%}.pure-u-x4k-17-24{width:70.8333%}.pure-u-x4k-18-24,.pure-u-x4k-3-4{width:75%}.pure-u-x4k-19-24{width:79.1667%}.pure-u-x4k-4-5{width:80%}.pure-u-x4k-20-24,.pure-u-x4k-5-6{width:83.3333%}.pure-u-x4k-21-24,.pure-u-x4k-7-8{width:87.5%}.pure-u-x4k-11-12,.pure-u-x4k-22-24{width:91.6667%}.pure-u-x4k-23-24{width:95.8333%}.pure-u-x4k-1,.pure-u-x4k-1-1,.pure-u-x4k-24-24,.pure-u-x4k-5-5{width:100%}}"
  },
  {
    "path": "public/css/pure-min.css",
    "content": "/*!\nPure v3.0.0\nCopyright 2013 Yahoo!\nLicensed under the BSD License.\nhttps://github.com/pure-css/pure/blob/master/LICENSE\n*/\n/*!\nnormalize.css v | MIT License | https://necolas.github.io/normalize.css/\nCopyright (c) Nicolas Gallagher and Jonathan Neal\n*/\n/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-family:sans-serif}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{display:flex;flex-flow:row wrap;align-content:flex-start}.pure-u{display:inline-block;vertical-align:top}.pure-u-1,.pure-u-1-1,.pure-u-1-12,.pure-u-1-2,.pure-u-1-24,.pure-u-1-3,.pure-u-1-4,.pure-u-1-5,.pure-u-1-6,.pure-u-1-8,.pure-u-10-24,.pure-u-11-12,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-2-24,.pure-u-2-3,.pure-u-2-5,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24,.pure-u-3-24,.pure-u-3-4,.pure-u-3-5,.pure-u-3-8,.pure-u-4-24,.pure-u-4-5,.pure-u-5-12,.pure-u-5-24,.pure-u-5-5,.pure-u-5-6,.pure-u-5-8,.pure-u-6-24,.pure-u-7-12,.pure-u-7-24,.pure-u-7-8,.pure-u-8-24,.pure-u-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-10-24,.pure-u-5-12{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-14-24,.pure-u-7-12{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-15-24,.pure-u-5-8{width:62.5%}.pure-u-16-24,.pure-u-2-3{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-18-24,.pure-u-3-4{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-20-24,.pure-u-5-6{width:83.3333%}.pure-u-21-24,.pure-u-7-8{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-24-24,.pure-u-5-5{width:100%}.pure-button{display:inline-block;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;user-select:none;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-group{letter-spacing:-.31em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-button-group{word-spacing:-0.43em}.pure-button-group .pure-button{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:rgba(0,0,0,.8);border:none transparent;background-color:#e6e6e6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:focus,.pure-button:hover{background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000}.pure-button-disabled,.pure-button-disabled:active,.pure-button-disabled:focus,.pure-button-disabled:hover,.pure-button[disabled]{border:none;background-image:none;opacity:.4;cursor:not-allowed;box-shadow:none;pointer-events:none}.pure-button-hidden{display:none}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-button-group .pure-button{margin:0;border-radius:0;border-right:1px solid rgba(0,0,0,.2)}.pure-button-group .pure-button:first-child{border-top-left-radius:2px;border-bottom-left-radius:2px}.pure-button-group .pure-button:last-child{border-top-right-radius:2px;border-bottom-right-radius:2px;border-right:none}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=color]:focus,.pure-form input[type=date]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=email]:focus,.pure-form input[type=month]:focus,.pure-form input[type=number]:focus,.pure-form input[type=password]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=text]:focus,.pure-form input[type=time]:focus,.pure-form input[type=url]:focus,.pure-form input[type=week]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129fea}.pure-form input:not([type]):focus{outline:0;border-color:#129fea}.pure-form input[type=checkbox]:focus,.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus{outline:thin solid #129FEA;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=color][disabled],.pure-form input[type=date][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=email][disabled],.pure-form input[type=month][disabled],.pure-form input[type=number][disabled],.pure-form input[type=password][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=text][disabled],.pure-form input[type=time][disabled],.pure-form input[type=url][disabled],.pure-form input[type=week][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form select:focus:invalid,.pure-form textarea:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=checkbox]:focus:invalid:focus,.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=color],.pure-form-stacked input[type=date],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=email],.pure-form-stacked input[type=file],.pure-form-stacked input[type=month],.pure-form-stacked input[type=number],.pure-form-stacked input[type=password],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=text],.pure-form-stacked input[type=time],.pure-form-stacked input[type=url],.pure-form-stacked input[type=week],.pure-form-stacked label,.pure-form-stacked select,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned select,.pure-form-aligned textarea,.pure-form-message-inline{display:inline-block;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form .pure-input-rounded,.pure-form input.pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-3-4{width:75%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=color],.pure-group input[type=date],.pure-group input[type=datetime-local],.pure-group input[type=datetime],.pure-group input[type=email],.pure-group input[type=month],.pure-group input[type=number],.pure-group input[type=password],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=text],.pure-group input[type=time],.pure-group input[type=url],.pure-group input[type=week]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0 0}.pure-form-message,.pure-form-message-inline{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-item,.pure-menu-list{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-heading,.pure-menu-link{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-separator{display:inline-block;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-active>.pure-menu-children,.pure-menu-allow-hover:hover>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:\"\\25B8\";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:\"\\25BE\"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;padding:.5em 0}.pure-menu-horizontal .pure-menu-children .pure-menu-separator,.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-horizontal .pure-menu-children .pure-menu-separator{display:block;width:auto}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-heading,.pure-menu-link{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent;cursor:default}.pure-menu-active>.pure-menu-link,.pure-menu-link:focus,.pure-menu-link:hover{background-color:#eee}.pure-menu-selected>.pure-menu-link,.pure-menu-selected>.pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px 0;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0}"
  },
  {
    "path": "public/fonts/.gitkeep",
    "content": ""
  },
  {
    "path": "public/index.php",
    "content": "<?php\n\nuse App\\Kernel;\n\nrequire_once dirname(__DIR__) . '/vendor/autoload_runtime.php';\n\nreturn static fn (array $context) => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);\n"
  },
  {
    "path": "public/js/banditore.js",
    "content": "(function (window, document) {\n    // close alert messages\n    var alerts = document.querySelectorAll('span.close')\n    for (var i = 0; i < alerts.length; ++i) {\n        alerts[i].addEventListener('click', function (event) {\n            // in case the font awesome element isn't loaded (might be the case on iOS)\n            if (event.target.className === 'fa fa-close') {\n                event\n                    .target // font awesome element\n                    .parentElement // span element\n                    .parentElement // alert element\n                    .style.display = 'none'\n            } else {\n                event\n                    .target // span element\n                    .parentElement // alert element\n                    .style.display = 'none'\n            }\n        }, false)\n    }\n\n    // handle rwd menu\n    var menu = document.getElementById('menu'),\n    WINDOW_CHANGE_EVENT = ('onorientationchange' in window) ? 'orientationchange':'resize'\n\n    function toggleHorizontal() {\n        [].forEach.call(\n            document.getElementById('menu').querySelectorAll('.menu-can-transform'),\n            function(el){\n                el.classList.toggle('pure-menu-horizontal')\n            }\n        )\n    }\n\n    function toggleMenu() {\n        // set timeout so that the panel has a chance to roll up\n        // before the menu switches states\n        if (menu.classList.contains('open')) {\n            setTimeout(toggleHorizontal, 500)\n        } else {\n            toggleHorizontal()\n        }\n\n        menu.classList.toggle('open')\n        document.getElementById('toggle').classList.toggle('x')\n    }\n\n    function closeMenu() {\n        if (menu.classList.contains('open')) {\n            toggleMenu()\n        }\n    }\n\n    document.getElementById('toggle').addEventListener('click', function (e) {\n        toggleMenu()\n        e.preventDefault()\n    })\n\n    window.addEventListener(WINDOW_CHANGE_EVENT, closeMenu)\n})(this, this.document)\n"
  },
  {
    "path": "public/robots.txt",
    "content": "# www.robotstxt.org/\n# www.google.com/support/webmasters/bin/answer.py?hl=en&answer=156449\n\nUser-agent: *\nDisallow:\n"
  },
  {
    "path": "rector.php",
    "content": "<?php\n\nuse Rector\\Config\\RectorConfig;\nuse Rector\\Doctrine\\Set\\DoctrineSetList;\nuse Rector\\Php80\\Rector\\Class_\\ClassPropertyAssignToConstructorPromotionRector;\nuse Rector\\PHPUnit\\Set\\PHPUnitSetList;\nuse Rector\\Symfony\\Set\\SymfonySetList;\nuse Rector\\TypeDeclaration\\Rector\\ClassMethod\\AddVoidReturnTypeWhereNoReturnRector;\n\nreturn RectorConfig::configure()\n    ->withPaths([\n        __DIR__ . '/config',\n        __DIR__ . '/migrations',\n        __DIR__ . '/public',\n        __DIR__ . '/src',\n        __DIR__ . '/templates',\n        __DIR__ . '/tests',\n    ])\n    ->withRootFiles()\n    ->withImportNames(importShortClasses: false)\n    ->withTypeCoverageLevel(0)\n    ->withDeadCodeLevel(0)\n    ->withCodeQualityLevel(0)\n    ->withRules([\n        AddVoidReturnTypeWhereNoReturnRector::class,\n    ])\n    ->withPhpSets()\n    ->withSets([\n        DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,\n        SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES,\n        DoctrineSetList::GEDMO_ANNOTATIONS_TO_ATTRIBUTES,\n        PHPUnitSetList::PHPUNIT_110,\n    ])\n    ->withAttributesSets(symfony: true, doctrine: true, gedmo: true, jms: true, sensiolabs: true)\n    ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)\n    ->withConfiguredRule(ClassPropertyAssignToConstructorPromotionRector::class, [\n        'inline_public' => true,\n    ])\n    ->withSkip([\n        ClassPropertyAssignToConstructorPromotionRector::class => [\n            __DIR__ . '/src/Entity/*',\n        ],\n    ]);\n"
  },
  {
    "path": "src/Cache/CustomRedisCachePool.php",
    "content": "<?php\n\nnamespace App\\Cache;\n\nuse Cache\\Adapter\\Common\\PhpCacheItem;\n\n/**\n * Store lightweight response from GitHub to avoid having a huge Redis database.\n * Stored response will only have what Bandito.re needs. We should use GraphQL to only request fields we want\n * but rate limit is still too low for the app.\n *\n * Affected url from the GitHub API:\n *     - starred\n *     - git/refs/tags\n *     - tag\n *     - release\n *\n * All other response are usually for a version and we don't need to store them. They won't be cached.\n */\nclass CustomRedisCachePool extends PredisCachePool\n{\n    protected function storeItemInCache(PhpCacheItem $item, $ttl): bool\n    {\n        if ($ttl < 0) {\n            return false;\n        }\n\n        $currentItem = $item->get();\n\n        if (404 === $currentItem['response']->getStatusCode() || 451 === $currentItem['response']->getStatusCode()) {\n            return parent::storeItemInCache($item, $ttl);\n        }\n\n        $body = json_decode((string) $currentItem['body'], true);\n        // we don't need to reduce empty array ^^\n        if (empty($body)) {\n            return parent::storeItemInCache($item, $ttl);\n        }\n\n        // do not cache version (ie: release or tag) information\n        // we don't query them later because the version will be saved and never updated\n        if (isset($body['committer']) || isset($body['tagger']) || isset($body['prerelease'])) {\n            return true;\n        }\n\n        if (isset($body[0]['ref']) && str_contains((string) $body[0]['ref'], 'refs/tags/')) {\n            // response for git/refs/tags\n            foreach ($body as $key => $element) {\n                $body[$key] = [\n                    'ref' => $element['ref'],\n                    'object' => [\n                        'sha' => $element['object']['sha'],\n                        'type' => $element['object']['type'],\n                    ],\n                ];\n            }\n        } elseif (isset($body[0]['zipball_url'])) {\n            // response for only one tag\n            $body = [\n                0 => [\n                    'name' => $body[0]['name'],\n                ],\n            ];\n        } elseif (isset($body[0]['full_name'])) {\n            // response for starred repos\n            foreach ($body as $key => $element) {\n                $body[$key] = [\n                    'id' => $element['id'],\n                    'name' => $element['name'],\n                    'homepage' => $element['homepage'],\n                    'language' => $element['language'],\n                    'full_name' => $element['full_name'],\n                    'description' => $element['description'],\n                    'owner' => [\n                        'avatar_url' => $element['owner']['avatar_url'],\n                    ],\n                ];\n            }\n        } else {\n            $this->log('warning', 'Unmatched response from custom Redis cache', ['body' => $body]);\n        }\n\n        $currentItem['body'] = json_encode($body);\n\n        $item->set($currentItem);\n\n        return parent::storeItemInCache($item, $ttl);\n    }\n}\n"
  },
  {
    "path": "src/Cache/HierarchicalCachePoolTrait.php",
    "content": "<?php\n\n/*\n * This file is part of php-cache organization.\n *\n * (c) 2015 Aaron Scherer <aequasi@gmail.com>, Tobias Nyholm <tobias.nyholm@gmail.com>\n *\n * This source file is subject to the MIT license that is bundled\n * with this source code in the file LICENSE.\n */\n\nnamespace App\\Cache;\n\nuse Cache\\Adapter\\Common\\AbstractCachePool;\n\n/**\n * @author Tobias Nyholm <tobias.nyholm@gmail.com>\n */\ntrait HierarchicalCachePoolTrait\n{\n    /**\n     * A temporary cache for keys.\n     *\n     * @var array\n     */\n    private $keyCache = [];\n\n    /**\n     * Get a value from the storage.\n     *\n     * @param string $name\n     */\n    abstract public function getDirectValue($name);\n\n    /**\n     * Get a key to use with the hierarchy. If the key does not start with HierarchicalPoolInterface::SEPARATOR\n     * this will return an unalterered key. This function supports a tagged key. Ie \"foo:bar\".\n     *\n     * @param string $key      The original key\n     * @param string &$pathKey A cache key for the path. If this key is changed everything beyond that path is changed.\n     *\n     * @return string|array\n     */\n    protected function getHierarchyKey($key, &$pathKey = null)\n    {\n        if (!$this->isHierarchyKey($key)) {\n            return $key;\n        }\n\n        $key = $this->explodeKey($key);\n\n        $keyString = '';\n        // The comments below is for a $key = [\"foo!tagHash\", \"bar!tagHash\"]\n        foreach ($key as $name) {\n            // 1) $keyString = \"foo!tagHash\"\n            // 2) $keyString = \"foo!tagHash![foo_index]!bar!tagHash\"\n            $keyString .= (string) $name;\n            $pathKey = sha1('path' . AbstractCachePool::SEPARATOR_TAG . $keyString);\n\n            if (isset($this->keyCache[$pathKey])) {\n                $index = $this->keyCache[$pathKey];\n            } else {\n                $index = $this->getDirectValue($pathKey);\n                $this->keyCache[$pathKey] = $index;\n            }\n\n            // 1) $keyString = \"foo!tagHash![foo_index]!\"\n            // 2) $keyString = \"foo!tagHash![foo_index]!bar!tagHash![bar_index]!\"\n            $keyString .= AbstractCachePool::SEPARATOR_TAG . $index . AbstractCachePool::SEPARATOR_TAG;\n        }\n\n        // Assert: $pathKey = \"path!foo!tagHash![foo_index]!bar!tagHash\"\n        // Assert: $keyString = \"foo!tagHash![foo_index]!bar!tagHash![bar_index]!\"\n\n        // Make sure we do not get awfully long (>250 chars) keys\n        return sha1($keyString);\n    }\n\n    /**\n     * Clear the cache for the keys.\n     */\n    protected function clearHierarchyKeyCache(): void\n    {\n        $this->keyCache = [];\n    }\n\n    /**\n     * A hierarchy key MUST begin with the separator.\n     *\n     * @param string $key\n     *\n     * @return bool\n     */\n    private function isHierarchyKey($key)\n    {\n        return str_starts_with($key, '|');\n    }\n\n    /**\n     * This will take a hierarchy key (\"|foo|bar\") with tags (\"|foo|bar!tagHash\") and return an array with\n     * each level in the hierarchy appended with the tags. [\"foo!tagHash\", \"bar!tagHash\"].\n     *\n     * @param string $string\n     *\n     * @return array\n     */\n    private function explodeKey($string)\n    {\n        [$key, $tag] = explode(AbstractCachePool::SEPARATOR_TAG, $string . AbstractCachePool::SEPARATOR_TAG);\n\n        if ('|' === $key) {\n            $parts = ['root'];\n        } else {\n            $parts = explode('|', $key);\n            // remove first element since it is always empty and replace it with 'root'\n            $parts[0] = 'root';\n        }\n\n        return array_map(static fn ($level) => $level . AbstractCachePool::SEPARATOR_TAG . $tag, $parts);\n    }\n}\n"
  },
  {
    "path": "src/Cache/PredisCachePool.php",
    "content": "<?php\n\nnamespace App\\Cache;\n\nuse Cache\\Adapter\\Common\\AbstractCachePool;\nuse Cache\\Adapter\\Common\\PhpCacheItem;\nuse Predis\\ClientInterface as Client;\n\n/**\n * Kind of copy/pasted from `cache/predis-adapter` because the project looks dead.\n */\nclass PredisCachePool extends AbstractCachePool\n{\n    use HierarchicalCachePoolTrait;\n\n    public function __construct(protected Client $cache)\n    {\n    }\n\n    protected function fetchObjectFromCache($key): array\n    {\n        $value = $this->cache->get($this->getHierarchyKey($key));\n        if (!$value) {\n            return [false, null, [], null];\n        }\n\n        $result = unserialize($value);\n        if (false === $result) {\n            return [false, null, [], null];\n        }\n\n        return $result;\n    }\n\n    protected function clearAllObjectsFromCache(): bool\n    {\n        return 'OK' === $this->cache->flushdb()->getPayload();\n    }\n\n    protected function clearOneObjectFromCache($key): bool\n    {\n        $path = null;\n        $keyString = $this->getHierarchyKey($key, $path);\n        if ($path) {\n            $this->cache->incr($path);\n        }\n        $this->clearHierarchyKeyCache();\n\n        return $this->cache->del($keyString) >= 0;\n    }\n\n    protected function storeItemInCache(PhpCacheItem $item, $ttl): bool\n    {\n        if ($ttl < 0) {\n            return false;\n        }\n\n        $key = $this->getHierarchyKey($item->getKey());\n        $data = serialize([true, $item->get(), $item->getTags(), $item->getExpirationTimestamp()]);\n\n        if (null === $ttl || 0 === $ttl) {\n            return 'OK' === $this->cache->set($key, $data)->getPayload();\n        }\n\n        return 'OK' === $this->cache->setex($key, $ttl, $data)->getPayload();\n    }\n\n    protected function getDirectValue($key): mixed\n    {\n        return $this->cache->get($key);\n    }\n\n    protected function appendListItem($name, $value): void\n    {\n        $this->cache->lpush($name, $value);\n    }\n\n    protected function getList($name): array\n    {\n        return $this->cache->lrange($name, 0, -1);\n    }\n\n    protected function removeList($name): bool\n    {\n        return $this->cache->del($name);\n    }\n\n    protected function removeListItem($name, $key): int\n    {\n        return $this->cache->lrem($name, 0, $key);\n    }\n}\n"
  },
  {
    "path": "src/Command/SyncStarredReposCommand.php",
    "content": "<?php\n\nnamespace App\\Command;\n\nuse App\\Message\\StarredReposSync;\nuse App\\MessageHandler\\StarredReposSyncHandler;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Attribute\\Option;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Messenger\\Transport\\Receiver\\MessageCountAwareInterface;\nuse Symfony\\Component\\Messenger\\Transport\\TransportInterface;\n\n/**\n * This command sync starred repos from user(s).\n *\n * It can do it:\n *     - right away, might take longer to process\n *     - by publishing a message in a queue\n */\n#[AsCommand(name: 'banditore:sync:starred-repos', description: 'Sync starred repos for all users')]\nclass SyncStarredReposCommand\n{\n    public function __construct(private readonly UserRepository $userRepository, private readonly StarredReposSyncHandler $syncRepo, private readonly TransportInterface $transport, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public function __invoke(\n        OutputInterface $output,\n        #[Option(description: 'Retrieve only one user using its id')] string|bool $id = false,\n        #[Option(description: 'Retrieve only one user using its username')] string|bool $username = false,\n        #[Option(description: 'Push each user into a queue instead of fetching it right away')] bool $useQueue = false,\n    ): int {\n        if ($useQueue && $this->transport instanceof MessageCountAwareInterface) {\n            // check that queue is empty before pushing new messages\n            $count = $this->transport->getMessageCount();\n            if (0 < $count) {\n                $output->writeln('Current queue as too much messages (<error>' . $count . '</error>), <comment>skipping</comment>.');\n\n                return Command::FAILURE;\n            }\n        }\n\n        $users = $this->retrieveUsers($id, $username);\n\n        if (\\count(array_filter($users)) <= 0) {\n            $output->writeln('<error>No users found</error>');\n\n            return Command::FAILURE;\n        }\n\n        $userSynced = 0;\n        $totalUsers = \\count($users);\n\n        foreach ($users as $userId) {\n            ++$userSynced;\n\n            $output->writeln('[' . $userSynced . '/' . $totalUsers . '] Sync user <info>' . $userId . '</info> … ');\n\n            $message = new StarredReposSync($userId);\n\n            if ($useQueue) {\n                $this->bus->dispatch($message);\n            } else {\n                $this->syncRepo->__invoke($message);\n            }\n        }\n\n        $output->writeln('<info>User synced: ' . $userSynced . '</info>');\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Retrieve users to work on.\n     */\n    private function retrieveUsers(?string $id, ?string $username): array\n    {\n        if ($id) {\n            return [$id];\n        }\n\n        if ($username) {\n            $user = $this->userRepository->findOneByUsername((string) $username);\n\n            if ($user) {\n                return [$user->getId()];\n            }\n\n            return [];\n        }\n\n        return $this->userRepository->findAllToSync();\n    }\n}\n"
  },
  {
    "path": "src/Command/SyncVersionsCommand.php",
    "content": "<?php\n\nnamespace App\\Command;\n\nuse App\\Message\\VersionsSync;\nuse App\\MessageHandler\\VersionsSyncHandler;\nuse App\\Repository\\RepoRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Attribute\\Option;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Messenger\\Transport\\Receiver\\MessageCountAwareInterface;\nuse Symfony\\Component\\Messenger\\Transport\\TransportInterface;\n\n/**\n * This command send contents to opt-in Messenger users.\n * It can send one content or many.\n *\n * Options priority is build this way:\n *     - one content\n *     - many contents\n */\n#[AsCommand(name: 'banditore:sync:versions', description: 'Sync new version for each repository')]\nclass SyncVersionsCommand\n{\n    public function __construct(private readonly RepoRepository $repoRepository, private readonly VersionsSyncHandler $syncVersions, private readonly TransportInterface $transport, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public function __invoke(\n        OutputInterface $output,\n        #[Option(description: 'Retrieve version only for that repository (using its id)')] string|bool $repoId = false,\n        #[Option(description: 'Retrieve version only for that repository (using it full name: username/repo)')] string|bool $repoName = false,\n        #[Option(description: 'Push each repo into a queue instead of fetching it right away')] bool $useQueue = false,\n    ): int {\n        if ($useQueue && $this->transport instanceof MessageCountAwareInterface) {\n            // check that queue is empty before pushing new messages\n            $count = $this->transport->getMessageCount();\n            if (0 < $count) {\n                $output->writeln('Current queue as too much messages (<error>' . $count . '</error>), <comment>skipping</comment>.');\n\n                return Command::FAILURE;\n            }\n        }\n\n        $repos = $this->retrieveRepos($repoId, $repoName);\n\n        if (\\count(array_filter($repos)) <= 0) {\n            $output->writeln('<error>No repos found</error>');\n\n            return Command::FAILURE;\n        }\n\n        $repoChecked = 0;\n        $totalRepos = \\count($repos);\n\n        foreach ($repos as $repoId) {\n            ++$repoChecked;\n\n            $output->writeln('[' . $repoChecked . '/' . $totalRepos . '] Check <info>' . $repoId . '</info> … ');\n\n            $message = new VersionsSync($repoId);\n\n            if ($useQueue) {\n                $this->bus->dispatch($message);\n            } else {\n                $this->syncVersions->__invoke($message);\n            }\n        }\n\n        $output->writeln('<info>Repo checked: ' . $repoChecked . '</info>');\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Retrieve repos to work on.\n     */\n    private function retrieveRepos(?string $repoId, ?string $repoName): array\n    {\n        if ($repoId) {\n            return [$repoId];\n        }\n\n        if ($repoName) {\n            $repo = $this->repoRepository->findOneByFullName((string) $repoName);\n\n            if ($repo) {\n                return [$repo->getId()];\n            }\n\n            return [];\n        }\n\n        return $this->repoRepository->findAllForRelease();\n    }\n}\n"
  },
  {
    "path": "src/Controller/DefaultController.php",
    "content": "<?php\n\nnamespace App\\Controller;\n\nuse App\\Entity\\User;\nuse App\\Pagination\\Exception\\InvalidPageNumberException;\nuse App\\Pagination\\Paginator;\nuse App\\Repository\\RepoRepository;\nuse App\\Repository\\StarRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Repository\\VersionRepository;\nuse App\\Rss\\Generator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse MarcW\\RssWriter\\Bridge\\Symfony\\HttpFoundation\\RssStreamedResponse;\nuse MarcW\\RssWriter\\RssWriter;\nuse Predis\\Client as RedisClient;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Routing\\Attribute\\Route;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass DefaultController extends AbstractController\n{\n    public function __construct(private readonly VersionRepository $repoVersion, private readonly int $diffInterval, private readonly RedisClient $redis, private readonly Security $security)\n    {\n    }\n\n    #[Route(path: '/', name: 'homepage')]\n    public function indexAction(): Response\n    {\n        if ($this->security->isGranted('IS_AUTHENTICATED_FULLY')) {\n            return $this->redirect($this->generateUrl('dashboard'));\n        }\n\n        return $this->render('default/index.html.twig');\n    }\n\n    #[Route(path: '/status', name: 'status')]\n    public function statusAction(): Response\n    {\n        $latest = $this->repoVersion->findLatest();\n\n        if (null === $latest) {\n            return $this->json([]);\n        }\n\n        $diff = (new \\DateTime())->getTimestamp() - $latest['createdAt']->getTimestamp();\n\n        return $this->json([\n            'latest' => $latest['createdAt'],\n            'diff' => $diff,\n            'is_fresh' => $diff / 60 < $this->diffInterval,\n        ]);\n    }\n\n    #[Route(path: '/dashboard', name: 'dashboard')]\n    public function dashboardAction(Request $request, Paginator $paginator): Response\n    {\n        if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {\n            return $this->redirect($this->generateUrl('homepage'));\n        }\n\n        /** @var User */\n        $user = $this->getUser();\n        $userId = $user->getId();\n\n        // Pass the item total\n        $paginator->setItemTotalCallback(fn () => $this->repoVersion->countForUser($userId));\n\n        // Pass the slice\n        $paginator->setSliceCallback(fn ($offset, $length) => $this->repoVersion->findForUser($userId, $offset, $length));\n\n        // Paginate using the current page number\n        try {\n            $pagination = $paginator->paginate((int) $request->query->get('page', '1'));\n        } catch (InvalidPageNumberException $e) {\n            throw $this->createNotFoundException($e->getMessage());\n        }\n\n        // Avoid displaying empty page when page is too high\n        if ($request->query->get('page') > $pagination->getTotalNumberOfPages()) {\n            return $this->redirect($this->generateUrl('dashboard'));\n        }\n\n        return $this->render('default/dashboard.html.twig', [\n            'pagination' => $pagination,\n            'sync_status' => $this->redis->get('banditore:user-sync:' . $userId),\n        ]);\n    }\n\n    #[Route(path: '/dashboard/repositories/{repoId}/feed', name: 'dashboard_repo_feed', methods: ['POST'])]\n    public function updateDashboardRepoFeedAction(int $repoId, Request $request, StarRepository $starRepository, EntityManagerInterface $entityManager): RedirectResponse\n    {\n        if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {\n            return $this->redirect($this->generateUrl('homepage'));\n        }\n\n        /** @var User */\n        $user = $this->getUser();\n        $star = $starRepository->findOneByUserAndRepo($user->getId(), $repoId);\n\n        if (null === $star) {\n            throw $this->createNotFoundException('Repository subscription not found.');\n        }\n\n        $ignoreInFeed = $request->request->getBoolean('ignore_in_feed');\n\n        $star->setIgnoredInFeed($ignoreInFeed);\n        $entityManager->flush();\n\n        $this->addFlash(\n            'info',\n            \\sprintf(\n                '%s %s in your RSS feed.',\n                $star->getRepo()->getFullName(),\n                $ignoreInFeed ? 'is now ignored' : 'is now included again'\n            )\n        );\n\n        return $this->redirect($this->generateUrl('dashboard'));\n    }\n\n    /**\n     * Empty callback action.\n     * The request will be handle by the GithubAuthenticator.\n     */\n    #[Route(path: '/callback', name: 'github_callback')]\n    public function githubCallbackAction(): RedirectResponse\n    {\n        return $this->redirect($this->generateUrl('github_connect'));\n    }\n\n    /**\n     * Link to this controller to start the \"connect\" process.\n     */\n    #[Route(path: '/connect', name: 'github_connect')]\n    public function connectAction(ClientRegistry $oauth): RedirectResponse\n    {\n        if ($this->security->isGranted('IS_AUTHENTICATED_FULLY')) {\n            return $this->redirect($this->generateUrl('dashboard'));\n        }\n\n        return $oauth\n            ->getClient('github')\n            ->redirect(['user:email'], []);\n    }\n\n    #[Route(path: '/{uuid}.atom', name: 'rss_user')]\n    public function rssAction(\n        #[MapEntity(expr: 'repository.findOneBy({\"uuid\": uuid})')]\n        User $user,\n        Generator $rssGenerator,\n        RssWriter $rssWriter,\n    ): RssStreamedResponse {\n        $channel = $rssGenerator->generate(\n            $user,\n            $this->repoVersion->findForFeedUser($user->getId()),\n            $this->generateUrl('rss_user', ['uuid' => $user->getUuid()], UrlGeneratorInterface::ABSOLUTE_URL)\n        );\n\n        return new RssStreamedResponse($channel, $rssWriter);\n    }\n\n    /**\n     * Display some global stats.\n     */\n    #[Route(path: '/stats', name: 'stats')]\n    public function statsAction(RepoRepository $repoRepo, StarRepository $repoStar, UserRepository $repoUser): Response\n    {\n        $nbRepos = $repoRepo->countTotal();\n        $nbReleases = $this->repoVersion->countTotal();\n        $nbStars = $repoStar->countTotal();\n        $nbUsers = $repoUser->countTotal();\n\n        return $this->render('default/stats.html.twig', [\n            'counters' => [\n                'nbRepos' => $nbRepos,\n                'nbReleases' => $nbReleases,\n                'avgReleasePerRepo' => ($nbRepos > 0) ? round($nbReleases / $nbRepos, 2) : 0,\n                'avgStarPerUser' => ($nbUsers > 0) ? round($nbStars / $nbUsers, 2) : 0,\n            ],\n            'mostReleases' => $repoRepo->mostVersionsPerRepo(),\n            'lastestReleases' => $this->repoVersion->findLastVersionForEachRepo(20),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/AppFixtures.php",
    "content": "<?php\n\nnamespace App\\DataFixtures;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Star;\nuse App\\Entity\\User;\nuse App\\Entity\\Version;\nuse Doctrine\\Bundle\\FixturesBundle\\Fixture;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass AppFixtures extends Fixture\n{\n    public function load(ObjectManager $manager): void\n    {\n        $this->loadUsers($manager);\n        $this->loadRepos($manager);\n        $this->loadStars($manager);\n        $this->loadVersions($manager);\n    }\n\n    private function loadUsers(ObjectManager $manager): void\n    {\n        $user1 = new User();\n        $user1->setId(123);\n        $user1->setUsername('admin');\n        $user1->setName('Bob');\n        $user1->setAccessToken('1234567890');\n        $user1->setAvatar('http://0.0.0.0/avatar.jpg');\n\n        $manager->persist($user1);\n        $manager->flush();\n\n        $this->addReference('user1', $user1);\n    }\n\n    private function loadRepos(ObjectManager $manager): void\n    {\n        $repo1 = new Repo();\n        $repo1->hydrateFromGithub([\n            'id' => 666,\n            'name' => 'test',\n            'full_name' => 'test/test',\n            'description' => 'This is a test repo',\n            'homepage' => 'http://homepage.io',\n            'language' => 'Go',\n            'owner' => [\n                'avatar_url' => 'http://0.0.0.0/test.jpg',\n            ],\n        ]);\n        $manager->persist($repo1);\n\n        $repo2 = new Repo();\n        $repo2->hydrateFromGithub([\n            'id' => 555,\n            'name' => 'symfony',\n            'full_name' => 'symfony/symfony',\n            'description' => 'The Symfony PHP framework',\n            'homepage' => 'http://symfony.com',\n            'language' => 'PHP',\n            'owner' => [\n                'avatar_url' => 'https://avatars2.githubusercontent.com/u/143937?v=3',\n            ],\n        ]);\n        $manager->persist($repo2);\n\n        $repo3 = new Repo();\n        $repo3->hydrateFromGithub([\n            'id' => 444,\n            'name' => 'graby',\n            'full_name' => 'j0k3r/graby',\n            'description' => 'graby',\n            'homepage' => 'http://graby.io',\n            'language' => 'PHP',\n            'owner' => [\n                'avatar_url' => 'http://0.0.0.0/graby.jpg',\n            ],\n        ]);\n        $manager->persist($repo3);\n\n        $manager->flush();\n\n        $this->addReference('repo1', $repo1);\n        $this->addReference('repo2', $repo2);\n        $this->addReference('repo3', $repo3);\n    }\n\n    private function loadStars(ObjectManager $manager): void\n    {\n        /** @var User */\n        $user1 = $this->getReference('user1', User::class);\n        /** @var Repo */\n        $repo1 = $this->getReference('repo1', Repo::class);\n        /** @var Repo */\n        $repo2 = $this->getReference('repo2', Repo::class);\n\n        $star1 = new Star($user1, $repo1);\n        $star2 = new Star($user1, $repo2);\n\n        $manager->persist($star1);\n        $manager->persist($star2);\n        $manager->flush();\n\n        $this->addReference('star1', $star1);\n        $this->addReference('star2', $star2);\n    }\n\n    private function loadVersions(ObjectManager $manager): void\n    {\n        /** @var Repo */\n        $repo1 = $this->getReference('repo1', Repo::class);\n        /** @var Repo */\n        $repo2 = $this->getReference('repo2', Repo::class);\n        /** @var Repo */\n        $repo3 = $this->getReference('repo3', Repo::class);\n\n        $version1 = new Version($repo1);\n        $version1->hydrateFromGithub([\n            'tag_name' => '1.0.0',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY',\n            'published_at' => '2019-10-15T07:49:21Z',\n        ]);\n        $manager->persist($version1);\n\n        $version2 = new Version($repo2);\n        $version2->hydrateFromGithub([\n            'tag_name' => '1.0.21',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY 555',\n            'published_at' => '2019-06-15T07:49:21Z',\n        ]);\n\n        $manager->persist($version2);\n        $manager->flush();\n\n        $version3 = new Version($repo3);\n        $version3->hydrateFromGithub([\n            'tag_name' => '0.0.21',\n            'name' => 'Outdated release',\n            'prerelease' => false,\n            'message' => 'YAY OLD',\n            'published_at' => date('Y') . '-06-15T07:49:21Z',\n        ]);\n\n        $manager->persist($version3);\n        $manager->flush();\n\n        $this->addReference('version1', $version1);\n        $this->addReference('version2', $version2);\n    }\n}\n"
  },
  {
    "path": "src/Entity/Repo.php",
    "content": "<?php\n\nnamespace App\\Entity;\n\nuse App\\Repository\\RepoRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\ORM\\Mapping as ORM;\n\n/**\n * Repo.\n */\n#[ORM\\Entity(repositoryClass: RepoRepository::class)]\n#[ORM\\HasLifecycleCallbacks]\n#[ORM\\Table(name: 'repo')]\nclass Repo\n{\n    /**\n     * @var int\n     */\n    #[ORM\\Column(name: 'id', type: 'integer')]\n    #[ORM\\Id]\n    #[ORM\\GeneratedValue(strategy: 'NONE')]\n    private $id;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'name', type: 'string', length: 191)]\n    private $name;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'full_name', type: 'string', length: 191)]\n    private $fullName;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'description', type: 'text', nullable: true)]\n    private $description;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'homepage', type: 'string', nullable: true)]\n    private $homepage;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'language', type: 'string', nullable: true)]\n    private $language;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'owner_avatar', type: 'string', length: 191)]\n    private $ownerAvatar;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'created_at', type: 'datetime', nullable: false)]\n    private $createdAt;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'updated_at', type: 'datetime', nullable: false)]\n    private $updatedAt;\n\n    /**\n     * @var \\DateTime|null\n     */\n    #[ORM\\Column(name: 'removed_at', type: 'datetime', nullable: true)]\n    private $removedAt;\n\n    /**\n     * @var ArrayCollection<int, Star>\n     */\n    #[ORM\\OneToMany(targetEntity: Star::class, mappedBy: 'repo')]\n    private $stars;\n\n    /**\n     * @var ArrayCollection<int, Version>\n     */\n    #[ORM\\OneToMany(targetEntity: Version::class, mappedBy: 'repo')]\n    private $versions;\n\n    public function __construct()\n    {\n        $this->stars = new ArrayCollection();\n        $this->versions = new ArrayCollection();\n    }\n\n    /**\n     * Set id.\n     *\n     * @param int $id\n     *\n     * @return Repo\n     */\n    public function setId($id)\n    {\n        $this->id = $id;\n\n        return $this;\n    }\n\n    /**\n     * Get id.\n     *\n     * @return int\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * Set name.\n     *\n     * @param string $name\n     *\n     * @return Repo\n     */\n    public function setName($name)\n    {\n        $this->name = $name;\n\n        return $this;\n    }\n\n    /**\n     * Get name.\n     *\n     * @return string\n     */\n    public function getName()\n    {\n        return $this->name;\n    }\n\n    /**\n     * Set fullName.\n     *\n     * @param string $fullName\n     *\n     * @return Repo\n     */\n    public function setFullName($fullName)\n    {\n        $this->fullName = $fullName;\n\n        return $this;\n    }\n\n    /**\n     * Get fullName.\n     *\n     * @return string\n     */\n    public function getFullName()\n    {\n        return $this->fullName;\n    }\n\n    /**\n     * Set description.\n     *\n     * @param string $description\n     *\n     * @return Repo\n     */\n    public function setDescription($description)\n    {\n        $this->description = $description;\n\n        return $this;\n    }\n\n    /**\n     * Get description.\n     *\n     * @return string\n     */\n    public function getDescription()\n    {\n        return (string) $this->description;\n    }\n\n    /**\n     * Set homepage.\n     *\n     * @param string $homepage\n     *\n     * @return Repo\n     */\n    public function setHomepage($homepage)\n    {\n        $this->homepage = $homepage;\n\n        return $this;\n    }\n\n    /**\n     * Get homepage.\n     *\n     * @return string\n     */\n    public function getHomepage()\n    {\n        return (string) $this->homepage;\n    }\n\n    /**\n     * Set language.\n     *\n     * @param string $language\n     *\n     * @return Repo\n     */\n    public function setLanguage($language)\n    {\n        $this->language = $language;\n\n        return $this;\n    }\n\n    /**\n     * Get language.\n     *\n     * @return string\n     */\n    public function getLanguage()\n    {\n        return (string) $this->language;\n    }\n\n    /**\n     * Set ownerAvatar.\n     *\n     * @param string $ownerAvatar\n     *\n     * @return Repo\n     */\n    public function setOwnerAvatar($ownerAvatar)\n    {\n        $this->ownerAvatar = $ownerAvatar;\n\n        return $this;\n    }\n\n    /**\n     * Get ownerAvatar.\n     *\n     * @return string\n     */\n    public function getOwnerAvatar()\n    {\n        return $this->ownerAvatar;\n    }\n\n    /**\n     * Set createdAt.\n     *\n     * @param \\DateTime $createdAt\n     *\n     * @return Repo\n     */\n    public function setCreatedAt($createdAt)\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    /**\n     * Get createdAt.\n     *\n     * @return \\DateTime\n     */\n    public function getCreatedAt()\n    {\n        return $this->createdAt;\n    }\n\n    /**\n     * Set updatedAt.\n     *\n     * @param \\DateTime $updatedAt\n     *\n     * @return Repo\n     */\n    public function setUpdatedAt($updatedAt)\n    {\n        $this->updatedAt = $updatedAt;\n\n        return $this;\n    }\n\n    /**\n     * Get updatedAt.\n     *\n     * @return \\DateTime\n     */\n    public function getUpdatedAt()\n    {\n        return $this->updatedAt;\n    }\n\n    #[ORM\\PrePersist]\n    #[ORM\\PreUpdate]\n    public function timestamps(): void\n    {\n        if (null === $this->createdAt) {\n            $this->createdAt = new \\DateTime();\n        }\n        $this->updatedAt = new \\DateTime();\n    }\n\n    public function hydrateFromGithub(array $data): void\n    {\n        $this->setId($data['id']);\n        $this->setName($data['name']);\n        $this->setHomepage($data['homepage']);\n        $this->setLanguage($data['language']);\n        $this->setFullName($data['full_name']);\n        $this->setDescription($data['description']);\n        $this->setOwnerAvatar($data['owner']['avatar_url']);\n    }\n\n    /**\n     * Set removedAt.\n     *\n     * @param \\DateTime $removedAt\n     *\n     * @return Repo\n     */\n    public function setRemovedAt($removedAt)\n    {\n        $this->removedAt = $removedAt;\n\n        return $this;\n    }\n\n    /**\n     * Get removedAt.\n     *\n     * @return \\DateTime|null\n     */\n    public function getRemovedAt()\n    {\n        return $this->removedAt;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Star.php",
    "content": "<?php\n\nnamespace App\\Entity;\n\nuse App\\Repository\\StarRepository;\nuse Doctrine\\ORM\\Mapping as ORM;\n\n/**\n * Repo.\n */\n#[ORM\\Entity(repositoryClass: StarRepository::class)]\n#[ORM\\Table(name: 'star')]\n#[ORM\\UniqueConstraint(name: 'user_repo_unique', columns: ['user_id', 'repo_id'])]\nclass Star\n{\n    /**\n     * @var int\n     */\n    #[ORM\\Column(name: 'id', type: 'integer')]\n    #[ORM\\Id]\n    #[ORM\\GeneratedValue(strategy: 'AUTO')]\n    private $id;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'created_at', type: 'datetime', nullable: false)]\n    private $createdAt;\n\n    #[ORM\\Column(name: 'ignored_in_feed', type: 'boolean', options: ['default' => false])]\n    private bool $ignoredInFeed = false;\n\n    public function __construct(#[ORM\\ManyToOne(targetEntity: User::class, inversedBy: 'stars')]\n        #[ORM\\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false)]\n        private readonly User $user, #[ORM\\ManyToOne(targetEntity: Repo::class, inversedBy: 'stars')]\n        #[ORM\\JoinColumn(name: 'repo_id', referencedColumnName: 'id', nullable: false)]\n        private readonly Repo $repo)\n    {\n        $this->createdAt = new \\DateTime();\n    }\n\n    /**\n     * Get id.\n     *\n     * @return int\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * Set createdAt.\n     *\n     * @param \\DateTime $createdAt\n     *\n     * @return Star\n     */\n    public function setCreatedAt($createdAt)\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    /**\n     * Get createdAt.\n     *\n     * @return \\DateTime\n     */\n    public function getCreatedAt()\n    {\n        return $this->createdAt;\n    }\n\n    public function getUser(): User\n    {\n        return $this->user;\n    }\n\n    public function getRepo(): Repo\n    {\n        return $this->repo;\n    }\n\n    public function isIgnoredInFeed(): bool\n    {\n        return $this->ignoredInFeed;\n    }\n\n    public function setIgnoredInFeed(bool $ignoredInFeed): self\n    {\n        $this->ignoredInFeed = $ignoredInFeed;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/User.php",
    "content": "<?php\n\nnamespace App\\Entity;\n\nuse App\\Repository\\UserRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\ORM\\Mapping as ORM;\nuse League\\OAuth2\\Client\\Provider\\GithubResourceOwner;\nuse Ramsey\\Uuid\\Uuid;\nuse Symfony\\Component\\Security\\Core\\User\\EquatableInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\n\n/**\n * User.\n */\n#[ORM\\Entity(repositoryClass: UserRepository::class)]\n#[ORM\\HasLifecycleCallbacks]\n#[ORM\\Table(name: 'user')]\n#[ORM\\UniqueConstraint(name: 'uuid', columns: ['uuid'])]\nclass User implements UserInterface, EquatableInterface\n{\n    /**\n     * @var int\n     */\n    #[ORM\\Column(name: 'id', type: 'integer')]\n    #[ORM\\Id]\n    #[ORM\\GeneratedValue(strategy: 'NONE')]\n    private $id;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'uuid', type: 'guid', length: 191, unique: true, nullable: false)]\n    private $uuid;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'username', type: 'string', length: 191, unique: true, nullable: false)]\n    private $username;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'avatar', type: 'string', length: 191)]\n    private $avatar;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'name', type: 'string', length: 191, nullable: true)]\n    private $name;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'access_token', type: 'string', length: 100, nullable: false)]\n    private $accessToken;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'created_at', type: 'datetime', nullable: false)]\n    private $createdAt;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'updated_at', type: 'datetime', nullable: false)]\n    private $updatedAt;\n\n    /**\n     * @var \\DateTime|null\n     */\n    #[ORM\\Column(name: 'removed_at', type: 'datetime', nullable: true)]\n    private $removedAt;\n\n    /**\n     * @var ArrayCollection<int, Star>\n     */\n    #[ORM\\OneToMany(targetEntity: Star::class, mappedBy: 'user')]\n    private $stars;\n\n    public function __construct()\n    {\n        $this->uuid = Uuid::uuid4()->toString();\n        $this->stars = new ArrayCollection();\n    }\n\n    /**\n     * Set id.\n     *\n     * @param int $id\n     *\n     * @return User\n     */\n    public function setId($id)\n    {\n        $this->id = $id;\n\n        return $this;\n    }\n\n    /**\n     * Get id.\n     *\n     * @return int\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * Set username.\n     *\n     * @param string $username\n     *\n     * @return User\n     */\n    public function setUsername($username)\n    {\n        $this->username = $username;\n\n        return $this;\n    }\n\n    /**\n     * Get username.\n     *\n     * @return string\n     */\n    public function getUsername()\n    {\n        return $this->username;\n    }\n\n    /**\n     * Get uuid.\n     *\n     * @return string\n     */\n    public function getUuid()\n    {\n        return $this->uuid;\n    }\n\n    /**\n     * Set avatar.\n     *\n     * @param string $avatar\n     *\n     * @return User\n     */\n    public function setAvatar($avatar)\n    {\n        $this->avatar = $avatar;\n\n        return $this;\n    }\n\n    /**\n     * Get avatar.\n     *\n     * @return string\n     */\n    public function getAvatar()\n    {\n        return $this->avatar;\n    }\n\n    /**\n     * Set name.\n     *\n     * @param string $name\n     *\n     * @return User\n     */\n    public function setName($name)\n    {\n        $this->name = $name;\n\n        return $this;\n    }\n\n    /**\n     * Get name.\n     *\n     * @return string\n     */\n    public function getName()\n    {\n        return (string) $this->name;\n    }\n\n    /**\n     * Set createdAt.\n     *\n     * @param \\DateTime $createdAt\n     *\n     * @return User\n     */\n    public function setCreatedAt($createdAt)\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    /**\n     * Get createdAt.\n     *\n     * @return \\DateTime\n     */\n    public function getCreatedAt()\n    {\n        return $this->createdAt;\n    }\n\n    /**\n     * Set updatedAt.\n     *\n     * @param \\DateTime $updatedAt\n     *\n     * @return User\n     */\n    public function setUpdatedAt($updatedAt)\n    {\n        $this->updatedAt = $updatedAt;\n\n        return $this;\n    }\n\n    /**\n     * Get updatedAt.\n     *\n     * @return \\DateTime\n     */\n    public function getUpdatedAt()\n    {\n        return $this->updatedAt;\n    }\n\n    #[ORM\\PrePersist]\n    #[ORM\\PreUpdate]\n    public function timestamps(): void\n    {\n        if (null === $this->createdAt) {\n            $this->createdAt = new \\DateTime();\n        }\n        $this->updatedAt = new \\DateTime();\n    }\n\n    /**\n     * Hydrate a user with data from Github.\n     */\n    public function hydrateFromGithub(GithubResourceOwner $data): void\n    {\n        $info = $data->toArray();\n\n        $this->setId($info['id']);\n        $this->setUsername($info['login']);\n        $this->setAvatar($info['avatar_url']);\n        $this->setName($info['name']);\n    }\n\n    public function getRoles(): array\n    {\n        return ['ROLE_USER'];\n    }\n\n    public function getPassword(): ?string\n    {\n        return '';\n    }\n\n    public function getSalt(): ?string\n    {\n        return null;\n    }\n\n    public function eraseCredentials(): void\n    {\n    }\n\n    /**\n     * Set accessToken.\n     *\n     * @param string $accessToken\n     *\n     * @return User\n     */\n    public function setAccessToken($accessToken)\n    {\n        $this->accessToken = $accessToken;\n\n        return $this;\n    }\n\n    /**\n     * Get accessToken.\n     *\n     * @return string\n     */\n    public function getAccessToken()\n    {\n        return $this->accessToken;\n    }\n\n    /**\n     * Set removedAt.\n     *\n     * @param \\DateTime $removedAt\n     *\n     * @return User\n     */\n    public function setRemovedAt($removedAt)\n    {\n        $this->removedAt = $removedAt;\n\n        return $this;\n    }\n\n    /**\n     * Get removedAt.\n     *\n     * @return \\DateTime|null\n     */\n    public function getRemovedAt()\n    {\n        return $this->removedAt;\n    }\n\n    /**\n     * Trying to determine if the user should be logged out because it has changed or not.\n     *\n     * @see https://stackoverflow.com/a/47676103/569101\n     * @see https://symfony.com/doc/4.4/reference/configuration/security.html#logout-on-user-change\n     */\n    public function isEqualTo(UserInterface $user): bool\n    {\n        if ($user instanceof self) {\n            if ($this->accessToken !== $user->getAccessToken()) {\n                return false;\n            }\n\n            if ($this->uuid !== $user->getUuid()) {\n                return false;\n            }\n        }\n\n        if ($this->username !== $user->getUserIdentifier()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    public function getUserIdentifier(): string\n    {\n        return $this->getUsername();\n    }\n}\n"
  },
  {
    "path": "src/Entity/Version.php",
    "content": "<?php\n\nnamespace App\\Entity;\n\nuse App\\Repository\\VersionRepository;\nuse Doctrine\\ORM\\Mapping as ORM;\n\n/**\n * Version\n * Which is an alias of Release (because RELEASE is a reserved keywords).\n */\n#[ORM\\Entity(repositoryClass: VersionRepository::class)]\n#[ORM\\Table(name: 'version')]\n#[ORM\\Index(name: 'created_at_idx', columns: ['created_at'])]\n#[ORM\\Index(name: 'tag_name_name_created_at_prerelease_repo_id', columns: ['tag_name', 'name', 'created_at', 'prerelease', 'repo_id'])]\n#[ORM\\UniqueConstraint(name: 'repo_version_unique', columns: ['repo_id', 'tag_name'])]\nclass Version\n{\n    /**\n     * @var int\n     */\n    #[ORM\\Column(name: 'id', type: 'integer')]\n    #[ORM\\Id]\n    #[ORM\\GeneratedValue(strategy: 'AUTO')]\n    private $id;\n\n    /**\n     * @var string\n     */\n    #[ORM\\Column(name: 'tag_name', type: 'string', length: 191)]\n    private $tagName;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'name', type: 'string', length: 191, nullable: true)]\n    private $name;\n\n    /**\n     * @var bool\n     */\n    #[ORM\\Column(name: 'prerelease', type: 'boolean')]\n    private $prerelease;\n\n    /**\n     * @var \\DateTime\n     */\n    #[ORM\\Column(name: 'created_at', type: 'datetime')]\n    private $createdAt;\n\n    /**\n     * @var string|null\n     */\n    #[ORM\\Column(name: 'body', type: 'text', nullable: true)]\n    private $body;\n\n    public function __construct(\n        #[ORM\\ManyToOne(targetEntity: Repo::class, inversedBy: 'versions')]\n        #[ORM\\JoinColumn(name: 'repo_id', referencedColumnName: 'id', nullable: false)]\n        private readonly Repo $repo,\n    ) {\n    }\n\n    /**\n     * Get id.\n     *\n     * @return int\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * Set tagName.\n     *\n     * @param string $tagName\n     *\n     * @return Version\n     */\n    public function setTagName($tagName)\n    {\n        $this->tagName = $tagName;\n\n        return $this;\n    }\n\n    /**\n     * Get tagName.\n     *\n     * @return string\n     */\n    public function getTagName()\n    {\n        return $this->tagName;\n    }\n\n    /**\n     * Set name.\n     *\n     * @param string $name\n     *\n     * @return Version\n     */\n    public function setName($name)\n    {\n        // hard truncate name\n        if (mb_strlen($name) > 190) {\n            $name = mb_substr($name, 0, 190);\n        }\n\n        $this->name = $name;\n\n        return $this;\n    }\n\n    /**\n     * Get name.\n     *\n     * @return string\n     */\n    public function getName()\n    {\n        return (string) $this->name;\n    }\n\n    /**\n     * Set prerelease.\n     *\n     * @param bool $prerelease\n     *\n     * @return Version\n     */\n    public function setPrerelease($prerelease)\n    {\n        $this->prerelease = $prerelease;\n\n        return $this;\n    }\n\n    /**\n     * Get prerelease.\n     *\n     * @return bool\n     */\n    public function getPrerelease()\n    {\n        return $this->prerelease;\n    }\n\n    /**\n     * Set createdAt.\n     *\n     * @param \\DateTime $createdAt\n     *\n     * @return Version\n     */\n    public function setCreatedAt($createdAt)\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    /**\n     * Get createdAt.\n     *\n     * @return \\DateTime\n     */\n    public function getCreatedAt()\n    {\n        return $this->createdAt;\n    }\n\n    /**\n     * Set body.\n     *\n     * @param string $body\n     *\n     * @return Version\n     */\n    public function setBody($body)\n    {\n        $this->body = $body;\n\n        return $this;\n    }\n\n    /**\n     * Get body.\n     *\n     * @return string\n     */\n    public function getBody()\n    {\n        return (string) $this->body;\n    }\n\n    public function hydrateFromGithub(array $data): void\n    {\n        $this->setTagName($data['tag_name']);\n        $this->setName($data['name']);\n        $this->setPrerelease($data['prerelease']);\n        $this->setCreatedAt((new \\DateTime())->setTimestamp(strtotime((string) $data['published_at'])));\n        $this->setBody($data['message']);\n    }\n}\n"
  },
  {
    "path": "src/Github/ClientDiscovery.php",
    "content": "<?php\n\nnamespace App\\Github;\n\nuse App\\Cache\\CustomRedisCachePool;\nuse App\\Repository\\UserRepository;\nuse Github\\AuthMethod;\nuse Github\\Client as GithubClient;\nuse Predis\\Client as RedisClient;\nuse Psr\\Log\\LoggerInterface;\n\n/**\n * This class aim to find the best authenticated method to avoid hitting the Github rate limit.\n * We first try with the default application authentication.\n * And if it fails, we'll try each user until we find one with enough rate limit.\n * In fact, the more user in database, the bigger chance to never hit the rate limit.\n */\nclass ClientDiscovery\n{\n    use RateLimitTrait;\n\n    public const THRESHOLD_RATE_REMAIN_APP = 200;\n    public const THRESHOLD_RATE_REMAIN_USER = 2000;\n    private $client;\n\n    public function __construct(private UserRepository $userRepository, private RedisClient $redis, private string $clientId, private string $clientSecret, private LoggerInterface $logger)\n    {\n        $this->client = new GithubClient();\n    }\n\n    /**\n     * Allow to override Github client.\n     * Only used in test.\n     */\n    public function setGithubClient(GithubClient $client): void\n    {\n        $this->client = $client;\n    }\n\n    /**\n     * Find the best authentication to use:\n     *     - check the rate limit of the application default client (which should be used in most case)\n     *     - if the rate limit is too low for the application client, loop on all user to check their rate limit\n     *     - if none client have enough rate limit, we'll have a problem to perform further request, stop every thing !\n     *\n     * @return GithubClient|null\n     */\n    public function find()\n    {\n        // attache the cache in anycase\n        $this->client->addCache(\n            new CustomRedisCachePool($this->redis),\n            [\n                // the default config include \"private\" to avoid caching request with this header\n                // since we can use a user token, Github will return a \"private\" but we want to cache that request\n                // it's safe because we don't require critical user value\n                'respect_response_cache_directives' => ['no-cache', 'max-age', 'no-store'],\n            ]\n        );\n\n        // try with the application default client\n        $this->client->authenticate($this->clientId, $this->clientSecret, AuthMethod::CLIENT_ID);\n\n        $remaining = $this->getRateLimits($this->client, $this->logger);\n        if ($remaining >= self::THRESHOLD_RATE_REMAIN_APP) {\n            $this->logger->notice('RateLimit ok (' . $remaining . ') with default application');\n\n            return $this->client;\n        }\n\n        // if it doesn't work, try with all user tokens\n        // when at least one is ok, use it!\n        $users = $this->userRepository->findAllTokens();\n        foreach ($users as $user) {\n            $this->client->authenticate($user['accessToken'], null, AuthMethod::ACCESS_TOKEN);\n\n            $remaining = $this->getRateLimits($this->client, $this->logger);\n            if ($remaining >= self::THRESHOLD_RATE_REMAIN_USER) {\n                $this->logger->notice('RateLimit ok (' . $remaining . ') with user: ' . $user['username']);\n\n                return $this->client;\n            }\n        }\n\n        $this->logger->warning('No way to authenticate a client with enough rate limit remaining :(');\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Github/RateLimitTrait.php",
    "content": "<?php\n\nnamespace App\\Github;\n\nuse Github\\Api\\RateLimit;\nuse Github\\Client;\nuse Http\\Client\\Exception\\HttpException;\nuse Psr\\Log\\LoggerInterface;\n\ntrait RateLimitTrait\n{\n    /**\n     * Retrieve rate limit for the given authenticated client.\n     * It's in a separate method to be able to catch error in case of glimpse on the Github side.\n     *\n     * @return false|int\n     */\n    private function getRateLimits(Client $client, LoggerInterface $logger)\n    {\n        try {\n            /** @var RateLimit */\n            $rateLimit = $client->api('rate_limit');\n\n            $rateLimitResource = $rateLimit->getResource('core');\n\n            if (false === $rateLimitResource) {\n                throw new \\Exception('Unable to retrieve \"core\" resource from RateLimitTrait');\n            }\n\n            return $rateLimitResource->getRemaining();\n        } catch (HttpException $e) {\n            $logger->error('RateLimit call goes bad.', ['exception' => $e]);\n\n            return false;\n        } catch (\\Exception $e) {\n            $logger->error('RateLimit call goes REALLY bad.', ['exception' => $e]);\n\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Kernel.php",
    "content": "<?php\n\nnamespace App;\n\nuse Symfony\\Bundle\\FrameworkBundle\\Kernel\\MicroKernelTrait;\nuse Symfony\\Component\\HttpKernel\\Kernel as BaseKernel;\n\nclass Kernel extends BaseKernel\n{\n    use MicroKernelTrait;\n}\n"
  },
  {
    "path": "src/Message/StarredReposSync.php",
    "content": "<?php\n\nnamespace App\\Message;\n\nclass StarredReposSync\n{\n    public function __construct(private readonly int $userId)\n    {\n    }\n\n    public function getUserId(): int\n    {\n        return $this->userId;\n    }\n}\n"
  },
  {
    "path": "src/Message/VersionsSync.php",
    "content": "<?php\n\nnamespace App\\Message;\n\nclass VersionsSync\n{\n    public function __construct(private readonly int $repoId)\n    {\n    }\n\n    public function getRepoId(): int\n    {\n        return $this->repoId;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/StarredReposSyncHandler.php",
    "content": "<?php\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Star;\nuse App\\Entity\\User;\nuse App\\Github\\RateLimitTrait;\nuse App\\Message\\StarredReposSync;\nuse App\\Repository\\RepoRepository;\nuse App\\Repository\\StarRepository;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManager;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Github\\Client;\nuse Github\\Exception\\RuntimeException;\nuse Predis\\ClientInterface as RedisClientInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n/**\n * Consumer message to sync starred repos from user.\n *\n * It might come from:\n *     - when user logged in\n *     - when we periodically sync user starred repos\n */\n#[AsMessageHandler]\nclass StarredReposSyncHandler\n{\n    use RateLimitTrait;\n\n    public const DAYS_SINCE_LAST_UPDATE = 1;\n\n    /**\n     * Client parameter can be null when no available client were found by the Github Client Discovery.\n     */\n    public function __construct(private ManagerRegistry $doctrine, private UserRepository $userRepository, private StarRepository $starRepository, private RepoRepository $repoRepository, private ?Client $client, private LoggerInterface $logger, private RedisClientInterface $redis)\n    {\n    }\n\n    public function __invoke(StarredReposSync $message): bool\n    {\n        // in case no client with safe RateLimit were found\n        if (null === $this->client) {\n            $this->logger->error('No client provided');\n\n            return false;\n        }\n\n        $userId = $message->getUserId();\n\n        /** @var User|null */\n        $user = $this->userRepository->find($userId);\n\n        if (null === $user) {\n            $this->logger->error('Can not find user', ['user' => $userId]);\n\n            return false;\n        }\n\n        // to be able to notify user about repos sync (will be remove after 1h to avoid infinite sync notification)\n        $this->redis->setex('banditore:user-sync:' . $user->getId(), 3600, time());\n\n        $this->logger->info('Consume banditore.sync_starred_repos message', ['user' => $user->getUsername()]);\n\n        $rateLimit = $this->getRateLimits($this->client, $this->logger);\n\n        $this->logger->info('[' . $rateLimit . '] Check <info>' . $user->getUsername() . '</info> … ');\n\n        if (0 === $rateLimit || false === $rateLimit) {\n            $this->logger->warning('RateLimit reached, stopping.');\n\n            return false;\n        }\n\n        // this shouldn't be catched so the worker will die when an exception is thrown\n        $nbRepos = $this->doSyncRepo($user);\n\n        $this->logger->notice('[' . $this->getRateLimits($this->client, $this->logger) . '] Synced repos: ' . $nbRepos, ['user' => $user->getUsername()]);\n\n        // sync is done, remove notification\n        $this->redis->del(['banditore:user-sync:' . $user->getId()]);\n\n        return true;\n    }\n\n    /**\n     * Do the job to sync repo & star of a user.\n     *\n     * @param User $user User to work on\n     */\n    private function doSyncRepo(User $user): ?int\n    {\n        $newStars = [];\n        $page = 1;\n        $perPage = 100;\n\n        /** @var EntityManager */\n        $em = $this->doctrine->getManager();\n\n        /** @var \\Github\\Api\\User */\n        $githubUserApi = $this->client->api('user');\n\n        // in case of the manager is closed following a previous exception\n        if (!$em->isOpen()) {\n            /** @var EntityManager */\n            $em = $this->doctrine->resetManager();\n        }\n\n        try {\n            $starredRepos = $githubUserApi->starred($user->getUsername(), $page, $perPage);\n        } catch (\\Exception $e) {\n            $this->logger->warning('(starred) <error>' . $e->getMessage() . '</error>');\n\n            // user got removed from GitHub\n            if (404 === $e->getCode()) {\n                $user->setRemovedAt(new \\DateTime());\n                $em->persist($user);\n            }\n\n            return null;\n        }\n\n        $currentStars = $this->starRepository->findAllByUser($user->getId());\n\n        do {\n            $this->logger->info('    sync ' . \\count($starredRepos) . ' starred repos', [\n                'user' => $user->getUsername(),\n                'rate' => $this->getRateLimits($this->client, $this->logger),\n            ]);\n\n            foreach ($starredRepos as $starredRepo) {\n                /** @var Repo|null */\n                $repo = $this->repoRepository->find($starredRepo['id']);\n\n                // if repo doesn't exist\n                // OR repo doesn't get updated since XX days\n                if (null === $repo || $repo->getUpdatedAt()->diff(new \\DateTime())->days > self::DAYS_SINCE_LAST_UPDATE) {\n                    if (null === $repo) {\n                        $repo = new Repo();\n                    }\n\n                    $repo->hydrateFromGithub($starredRepo);\n                    $em->persist($repo);\n                }\n\n                // store current repo id to compare it later when we'll sync removed star\n                // using `id` instead of `full_name` to be more accurated (full_name can change)\n                $newStars[] = $repo->getId();\n\n                if (false === \\in_array($repo->getId(), $currentStars, true)) {\n                    $star = new Star($user, $repo);\n\n                    $em->persist($star);\n                }\n            }\n\n            $em->flush();\n\n            try {\n                $starredRepos = $githubUserApi->starred($user->getUsername(), ++$page, $perPage);\n            } catch (RuntimeException) {\n                // api limit is reached or whatever other error, we'll try next time\n                return null;\n            }\n        } while (!empty($starredRepos));\n\n        // now remove unstarred repos\n        $this->doCleanOldStar($user, $newStars);\n\n        return \\count($newStars);\n    }\n\n    /**\n     * Clean old star.\n     * When user unstar a repo we also need to remove that association.\n     *\n     * @param array $newStars Current starred repos Id of the user\n     */\n    private function doCleanOldStar(User $user, array $newStars): void\n    {\n        $currentStars = $this->starRepository->findAllByUser($user->getId());\n        $repoIdsToRemove = array_diff($currentStars, $newStars);\n\n        if (!empty($repoIdsToRemove)) {\n            $this->logger->info('Removed stars: ' . \\count($repoIdsToRemove), ['user' => $user->getUsername()]);\n\n            $this->starRepository->removeFromUser($repoIdsToRemove, $user->getId());\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/VersionsSyncHandler.php",
    "content": "<?php\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Version;\nuse App\\Github\\RateLimitTrait;\nuse App\\Message\\VersionsSync;\nuse App\\PubSubHubbub\\Publisher;\nuse App\\Repository\\RepoRepository;\nuse App\\Repository\\VersionRepository;\nuse Doctrine\\ORM\\EntityManager;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Github\\Api\\GitData;\nuse Github\\Api\\Markdown;\nuse Github\\Client;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n/**\n * Consumer message to sync new version from a given repo.\n */\n#[AsMessageHandler]\nclass VersionsSyncHandler\n{\n    use RateLimitTrait;\n\n    /**\n     * Client parameter can be null when no available client were found by the Github Client Discovery.\n     */\n    public function __construct(private ManagerRegistry $doctrine, private RepoRepository $repoRepository, private VersionRepository $versionRepository, private Publisher $pubsubhubbub, private ?Client $client, private LoggerInterface $logger)\n    {\n    }\n\n    public function __invoke(VersionsSync $message): bool\n    {\n        // in case no client with safe RateLimit were found\n        if (null === $this->client) {\n            $this->logger->error('No client provided');\n\n            return false;\n        }\n\n        $repoId = $message->getRepoId();\n\n        /** @var Repo|null */\n        $repo = $this->repoRepository->find($repoId);\n\n        if (null === $repo) {\n            $this->logger->error('Can not find repo', ['repo' => $repoId]);\n\n            return false;\n        }\n\n        $this->logger->info('Consume banditore.sync_versions message', ['repo' => $repo->getFullName()]);\n\n        $rateLimit = $this->getRateLimits($this->client, $this->logger);\n\n        $this->logger->info('[' . $rateLimit . '] Check <info>' . $repo->getFullName() . '</info> … ');\n\n        if (0 === $rateLimit || false === $rateLimit) {\n            $this->logger->warning('RateLimit reached, stopping.');\n\n            return false;\n        }\n\n        // this shouldn't be catched so the worker will die when an exception is thrown\n        $nbVersions = $this->doSyncVersions($repo);\n\n        // notify pubsubhubbub for that repo\n        if ($nbVersions > 0) {\n            $this->pubsubhubbub->pingHub([$repoId]);\n        }\n\n        $this->logger->notice('[' . $this->getRateLimits($this->client, $this->logger) . '] <comment>' . $nbVersions . '</comment> new versions for <info>' . $repo->getFullName() . '</info>');\n\n        return true;\n    }\n\n    /**\n     * Do the job to sync repo & star of a user.\n     *\n     * @param Repo $repo Repo to work on\n     */\n    private function doSyncVersions(Repo $repo): ?int\n    {\n        $newVersion = 0;\n\n        /** @var EntityManager */\n        $em = $this->doctrine->getManager();\n\n        /** @var \\Github\\Api\\Repo */\n        $githubRepoApi = $this->client->api('repo');\n\n        // in case of the manager is closed following a previous exception\n        if (!$em->isOpen()) {\n            /** @var EntityManager */\n            $em = $this->doctrine->resetManager();\n        }\n\n        [$username, $repoName] = explode('/', $repo->getFullName());\n\n        // this is a simple call to retrieve at least one tag from the selected repo\n        // using git/refs/tags when repo has no tag throws a 404 which can't be cached\n        // this query return an empty array when repo has no tag and it can be cached\n        try {\n            $tags = $githubRepoApi->tags($username, $repoName, ['per_page' => 1, 'page' => 1]);\n        } catch (\\Exception $e) {\n            $this->logger->warning('(repo/tags) <error>' . $e->getMessage() . '</error>');\n\n            // repo not found OR access blocked? Ignore it in future loops\n            if (404 === $e->getCode() || 451 === $e->getCode()) {\n                $repo->setRemovedAt(new \\DateTime());\n                $em->persist($repo);\n            }\n\n            return null;\n        }\n\n        if (empty($tags)) {\n            return $newVersion;\n        }\n\n        // use git/refs/tags because tags aren't order by date creation (so we retrieve ALL tags every time …)\n        try {\n            /** @var GitData */\n            $githubGitApi = $this->client->api('git');\n\n            $tags = $githubGitApi->tags()->all($username, $repoName);\n        } catch (\\Exception $e) {\n            $this->logger->warning('(git/refs/tags) <error>' . $e->getMessage() . '</error>');\n\n            return null;\n        }\n\n        foreach ($tags as $tag) {\n            // it'll be like `refs/tags/2.2.1`\n            $tag['name'] = str_replace('refs/tags/', '', $tag['ref']);\n            $version = $this->versionRepository->findExistingOne($tag['name'], $repo->getId());\n\n            if (null !== $version) {\n                continue;\n            }\n\n            // check for scheduled version to be persisted later\n            // in rare case where the tag name is almost equal, like \"v1.1.0\" & \"V1.1.0\" in might avoid error\n            foreach ($em->getUnitOfWork()->getScheduledEntityInsertions() as $entity) {\n                if ($entity instanceof Version && strtolower($entity->getTagName()) === strtolower($tag['name'])) {\n                    $this->logger->info($tag['name'] . ' skipped because it seems to be already scheduled');\n\n                    continue 2;\n                }\n            }\n\n            // is there an associated release?\n            $newRelease = [\n                'tag_name' => $tag['name'],\n            ];\n\n            try {\n                $newRelease = $githubRepoApi->releases()->tag($username, $repoName, $tag['name']);\n\n                // use same key as tag to store the content of the release\n                $newRelease['message'] = $newRelease['body'];\n            } catch (\\Exception $e) { // it should be `Github\\Exception\\RuntimeException` but I can't reproduce this exception in test :-(\n                // when a tag isn't a release, it'll be catched here\n                switch ($tag['object']['type']) {\n                    // https://api.github.com/repos/ampproject/amphtml/git/tags/694b8cc3983f52209029605300910507bec700b4\n                    case 'tag':\n                        $tagInfo = $githubGitApi->tags()->show($username, $repoName, $tag['object']['sha']);\n\n                        $newRelease += [\n                            'name' => $tag['name'],\n                            'prerelease' => false,\n                            'published_at' => $tagInfo['tagger']['date'],\n                            'message' => $tagInfo['message'],\n                        ];\n                        break;\n                        // https://api.github.com/repos/ampproject/amphtml/git/commits/c0a5834b32ae4b45b4bacf677c391e0f9cca82fb\n                    case 'commit':\n                        $commitInfo = $githubGitApi->commits()->show($username, $repoName, $tag['object']['sha']);\n\n                        $newRelease += [\n                            'name' => $tag['name'],\n                            'prerelease' => false,\n                            'published_at' => $commitInfo['author']['date'],\n                            'message' => $commitInfo['message'],\n                        ];\n                        break;\n                    case 'blob':\n                        $blobInfo = $githubGitApi->blobs()->show($username, $repoName, $tag['object']['sha']);\n\n                        $newRelease += [\n                            'name' => $tag['name'],\n                            'prerelease' => false,\n                            // we can't retrieve a date for a blob tag, sadly.\n                            'published_at' => date('c'),\n                            'message' => '(blob, size ' . $blobInfo['size'] . ') ' . base64_decode((string) $blobInfo['content'], true),\n                        ];\n                        break;\n                    default:\n                        $this->logger->error('<error>Tag object type not supported: ' . $tag['object']['type'] . ' (for: ' . $repo->getFullName() . ')</error>');\n\n                        continue 2;\n                }\n\n                $newRelease['message'] = $this->removePgpSignature((string) $newRelease['message']);\n            }\n\n            // render markdown in plain html and use default markdown file if it fails\n            if (isset($newRelease['message']) && '' !== trim($newRelease['message'])) {\n                try {\n                    /** @var Markdown */\n                    $githubMarkdownApi = $this->client->api('markdown');\n\n                    $newRelease['message'] = $githubMarkdownApi->render($newRelease['message'], 'gfm', $repo->getFullName());\n                } catch (\\Exception $e) {\n                    $this->logger->warning('<error>Failed to parse markdown: ' . $e->getMessage() . '</error>');\n\n                    // it is usually a problem from the abuse detection mechanism, to avoid multiple call, we just skip to the next repo\n                    return $newVersion;\n                }\n            }\n\n            $version = new Version($repo);\n            $version->hydrateFromGithub($newRelease);\n\n            $em->persist($version);\n\n            ++$newVersion;\n\n            // for big repos, flush every 200 versions in case of hitting rate limit\n            if (0 === ($newVersion % 200)) {\n                $em->flush();\n            }\n        }\n\n        $em->flush();\n\n        return $newVersion;\n    }\n\n    /**\n     * Remove PGP signature from commit / tag.\n     */\n    private function removePgpSignature(string $message): string\n    {\n        $pos = stripos($message, '-----BEGIN PGP SIGNATURE-----');\n        if ($pos) {\n            return trim(substr($message, 0, $pos));\n        }\n\n        return $message;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Exception/CallbackNotFoundException.php",
    "content": "<?php\n\nnamespace App\\Pagination\\Exception;\n\n/**\n * Class CallbackNotFoundException.\n *\n * @author Ashley Dawson <ashley@ashleydawson.co.uk>\n */\nclass CallbackNotFoundException extends \\RuntimeException\n{\n}\n"
  },
  {
    "path": "src/Pagination/Exception/InvalidPageNumberException.php",
    "content": "<?php\n\nnamespace App\\Pagination\\Exception;\n\n/**\n * Class InvalidPageNumberException.\n *\n * @author Ashley Dawson <ashley@ashleydawson.co.uk>\n */\nclass InvalidPageNumberException extends \\InvalidArgumentException\n{\n}\n"
  },
  {
    "path": "src/Pagination/Pagination.php",
    "content": "<?php\n\nnamespace App\\Pagination;\n\n/**\n * Class Pagination.\n *\n * @implements \\IteratorAggregate<int, mixed>\n *\n * @author Ashley Dawson <ashley@ashleydawson.co.uk>\n */\nclass Pagination implements \\IteratorAggregate, \\Countable\n{\n    private array $items = [];\n    private array $pages = [];\n    private int $totalNumberOfPages = 0;\n    private int $currentPageNumber = 0;\n    private int $firstPageNumber = 0;\n    private int $lastPageNumber = 0;\n    private int $previousPageNumber = 0;\n    private int $nextPageNumber = 0;\n    private int $itemsPerPage = 0;\n    private int $totalNumberOfItems = 0;\n    private int $firstPageNumberInRange = 0;\n    private int $lastPageNumberInRange = 0;\n\n    /**\n     * Get items.\n     */\n    public function getItems(): array\n    {\n        return $this->items;\n    }\n\n    /**\n     * Set items.\n     *\n     * @return $this\n     */\n    public function setItems(array $items)\n    {\n        $this->items = $items;\n\n        return $this;\n    }\n\n    /**\n     * Get currentPageNumber.\n     *\n     * @return int\n     */\n    public function getCurrentPageNumber()\n    {\n        return $this->currentPageNumber;\n    }\n\n    /**\n     * Set currentPageNumber.\n     *\n     * @param int $currentPageNumber\n     *\n     * @return $this\n     */\n    public function setCurrentPageNumber($currentPageNumber)\n    {\n        $this->currentPageNumber = $currentPageNumber;\n\n        return $this;\n    }\n\n    /**\n     * Get firstPageNumber.\n     *\n     * @return int\n     */\n    public function getFirstPageNumber()\n    {\n        return $this->firstPageNumber;\n    }\n\n    /**\n     * Set firstPageNumber.\n     *\n     * @param int $firstPageNumber\n     *\n     * @return $this\n     */\n    public function setFirstPageNumber($firstPageNumber)\n    {\n        $this->firstPageNumber = $firstPageNumber;\n\n        return $this;\n    }\n\n    /**\n     * Get firstPageNumberInRange.\n     *\n     * @return int\n     */\n    public function getFirstPageNumberInRange()\n    {\n        return $this->firstPageNumberInRange;\n    }\n\n    /**\n     * Set firstPageNumberInRange.\n     *\n     * @param int $firstPageNumberInRange\n     *\n     * @return $this\n     */\n    public function setFirstPageNumberInRange($firstPageNumberInRange)\n    {\n        $this->firstPageNumberInRange = $firstPageNumberInRange;\n\n        return $this;\n    }\n\n    /**\n     * Get itemsPerPage.\n     *\n     * @return int\n     */\n    public function getItemsPerPage()\n    {\n        return $this->itemsPerPage;\n    }\n\n    /**\n     * Set itemsPerPage.\n     *\n     * @param int $itemsPerPage\n     *\n     * @return $this\n     */\n    public function setItemsPerPage($itemsPerPage)\n    {\n        $this->itemsPerPage = $itemsPerPage;\n\n        return $this;\n    }\n\n    /**\n     * Get lastPageNumber.\n     *\n     * @return int\n     */\n    public function getLastPageNumber()\n    {\n        return $this->lastPageNumber;\n    }\n\n    /**\n     * Set lastPageNumber.\n     *\n     * @param int $lastPageNumber\n     *\n     * @return $this\n     */\n    public function setLastPageNumber($lastPageNumber)\n    {\n        $this->lastPageNumber = $lastPageNumber;\n\n        return $this;\n    }\n\n    /**\n     * Get lastPageNumberInRange.\n     *\n     * @return int\n     */\n    public function getLastPageNumberInRange()\n    {\n        return $this->lastPageNumberInRange;\n    }\n\n    /**\n     * Set lastPageNumberInRange.\n     *\n     * @param int $lastPageNumberInRange\n     *\n     * @return $this\n     */\n    public function setLastPageNumberInRange($lastPageNumberInRange)\n    {\n        $this->lastPageNumberInRange = $lastPageNumberInRange;\n\n        return $this;\n    }\n\n    /**\n     * Get nextPageNumber.\n     *\n     * @return int\n     */\n    public function getNextPageNumber()\n    {\n        return $this->nextPageNumber;\n    }\n\n    /**\n     * Set nextPageNumber.\n     *\n     * @param int $nextPageNumber\n     *\n     * @return $this\n     */\n    public function setNextPageNumber($nextPageNumber)\n    {\n        $this->nextPageNumber = $nextPageNumber;\n\n        return $this;\n    }\n\n    /**\n     * Get pages.\n     *\n     * @return array\n     */\n    public function getPages()\n    {\n        return $this->pages;\n    }\n\n    /**\n     * Set pages.\n     *\n     * @return $this\n     */\n    public function setPages(array $pages)\n    {\n        $this->pages = $pages;\n\n        return $this;\n    }\n\n    /**\n     * Get previousPageNumber.\n     *\n     * @return int\n     */\n    public function getPreviousPageNumber()\n    {\n        return $this->previousPageNumber;\n    }\n\n    /**\n     * Set previousPageNumber.\n     *\n     * @param int $previousPageNumber\n     *\n     * @return $this\n     */\n    public function setPreviousPageNumber($previousPageNumber)\n    {\n        $this->previousPageNumber = $previousPageNumber;\n\n        return $this;\n    }\n\n    /**\n     * Get totalNumberOfItems.\n     *\n     * @return int\n     */\n    public function getTotalNumberOfItems()\n    {\n        return $this->totalNumberOfItems;\n    }\n\n    /**\n     * Set totalNumberOfItems.\n     *\n     * @param int $totalNumberOfItems\n     *\n     * @return $this\n     */\n    public function setTotalNumberOfItems($totalNumberOfItems)\n    {\n        $this->totalNumberOfItems = $totalNumberOfItems;\n\n        return $this;\n    }\n\n    /**\n     * Get totalNumberOfPages.\n     *\n     * @return int\n     */\n    public function getTotalNumberOfPages()\n    {\n        return $this->totalNumberOfPages;\n    }\n\n    /**\n     * Set totalNumberOfPages.\n     *\n     * @param int $totalNumberOfPages\n     *\n     * @return $this\n     */\n    public function setTotalNumberOfPages($totalNumberOfPages)\n    {\n        $this->totalNumberOfPages = $totalNumberOfPages;\n\n        return $this;\n    }\n\n    public function getIterator(): \\Traversable\n    {\n        return new \\ArrayIterator($this->items);\n    }\n\n    public function count(): int\n    {\n        return \\count($this->items);\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Paginator.php",
    "content": "<?php\n\nnamespace App\\Pagination;\n\nuse App\\Pagination\\Exception\\CallbackNotFoundException;\nuse App\\Pagination\\Exception\\InvalidPageNumberException;\n\n/**\n * Class Paginator.\n *\n * @author Ashley Dawson <ashley@ashleydawson.co.uk>\n */\nclass Paginator implements PaginatorInterface\n{\n    /**\n     * @var \\Closure\n     */\n    private $itemTotalCallback;\n\n    /**\n     * @var \\Closure\n     */\n    private $sliceCallback;\n\n    /**\n     * @var \\Closure\n     */\n    private $beforeQueryCallback;\n\n    /**\n     * @var \\Closure\n     */\n    private $afterQueryCallback;\n\n    /**\n     * @var int\n     */\n    private $itemsPerPage = 10;\n\n    /**\n     * @var int\n     */\n    private $pagesInRange = 5;\n\n    /**\n     * Constructor - passing optional configuration.\n     *\n     * <code>\n     * $paginator = new Paginator(array(\n     *     'itemTotalCallback' => function () {\n     *         // ...\n     *     },\n     *     'sliceCallback' => function ($offset, $length) {\n     *         // ...\n     *     },\n     *     'itemsPerPage' => 10,\n     *     'pagesInRange' => 5\n     * ));\n     * </code>\n     */\n    public function __construct(?array $config = null)\n    {\n        if (\\array_key_exists('itemTotalCallback', $config)) {\n            $this->setItemTotalCallback($config['itemTotalCallback']);\n        }\n        if (\\array_key_exists('sliceCallback', $config)) {\n            $this->setSliceCallback($config['sliceCallback']);\n        }\n        if (\\array_key_exists('itemsPerPage', $config)) {\n            $this->setItemsPerPage($config['itemsPerPage']);\n        }\n        if (\\array_key_exists('pagesInRange', $config)) {\n            $this->setPagesInRange($config['pagesInRange']);\n        }\n    }\n\n    public function paginate($currentPageNumber = 1)\n    {\n        if (!$this->itemTotalCallback instanceof \\Closure) {\n            throw new CallbackNotFoundException('Item total callback not found, set it using Paginator::setItemTotalCallback()');\n        }\n\n        if (!$this->sliceCallback instanceof \\Closure) {\n            throw new CallbackNotFoundException('Slice callback not found, set it using Paginator::setSliceCallback()');\n        }\n\n        if (!\\is_int($currentPageNumber)) {\n            throw new \\InvalidArgumentException(\\sprintf('Current page number must be of type integer, %s given', \\gettype($currentPageNumber)));\n        }\n\n        if ($currentPageNumber <= 0) {\n            throw new InvalidPageNumberException(\\sprintf('Current page number must have a value of 1 or more, %s given', $currentPageNumber));\n        }\n\n        $beforeQueryCallback = $this->beforeQueryCallback instanceof \\Closure\n            ? $this->beforeQueryCallback\n            : static function (): void {}\n        ;\n\n        $afterQueryCallback = $this->afterQueryCallback instanceof \\Closure\n            ? $this->afterQueryCallback\n            : static function (): void {}\n        ;\n\n        $pagination = new Pagination();\n\n        $itemTotalCallback = $this->itemTotalCallback;\n\n        $beforeQueryCallback($this, $pagination);\n        $totalNumberOfItems = (int) $itemTotalCallback($pagination);\n        $afterQueryCallback($this, $pagination);\n\n        $numberOfPages = (int) ceil($totalNumberOfItems / $this->itemsPerPage);\n        $pagesInRange = $this->pagesInRange;\n\n        if ($pagesInRange > $numberOfPages) {\n            $pagesInRange = $numberOfPages;\n        }\n\n        $change = (int) ceil($pagesInRange / 2);\n\n        if (($currentPageNumber - $change) > ($numberOfPages - $pagesInRange)) {\n            $pages = range(($numberOfPages - $pagesInRange) + 1, $numberOfPages);\n        } else {\n            if (($currentPageNumber - $change) < 0) {\n                $change = $currentPageNumber;\n            }\n            $offset = $currentPageNumber - $change;\n            $pages = range($offset + 1, $offset + $pagesInRange);\n        }\n\n        $offset = ($currentPageNumber - 1) * $this->itemsPerPage;\n\n        $sliceCallback = $this->sliceCallback;\n\n        $beforeQueryCallback($this, $pagination);\n        if (-1 === $this->itemsPerPage) {\n            $items = $sliceCallback(0, 999999999, $pagination);\n        } else {\n            $items = $sliceCallback($offset, $this->itemsPerPage, $pagination);\n        }\n        if ($items instanceof \\Iterator) {\n            $items = iterator_to_array($items);\n        }\n        $afterQueryCallback($this, $pagination);\n\n        $pagination\n            ->setItems($items)\n            ->setPages($pages)\n            ->setTotalNumberOfPages($numberOfPages)\n            ->setCurrentPageNumber($currentPageNumber)\n            ->setFirstPageNumber(1)\n            ->setLastPageNumber($numberOfPages)\n            ->setItemsPerPage($this->itemsPerPage)\n            ->setTotalNumberOfItems($totalNumberOfItems)\n            ->setFirstPageNumberInRange(min($pages))\n            ->setLastPageNumberInRange(max($pages))\n        ;\n\n        $previousPageNumber = null;\n        if (($currentPageNumber - 1) > 0) {\n            $pagination->setPreviousPageNumber($currentPageNumber - 1);\n        }\n\n        $nextPageNumber = null;\n        if (($currentPageNumber + 1) <= $numberOfPages) {\n            $pagination->setNextPageNumber($currentPageNumber + 1);\n        }\n\n        return $pagination;\n    }\n\n    public function getSliceCallback()\n    {\n        return $this->sliceCallback;\n    }\n\n    public function setSliceCallback(\\Closure $sliceCallback)\n    {\n        $this->sliceCallback = $sliceCallback;\n\n        return $this;\n    }\n\n    public function getItemTotalCallback()\n    {\n        return $this->itemTotalCallback;\n    }\n\n    public function getBeforeQueryCallback()\n    {\n        return $this->beforeQueryCallback;\n    }\n\n    public function setBeforeQueryCallback(\\Closure $beforeQueryCallback)\n    {\n        $this->beforeQueryCallback = $beforeQueryCallback;\n\n        return $this;\n    }\n\n    public function getAfterQueryCallback()\n    {\n        return $this->afterQueryCallback;\n    }\n\n    public function setAfterQueryCallback(\\Closure $afterQueryCallback)\n    {\n        $this->afterQueryCallback = $afterQueryCallback;\n\n        return $this;\n    }\n\n    public function setItemTotalCallback(\\Closure $itemTotalCallback)\n    {\n        $this->itemTotalCallback = $itemTotalCallback;\n\n        return $this;\n    }\n\n    public function getItemsPerPage()\n    {\n        return $this->itemsPerPage;\n    }\n\n    public function setItemsPerPage($itemsPerPage)\n    {\n        if (!\\is_int($itemsPerPage)) {\n            throw new \\InvalidArgumentException(\\sprintf('Items per page must be of type integer, %s given', \\gettype($itemsPerPage)));\n        }\n\n        $this->itemsPerPage = $itemsPerPage;\n\n        return $this;\n    }\n\n    public function getPagesInRange()\n    {\n        return $this->pagesInRange;\n    }\n\n    public function setPagesInRange($pagesInRange)\n    {\n        if (!\\is_int($pagesInRange)) {\n            throw new \\InvalidArgumentException(\\sprintf('Pages in range must be of type integer, %s given', \\gettype($pagesInRange)));\n        }\n\n        $this->pagesInRange = $pagesInRange;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/PaginatorInterface.php",
    "content": "<?php\n\nnamespace App\\Pagination;\n\n/**\n * Interface PaginatorInterface.\n *\n * @author Ashley Dawson <ashley@ashleydawson.co.uk>\n */\ninterface PaginatorInterface\n{\n    /**\n     * Run paginate algorithm using the current page number.\n     *\n     * @param int $currentPageNumber Page number, usually passed from the current request\n     *\n     * @throws \\InvalidArgumentException\n     * @throws InvalidPageNumberException\n     *\n     * @return Pagination Collection of items returned by the slice callback with pagination meta information\n     */\n    public function paginate($currentPageNumber = 1);\n\n    /**\n     * Get sliceCallback.\n     *\n     * @return \\Closure\n     */\n    public function getSliceCallback();\n\n    /**\n     * Set sliceCallback.\n     *\n     * @return $this\n     */\n    public function setSliceCallback(\\Closure $sliceCallback);\n\n    /**\n     * Get itemTotalCallback.\n     *\n     * @return \\Closure\n     */\n    public function getItemTotalCallback();\n\n    /**\n     * Set itemTotalCallback.\n     *\n     * @return $this\n     */\n    public function setItemTotalCallback(\\Closure $itemTotalCallback);\n\n    /**\n     * @return \\Closure\n     */\n    public function getBeforeQueryCallback();\n\n    /**\n     * @return $this\n     */\n    public function setBeforeQueryCallback(\\Closure $beforeQueryCallback);\n\n    /**\n     * @return \\Closure\n     */\n    public function getAfterQueryCallback();\n\n    /**\n     * @return $this\n     */\n    public function setAfterQueryCallback(\\Closure $afterQueryCallback);\n\n    /**\n     * Get itemsPerPage.\n     *\n     * @return int\n     */\n    public function getItemsPerPage();\n\n    /**\n     * Set itemsPerPage.\n     *\n     * @param int $itemsPerPage\n     *\n     * @throws \\InvalidArgumentException\n     *\n     * @return $this\n     */\n    public function setItemsPerPage($itemsPerPage);\n\n    /**\n     * Get pagesInRange.\n     *\n     * @return int\n     */\n    public function getPagesInRange();\n\n    /**\n     * Set pagesInRange.\n     *\n     * @param int $pagesInRange\n     *\n     * @throws \\InvalidArgumentException\n     *\n     * @return $this\n     */\n    public function setPagesInRange($pagesInRange);\n}\n"
  },
  {
    "path": "src/PubSubHubbub/Publisher.php",
    "content": "<?php\n\nnamespace App\\PubSubHubbub;\n\nuse App\\Repository\\UserRepository;\nuse GuzzleHttp\\Client;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Routing\\RouterInterface;\n\n/**\n * Publish feed to pubsubhubbub.appspot.com.\n */\nclass Publisher\n{\n    /**\n     * Create a new publisher.\n     *\n     * @param string          $hub    A hub (url) to ping\n     * @param RouterInterface $router Symfony Router to generate the feed xml\n     * @param Client          $client Guzzle client to send the request\n     * @param string          $host   Host of the project (used to generate route from a command)\n     * @param string          $scheme Scheme of the project (used to generate route from a command)\n     */\n    public function __construct(protected $hub, protected RouterInterface $router, protected Client $client, protected UserRepository $userRepository, $host, $scheme)\n    {\n        // allow generating url from command to use the correct host/scheme (instead of http://localhost)\n        // @see http://symfony.com/doc/current/console/request_context.html\n        $context = $this->router->getContext();\n        $context->setHost($host);\n        $context->setScheme($scheme);\n    }\n\n    /**\n     * Ping available hub when new items are cached.\n     *\n     * http://nathangrigg.net/2012/09/real-time-publishing/\n     *\n     * @param array $repoIds Id of repo from the database\n     *\n     * @return bool\n     */\n    public function pingHub(array $repoIds)\n    {\n        if (empty($this->hub) || empty($repoIds)) {\n            return false;\n        }\n\n        $urls = $this->retrieveFeedUrls($repoIds);\n\n        // ping publisher\n        // https://github.com/pubsubhubbub/php-publisher/blob/master/library/Publisher.php\n        $params = 'hub.mode=publish';\n        foreach ($urls as $url) {\n            $params .= '&hub.url=' . $url;\n        }\n\n        $response = $this->client->post(\n            $this->hub,\n            [\n                'http_errors' => false,\n                'body' => $params,\n                'headers' => [\n                    'Content-Type' => 'application/x-www-form-urlencoded',\n                    'User-Agent' => 'Banditore/1.0',\n                ],\n            ]\n        );\n\n        // hub should response 204 if everything went fine\n        return !(204 !== $response->getStatusCode());\n    }\n\n    /**\n     * Retrieve user feed urls from a list of repository ids.\n     *\n     * @return array\n     */\n    private function retrieveFeedUrls(array $repoIds)\n    {\n        $users = $this->userRepository->findByRepoIds($repoIds);\n\n        $urls = [];\n        foreach ($users as $user) {\n            $urls[] = $this->router->generate(\n                'rss_user',\n                ['uuid' => $user['uuid']],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n\n        return $urls;\n    }\n}\n"
  },
  {
    "path": "src/Repository/RepoRepository.php",
    "content": "<?php\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Repo;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method Repo|null findOneByFullName(string $fullName)\n *\n * @extends ServiceEntityRepository<Repo>\n */\nclass RepoRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Repo::class);\n    }\n\n    /**\n     * Retrieve all repositories to be fetched for new release.\n     *\n     * @return array\n     */\n    public function findAllForRelease()\n    {\n        $data = $this->createQueryBuilder('r')\n            ->select('r.id')\n            ->where('r.removedAt IS NULL')\n            ->getQuery()\n            ->getArrayResult();\n\n        $return = [];\n        foreach ($data as $oneData) {\n            $return[] = $oneData['id'];\n        }\n\n        return $return;\n    }\n\n    /**\n     * Count total repos.\n     *\n     * @return int\n     */\n    public function countTotal()\n    {\n        return (int) $this->createQueryBuilder('r')\n            ->select('COUNT(r.id) as total')\n            ->getQuery()\n            ->getSingleScalarResult();\n    }\n\n    /**\n     * Retrieve repos with the most releases.\n     * Used for stats.\n     *\n     * @return array\n     */\n    public function mostVersionsPerRepo()\n    {\n        return $this->createQueryBuilder('r')\n            ->select('r.fullName', 'r.description', 'r.ownerAvatar')\n            ->addSelect('(SELECT COUNT(v.id)\n                FROM App\\Entity\\Version v\n                WHERE v.repo = r.id) AS total'\n            )\n            ->groupBy('r.fullName', 'r.description', 'r.ownerAvatar', 'total')\n            ->orderBy('total', 'desc')\n            ->setMaxResults(5)\n            ->getQuery()\n            ->getArrayResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/StarRepository.php",
    "content": "<?php\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Star;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Star>\n */\nclass StarRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Star::class);\n    }\n\n    /**\n     * Retrieve all repos starred by a user.\n     *\n     * @param int $userId User id\n     *\n     * @return array\n     */\n    public function findAllByUser($userId)\n    {\n        $repos = $this->createQueryBuilder('s')\n            ->select('r.id')\n            ->leftJoin('s.repo', 'r')\n            ->where('s.user = ' . $userId)\n            ->getQuery()\n            ->getArrayResult();\n\n        $res = [];\n        foreach ($repos as $repo) {\n            $res[] = $repo['id'];\n        }\n\n        return $res;\n    }\n\n    /**\n     * Remove stars for a user.\n     */\n    public function removeFromUser(array $repoIds, int $userId): void\n    {\n        $this->createQueryBuilder('s')\n            ->delete()\n            ->where('s.repo IN (:ids)')->setParameter('ids', $repoIds)\n            ->andWhere('s.user = :userId')->setParameter('userId', $userId)\n            ->getQuery()\n            ->execute();\n    }\n\n    /**\n     * Count total stars.\n     *\n     * @return int\n     */\n    public function countTotal()\n    {\n        return (int) $this->createQueryBuilder('s')\n            ->select('COUNT(s.id) as total')\n            ->getQuery()\n            ->getSingleScalarResult();\n    }\n\n    public function findOneByUserAndRepo(int $userId, int $repoId): ?Star\n    {\n        $stars = $this->createQueryBuilder('s')\n            ->leftJoin('s.repo', 'r')\n            ->where('s.user = :userId')->setParameter('userId', $userId)\n            ->andWhere('r.id = :repoId')->setParameter('repoId', $repoId)\n            ->setMaxResults(1)\n            ->getQuery()\n            ->getResult();\n\n        return $stars[0] ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserRepository.php",
    "content": "<?php\n\nnamespace App\\Repository;\n\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method User|null findOneByUsername(string $username)\n *\n * @extends ServiceEntityRepository<User>\n */\nclass UserRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, User::class);\n    }\n\n    /**\n     * Retrieve user.\n     *\n     * @return array\n     */\n    public function findByRepoIds(array $repoIds)\n    {\n        return $this->createQueryBuilder('u')\n            ->select('DISTINCT u.uuid')\n            ->leftJoin('u.stars', 's')\n            ->where('s.repo IN (:ids)')->setParameter('ids', $repoIds)\n            ->andWhere('s.ignoredInFeed = :ignoredInFeed')->setParameter('ignoredInFeed', false)\n            ->getQuery()\n            ->getArrayResult();\n    }\n\n    /**\n     * Retrieve all users to be synced.\n     * We only retrieve ids to be as fast as possible.\n     *\n     * @return array\n     */\n    public function findAllToSync()\n    {\n        $data = $this->createQueryBuilder('u')\n            ->select('u.id')\n            ->where('u.removedAt IS NULL')\n            ->getQuery()\n            ->getArrayResult();\n\n        $return = [];\n        foreach ($data as $oneData) {\n            $return[] = $oneData['id'];\n        }\n\n        return $return;\n    }\n\n    /**\n     * Retrieve all tokens available.\n     * This is used for the GithubClientDiscovery.\n     *\n     * @return array\n     */\n    public function findAllTokens()\n    {\n        return $this->createQueryBuilder('u')\n            ->select('u.id', 'u.username', 'u.accessToken')\n            ->where('u.removedAt IS NULL')\n            ->getQuery()\n            ->enableResultCache()\n            ->setResultCacheLifetime(10 * 60)\n            ->getArrayResult();\n    }\n\n    /**\n     * Count total users.\n     *\n     * @return int\n     */\n    public function countTotal()\n    {\n        return (int) $this->createQueryBuilder('u')\n            ->select('COUNT(u.id) as total')\n            ->getQuery()\n            ->getSingleScalarResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/VersionRepository.php",
    "content": "<?php\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Version;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\ORM\\AbstractQuery;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Version>\n */\nclass VersionRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Version::class);\n    }\n\n    /**\n     * Find one version for a given tag name and repo id.\n     * This is exactly the same as `findOneBy` but this one use a result cache.\n     * Version doesn't change after being inserted and since we check to many times for a version\n     * it's faster to store result in a cache.\n     *\n     * @param string $tagName Tag name to search, like v1.0.0\n     * @param int    $repoId  Repository ID\n     *\n     * @return int|null\n     */\n    public function findExistingOne($tagName, $repoId)\n    {\n        $query = $this->createQueryBuilder('v')\n            ->select('v.id')\n            ->where('v.repo = :repoId')->setParameter('repoId', $repoId)\n            ->andWhere('v.tagName = :tagName')->setParameter('tagName', $tagName)\n            ->setMaxResults(1)\n            ->getQuery()\n        ;\n\n        return $query->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR);\n    }\n\n    /**\n     * Find all versions available for the given user.\n     *\n     * @param int $userId\n     * @param int $offset\n     * @param int $length\n     *\n     * @return array\n     */\n    public function findForUser($userId, $offset = 0, $length = 20)\n    {\n        return $this->createQueryBuilder('v')\n            ->select('r.id AS repoId', 'v.tagName', 'v.name', 'v.createdAt', 'v.body', 'v.prerelease', 's.ignoredInFeed', 'r.fullName', 'r.ownerAvatar', 'r.ownerAvatar', 'r.homepage', 'r.language', 'r.description')\n            ->leftJoin('v.repo', 'r')\n            ->leftJoin('r.stars', 's')\n            ->where('s.user = :userId')->setParameter('userId', $userId)\n            ->orderBy('v.createdAt', 'desc')\n            ->setFirstResult($offset)\n            ->setMaxResults($length)\n            ->getQuery()\n            ->getArrayResult();\n    }\n\n    /**\n     * Count all versions available for the given user.\n     * Used in the dashboard pagination and auth process.\n     *\n     * @param int $userId\n     *\n     * @return int\n     */\n    public function countForUser($userId)\n    {\n        return (int) $this->createQueryBuilder('v')\n            ->select('COUNT(v.id)')\n            ->leftJoin('v.repo', 'r')\n            ->leftJoin('r.stars', 's')\n            ->where('s.user = :userId')->setParameter('userId', $userId)\n            ->getQuery()\n            ->getSingleScalarResult();\n    }\n\n    /**\n     * Find all versions available in the feed for the given user.\n     */\n    public function findForFeedUser(int $userId, int $offset = 0, int $length = 20): array\n    {\n        return $this->createQueryBuilder('v')\n            ->select('r.id AS repoId', 'v.tagName', 'v.name', 'v.createdAt', 'v.body', 'v.prerelease', 'r.fullName', 'r.ownerAvatar', 'r.ownerAvatar', 'r.homepage', 'r.language', 'r.description')\n            ->leftJoin('v.repo', 'r')\n            ->leftJoin('r.stars', 's')\n            ->where('s.user = :userId')->setParameter('userId', $userId)\n            ->andWhere('s.ignoredInFeed = :ignoredInFeed')->setParameter('ignoredInFeed', false)\n            ->orderBy('v.createdAt', 'desc')\n            ->setFirstResult($offset)\n            ->setMaxResults($length)\n            ->getQuery()\n            ->getArrayResult();\n    }\n\n    /**\n     * Retrieve latest version of each repo.\n     *\n     * @param int $length Number of items\n     *\n     * @return array\n     */\n    public function findLastVersionForEachRepo($length = 10)\n    {\n        $query = '\n            SELECT v1.tagName, v1.name, v1.createdAt, r.fullName, r.description, r.ownerAvatar, v1.prerelease\n            FROM App\\Entity\\Version v1\n            LEFT JOIN App\\Entity\\Version v2 WITH (v1.repo = v2.repo AND v1.createdAt < v2.createdAt)\n            LEFT JOIN App\\Entity\\Repo r WITH r.id = v1.repo\n            WHERE v2.repo IS NULL\n            ORDER BY v1.createdAt DESC\n        ';\n\n        return $this->getEntityManager()->createQuery($query)\n            ->setFirstResult(0)\n            ->setMaxResults($length)\n            ->getArrayResult();\n    }\n\n    /**\n     * Count total versions.\n     *\n     * @return int\n     */\n    public function countTotal()\n    {\n        return (int) $this->createQueryBuilder('v')\n            ->select('COUNT(v.id) as total')\n            ->getQuery()\n            ->getSingleScalarResult();\n    }\n\n    /**\n     * Retrieve the latest version saved.\n     *\n     * @return array|null\n     */\n    public function findLatest()\n    {\n        return $this->createQueryBuilder('v')\n            ->select('v.createdAt')\n            ->orderBy('v.createdAt', 'desc')\n            ->setMaxResults(1)\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n}\n"
  },
  {
    "path": "src/Rss/Generator.php",
    "content": "<?php\n\nnamespace App\\Rss;\n\nuse App\\Entity\\User;\nuse App\\Webfeeds\\Webfeeds;\nuse MarcW\\RssWriter\\Extension\\Atom\\AtomLink;\nuse MarcW\\RssWriter\\Extension\\Core\\Channel;\nuse MarcW\\RssWriter\\Extension\\Core\\Guid;\nuse MarcW\\RssWriter\\Extension\\Core\\Item;\n\n/**\n * Generate the RSS for a user.\n */\nclass Generator\n{\n    public const CHANNEL_TITLE = 'New releases from starred repo of %USERNAME%';\n    public const CHANNEL_DESCRIPTION = 'Here are all the new releases from all repos starred by %USERNAME%';\n\n    /**\n     * It will return the RSS for the given user with all the latests releases given.\n     *\n     * @param User   $user     User which require the RSS\n     * @param array  $releases An array of releases information\n     * @param string $feedUrl  The feed URL\n     *\n     * @return Channel Information to be dumped by `RssStreamedResponse` for example\n     */\n    public function generate(User $user, array $releases, $feedUrl)\n    {\n        $channel = new Channel();\n        $channel->addExtension(\n            (new AtomLink())\n                ->setRel('self')\n                ->setHref($feedUrl)\n                ->setType('application/rss+xml')\n        );\n        $channel->addExtension(\n            (new AtomLink())\n                ->setRel('hub')\n                ->setHref('http://pubsubhubbub.appspot.com/')\n        );\n        $channel->addExtension(\n            (new Webfeeds())\n                ->setLogo($user->getAvatar())\n                ->setIcon($user->getAvatar())\n                ->setAccentColor('10556B')\n        );\n        $channel->setTitle(str_replace('%USERNAME%', $user->getUsername(), self::CHANNEL_TITLE))\n            ->setLink($feedUrl)\n            ->setDescription(str_replace('%USERNAME%', $user->getUsername(), self::CHANNEL_DESCRIPTION))\n            ->setLanguage('en')\n            ->setCopyright('(c) ' . (new \\DateTime())->format('Y') . ' banditore')\n            ->setLastBuildDate(isset($releases[0]) ? $releases[0]['createdAt'] : new \\DateTime())\n            ->setGenerator('banditore');\n\n        foreach ($releases as $release) {\n            // build repo top information\n            $repoHome = $release['homepage'] ? '(<a href=\"' . $release['homepage'] . '\">' . $release['homepage'] . '</a>)' : '';\n            $repoLanguage = $release['language'] ? '<p>#' . $release['language'] . '</p>' : '';\n            $repoInformation = '<table>\n               <tr>\n                  <td>\n                     <a href=\"https://github.com/' . $release['fullName'] . '\">\n                        <img src=\"' . $release['ownerAvatar'] . '&amp;s=140\" alt=\"' . $release['fullName'] . '\" title=\"' . $release['fullName'] . '\" />\n                     </a>\n                  </td>\n                  <td>\n                     <b><a href=\"https://github.com/' . $release['fullName'] . '\">' . $release['fullName'] . '</a></b>\n                     ' . $repoHome . '<br/>\n                     ' . $release['description'] . '<br/>\n                     ' . $repoLanguage . '\n                  </td>\n               </tr>\n            </table>\n            <hr/>';\n\n            $item = new Item();\n            $item->setTitle($release['fullName'] . ' ' . $release['tagName'])\n                ->setLink('https://github.com/' . $release['fullName'] . '/releases/' . urlencode((string) $release['tagName']))\n                ->setDescription($repoInformation . $release['body'])\n                ->setPubDate($release['createdAt'])\n                ->setGuid((new Guid())->setIsPermaLink(true)->setGuid('https://github.com/' . $release['fullName'] . '/releases/' . urlencode((string) $release['tagName'])))\n            ;\n            $channel->addItem($item);\n        }\n\n        return $channel;\n    }\n}\n"
  },
  {
    "path": "src/Security/GithubAuthenticator.php",
    "content": "<?php\n\nnamespace App\\Security;\n\nuse App\\Entity\\User;\nuse App\\Entity\\Version;\nuse App\\Message\\StarredReposSync;\nuse App\\Repository\\VersionRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse KnpU\\OAuth2ClientBundle\\Security\\Authenticator\\OAuth2Authenticator;\nuse League\\OAuth2\\Client\\Provider\\GithubResourceOwner;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpFoundation\\Session\\Flash\\FlashBag;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass GithubAuthenticator extends OAuth2Authenticator\n{\n    public function __construct(private readonly ClientRegistry $clientRegistry, private readonly EntityManagerInterface $entityManager, private readonly RouterInterface $router, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        // continue ONLY if the current ROUTE matches the check ROUTE\n        return 'github_callback' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('github');\n        $accessToken = $this->fetchAccessToken($client);\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {\n                /** @var GithubResourceOwner */\n                $githubUser = $client->fetchUserFromToken($accessToken);\n\n                /** @var User|null */\n                $user = $this->entityManager->getRepository(User::class)->find($githubUser->getId());\n\n                // always update user information at login\n                if (null === $user) {\n                    $user = new User();\n                }\n\n                $user->setAccessToken($accessToken->getToken());\n                $user->hydrateFromGithub($githubUser);\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            })\n        );\n    }\n\n    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response\n    {\n        /** @var User */\n        $user = $token->getUser();\n\n        /** @var VersionRepository */\n        $versionRepo = $this->entityManager->getRepository(Version::class);\n        $versions = $versionRepo->countForUser($user->getId());\n\n        // if no versions were found, it means the user logged in for the first time\n        // and we need to display an explanation message\n        $message = 'Successfully logged in!';\n        if (0 === $versions) {\n            $message = 'Successfully logged in. Your starred repos will soon be synced!';\n        }\n\n        /** @var FlashBag */\n        $flash = $request->getSession()->getBag('flashes');\n        $flash->add('info', $message);\n\n        $this->bus->dispatch(new StarredReposSync($user->getId()));\n\n        return new RedirectResponse($this->router->generate('dashboard'));\n    }\n\n    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response\n    {\n        $message = strtr($exception->getMessageKey(), $exception->getMessageData());\n\n        return new Response($message, Response::HTTP_FORBIDDEN);\n    }\n}\n"
  },
  {
    "path": "src/Twig/PaginationExtension.php",
    "content": "<?php\n\nnamespace App\\Twig;\n\nuse App\\Pagination\\Pagination;\nuse Twig\\Attribute\\AsTwigFunction;\nuse Twig\\Environment;\n\nclass PaginationExtension\n{\n    #[AsTwigFunction(name: 'pagination_render', isSafe: ['html'], needsEnvironment: true)]\n    public function render(Environment $environment, Pagination $pagination, string $routeName, string $pageParameterName = 'page', array $queryParameters = []): string\n    {\n        return $environment->render('default/_pagination.html.twig', [\n            'pagination' => $pagination,\n            'routeName' => $routeName,\n            'pageParameterName' => $pageParameterName,\n            'queryParameters' => $queryParameters,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Twig/RepoVersionExtension.php",
    "content": "<?php\n\nnamespace App\\Twig;\n\nuse Twig\\Attribute\\AsTwigFilter;\n\n/**\n * Took a repo with version information to display a link to that version on Github.\n */\nclass RepoVersionExtension\n{\n    #[AsTwigFilter('link_to_version')]\n    public function linkToVersion(array $repo): ?string\n    {\n        if (!isset($repo['fullName']) || !isset($repo['tagName'])) {\n            return null;\n        }\n\n        return 'https://github.com/' . $repo['fullName'] . '/releases/' . urlencode($repo['tagName']);\n    }\n}\n"
  },
  {
    "path": "src/Webfeeds/Webfeeds.php",
    "content": "<?php\n\nnamespace App\\Webfeeds;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n/**\n * Webfeeds.\n *\n * @see http://webfeeds.org/rss/1.0\n */\nclass Webfeeds\n{\n    /**\n     * @var string|null\n     */\n    #[Assert\\Url]\n    private $logo;\n\n    /**\n     * @var string|null\n     */\n    #[Assert\\Url]\n    private $icon;\n\n    /**\n     * @var string|null\n     */\n    private $accentColor;\n\n    public function setLogo(?string $logo): self\n    {\n        $this->logo = $logo;\n\n        return $this;\n    }\n\n    public function getLogo(): ?string\n    {\n        return $this->logo;\n    }\n\n    public function setIcon(?string $icon): self\n    {\n        $this->icon = $icon;\n\n        return $this;\n    }\n\n    public function getIcon(): ?string\n    {\n        return $this->icon;\n    }\n\n    public function setAccentColor(?string $accentColor): self\n    {\n        $this->accentColor = $accentColor;\n\n        return $this;\n    }\n\n    public function getAccentColor(): ?string\n    {\n        return $this->accentColor;\n    }\n}\n"
  },
  {
    "path": "src/Webfeeds/WebfeedsWriter.php",
    "content": "<?php\n\nnamespace App\\Webfeeds;\n\nuse MarcW\\RssWriter\\RssWriter;\nuse MarcW\\RssWriter\\WriterRegistererInterface;\n\n/**\n * WebfeedsWriter.\n *\n * Mostly used (or handled) by Feedly.\n *\n * @see https://blog.feedly.com/10-ways-to-optimize-your-feed-for-feedly/\n */\nclass WebfeedsWriter implements WriterRegistererInterface\n{\n    public function getRegisteredWriters(): array\n    {\n        return [\n            Webfeeds::class => $this->write(...),\n        ];\n    }\n\n    public function getRegisteredNamespaces(): array\n    {\n        return [\n            'webfeeds' => 'http://webfeeds.org/rss/1.0',\n        ];\n    }\n\n    public function write(RssWriter $rssWriter, Webfeeds $extension): void\n    {\n        $writer = $rssWriter->getXmlWriter();\n\n        if ($extension->getLogo()) {\n            $writer->writeElement('webfeeds:logo', $extension->getLogo());\n        }\n\n        if ($extension->getIcon()) {\n            $writer->writeElement('webfeeds:icon', $extension->getIcon());\n        }\n\n        if ($extension->getAccentColor()) {\n            $writer->writeElement('webfeeds:accentColor', $extension->getAccentColor());\n        }\n    }\n}\n"
  },
  {
    "path": "templates/base.html.twig",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <meta name=\"description\" content=\"Banditore retrieves new releases from your GitHub starred repositories and put them in a RSS feed, just for you.\">\n    <link rel=\"icon\" type=\"image/icon\" href=\"{{ asset('favicon.ico') }}\" />\n    <link rel=\"shortcut icon\" type=\"image/ico\" href=\"{{ asset('favicon.ico') }}\" />\n\n    <link rel=\"apple-touch-icon\" href=\"{{ asset('images/touch-icon-iphone.png') }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"{{ asset('images/touch-icon-ipad.png') }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"{{ asset('images/touch-icon-iphone-retina.png') }}\">\n    <link rel=\"apple-touch-icon\" sizes=\"167x167\" href=\"{{ asset('images/touch-icon-ipad-retina.png') }}\">\n    <title>{% block title %}Bandito.re{% endblock %}</title>\n\n    <link rel=\"preconnect\" href=\"https://unpkg.com\">\n\n    {% block stylesheets -%}\n    <link rel=\"stylesheet\" href=\"{{ asset('css/pure-min.css') }}\">\n    <link rel=\"stylesheet\" href=\"{{ asset('css/grids-responsive-min.css') }}\">\n    <link rel=\"stylesheet\" href=\"{{ asset('css/font-awesome.min.css') }}\">\n    <link rel=\"stylesheet\" href=\"{{ asset('css/banditore.css') }}\">\n    {% endblock %}\n\n    {% block javascripts -%}\n    <script defer type=\"text/javascript\" src=\"{{ asset('js/banditore.js') }}\"></script>\n    {% endblock %}\n</head>\n<body>\n    <div class=\"middle-content\">\n        <div class=\"menu-wrapper pure-g\" id=\"menu\">\n            <div class=\"pure-u-1 pure-u-md-1-2\">\n                <div class=\"pure-menu\">\n                    <a href=\"{{ url('homepage') }}\" class=\"pure-menu-heading\">Bandito.re</a>\n                    <a href=\"#\" class=\"menu-toggle\" id=\"toggle\"><s class=\"bar\"></s><s class=\"bar\"></s></a>\n                </div>\n            </div>\n            <div class=\"pure-u-1 pure-u-md-1-2\">\n                <div class=\"pure-menu pure-menu-horizontal menu-can-transform\">\n                    <ul class=\"pure-menu-list\">\n                        <li class=\"pure-menu-item\"><a href=\"https://github.com/j0k3r/banditore\" class=\"pure-menu-link\">View it on GitHub</a></li>\n                        <li class=\"pure-menu-item\"><a href=\"{{ url('stats') }}\" class=\"pure-menu-link\">Stats</a></li>\n                        {% if app.user -%}\n                            <li class=\"pure-menu-item\"><a href=\"{{ url('logout') }}\" class=\"pure-menu-link\">Logout ({{ app.user.username }})</a></li>\n                        {% else -%}\n                            <li class=\"pure-menu-item\"><a href=\"{{ url('github_connect') }}\" class=\"pure-menu-link\">Login</a></li>\n                        {% endif -%}\n                    </ul>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <div class=\"content-wrapper\">\n        {% block body %}{% endblock %}\n\n        <div class=\"footer l-box is-center\">\n            Site built with <a href=\"https://purecss.io/\">Pure</a>,\n            a bit of <i class=\"fa fa-beer\"></i>\n            and some <a href=\"https://www.iconfinder.com/iconsets/business-productivity-set-1\">icons</a>,\n            by <a href=\"https://twitter.com/j0k\">@j0k</a>.\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{% block body %}\n<div class=\"content\">\n    <div class=\"middle-content\">\n        <h2 class=\"content-head is-center\">Hum. Problem.</h2>\n\n        <p>Looks like we have a problem here.</p>\n        <p><img src=\"https://i.giphy.com/xTiTnGeUsWOEwsGoG4.gif\" /></p>\n        <p>You might want to <a href=\"{{ path('homepage') }}\">return to the homepage</a>.</p>\n        <p>And if you are already on the homepage, it might be a bigger problem. So don't move.</p>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "templates/default/_line_version.html.twig",
    "content": "<tr class=\"{% if loop.index is odd %}pure-table-odd{% endif %}\">\n    <td data-th=\"Repo\">\n        <img class=\"repo-avatar\" src=\"{{ repo.ownerAvatar }}&amp;s=25\"/>\n        <a href=\"https://github.com/{{ repo.fullName }}\" title=\"{{ repo.description }}\">{{ repo.fullName }}</a>\n    </td>\n    <td data-th=\"Last\">\n        {% if repo.name and repo.name != repo.tagName -%}\n            {{ repo.name }} (<a href=\"{{ repo|link_to_version }}\">{{ repo.tagName }}</a>)\n        {%- else -%}\n            <a href=\"{{ repo|link_to_version }}\">{{ repo.tagName }}</a>\n        {%- endif %}\n\n        {% if repo.prerelease -%}\n            <span class=\"label_prerelease\">pre-release</span>\n        {% endif -%}\n\n    </td>\n    <td data-th=\"Date\">\n        <time datetime=\"{{ repo.createdAt|date(\"c\") }}\" title=\"{{ repo.createdAt|date(\"c\") }}\">{{ repo.createdAt|time_diff }}</time>\n    </td>\n    {% if repo.repoId is defined -%}\n        <td data-th=\"Included\">\n            {% if repo.ignoredInFeed|default(false) -%}\n                <span class=\"excluded-indicator\" title=\"Excluded from RSS\">\n                    <i class=\"fa fa-times\" aria-hidden=\"true\"></i>\n                </span>\n            {% else -%}\n                <span class=\"included-indicator\" title=\"Included in RSS\">\n                    <i class=\"fa fa-check\" aria-hidden=\"true\"></i>\n                </span>\n            {% endif -%}\n\n            <form action=\"{{ path('dashboard_repo_feed', { repoId: repo.repoId }) }}\" method=\"post\" class=\"feed-toggle-form\">\n                <input type=\"hidden\" name=\"ignore_in_feed\" value=\"{{ repo.ignoredInFeed|default(false) ? '0' : '1' }}\"/>\n                <button type=\"submit\" class=\"feed-toggle-button\">\n                    {{ repo.ignoredInFeed|default(false) ? 'Include again' : 'Exclude' }}\n                </button>\n            </form>\n        </td>\n    {% endif -%}\n</tr>\n"
  },
  {
    "path": "templates/default/_pagination.html.twig",
    "content": "\n<div class=\"pagination\">\n\n    {% if pagination.firstPageNumber and pagination.firstPageNumber != pagination.currentPageNumber %}\n        <a class=\"previous\" href=\"{{ path(routeName, queryParameters | merge({(pageParameterName): pagination.firstPageNumber})) }}\"><i class=\"fa fa-angle-double-left\"></i></a>\n    {% endif %}\n\n    {% if pagination.previousPageNumber %}\n        <a class=\"previous\" href=\"{{ path(routeName, queryParameters | merge({(pageParameterName): pagination.previousPageNumber})) }}\"><i class=\"fa fa-angle-left\"></i></a>\n    {% endif %}\n\n    {% for page in pagination.pages %}\n        {% if page == pagination.currentPageNumber %}\n            <strong>{{ page }}</strong>\n        {% else %}\n            <a href=\"{{ path(routeName, queryParameters | merge({(pageParameterName): page})) }}\">{{ page }}</a>\n        {% endif %}\n    {% endfor %}\n\n    {% if pagination.nextPageNumber %}\n        <a class=\"next\" href=\"{{ path(routeName, queryParameters | merge({(pageParameterName): pagination.nextPageNumber})) }}\"><i class=\"fa fa-angle-right\"></i></a>\n    {% endif %}\n\n    {% if pagination.lastPageNumber and pagination.lastPageNumber != pagination.currentPageNumber %}\n        <a class=\"next\" href=\"{{ path(routeName, queryParameters | merge({(pageParameterName): pagination.lastPageNumber})) }}\"><i class=\"fa fa-angle-double-right\"></i></a>\n    {% endif %}\n\n</div>\n"
  },
  {
    "path": "templates/default/dashboard.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{% block body %}\n<div class=\"content\">\n    <div class=\"middle-content\">\n        {% if app.session.flashBag.has('info') %}\n            <div class=\"alert success\">\n                <span class=\"close\"><i class=\"fa fa-close\"></i></span>\n                {{- app.session.flashBag.get('info')|first -}}\n            </div>\n        {% endif %}\n\n        {% if sync_status %}\n            <div class=\"alert info\">\n                Your starred repos are being sync (this can happen <a href=\"#faq-check-starred-repo\">many times per day</a>). It started <b>{{ sync_status|date|time_diff }}</b> and should be pretty fast depending on how many stars you have.\n            </div>\n        {% endif %}\n\n        <aside class=\"feed pure-u-lg-3-5 pure-u-md-4-5 pure-u-sm-5-5\">\n            <p>You can grab your <b><a href=\"{{ url('rss_user', { uuid: app.user.uuid }) }}\">feed link</a></b> and add it to your favorite feed reader. Every new release will show up in it.</p>\n            <p>If one repository is too noisy, you can ignore it from the RSS feed without unstarring it on GitHub.</p>\n        </aside>\n\n        <h2 class=\"content-head is-center\">Your latest releases</h2>\n\n        {% if pagination.items %}\n            {% if pagination.totalNumberOfPages > 1 %}\n                <p class=\"is-center\">\n                    {{ pagination_render(\n                        pagination,\n                        'dashboard',\n                        'page',\n                        app.request.query.all\n                    ) }}\n                </p>\n            {% endif %}\n        {% else %}\n            <p><i>Here are some sample display.</i></p>\n        {% endif %}\n\n        <table class=\"pure-table pure-table-rwd\">\n            <thead>\n                <tr>\n                    <th>Repository</th>\n                    <th>Last&nbsp;version</th>\n                    <th>Published&nbsp;at</th>\n                    <th>Included in feed</th>\n                </tr>\n            </thead>\n\n            <tbody>\n                {% for repo in pagination.items -%}\n                    {% include 'default/_line_version.html.twig' with { 'loop': loop, 'repo': repo} %}\n                {% else %}\n                    <tr>\n                        <td data-th=\"Repo\">\n                            <img class=\"repo-avatar\" src=\"https://avatars2.githubusercontent.com/u/5909549?v=3&amp;s=25\"/>\n                            <a href=\"#\">sample/sample</a>\n                        </td>\n                        <td data-th=\"Last\">First release (<a href=\"#\">v1.0.0</a>)</td>\n                        <td data-th=\"Date\"><time datetime=\"{{ app.user.createdAt|date(\"c\") }}\" title=\"{{ app.user.createdAt|date(\"c\") }}\">{{ app.user.createdAt|time_diff }}</time></td>\n                        <td data-th=\"Included\">\n                            <span class=\"included-indicator\" title=\"Included in RSS\">\n                                <i class=\"fa fa-check\" aria-hidden=\"true\"></i>\n                            </span>\n                            <button type=\"button\" class=\"feed-toggle-button\">Exclude</button>\n                        </td>\n                    </tr>\n                    <tr class=\"pure-table-odd\">\n                        <td data-th=\"Repo\">\n                            <img class=\"repo-avatar\" src=\"https://avatars2.githubusercontent.com/u/5909549?v=3&amp;s=25\"/>\n                            <a href=\"#\">sample/sample</a>\n                        </td>\n                        <td data-th=\"Last\">\n                            Prepare first release (<a href=\"#\">v1.0.0-alpha.1</a>)\n                            <span class=\"label_prerelease\">pre-release</span>\n                        </td>\n                        <td data-th=\"Date\"><time datetime=\"{{ date(\"-2days\")|date(\"c\") }}\" title=\"{{ date(\"-2days\")|date(\"c\") }}\">{{ date(\"-2days\")|time_diff }}</time></td>\n                        <td data-th=\"Included\">\n                            <span class=\"included-indicator\" title=\"Included in RSS\">\n                                <i class=\"fa fa-check\" aria-hidden=\"true\"></i>\n                            </span>\n                            <button type=\"button\" class=\"feed-toggle-button\">Exclude</button>\n                        </td>\n                    </tr>\n                {% endfor -%}\n            </tbody>\n        </table>\n\n        {% if pagination.items and pagination.totalNumberOfPages > 1 %}\n            <p class=\"is-center\">\n                {{ pagination_render(\n                    pagination,\n                    'dashboard',\n                    'page',\n                    app.request.query.all\n                ) }}\n            </p>\n        {% endif %}\n\n        <h2 class=\"content-head is-center\">Got some questions? Here is a FAQ</h2>\n\n        <div>\n            <h4 id=\"faq-check-new-release\">\n                <i class=\"fa fa-clock-o\"></i>\n                How often does the app check for new release?\n            </h4>\n            <p>\n                Banditore will check for new release <b>every 10 minutes</b>.\n            </p>\n\n            <h4 id=\"faq-check-starred-repo\">\n                <i class=\"fa fa-clock-o\"></i>\n                How often does the app check for my starred repos?\n            </h4>\n            <p>\n                Banditore will sync your starred repos <b>every 5 minutes</b>. You can also force it by logged out / logged in.\n            </p>\n\n            <h4 id=\"faq-dashboard\">\n                <i class=\"fa fa-eye-slash\"></i>\n                Why do I not see all my starred repos on the dashboard?\n            </h4>\n            <p>\n                First, they can still be in sync. This could be the case if it's the very first time you logged in here.\n                Second, not all repos got tag or release (which is sad). In that case, they'll never show up on your dashboard.\n            </p>\n\n            <h4 id=\"faq-other-question\">\n                <i class=\"fa fa-question-circle-o\"></i>\n                Another question?\n            </h4>\n            <p>\n                Feel free to <a href=\"https://github.com/j0k3r/banditore/issues/new\">open an issue</a>.\n            </p>\n        </div>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "templates/default/index.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{% block body %}\n<div class=\"splash-container middle-content\">\n    <div class=\"splash\">\n        <h1 class=\"splash-head\">Get new releases in your feed reader</h1>\n        <p>We gather new releases from your starred GitHub repositories and generate an Atom feed with them.</p>\n        <p>Just for you.</p>\n        <p>\n            <a href=\"{{ url('github_connect') }}\" class=\"pure-button pure-button-primary\">Try it!</a>\n        </p>\n    </div>\n</div>\n\n<div class=\"content\">\n    <div class=\"middle-content\">\n        <h2 class=\"content-head is-center\">What Banditore can do for me?</h2>\n\n        <div class=\"pure-g\">\n            <div class=\"l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-4\">\n\n                <h3 class=\"content-subhead\">\n                    <i class=\"fa fa-bell-o\"></i>\n                    Be notified for a new release\n                </h3>\n                <p>\n                    When a new release / tag is available, you'll know it <b>right away</b>. So you won't forget to update your project.\n                </p>\n            </div>\n            <div class=\"l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-4\">\n                <h3 class=\"content-subhead\">\n                    <i class=\"fa fa-rss\"></i>\n                    Soft notification, because of RSS feed\n                </h3>\n                <p>\n                    I know you're tired of emails, popups &amp; browser / mobile notifications. <b>RSS feed</b> is soft notification that won't bother you.\n                </p>\n            </div>\n            <div class=\"l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-4\">\n                <h3 class=\"content-subhead\">\n                    <i class=\"fa fa-thumbs-o-up\"></i>\n                    Don't forget starred repository\n                </h3>\n                <p>\n                    We all do that: you star a repo and the next few days you already forget about it. <b>That's over</b>. Any new release will remind you about it!\n                </p>\n            </div>\n            <div class=\"l-box pure-u-1 pure-u-md-1-2 pure-u-lg-1-4\">\n                <h3 class=\"content-subhead\">\n                    <i class=\"fa fa-github\"></i>\n                    Install your own Banditore\n                </h3>\n                <p>\n                    Banditore is full <b>open source</b>. If you don't want to use this website, you can install it on your own server.\n                </p>\n            </div>\n        </div>\n    </div>\n</div>\n\n<div class=\"ribbon l-box-lrg pure-g\">\n    <div class=\"middle-content\">\n        <h2 class=\"content-head is-center content-head-ribbon\">How it works</h2>\n\n        <div class=\"pure-u-1 pure-u-md-1-2 pure-u-lg-3-5\">\n            <h4 class=\"content-head-ribbon\">Retrieve starred repositories from your GitHub account</h4>\n            <p>\n                When you first login, we retrieve minimal information from you (name, username &amp; avatar).\n                Then we fetch your stars &amp; their associated repository.\n            </p>\n\n            <h4 class=\"content-head-ribbon\">Periodically retrieve new release &amp; tag</h4>\n            <p>\n                At least twice per hour, we retrieve new release or tag for your repository using your token.\n                Once we have gathered all these information, we build a feed with them, ordered by published date of each release.\n            </p>\n\n            <h4 class=\"content-head-ribbon\">Markdown<i>ified</i> content</h4>\n            <p>\n                Some release contains markdown information which will be converted in HTML for a better rendering.\n                For tag (which doesn't come with a body), we'll only display the tag name.\n            </p>\n\n            <h4 class=\"content-head-ribbon\">Do you want to improve it?</h4>\n            <p>\n                As I said, Banditore is <a href=\"https://github.com/j0k3r/banditore\">open source</a>.\n                If something bother you or if you want to improve it, I'll be much happy to check your issue or review your PR!\n            </p>\n        </div>\n\n        <div class=\"l-box-lrg is-center pure-u-1 pure-u-md-1-2 pure-u-lg-2-5 image-productivity\">\n            <img width=\"300\" alt=\"File Icons\" class=\"pure-img-responsive\" src=\"{{ asset('images/productivity.svg') }}\">\n        </div>\n    </div>\n</div>\n\n<div class=\"content l-box-lrg pure-g\">\n    <div class=\"middle-content\">\n        <div class=\"l-box-lrg is-center pure-u-1 pure-u-md-1-2 pure-u-lg-2-5 image-megaphone\">\n            <img width=\"300\" alt=\"File Icons\" class=\"pure-img-responsive\" src=\"{{ asset('images/megaphone.svg') }}\">\n        </div>\n\n        <div class=\"pure-u-1 pure-u-md-1-2 pure-u-lg-3-5\">\n            <h2 class=\"content-head\">Bandi … what?</h2>\n\n            <p><b>Banditore</b> is an italian word. It means a <i>town crier</i> (or in french <i>un crieur public</i>).</p>\n            <p>Wikipedia says: \"<i>A town crier, or bellman, is an officer of the court who makes public pronouncements as required by the court </i>\".</p>\n            <p>\n                We are not in court here but I think you get the idea. Banditore will makes \"announcements\" about new releases from repositories you starred.\n                On every new release (or tag, because some people don't use the Release feature) it'll push a new item right away in your Atom feed.\n            </p>\n        </div>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "templates/default/stats.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{% block body %}\n<div class=\"content\">\n    <div class=\"middle-content\">\n        {% if app.session.flashBag.has('info') %}\n            <div class=\"alert_success\">\n                {{ app.session.flashBag.get('info')|first }}\n            </div>\n        {% endif %}\n\n        <h2 class=\"content-head is-center\">Stats are cool, eh?</h2>\n\n        <div class=\"pure-g\">\n            <div class=\"pure-u-1 pure-u-lg-1-3\">\n                <div class=\"l-box\">\n                    <h3>Counters</h3>\n\n                    <table class=\"pure-table pure-table-horizontal\">\n                        <tr>\n                            <td>Number of repos</td>\n                            <td>{{ counters.nbRepos }}</td>\n                        </tr>\n                        <tr>\n                            <td>Numbers of releases</td>\n                            <td>{{ counters.nbReleases }}</td>\n                        </tr>\n                        <tr>\n                            <td>Average release per <b>repo</b></td>\n                            <td>{{ counters.avgReleasePerRepo }}</td>\n                        </tr>\n                        <tr>\n                            <td>Average star per <b>user</b></td>\n                            <td>{{ counters.avgStarPerUser }}</td>\n                        </tr>\n                    </table>\n                 </div>\n            </div>\n\n            <div class=\"pure-u-1 pure-u-lg-1-3\">\n                <div class=\"l-box\">\n                    <h3>Repos with most releases</h3>\n                    <table class=\"pure-table pure-table-horizontal\">\n                        {% for repo in mostReleases %}\n                            <tr>\n                                <td>\n                                    <img class=\"repo-avatar\" src=\"{{ repo.ownerAvatar }}&amp;s=25\"/>\n                                    <a href=\"https://github.com/{{ repo.fullName }}\" title=\"{{ repo.description }}\">{{ repo.fullName }}</a>\n                                </td>\n                                <td>{{ repo.total }}</td>\n                            </tr>\n                        {% endfor %}\n                    </table>\n                 </div>\n            </div>\n\n            <div class=\"pure-u-1 pure-u-lg-1-3\">\n                <div class=\"l-box\">\n                    <h3>Releases per day</h3>\n                    <p>Soon …</p>\n                 </div>\n            </div>\n        </div>\n\n        <h3 class=\"is-center\">Latest releases</h3>\n\n        <table class=\"pure-table pure-table-rwd\">\n            <thead>\n                <tr>\n                    <th>Repository</th>\n                    <th>Last&nbsp;version</th>\n                    <th>Published&nbsp;at</th>\n                </tr>\n            </thead>\n\n            <tbody>\n                {% for repo in lastestReleases -%}\n                    {% include 'default/_line_version.html.twig' with { 'loop': loop, 'repo': repo} %}\n                {% endfor %}\n            </tbody>\n        </table>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "tests/Cache/CustomRedisCachePoolTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Cache;\n\nuse App\\Cache\\CustomRedisCachePool;\nuse Cache\\Adapter\\Common\\CacheItem;\nuse GuzzleHttp\\Psr7\\Response;\nuse Predis\\ClientInterface;\nuse Predis\\Response\\Status;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass CustomRedisCachePoolTest extends WebTestCase\n{\n    public function testResponseWithEmptyBody(): void\n    {\n        $redisStatus = new Status('OK');\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->once())\n            ->method('__call')\n            ->willReturn($redisStatus);\n\n        $body = (string) json_encode([]);\n\n        $response = new Response(\n            200,\n            [],\n            $body\n        );\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => $body,\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n\n    public function testResponseWith404(): void\n    {\n        $redisStatus = new Status('OK');\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->once())\n            ->method('__call')\n            ->willReturn($redisStatus);\n\n        $response = new Response(404);\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => '',\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n\n    public function testResponseWithRelease(): void\n    {\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->never())\n            ->method('__call');\n\n        $body = (string) json_encode([\n            'tag_name' => 'V1.1.0',\n            'name' => 'V1.1.0',\n            'prerelease' => false,\n            'published_at' => '2014-12-01T18:28:39Z',\n            'body' => 'This is the first release after our major push.',\n        ]);\n\n        $response = new Response(\n            200,\n            [],\n            $body\n        );\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => $body,\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n\n    public function testResponseWithRefTags(): void\n    {\n        $redisStatus = new Status('OK');\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->once())\n            ->method('__call')\n            ->willReturn($redisStatus);\n\n        $body = (string) json_encode([[\n            'ref' => 'refs/tags/1.0.0',\n            'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.0',\n            'object' => [\n                'sha' => '04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                'type' => 'commit',\n                'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n            ],\n        ],\n            [\n                'ref' => 'refs/tags/1.0.1',\n                'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.1',\n                'object' => [\n                    'sha' => '4845571072d49c2794b165482420b66c206a942a',\n                    'type' => 'commit',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/4845571072d49c2794b165482420b66c206a942a',\n                ],\n            ],\n        ]);\n\n        $response = new Response(\n            200,\n            [],\n            $body\n        );\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => $body,\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n\n    public function testResponseWithTag(): void\n    {\n        $redisStatus = new Status('OK');\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->once())\n            ->method('__call')\n            ->willReturn($redisStatus);\n\n        $body = (string) json_encode([[\n            'name' => '2.0.1',\n            'zipball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/zipball/2.0.1',\n            'tarball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/tarball/2.0.1',\n            'commit' => [\n                'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                'url' => 'https://api.github.com/repos/snc/SncRedisBundle/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n            ],\n        ]]);\n\n        $response = new Response(\n            200,\n            [],\n            $body\n        );\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => $body,\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n\n    public function testResponseWithStarredRepos(): void\n    {\n        $redisStatus = new Status('OK');\n        $cache = $this->getMockBuilder(ClientInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $cache->expects($this->once())\n            ->method('__call')\n            ->willReturn($redisStatus);\n\n        $body = (string) json_encode([[\n            'description' => 'banditore',\n            'homepage' => 'http://banditore.io',\n            'language' => 'PHP',\n            'name' => 'banditore',\n            'full_name' => 'j0k3r/banditore',\n            'id' => 666,\n            'owner' => [\n                'avatar_url' => 'http://avatar.api/banditore.jpg',\n            ],\n        ]]);\n\n        $response = new Response(\n            200,\n            [],\n            $body\n        );\n        $item = new CacheItem('superkey', true, [\n            'response' => $response,\n            'body' => $body,\n        ]);\n\n        $cachePool = new CustomRedisCachePool($cache);\n        $cachePool->save($item);\n    }\n}\n"
  },
  {
    "path": "tests/Command/SyncStarredReposCommandTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Command;\n\nuse App\\Command\\SyncStarredReposCommand;\nuse App\\Message\\StarredReposSync;\nuse App\\MessageHandler\\StarredReposSyncHandler;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\KernelTestCase;\nuse Symfony\\Component\\Console\\Output\\BufferedOutput;\nuse Symfony\\Component\\Messenger\\Bridge\\Amqp\\Transport\\AmqpTransport;\nuse Symfony\\Component\\Messenger\\Bridge\\Amqp\\Transport\\Connection;\nuse Symfony\\Component\\Messenger\\Envelope;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass SyncStarredReposCommandTest extends KernelTestCase\n{\n    public function testCommandSyncAllUsersWithoutQueue(): void\n    {\n        $message = new StarredReposSync(123);\n\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->once())\n            ->method('__invoke')\n            ->with($message);\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            self::getContainer()->get('messenger.transport.sync_starred_repos'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, false);\n\n        $this->assertSame($res, 0);\n        $this->assertStringContainsString('Sync user 123 …', $output->fetch());\n    }\n\n    public function testCommandSyncAllUsersWithQueue(): void\n    {\n        $message = new StarredReposSync(123);\n\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->once())\n            ->method('dispatch')\n            ->with($message)\n            ->willReturn(new Envelope($message));\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            $this->getTransportMessageCount(0),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, true);\n\n        $this->assertSame($res, 0);\n        $this->assertStringContainsString('Sync user 123 …', $output->fetch());\n    }\n\n    public function testCommandSyncAllUsersWithQueueFull(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            $this->getTransportMessageCount(10),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, true);\n\n        $this->assertSame($res, 1);\n        $this->assertStringContainsString('Current queue as too much messages (10), skipping.', $output->fetch());\n    }\n\n    public function testCommandSyncOneUserById(): void\n    {\n        $message = new StarredReposSync(123);\n\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->once())\n            ->method('dispatch')\n            ->with($message)\n            ->willReturn(new Envelope($message));\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            $this->getTransportMessageCount(0),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, '123', false, true);\n\n        $this->assertSame($res, 0);\n\n        $buffer = $output->fetch();\n        $this->assertStringContainsString('Sync user 123 …', $buffer);\n        $this->assertStringContainsString('User synced: 1', $buffer);\n    }\n\n    public function testCommandSyncOneUserByUsername(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->once())\n            ->method('__invoke')\n            ->with(new StarredReposSync(123));\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            self::getContainer()->get('messenger.transport.sync_starred_repos'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, 'admin', false);\n\n        $this->assertSame($res, 0);\n\n        $buffer = $output->fetch();\n        $this->assertStringContainsString('Sync user 123 …', $buffer);\n        $this->assertStringContainsString('User synced: 1', $buffer);\n    }\n\n    public function testCommandSyncOneUserNotFound(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncRepo = $this->getMockBuilder(StarredReposSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncRepo->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncStarredReposCommand(\n            self::getContainer()->get(UserRepository::class),\n            $syncRepo,\n            self::getContainer()->get('messenger.transport.sync_starred_repos'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, 'toto', false);\n\n        $this->assertSame($res, 1);\n        $this->assertStringContainsString('No users found', $output->fetch());\n    }\n\n    private function getTransportMessageCount(int $totalMessage = 0): AmqpTransport\n    {\n        $connection = $this->getMockBuilder(Connection::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $connection->expects($this->once())\n            ->method('countMessagesInQueues')\n            ->willReturn($totalMessage);\n\n        return new AmqpTransport($connection);\n    }\n}\n"
  },
  {
    "path": "tests/Command/SyncVersionsCommandTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Command;\n\nuse App\\Command\\SyncVersionsCommand;\nuse App\\Message\\VersionsSync;\nuse App\\MessageHandler\\VersionsSyncHandler;\nuse App\\Repository\\RepoRepository;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\KernelTestCase;\nuse Symfony\\Component\\Console\\Output\\BufferedOutput;\nuse Symfony\\Component\\Messenger\\Bridge\\Amqp\\Transport\\AmqpTransport;\nuse Symfony\\Component\\Messenger\\Bridge\\Amqp\\Transport\\Connection;\nuse Symfony\\Component\\Messenger\\Envelope;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass SyncVersionsCommandTest extends KernelTestCase\n{\n    public function testCommandSyncAllUsersWithoutQueue(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->any())\n            ->method('__invoke');\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            self::getContainer()->get('messenger.transport.sync_versions'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, false);\n\n        $this->assertSame($res, 0);\n        $this->assertStringContainsString('Check 555 …', $output->fetch());\n    }\n\n    public function testCommandSyncAllUsersWithQueue(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->any())\n            ->method('dispatch')\n            ->willReturn(new Envelope(new VersionsSync(555)));\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->any())\n            ->method('__invoke');\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            $this->getTransportMessageCount(0),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, true);\n\n        $this->assertSame($res, 0);\n        $this->assertStringContainsString('Check 555 …', $output->fetch());\n    }\n\n    public function testCommandSyncAllUsersWithQueueFull(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            $this->getTransportMessageCount(10),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, false, true);\n\n        $this->assertSame($res, 1);\n        $this->assertStringContainsString('Current queue as too much messages (10), skipping.', $output->fetch());\n    }\n\n    public function testCommandSyncOneUserById(): void\n    {\n        $message = new VersionsSync(555);\n\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->once())\n            ->method('dispatch')\n            ->with($message)\n            ->willReturn(new Envelope($message));\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            $this->getTransportMessageCount(0),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, '555', false, true);\n\n        $this->assertSame($res, 0);\n\n        $buffer = $output->fetch();\n        $this->assertStringContainsString('Check 555 …', $buffer);\n        $this->assertStringContainsString('Repo checked: 1', $buffer);\n    }\n\n    public function testCommandSyncOneUserByUsername(): void\n    {\n        $message = new VersionsSync(666);\n\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->once())\n            ->method('__invoke')\n            ->with($message);\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            self::getContainer()->get('messenger.transport.sync_versions'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, 'test/test', false);\n\n        $this->assertSame($res, 0);\n\n        $buffer = $output->fetch();\n        $this->assertStringContainsString('Check 666 …', $buffer);\n        $this->assertStringContainsString('Repo checked: 1', $buffer);\n    }\n\n    public function testCommandSyncOneUserNotFound(): void\n    {\n        $bus = $this->getMockBuilder(MessageBusInterface::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $bus->expects($this->never())\n            ->method('dispatch');\n\n        $syncVersion = $this->getMockBuilder(VersionsSyncHandler::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $syncVersion->expects($this->never())\n            ->method('__invoke');\n\n        $command = new SyncVersionsCommand(\n            self::getContainer()->get(RepoRepository::class),\n            $syncVersion,\n            self::getContainer()->get('messenger.transport.sync_versions'),\n            $bus\n        );\n\n        $output = new BufferedOutput();\n\n        $res = $command->__invoke($output, false, 'toto', false);\n\n        $this->assertSame($res, 1);\n        $this->assertStringContainsString('No repos found', $output->fetch());\n    }\n\n    private function getTransportMessageCount(int $totalMessage = 0): AmqpTransport\n    {\n        $connection = $this->getMockBuilder(Connection::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $connection->expects($this->once())\n            ->method('countMessagesInQueues')\n            ->willReturn($totalMessage);\n\n        return new AmqpTransport($connection);\n    }\n}\n"
  },
  {
    "path": "tests/Controller/DefaultControllerTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Controller;\n\nuse App\\Entity\\User;\nuse App\\Repository\\StarRepository;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\DBAL\\Connection;\nuse MarcW\\RssWriter\\Bridge\\Symfony\\HttpFoundation\\RssStreamedResponse;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\n\nclass DefaultControllerTest extends WebTestCase\n{\n    /** @var KernelBrowser */\n    private $client;\n\n    protected function setUp(): void\n    {\n        $this->client = static::createClient();\n        self::getContainer()->get(Connection::class)->executeStatement('UPDATE star SET ignored_in_feed = 0');\n    }\n\n    public function testIndexNotLoggedIn(): void\n    {\n        $crawler = $this->client->request('GET', '/');\n\n        $this->assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('a.pure-menu-heading', 'Bandito.re');\n    }\n\n    public function testIndexLoggedIn(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $this->client->request('GET', '/');\n\n        $this->assertResponseRedirects('/dashboard', 302);\n    }\n\n    public function testConnect(): void\n    {\n        $this->client->request('GET', '/connect');\n\n        /** @var RedirectResponse */\n        $response = $this->client->getResponse();\n        $this->assertSame(302, $response->getStatusCode());\n        $this->assertStringContainsString('https://github.com/login/oauth/authorize?', $response->getTargetUrl());\n    }\n\n    public function testConnectWithLoggedInUser(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $this->client->request('GET', '/connect');\n\n        $this->assertResponseRedirects('/dashboard', 302);\n    }\n\n    public function testDashboardNotLoggedIn(): void\n    {\n        $this->client->request('GET', '/dashboard');\n\n        $this->assertResponseRedirects('/', 302);\n    }\n\n    public function testDashboard(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', '/dashboard');\n\n        $this->assertResponseIsSuccessful();\n\n        $menu = $crawler->filter('.menu-wrapper')->text();\n        $this->assertStringContainsString('View it on GitHub', $menu, 'Link to GitHub is here');\n        $this->assertStringContainsString('Logout (admin)', $menu, 'Info about logged in user is here');\n\n        $aside = $crawler->filter('aside.feed')->text();\n        $this->assertStringContainsString('your feed link', $aside, 'Feed link is here');\n\n        $table = $crawler->filter('table')->text();\n        $this->assertStringContainsString('test/test', $table, 'Repo test/test exist in a table');\n        $this->assertStringContainsString('Exclude', $table, 'Feed toggle is available from the dashboard');\n        $this->assertStringContainsString('ago', $table, 'Date is translated and ok');\n    }\n\n    public function testDashboardRepoFeedToggle(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $this->client->request('POST', '/dashboard/repositories/666/feed', [\n            'ignore_in_feed' => '1',\n        ]);\n\n        $this->assertResponseRedirects('/dashboard', 302);\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('.alert.success', 'test/test is now ignored in your RSS feed.');\n\n        $star = self::getContainer()->get(StarRepository::class)->findOneByUserAndRepo(123, 666);\n\n        $this->assertNotNull($star);\n        $this->assertTrue($star->isIgnoredInFeed());\n    }\n\n    public function testDashboardPageTooHigh(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', '/dashboard?page=20000');\n\n        $this->assertResponseRedirects('/dashboard', 302);\n    }\n\n    public function testDashboardBadPage(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n\n        $this->client->loginUser($user);\n        $this->client->request('GET', '/dashboard?page=dsdsds');\n\n        $this->assertResponseStatusCodeSame(404);\n    }\n\n    public function testRss(): void\n    {\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n        $crawler = $this->client->request('GET', '/' . $user->getUuid() . '.atom');\n\n        $this->assertResponseIsSuccessful();\n        $this->assertInstanceOf(RssStreamedResponse::class, $this->client->getResponse());\n\n        $this->assertSelectorTextContains('channel>title', 'New releases from starred repo of admin');\n        $this->assertSelectorTextContains('channel>description', 'Here are all the new releases from all repos starred by admin');\n\n        $this->assertSame('http://0.0.0.0/avatar.jpg', $crawler->filterXPath('//webfeeds:icon')->text());\n        $this->assertSame('10556B', $crawler->filterXPath('//webfeeds:accentColor')->text());\n\n        $link = $crawler->filterXPath('//atom:link');\n        $this->assertSame('http://localhost/' . $user->getUuid() . '.atom', $link->getNode(0)->getAttribute('href'));\n        $this->assertSame('http://pubsubhubbub.appspot.com/', $link->getNode(1)->getAttribute('href'));\n\n        $this->assertSame('http://localhost/' . $user->getUuid() . '.atom', $crawler->filter('channel>link')->text());\n        $this->assertSame('test/test 1.0.0', $crawler->filter('item>title')->text());\n    }\n\n    public function testStats(): void\n    {\n        $crawler = $this->client->request('GET', '/stats');\n\n        $this->assertResponseIsSuccessful();\n    }\n\n    public function testStatus(): void\n    {\n        $crawler = $this->client->request('GET', '/status');\n\n        $data = json_decode((string) $this->client->getResponse()->getContent(), true);\n\n        $this->assertTrue($data['is_fresh']);\n    }\n}\n"
  },
  {
    "path": "tests/Github/ClientDiscoveryTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Github;\n\nuse App\\Github\\ClientDiscovery;\nuse App\\Repository\\UserRepository;\nuse Github\\Client as GithubClient;\nuse Github\\HttpClient\\Builder;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Psr7\\Response;\nuse Http\\Adapter\\Guzzle7\\Client as Guzzle7Client;\nuse Monolog\\Handler\\TestHandler;\nuse Monolog\\Logger;\nuse Predis\\Client as RedisClient;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass ClientDiscoveryTest extends WebTestCase\n{\n    public function testUseApplicationDefaultClient(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $responses = new MockHandler([\n            // first rate_limit, it'll be ok because remaining > 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_APP + 1]]])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n        $redis = new RedisClient();\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $disco = new ClientDiscovery(\n            $userRepository,\n            $redis,\n            'client_id',\n            'client_secret',\n            $logger\n        );\n        $disco->setGithubClient($githubClient);\n\n        $resClient = $disco->find();\n\n        $records = $logHandler->getRecords();\n\n        $this->assertInstanceOf(GithubClient::class, $resClient);\n        $this->assertSame('RateLimit ok (' . (ClientDiscovery::THRESHOLD_RATE_REMAIN_APP + 1) . ') with default application', $records[0]['message']);\n    }\n\n    public function testUseUserToken(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('findAllTokens')\n            ->willReturn([\n                [\n                    'id' => '123',\n                    'username' => 'bob',\n                    'accessToken' => '123123',\n                ],\n                [\n                    'id' => '456',\n                    'username' => 'lion',\n                    'accessToken' => '456456',\n                ],\n            ]);\n\n        $responses = new MockHandler([\n            // first rate_limit, it won't be ok because remaining < 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_APP - 40]]])),\n            // second rate_limit, it won't be ok because remaining < 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_USER - 20]]])),\n            // third rate_limit, it'll' be ok because remaining > 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 150]]])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n        $redis = new RedisClient();\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $disco = new ClientDiscovery(\n            $userRepository,\n            $redis,\n            'client_id',\n            'client_secret',\n            $logger\n        );\n        $disco->setGithubClient($githubClient);\n\n        $resClient = $disco->find();\n\n        $records = $logHandler->getRecords();\n\n        $this->assertInstanceOf(GithubClient::class, $resClient);\n        $this->assertSame('RateLimit ok (' . (ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 150) . ') with user: lion', $records[0]['message']);\n    }\n\n    public function testNoTokenAvailable(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('findAllTokens')\n            ->willReturn([\n                [\n                    'id' => '123',\n                    'username' => 'bob',\n                    'accessToken' => '123123',\n                ],\n            ]);\n\n        $responses = new MockHandler([\n            // first rate_limit, it won't be ok because remaining < 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_APP - 10]]])),\n            // second rate_limit, it won't be ok because remaining < 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_APP - 20]]])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n        $redis = new RedisClient();\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $disco = new ClientDiscovery(\n            $userRepository,\n            $redis,\n            'client_id',\n            'client_secret',\n            $logger\n        );\n        $disco->setGithubClient($githubClient);\n\n        $resClient = $disco->find();\n\n        $records = $logHandler->getRecords();\n\n        $this->assertNull($resClient);\n\n        $this->assertSame('No way to authenticate a client with enough rate limit remaining :(', $records[0]['message']);\n    }\n\n    public function testOneCallFail(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('findAllTokens')\n            ->willReturn([\n                [\n                    'id' => '123',\n                    'username' => 'bob',\n                    'accessToken' => '123123',\n                ],\n            ]);\n\n        $responses = new MockHandler([\n            // first rate_limit request fail (Github booboo)\n            new Response(400, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 100]]])),\n            // second rate_limit, it'll be ok because remaining > 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 100]]])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n        $redis = new RedisClient();\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $disco = new ClientDiscovery(\n            $userRepository,\n            $redis,\n            'client_id',\n            'client_secret',\n            $logger\n        );\n        $disco->setGithubClient($githubClient);\n\n        $resClient = $disco->find();\n\n        $records = $logHandler->getRecords();\n\n        $this->assertInstanceOf(GithubClient::class, $resClient);\n        $this->assertSame('RateLimit call goes bad.', $records[0]['message']);\n        $this->assertSame('RateLimit ok (' . (ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 100) . ') with user: bob', $records[1]['message']);\n    }\n\n    /**\n     * Using only mocks for request.\n     */\n    public function testFunctionnal(): void\n    {\n        $client = static::createClient();\n\n        try {\n            self::getContainer()->get('snc_redis.guzzle_cache')->connect();\n        } catch (\\Exception) {\n            $this->markTestSkipped('Redis is not installed/activated');\n        }\n\n        $responses = new MockHandler([\n            // first rate_limit request fail (Github booboo)\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_APP - 10]]])),\n            // second rate_limit, it'll be ok because remaining > 50\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => ClientDiscovery::THRESHOLD_RATE_REMAIN_USER + 100]]])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $disco = self::getContainer()->get(ClientDiscovery::class);\n        $disco->setGithubClient($githubClient);\n\n        $resClient = $disco->find();\n\n        $this->assertInstanceOf(GithubClient::class, $resClient);\n    }\n}\n"
  },
  {
    "path": "tests/MessageHandler/StarredReposSyncHandlerTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\MessageHandler;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Star;\nuse App\\Entity\\User;\nuse App\\Message\\StarredReposSync;\nuse App\\MessageHandler\\StarredReposSyncHandler;\nuse App\\Repository\\RepoRepository;\nuse App\\Repository\\StarRepository;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\Bundle\\DoctrineBundle\\Registry;\nuse Doctrine\\ORM\\EntityManager;\nuse Github\\Client as GithubClient;\nuse Github\\HttpClient\\Builder;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Psr7\\Response;\nuse Http\\Adapter\\Guzzle7\\Client as Guzzle7Client;\nuse Monolog\\Handler\\TestHandler;\nuse Monolog\\Logger;\nuse Psr\\Log\\NullLogger;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass StarredReposSyncHandlerTest extends WebTestCase\n{\n    public function testProcessNoUser(): void\n    {\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn(null);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $githubClient = $this->getMockBuilder(GithubClient::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $githubClient->expects($this->never())\n            ->method('authenticate');\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->never())\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            new NullLogger(),\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n    }\n\n    public function testProcessSuccessfulMessage(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($user);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->exactly(2))\n            ->method('findAllByUser')\n            ->with(123)\n            ->willReturn([666, 777]);\n\n        $starRepository->expects($this->once())\n            ->method('removeFromUser')\n            ->with([1 => 777], 123);\n\n        $repo = new Repo();\n        $repo->setId(666);\n        $repo->setFullName('j0k3r/banditore');\n        $repo->setUpdatedAt((new \\DateTime())->setTimestamp(time() - 3600 * 72));\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(666)\n            ->willReturn($repo);\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // first /user/starred\n            $this->getOKResponse([[\n                'description' => 'banditore',\n                'homepage' => 'http://banditore.io',\n                'language' => 'PHP',\n                'name' => 'banditore',\n                'full_name' => 'j0k3r/banditore',\n                'id' => 666,\n                'owner' => [\n                    'avatar_url' => 'http://avatar.api/banditore.jpg',\n                ],\n            ]]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // third /user/starred will return empty response which means, we reached the last page\n            $this->getOKResponse([]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->exactly(2))\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            $logger,\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_starred_repos message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob</info> … ', $records[1]['message']);\n        $this->assertSame('    sync 1 starred repos', $records[2]['message']);\n        $this->assertSame('Removed stars: 1', $records[3]['message']);\n        $this->assertSame('[10] Synced repos: 1', $records[4]['message']);\n    }\n\n    public function testUserRemovedFromGitHub(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($user);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->never())\n            ->method('findAllByUser');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->never())\n            ->method('find');\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // first /user/starred\n            new Response(404, ['Content-Type' => 'application/json']),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->exactly(2))\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            $logger,\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_starred_repos message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('(starred) <error>', $records[2]['message']);\n\n        $this->assertNotNull($user->getRemovedAt());\n    }\n\n    public function testProcessUnexpectedError(): void\n    {\n        $this->expectException(\\Exception::class);\n        $this->expectExceptionMessage('booboo');\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($user);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->once())\n            ->method('findAllByUser')\n            ->with(123)\n            ->willReturn([666]);\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(666)\n            ->will($this->throwException(new \\Exception('booboo')));\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // first /user/starred\n            $this->getOKResponse([[\n                'description' => 'banditore',\n                'homepage' => 'http://banditore.io',\n                'language' => 'PHP',\n                'name' => 'banditore',\n                'full_name' => 'j0k3r/banditore',\n                'id' => 666,\n                'owner' => [\n                    'avatar_url' => 'http://avatar.api/banditore.jpg',\n                ],\n            ]]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // second /user/starred will return empty response which means, we reached the last page\n            $this->getOKResponse([]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->once())\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            new NullLogger(),\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n    }\n\n    /**\n     * Everything will goes fine (like testProcessSuccessfulMessage) and we won't remove old stars (no change detected in starred repos).\n     */\n    public function testProcessSuccessfulMessageNoStarToRemove(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(false); // simulate a closing manager\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n        $doctrine->expects($this->once())\n            ->method('resetManager')\n            ->willReturn($em);\n\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($user);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->exactly(2))\n            ->method('findAllByUser')\n            ->with(123)\n            ->willReturn([123]);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('j0k3r/banditore');\n        $repo->setUpdatedAt((new \\DateTime())->setTimestamp(time() - 3600 * 72));\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // first /user/starred\n            $this->getOKResponse([[\n                'description' => 'banditore',\n                'homepage' => 'http://banditore.io',\n                'language' => 'PHP',\n                'name' => 'banditore',\n                'full_name' => 'j0k3r/banditore',\n                'id' => 123,\n                'owner' => [\n                    'avatar_url' => 'http://avatar.api/banditore.jpg',\n                ],\n            ]]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // second /user/starred will return empty response which means, we reached the last page\n            $this->getOKResponse([]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->exactly(2))\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            $logger,\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_starred_repos message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob</info> … ', $records[1]['message']);\n        $this->assertSame('    sync 1 starred repos', $records[2]['message']);\n        $this->assertSame('[10] Synced repos: 1', $records[3]['message']);\n    }\n\n    public function testProcessWithBadClient(): void\n    {\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->never())\n            ->method('find');\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->never())\n            ->method('findAllByUser');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->never())\n            ->method('find');\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->never())\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            null, // simulate a bad client\n            $logger,\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('No client provided', $records[0]['message']);\n    }\n\n    public function testProcessWithRateLimitReached(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->never())\n            ->method('isOpen');\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->never())\n            ->method('getManager');\n\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $userRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($user);\n\n        $starRepository = $this->getMockBuilder(StarRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $starRepository->expects($this->never())\n            ->method('findAllByUser');\n\n        $starRepository->expects($this->never())\n            ->method('removeFromUser');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->never())\n            ->method('find');\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 0]]]),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $redisClient = $this->getMockBuilder(\\Predis\\Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        // will use `setex` & `del` but will be called dynamically by `_call`\n        $redisClient->expects($this->once())\n            ->method('__call');\n\n        $handler = new StarredReposSyncHandler(\n            $doctrine,\n            $userRepository,\n            $starRepository,\n            $repoRepository,\n            $githubClient,\n            $logger,\n            $redisClient\n        );\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_starred_repos message', $records[0]['message']);\n        $this->assertSame('[0] Check <info>bob</info> … ', $records[1]['message']);\n        $this->assertSame('RateLimit reached, stopping.', $records[2]['message']);\n    }\n\n    public function testFunctionalConsumer(): void\n    {\n        $this->restoreFunctionalState();\n\n        $responses = new MockHandler([\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // first /user/starred\n            $this->getOKResponse([[\n                'description' => 'banditore',\n                'homepage' => 'http://banditore.io',\n                'language' => 'PHP',\n                'name' => 'banditore',\n                'full_name' => 'j0k3r/banditore',\n                'id' => 777,\n                'owner' => [\n                    'avatar_url' => 'http://avatar.api/banditore.jpg',\n                ],\n            ]]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // second /user/starred\n            $this->getOKResponse([[\n                'description' => 'This is a test repo',\n                'homepage' => 'http://test.io',\n                'language' => 'Ruby',\n                'name' => 'test',\n                'full_name' => 'test/test',\n                'id' => 666,\n                'owner' => [\n                    'avatar_url' => 'http://0.0.0.0/test.jpg',\n                ],\n            ]]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 8]]]),\n            // third /user/starred will return empty response which means, we reached the last page\n            $this->getOKResponse([]),\n            // /rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 6]]]),\n        ]);\n\n        $githubClient = $this->getMockClient($responses);\n\n        $client = static::createClient();\n\n        // override factory to avoid real call to Github\n        self::getContainer()->set('banditore.client.github.test', $githubClient);\n\n        $handler = self::getContainer()->get(StarredReposSyncHandler::class);\n\n        // before import\n        $stars = self::getContainer()->get(StarRepository::class)->findAllByUser(123);\n        $this->assertCount(2, $stars, 'User 123 has 2 starred repos');\n        $this->assertSame(555, $stars[0], 'User 123 has \"symfony/symfony\" starred repo');\n        $this->assertSame(666, $stars[1], 'User 123 has \"test/test\" starred repo');\n\n        $handler->__invoke(new StarredReposSync(123));\n\n        /** @var Repo */\n        $repo = self::getContainer()->get(RepoRepository::class)->find(777);\n        $this->assertNotNull($repo, 'Imported repo with id 777 exists');\n        $this->assertSame('j0k3r/banditore', $repo->getFullName(), 'Imported repo with id 777 exists');\n\n        // validate that `test/test` association got removed\n        $stars = self::getContainer()->get(StarRepository::class)->findAllByUser(123);\n        $this->assertCount(2, $stars, 'User 123 has 2 starred repos');\n        $this->assertSame(666, $stars[0], 'User 123 has \"test/test\" starred repo');\n        $this->assertSame(777, $stars[1], 'User 123 has \"j0k3r/banditore\" starred repo');\n    }\n\n    private function getOKResponse(array $body): Response\n    {\n        return new Response(\n            200,\n            ['Content-Type' => 'application/json'],\n            (string) json_encode($body)\n        );\n    }\n\n    private function restoreFunctionalState(): void\n    {\n        static::ensureKernelShutdown();\n        static::createClient();\n\n        /** @var EntityManager $entityManager */\n        $entityManager = self::getContainer()->get('doctrine')->getManager();\n        /** @var User $user */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n        /** @var Repo $repoSymfony */\n        $repoSymfony = self::getContainer()->get(RepoRepository::class)->find(555);\n        /** @var Repo $repoTest */\n        $repoTest = self::getContainer()->get(RepoRepository::class)->find(666);\n\n        $entityManager->createQuery('DELETE FROM App\\Entity\\Star s WHERE s.user = :user')\n            ->setParameter('user', $user)\n            ->execute();\n\n        $entityManager->createQuery('DELETE FROM App\\Entity\\Repo r WHERE r.id = :repoId')\n            ->setParameter('repoId', 777)\n            ->execute();\n\n        $entityManager->persist(new Star($user, $repoSymfony));\n        $entityManager->persist(new Star($user, $repoTest));\n        $entityManager->flush();\n        $entityManager->clear();\n\n        static::ensureKernelShutdown();\n    }\n\n    private function getMockClient(MockHandler $responses): GithubClient\n    {\n        $clientHandler = HandlerStack::create($responses);\n\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        return $githubClient;\n    }\n}\n"
  },
  {
    "path": "tests/MessageHandler/VersionsSyncHandlerTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\MessageHandler;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Version;\nuse App\\Message\\VersionsSync;\nuse App\\MessageHandler\\VersionsSyncHandler;\nuse App\\PubSubHubbub\\Publisher;\nuse App\\Repository\\RepoRepository;\nuse App\\Repository\\VersionRepository;\nuse Doctrine\\Bundle\\DoctrineBundle\\Registry;\nuse Doctrine\\ORM\\EntityManager;\nuse Doctrine\\ORM\\UnitOfWork;\nuse Github\\Client as GithubClient;\nuse Github\\HttpClient\\Builder;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Psr7\\Response;\nuse Http\\Adapter\\Guzzle7\\Client as Guzzle7Client;\nuse Monolog\\Handler\\TestHandler;\nuse Monolog\\Logger;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Psr\\Log\\NullLogger;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass VersionsSyncHandlerTest extends WebTestCase\n{\n    public function testProcessNoRepo(): void\n    {\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn(null);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $githubClient = $this->getMockBuilder(GithubClient::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $githubClient->expects($this->never())\n            ->method('authenticate');\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            new NullLogger()\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n    }\n\n    public function getWorkingResponses(): MockHandler\n    {\n        return new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => '2.0.1',\n                'zipball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/zipball/2.0.1',\n                'tarball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/tarball/2.0.1',\n                'commit' => [\n                    'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n                ],\n            ]]),\n            // git/refs/tags\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/1.0.0',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.0',\n                    'object' => [\n                        'sha' => '04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                    ],\n                ],\n                [\n                    'ref' => 'refs/tags/1.0.1',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.1',\n                    'object' => [\n                        'sha' => '4845571072d49c2794b165482420b66c206a942a',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/4845571072d49c2794b165482420b66c206a942a',\n                    ],\n                ],\n                [\n                    'ref' => 'refs/tags/1.0.2',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.2',\n                    'object' => [\n                        'sha' => '694b8cc3983f52209029605300910507bec700b4',\n                        'type' => 'tag',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/tags/694b8cc3983f52209029605300910507bec700b4',\n                    ],\n                ],\n                [\n                    'ref' => 'refs/tags/2.0.1',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/2.0.1',\n                    'object' => [\n                        'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n                    ],\n                ],\n            ]),\n            // TAG 1.0.1\n            // repos/release with tag 1.0.1 (which is not a release)\n            new Response(404, ['Content-Type' => 'application/json'], (string) json_encode([\n                'message' => 'Not Found',\n                'documentation_url' => 'https://developer.github.com/v3',\n            ])),\n            // retrieve tag information from the commit (since the release does not exist)\n            $this->getOKResponse([\n                'sha' => '4845571072d49c2794b165482420b66c206a942a',\n                'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/4845571072d49c2794b165482420b66c206a942a',\n                'html_url' => 'https://github.com/snc/SncRedisBundle/commit/4845571072d49c2794b165482420b66c206a942a',\n                'author' => [\n                    'name' => 'Daniele Alessandri',\n                    'email' => 'suppakilla@gmail.com',\n                    'date' => '2011-10-15T07:49:04Z',\n                ],\n                'committer' => [\n                    'name' => 'Daniele Alessandri',\n                    'email' => 'suppakilla@gmail.com',\n                    'date' => '2011-10-15T07:49:21Z',\n                ],\n                'tree' => [\n                    'sha' => '0f570c5083aa017b7cb5a4b83869ed5054c17764',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/trees/0f570c5083aa017b7cb5a4b83869ed5054c17764',\n                ],\n                'message' => 'Use the correct package type for composer.',\n                'parents' => [[\n                    'sha' => '40f7ee543e217aa3a1eadbc952df56b548071d20',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/40f7ee543e217aa3a1eadbc952df56b548071d20',\n                    'html_url' => 'https://github.com/snc/SncRedisBundle/commit/40f7ee543e217aa3a1eadbc952df56b548071d20',\n                ]],\n            ]),\n            // markdown\n            new Response(200, ['Content-Type' => 'text/html'], '<p>Use the correct package type for composer.</p>'),\n            // TAG 1.0.2\n            // repos/release with tag 1.0.2 (which is not a release)\n            new Response(404, ['Content-Type' => 'application/json'], (string) json_encode([\n                'message' => 'Not Found',\n                'documentation_url' => 'https://developer.github.com/v3',\n            ])),\n            // retrieve tag information from the tag (since the release does not exist)\n            $this->getOKResponse([\n                'sha' => '694b8cc3983f52209029605300910507bec700b4',\n                'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/tags/694b8cc3983f52209029605300910507bec700b4',\n                'tagger' => [\n                    'name' => 'Erwin Mombay',\n                    'email' => 'erwinm@google.com',\n                    'date' => '2012-10-18T17:23:37Z',\n                ],\n                'object' => [\n                    'sha' => '694b8cc3983f52209029605300910507bec700b5',\n                    'type' => 'commit',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/694b8cc3983f52209029605300910507bec700b5',\n                ],\n                'tag' => '1.0.2',\n                'message' => \"weekly release\\n-----BEGIN PGP SIGNATURE-----\\nVersion: GnuPG v2\\n\\niF4EABEIAAYFAliw58IACgkQ64qmmlZsB5VNFwD+L1M86cO76oohqSy4TCbubPAL\\n6341glOKJpfkwyjQnUkBAPCTZSBbe8CFHLxLUvypIiQSMn+AIkPfvzvSEahA40Vz\\n=SaF+\\n-----END PGP SIGNATURE-----\\n\",\n            ]),\n            // markdown\n            new Response(200, ['Content-Type' => 'text/html'], '<p>weekly release</p>'),\n            // TAG 2.0.1\n            // now tag 2.0.1 which is a release\n            $this->getOKResponse([\n                'tag_name' => '2.0.1',\n                'name' => 'Trade-off memory for compute, Windows support, 24 distributions with cdf, variance etc., dtypes, zero-dimensional Tensors, Tensor-Variable merge, , faster distributed, perf and bug fixes, CuDNN 7.1',\n                'prerelease' => false,\n                'published_at' => '2017-02-19T13:27:32Z',\n                'body' => 'yay',\n            ]),\n            // markdown\n            new Response(200, ['Content-Type' => 'text/html'], '<p>yay</p>'),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n    }\n\n    public function testProcessSuccessfulMessage(): void\n    {\n        $uow = $this->getMockBuilder(UnitOfWork::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $uow->expects($this->exactly(3))\n            ->method('getScheduledEntityInsertions')\n            ->willReturn([]);\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(false); // simulate a closing manager\n        $em->expects($this->exactly(3))\n            ->method('getUnitOfWork')\n            ->willReturn($uow);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n        $doctrine->expects($this->once())\n            ->method('resetManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $versionRepository->expects($this->exactly(4))\n            ->method('findExistingOne')\n            ->willReturnCallback(static function ($tagName, $repoId) use ($repo) {\n                // first version will exist, next one won't\n                if ('1.0.0' === $tagName) {\n                    return new Version($repo);\n                }\n            });\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->once())\n            ->method('pingHub')\n            ->with([123]);\n\n        $clientHandler = HandlerStack::create($this->getWorkingResponses());\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertSame('[10] <comment>3</comment> new versions for <info>bob/wow</info>', $records[2]['message']);\n    }\n\n    /**\n     * The call to repo/tags will return a bad response.\n     */\n    public function testProcessRepoTagFailed(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags generate a bad request\n            new Response(400, ['Content-Type' => 'application/json']),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('(repo/tags) <error>', $records[2]['message']);\n    }\n\n    /**\n     * The call to repo/tags will return a \"404\" then the repo will be flag as removed.\n     */\n    public function testProcessRepoNotFound(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags generate a bad request\n            new Response(404, ['Content-Type' => 'application/json']),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('(repo/tags) <error>', $records[2]['message']);\n\n        $this->assertNotNull($repo->getRemovedAt());\n    }\n\n    /**\n     * Not enough calls remaining.\n     */\n    public function testProcessCallsRemaingLow(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->never())\n            ->method('isOpen');\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->never())\n            ->method('getManager');\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 0]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[0] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('RateLimit reached, stopping.', $records[2]['message']);\n    }\n\n    /**\n     * The call to markdown will return a bad response.\n     */\n    public function testProcessMarkdownFailed(): void\n    {\n        $uow = $this->getMockBuilder(UnitOfWork::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $uow->expects($this->once())\n            ->method('getScheduledEntityInsertions')\n            ->willReturn([]);\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n        $em->expects($this->once())\n            ->method('getUnitOfWork')\n            ->willReturn($uow);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $versionRepository->expects($this->once())\n            ->method('findExistingOne')\n            ->willReturn(null);\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => '2.0.1',\n                'zipball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/zipball/2.0.1',\n                'tarball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/tarball/2.0.1',\n                'commit' => [\n                    'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n                ],\n            ]]),\n            // git/refs/tags generate a bad request\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/1.0.0',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.0',\n                    'object' => [\n                        'sha' => '04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                    ],\n                ],\n            ]),\n            // now tag 1.0.0 which is a release\n            $this->getOKResponse([\n                'tag_name' => '1.0.0',\n                'name' => '1.0.0',\n                'prerelease' => false,\n                'published_at' => '2017-02-19T13:27:32Z',\n                'body' => 'yay',\n            ]),\n            // markdown failed\n            new Response(400, ['Content-Type' => 'text/html'], 'booboo'),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('<error>Failed to parse markdown', $records[2]['message']);\n    }\n\n    /**\n     * No tag found for that repo.\n     */\n    public function testProcessNoTagFound(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([]),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertSame('[10] <comment>0</comment> new versions for <info>bob/wow</info>', $records[2]['message']);\n    }\n\n    /**\n     * Generate an unexpected error (like from MySQL).\n     */\n    public function testProcessUnexpectedError(): void\n    {\n        $this->expectException(\\Exception::class);\n        $this->expectExceptionMessage('booboo');\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $versionRepository->expects($this->once())\n            ->method('findExistingOne')\n            ->will($this->throwException(new \\Exception('booboo')));\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => '2.0.1',\n                'zipball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/zipball/2.0.1',\n                'tarball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/tarball/2.0.1',\n                'commit' => [\n                    'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n                ],\n            ]]),\n            // git/refs/tags\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/1.0.0',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/refs/tags/1.0.0',\n                    'object' => [\n                        'sha' => '04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/snc/SncRedisBundle/git/commits/04b99722e0c25bfc45926cd3a1081c04a8e950ed',\n                    ],\n                ],\n            ]),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            new NullLogger()\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n    }\n\n    /**\n     * The call to git/refs/tags will return a bad response.\n     */\n    public function testProcessGitRefTagFailed(): void\n    {\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(true);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => '2.0.1',\n                'zipball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/zipball/2.0.1',\n                'tarball_url' => 'https://api.github.com/repos/snc/SncRedisBundle/tarball/2.0.1',\n                'commit' => [\n                    'sha' => '02c808d157c79ac32777e19f3ec31af24a32d2df',\n                    'url' => 'https://api.github.com/repos/snc/SncRedisBundle/commits/02c808d157c79ac32777e19f3ec31af24a32d2df',\n                ],\n            ]]),\n            // git/refs/tags generate a bad request\n            new Response(400, ['Content-Type' => 'application/json']),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertStringContainsString('(git/refs/tags) <error>', $records[2]['message']);\n    }\n\n    public function testProcessWithBadClient(): void\n    {\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->never())\n            ->method('find');\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            null, // simulate a bad client\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('No client provided', $records[0]['message']);\n    }\n\n    /**\n     * Using mocks only for request.\n     */\n    #[Group('only')]\n    public function testFunctionalConsumer(): void\n    {\n        $this->restoreFunctionalState();\n\n        $clientHandler = HandlerStack::create($this->getWorkingResponses());\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $client = static::createClient();\n\n        // override factory to avoid real call to Github\n        self::getContainer()->set('banditore.client.github.test', $githubClient);\n\n        // mock pubsubhubbub request\n        $guzzleClientPub = $this->getMockBuilder(Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $guzzleClientPub->expects($this->once())\n            ->method('post')\n            ->willReturn(new Response(204));\n\n        self::getContainer()->set('banditore.client.guzzle.test', $guzzleClientPub);\n\n        $handler = self::getContainer()->get(VersionsSyncHandler::class);\n\n        /** @var Version[] */\n        $versions = self::getContainer()->get(VersionRepository::class)->findBy(['repo' => 666]);\n        $this->assertCount(1, $versions, 'Repo 666 has 1 version');\n        $this->assertSame('1.0.0', $versions[0]->getTagName(), 'Repo 666 has 1 version, which is 1.0.0');\n\n        $handler->__invoke(new VersionsSync(666));\n\n        /** @var Version[] */\n        $versions = self::getContainer()->get(VersionRepository::class)->findBy(['repo' => 666]);\n        $this->assertCount(4, $versions, 'Repo 666 has now 4 versions');\n        $this->assertSame('1.0.0', $versions[0]->getTagName(), 'Repo 666 has 4 version. First one is 1.0.0');\n        $this->assertSame('1.0.1', $versions[1]->getTagName(), 'Repo 666 has 4 version. Second one is 1.0.1');\n        $this->assertSame('1.0.2', $versions[2]->getTagName(), 'Repo 666 has 4 version. Third one is 1.0.2');\n        $this->assertSame('<p>weekly release</p>', $versions[2]->getBody(), 'Version 1.0.2 does NOT have a PGP signature');\n        $this->assertSame('2.0.1', $versions[3]->getTagName(), 'Repo 666 has 4 version. Fourth one is 2.0.1');\n    }\n\n    public function testFunctionalConsumerWithTagCaseInsensitive(): void\n    {\n        $this->restoreFunctionalState();\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => 'v2.11.0',\n                'zipball_url' => 'https://api.github.com/repos/mozilla/metrics-graphics/zipball/v2.11.0',\n                'tarball_url' => 'https://api.github.com/repos/mozilla/metrics-graphics/tarball/v2.11.0',\n            ]]),\n            // git/refs/tags\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/V1.1.0',\n                    'url' => 'https://api.github.com/repos/mozilla/metrics-graphics/git/refs/tags/V1.1.0',\n                    'object' => [\n                        'sha' => '6402716c3165eb90cdace5729a18706ea2921187',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/mozilla/metrics-graphics/git/commits/6402716c3165eb90cdace5729a18706ea2921187',\n                    ],\n                ],\n                [\n                    'ref' => 'refs/tags/v1.1.0',\n                    'url' => 'https://api.github.com/repos/mozilla/metrics-graphics/git/refs/tags/v1.1.0',\n                    'object' => [\n                        'sha' => '15a4703db568342043f156b5635d10b17ebe98cb',\n                        'type' => 'commit',\n                        'url' => 'https://api.github.com/repos/mozilla/metrics-graphics/git/commits/15a4703db568342043f156b5635d10b17ebe98cb',\n                    ],\n                ],\n            ]),\n            // TAG V1.1.0\n            // now tag V1.1.0 which is a release\n            $this->getOKResponse([\n                'tag_name' => 'V1.1.0',\n                'name' => 'V1.1.0',\n                'prerelease' => false,\n                'published_at' => '2014-12-01T18:28:39Z',\n                'body' => 'This is the first release after our major push.',\n            ]),\n            // markdown\n            new Response(200, ['Content-Type' => 'text/html'], '<p>This is the first release after our major push.</p>'),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $client = static::createClient();\n\n        // override factory to avoid real call to Github\n        self::getContainer()->set('banditore.client.github.test', $githubClient);\n\n        // mock pubsubhubbub request\n        $guzzleClientPub = $this->getMockBuilder(Client::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $guzzleClientPub->expects($this->once())\n            ->method('post')\n            ->willReturn(new Response(204));\n\n        self::getContainer()->set('banditore.client.guzzle.test', $guzzleClientPub);\n\n        $handler = self::getContainer()->get(VersionsSyncHandler::class);\n\n        /** @var Version[] */\n        $versions = self::getContainer()->get(VersionRepository::class)->findBy(['repo' => 555]);\n        $this->assertCount(1, $versions, 'Repo 555 has 1 version');\n        $this->assertSame('1.0.21', $versions[0]->getTagName(), 'Repo 555 has 1 version, which is 1.0.21');\n\n        $handler->__invoke(new VersionsSync(555));\n\n        /** @var Version[] */\n        $versions = self::getContainer()->get(VersionRepository::class)->findBy(['repo' => 555]);\n        $this->assertCount(2, $versions, 'Repo 555 has now 2 versions');\n        $this->assertSame('1.0.21', $versions[0]->getTagName(), 'Repo 555 has 2 version. First one is 1.0.21');\n        $this->assertSame('V1.1.0', $versions[1]->getTagName(), 'Repo 555 has 2 version. Second one is V1.1.0');\n        $this->assertSame('<p>This is the first release after our major push.</p>', $versions[1]->getBody(), 'Version V1.1.0 body is ok');\n    }\n\n    public function testProcessSuccessfulMessageWithBlobTag(): void\n    {\n        $uow = $this->getMockBuilder(UnitOfWork::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $uow->expects($this->once())\n            ->method('getScheduledEntityInsertions')\n            ->willReturn([]);\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(false); // simulate a closing manager\n        $em->expects($this->once())\n            ->method('getUnitOfWork')\n            ->willReturn($uow);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n        $doctrine->expects($this->once())\n            ->method('resetManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $versionRepository->expects($this->once())\n            ->method('findExistingOne')\n            ->willReturn(null);\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->once())\n            ->method('pingHub')\n            ->with([123]);\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => 'street/wilson_gardens',\n                'zipball_url' => 'https://api.github.com/repos/nivbend/gitstery/zipball/street/wilson_gardens',\n                'tarball_url' => 'https://api.github.com/repos/nivbend/gitstery/tarball/street/wilson_gardens',\n                'commit' => [\n                    'sha' => '659f0c110cd80286eaff33d34b9caf6c8e183102',\n                    'url' => 'https://api.github.com/repos/nivbend/gitstery/commits/659f0c110cd80286eaff33d34b9caf6c8e183102',\n                ],\n            ]]),\n            // git/refs/tags\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/solution',\n                    'url' => 'https://api.github.com/repos/nivbend/gitstery/git/refs/tags/solution',\n                    'object' => [\n                        'sha' => 'b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                        'type' => 'blob',\n                        'url' => 'https://api.github.com/repos/nivbend/gitstery/git/blobs/b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                    ],\n                ],\n            ]),\n            // TAG solution\n            // repos/release with tag solution (which is not a release)\n            new Response(404, ['Content-Type' => 'application/json'], (string) json_encode([\n                'message' => 'Not Found',\n                'documentation_url' => 'https://developer.github.com/v3',\n            ])),\n            // retrieve tag information from the blob\n            $this->getOKResponse([\n                'sha' => 'b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                'url' => 'https://api.github.com/repos/nivbend/gitstery/git/blobs/b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                'size' => 40,\n                'content' => \"ZGUxMzI0OTUxYWZlNmU0NjI0MDY2MGNiYzAzYzE1MDBhOTBmYzkyOA==\\n\",\n                'encoding' => 'base64',\n            ]),\n            // markdown\n            new Response(200, ['Content-Type' => 'text/html'], '<p>(blob, size 40) de1324951afe6e46240660cbc03c1500a90fc928</p>'),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertSame('[10] <comment>1</comment> new versions for <info>bob/wow</info>', $records[2]['message']);\n    }\n\n    public function testBadTagObjectType(): void\n    {\n        $uow = $this->getMockBuilder(UnitOfWork::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $uow->expects($this->once())\n            ->method('getScheduledEntityInsertions')\n            ->willReturn([]);\n\n        $em = $this->getMockBuilder(EntityManager::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $em->expects($this->once())\n            ->method('isOpen')\n            ->willReturn(false); // simulate a closing manager\n        $em->expects($this->once())\n            ->method('getUnitOfWork')\n            ->willReturn($uow);\n\n        $doctrine = $this->getMockBuilder(Registry::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $doctrine->expects($this->once())\n            ->method('getManager')\n            ->willReturn($em);\n        $doctrine->expects($this->once())\n            ->method('resetManager')\n            ->willReturn($em);\n\n        $repo = new Repo();\n        $repo->setId(123);\n        $repo->setFullName('bob/wow');\n        $repo->setName('wow');\n\n        $repoRepository = $this->getMockBuilder(RepoRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $repoRepository->expects($this->once())\n            ->method('find')\n            ->with(123)\n            ->willReturn($repo);\n\n        $versionRepository = $this->getMockBuilder(VersionRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $versionRepository->expects($this->once())\n            ->method('findExistingOne')\n            ->willReturn(null);\n\n        $pubsubhubbub = $this->getMockBuilder(Publisher::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $pubsubhubbub->expects($this->never())\n            ->method('pingHub');\n\n        $responses = new MockHandler([\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n            // repo/tags\n            $this->getOKResponse([[\n                'name' => 'awesometag',\n                'zipball_url' => 'https://api.github.com/repos/aweosome/repo/zipball/street/awesometag',\n                'tarball_url' => 'https://api.github.com/repos/aweosome/repo/tarball/street/awesometag',\n                'commit' => [\n                    'sha' => '659f0c110cd80286eaff33d34b9caf6c8e183102',\n                    'url' => 'https://api.github.com/repos/aweosome/repo/commits/659f0c110cd80286eaff33d34b9caf6c8e183102',\n                ],\n            ]]),\n            // git/refs/tags\n            $this->getOKResponse([\n                [\n                    'ref' => 'refs/tags/awesometag',\n                    'url' => 'https://api.github.com/repos/aweosome/repo/git/refs/tags/awesometag',\n                    'object' => [\n                        'sha' => 'b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                        'type' => 'unknown',\n                        'url' => 'https://api.github.com/repos/aweosome/repo/git/blobs/b3618a9ec1bbc13bf7133c50fb8d15ef8cbe7594',\n                    ],\n                ],\n            ]),\n            // release for that tag does not exist\n            new Response(404, ['Content-Type' => 'application/json']),\n            // rate_limit\n            $this->getOKResponse(['resources' => ['core' => ['reset' => time() + 1000, 'limit' => 200, 'remaining' => 10]]]),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        $logger = new Logger('foo');\n        $logHandler = new TestHandler();\n        $logger->pushHandler($logHandler);\n\n        $handler = new VersionsSyncHandler(\n            $doctrine,\n            $repoRepository,\n            $versionRepository,\n            $pubsubhubbub,\n            $githubClient,\n            $logger\n        );\n\n        $handler->__invoke(new VersionsSync(123));\n\n        $records = $logHandler->getRecords();\n\n        $this->assertSame('Consume banditore.sync_versions message', $records[0]['message']);\n        $this->assertSame('[10] Check <info>bob/wow</info> … ', $records[1]['message']);\n        $this->assertSame('<error>Tag object type not supported: unknown (for: bob/wow)</error>', $records[2]['message']);\n    }\n\n    private function getOKResponse(array $body): Response\n    {\n        return new Response(\n            200,\n            ['Content-Type' => 'application/json'],\n            (string) json_encode($body)\n        );\n    }\n\n    private function restoreFunctionalState(): void\n    {\n        static::ensureKernelShutdown();\n        static::createClient();\n\n        /** @var EntityManager $entityManager */\n        $entityManager = self::getContainer()->get('doctrine')->getManager();\n        /** @var Repo $repoTest */\n        $repoTest = self::getContainer()->get(RepoRepository::class)->find(666);\n        /** @var Repo $repoSymfony */\n        $repoSymfony = self::getContainer()->get(RepoRepository::class)->find(555);\n\n        $entityManager->createQuery('DELETE FROM App\\Entity\\Version v WHERE v.repo IN (:repoIds)')\n            ->setParameter('repoIds', [555, 666])\n            ->execute();\n\n        $versionTest = new Version($repoTest);\n        $versionTest->hydrateFromGithub([\n            'tag_name' => '1.0.0',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY',\n            'published_at' => '2019-10-15T07:49:21Z',\n        ]);\n        $entityManager->persist($versionTest);\n\n        $versionSymfony = new Version($repoSymfony);\n        $versionSymfony->hydrateFromGithub([\n            'tag_name' => '1.0.21',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY 555',\n            'published_at' => '2019-06-15T07:49:21Z',\n        ]);\n        $entityManager->persist($versionSymfony);\n\n        $entityManager->flush();\n        $entityManager->clear();\n\n        static::ensureKernelShutdown();\n    }\n}\n"
  },
  {
    "path": "tests/PubSubHubbub/PublisherTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\PubSubHubbub;\n\nuse App\\PubSubHubbub\\Publisher;\nuse App\\Repository\\UserRepository;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Psr7\\Response;\nuse PHPUnit\\Framework\\TestCase;\nuse Symfony\\Bundle\\FrameworkBundle\\Routing\\Router;\nuse Symfony\\Component\\Config\\Loader\\LoaderInterface;\nuse Symfony\\Component\\DependencyInjection\\Container;\nuse Symfony\\Component\\Routing\\Route;\nuse Symfony\\Component\\Routing\\RouteCollection;\n\nclass PublisherTest extends TestCase\n{\n    /** @var Router */\n    private $router;\n\n    protected function setUp(): void\n    {\n        $routes = new RouteCollection();\n        $routes->add('rss_user', new Route('/{uuid}.atom'));\n\n        $sc = $this->getServiceContainer($routes);\n\n        $this->router = new Router($sc, 'rss_user');\n    }\n\n    public function testNoHubDefined(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n\n        $client = new Client();\n\n        $publisher = new Publisher('', $this->router, $client, $userRepository, 'banditore.com', 'http');\n\n        $res = $publisher->pingHub([1]);\n\n        // the hub url is invalid, so it will be generate an error and return false\n        $this->assertFalse($res);\n    }\n\n    public function testBadResponse(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $userRepository->expects($this->once())\n            ->method('findByRepoIds')\n            ->with([123])\n            ->willReturn([['uuid' => '7fc8de31-5371-4f0a-b606-a7e164c41d46']]);\n\n        $mock = new MockHandler([\n            new Response(500),\n        ]);\n\n        $handler = HandlerStack::create($mock);\n        $client = new Client(['handler' => $handler]);\n\n        $publisher = new Publisher('http://pubsubhubbub.io', $this->router, $client, $userRepository, 'banditore.com', 'http');\n        $res = $publisher->pingHub([123]);\n\n        // the response is bad, so it will return false\n        $this->assertFalse($res);\n    }\n\n    public function testGoodResponse(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $userRepository->expects($this->once())\n            ->method('findByRepoIds')\n            ->with([123])\n            ->willReturn([['uuid' => '7fc8de31-5371-4f0a-b606-a7e164c41d46']]);\n\n        $mock = new MockHandler([\n            new Response(204),\n        ]);\n\n        $handler = HandlerStack::create($mock);\n        $client = new Client(['handler' => $handler]);\n\n        $publisher = new Publisher('http://pubsubhubbub.io', $this->router, $client, $userRepository, 'banditore.com', 'http');\n        $res = $publisher->pingHub([123]);\n\n        $this->assertTrue($res);\n    }\n\n    public function testUrlGeneration(): void\n    {\n        $userRepository = $this->getMockBuilder(UserRepository::class)\n            ->disableOriginalConstructor()\n            ->getMock();\n        $userRepository->expects($this->once())\n            ->method('findByRepoIds')\n            ->with([123])\n            ->willReturn([['uuid' => '7fc8de31-5371-4f0a-b606-a7e164c41d46']]);\n\n        $method = new \\ReflectionMethod(\n            Publisher::class, 'retrieveFeedUrls'\n        );\n\n        $urls = $method->invoke(\n            new Publisher('http://pubsubhubbub.io', $this->router, new Client(), $userRepository, 'banditore.com', 'http'),\n            [123]\n        );\n\n        $this->assertSame(['http://banditore.com/7fc8de31-5371-4f0a-b606-a7e164c41d46.atom'], $urls);\n    }\n\n    /**\n     * @see \\Symfony\\Bundle\\FrameworkBundle\\Tests\\Routing\\RouterTest\n     */\n    private function getServiceContainer(RouteCollection $routes): Container\n    {\n        $loader = $this->getMockBuilder(LoaderInterface::class)->getMock();\n\n        $loader\n            ->expects($this->any())\n            ->method('load')\n            ->willReturn($routes)\n        ;\n\n        $sc = $this->getMockBuilder(Container::class)->onlyMethods(['get'])->getMock();\n\n        $sc\n            ->expects($this->any())\n            ->method('get')\n            ->willReturn($loader)\n        ;\n\n        return $sc;\n    }\n}\n"
  },
  {
    "path": "tests/Repository/UserRepositoryTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Repository;\n\nuse App\\Repository\\UserRepository;\nuse Doctrine\\DBAL\\Connection;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass UserRepositoryTest extends WebTestCase\n{\n    protected function setUp(): void\n    {\n        static::createClient();\n        self::getContainer()->get(Connection::class)->executeStatement('UPDATE star SET ignored_in_feed = 0');\n    }\n\n    public function testFindByRepoIdsExcludesIgnoredStars(): void\n    {\n        $repository = self::getContainer()->get(UserRepository::class);\n\n        $this->assertCount(1, $repository->findByRepoIds([666]));\n\n        self::getContainer()->get(Connection::class)->executeStatement('UPDATE star SET ignored_in_feed = 1 WHERE user_id = 123 AND repo_id = 666');\n\n        $this->assertSame([], $repository->findByRepoIds([666]));\n    }\n}\n"
  },
  {
    "path": "tests/Repository/VersionRepositoryTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Repository;\n\nuse App\\Entity\\Repo;\nuse App\\Entity\\Star;\nuse App\\Entity\\User;\nuse App\\Entity\\Version;\nuse App\\Repository\\VersionRepository;\nuse Doctrine\\DBAL\\Connection;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass VersionRepositoryTest extends WebTestCase\n{\n    protected function setUp(): void\n    {\n        static::createClient();\n        $entityManager = self::getContainer()->get(EntityManagerInterface::class);\n        $entityManager->createQuery('DELETE FROM App\\Entity\\Star s WHERE s.user = :userId')\n            ->setParameter('userId', 123)\n            ->execute();\n        $entityManager->createQuery('DELETE FROM App\\Entity\\Version v WHERE v.repo IN (:repoIds)')\n            ->setParameter('repoIds', [555, 666])\n            ->execute();\n\n        $user = $entityManager->getReference(User::class, 123);\n        $repo666 = $entityManager->getReference(Repo::class, 666);\n        $repo555 = $entityManager->getReference(Repo::class, 555);\n\n        $entityManager->persist(new Star($user, $repo666));\n        $entityManager->persist(new Star($user, $repo555));\n\n        $version666 = new Version($repo666);\n        $version666->hydrateFromGithub([\n            'tag_name' => '1.0.0',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY',\n            'published_at' => '2019-10-15T07:49:21Z',\n        ]);\n\n        $version555 = new Version($repo555);\n        $version555->hydrateFromGithub([\n            'tag_name' => '1.0.21',\n            'name' => 'First release',\n            'prerelease' => false,\n            'message' => 'YAY 555',\n            'published_at' => '2019-06-15T07:49:21Z',\n        ]);\n\n        $entityManager->persist($version666);\n        $entityManager->persist($version555);\n        $entityManager->flush();\n        $entityManager->clear();\n    }\n\n    public function testFindForFeedUserExcludesIgnoredStars(): void\n    {\n        $repository = self::getContainer()->get(VersionRepository::class);\n\n        self::getContainer()->get(Connection::class)->executeStatement('UPDATE star SET ignored_in_feed = 1 WHERE user_id = 123 AND repo_id = 666');\n\n        $dashboardVersions = $repository->findForUser(123);\n        $feedVersions = $repository->findForFeedUser(123);\n\n        $this->assertNotEmpty($dashboardVersions);\n        $this->assertTrue($dashboardVersions[0]['ignoredInFeed']);\n        $this->assertCount(1, $feedVersions);\n        $this->assertSame('symfony/symfony', $feedVersions[0]['fullName']);\n    }\n}\n"
  },
  {
    "path": "tests/Rss/GeneratorTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Rss;\n\nuse App\\Entity\\User;\nuse App\\Rss\\Generator;\nuse PHPUnit\\Framework\\TestCase;\n\nclass GeneratorTest extends TestCase\n{\n    public function test(): void\n    {\n        $user = new User();\n        $user->setId(123);\n        $user->setUsername('bob');\n        $user->setName('Bobby');\n\n        $generator = new Generator();\n        $channel = $generator->generate(\n            $user,\n            [\n                [\n                    'homepage' => 'http://homepa.ge',\n                    'language' => 'Thus',\n                    'ownerAvatar' => 'http://avat.ar/mine.png',\n                    'fullName' => 'test/test',\n                    'description' => 'This is an awesome description',\n                    'tagName' => '1.0.0',\n                    'body' => '<p>yay</p>',\n                    'createdAt' => (new \\DateTime())->setTimestamp(1171502725),\n                ],\n            ],\n            'http://myfeed.api/.rss'\n        );\n\n        $this->assertSame('New releases from starred repo of bob', $channel->getTitle());\n        $this->assertSame('http://myfeed.api/.rss', $channel->getLink());\n        $this->assertSame('Here are all the new releases from all repos starred by bob', $channel->getDescription());\n        $this->assertSame('en', $channel->getLanguage());\n        $this->assertStringContainsString('(c)', $channel->getCopyright());\n        $this->assertStringContainsString('banditore', $channel->getCopyright());\n        $this->assertStringContainsString('15 Feb 2007', $channel->getLastBuildDate()->format('r'));\n        $this->assertSame('banditore', $channel->getGenerator());\n\n        $items = $channel->getItems();\n        $this->assertCount(1, $items);\n\n        $this->assertSame('test/test 1.0.0', $items[0]->getTitle());\n        $this->assertSame('https://github.com/test/test/releases/1.0.0', $items[0]->getLink());\n        $this->assertStringContainsString('<img src=\"http://avat.ar/mine.png&amp;s=140\" alt=\"test/test\" title=\"test/test\" />', $items[0]->getDescription());\n        $this->assertStringContainsString('#Thus', $items[0]->getDescription());\n        $this->assertStringContainsString('<p>yay</p>', $items[0]->getDescription());\n        $this->assertStringContainsString('<b><a href=\"https://github.com/test/test\">test/test</a></b>', $items[0]->getDescription());\n        $this->assertStringContainsString('(<a href=\"http://homepa.ge\">http://homepa.ge</a>)', $items[0]->getDescription());\n        $this->assertStringContainsString('This is an awesome description', $items[0]->getDescription());\n        $this->assertSame('https://github.com/test/test/releases/1.0.0', $items[0]->getGuid()->getGuid());\n        $this->assertTrue($items[0]->getGuid()->getIsPermaLink());\n        $this->assertStringContainsString('15 Feb 2007', $items[0]->getPubDate()->format('r'));\n    }\n}\n"
  },
  {
    "path": "tests/Security/GithubAuthenticatorTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Security;\n\nuse App\\Entity\\User;\nuse App\\Message\\StarredReposSync;\nuse App\\Repository\\UserRepository;\nuse Github\\Client as GithubClient;\nuse Github\\HttpClient\\Builder;\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Psr7\\Response;\nuse Http\\Adapter\\Guzzle7\\Client as Guzzle7Client;\nuse KnpU\\OAuth2ClientBundle\\Client\\OAuth2Client;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\n\nclass GithubAuthenticatorTest extends WebTestCase\n{\n    public function testCallbackWithExistingUser(): void\n    {\n        $this->markTestSkipped('Dunno how to mock the session / access it from the container');\n\n        $client = static::createClient();\n\n        $responses = new MockHandler([\n            // /login/oauth/access_token (to retrieve the access_token from `authenticate()`)\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([\n                'access_token' => 'blablabla',\n            ])),\n            // /api/v3/user (to retrieve user information from Github)\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([\n                'id' => 123,\n                'email' => 'toto@test.io',\n                'name' => 'Bob',\n                'login' => 'admin',\n                'avatar_url' => 'http://avat.ar/my.png',\n            ])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        self::getContainer()->set('banditore.client.github.application', $githubClient);\n        self::getContainer()->get('oauth2.registry')->getClient('github')->getOAuth2Provider()->setHttpClient($guzzleClient);\n\n        self::getContainer()->get('session')->set(OAuth2Client::OAUTH2_SESSION_STATE_KEY, 'MyAwesomeState');\n\n        // before login\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n        $this->assertSame('1234567890', $user->getAccessToken());\n        $this->assertSame('http://0.0.0.0/avatar.jpg', $user->getAvatar());\n\n        $client->request('GET', '/callback?state=MyAwesomeState&code=MyAwesomeCode');\n\n        // after login\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(123);\n        $this->assertSame('blablabla', $user->getAccessToken());\n        $this->assertSame('http://avat.ar/my.png', $user->getAvatar());\n\n        $this->assertSame(302, $client->getResponse()->getStatusCode());\n        /** @var RedirectResponse */\n        $response = $client->getResponse();\n        $this->assertSame('/dashboard', $response->getTargetUrl());\n\n        $message = self::getContainer()->get('session')->getFlashBag()->get('info');\n        $this->assertSame('Successfully logged in!', $message[0]);\n\n        $transport = self::getContainer()->get('messenger.transport.sync_starred_repos');\n        $this->assertCount(1, $transport->get());\n\n        $messages = (array) $transport->get();\n        /** @var StarredReposSync */\n        $message = $messages[0]->getMessage();\n        $this->assertSame(123, $message->getUserId());\n    }\n\n    public function testCallbackWithNewUser(): void\n    {\n        $this->markTestSkipped('Dunno how to mock the session / access it from the container');\n\n        $client = static::createClient();\n\n        $responses = new MockHandler([\n            // /login/oauth/access_token (to retrieve the access_token from `authenticate()`)\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([\n                'access_token' => 'superboum',\n            ])),\n            // /api/v3/user (to retrieve user information from Github)\n            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([\n                'id' => 456,\n                'email' => 'down@g.et',\n                'name' => 'Any',\n                'login' => 'getdown',\n                'avatar_url' => 'http://avat.ar/down.png',\n            ])),\n        ]);\n\n        $clientHandler = HandlerStack::create($responses);\n        $guzzleClient = new Client([\n            'handler' => $clientHandler,\n        ]);\n\n        $httpClient = new Guzzle7Client($guzzleClient);\n        $httpBuilder = new Builder($httpClient);\n        $githubClient = new GithubClient($httpBuilder);\n\n        self::getContainer()->set('banditore.client.github.application', $githubClient);\n        self::getContainer()->get('oauth2.registry')->getClient('github')->getOAuth2Provider()->setHttpClient($guzzleClient);\n\n        self::getContainer()->get('session')->set(OAuth2Client::OAUTH2_SESSION_STATE_KEY, 'MyAwesomeState');\n\n        // before login\n        $user = self::getContainer()->get(UserRepository::class)->find(456);\n        $this->assertNull($user, 'User 456 does not YET exist');\n\n        $client->request('GET', '/callback?state=MyAwesomeState&code=MyAwesomeCode');\n\n        // after login\n        /** @var User */\n        $user = self::getContainer()->get(UserRepository::class)->find(456);\n        $this->assertSame('superboum', $user->getAccessToken());\n        $this->assertSame('http://avat.ar/down.png', $user->getAvatar());\n        $this->assertSame('getdown', $user->getUsername());\n        $this->assertSame('Any', $user->getName());\n\n        $this->assertSame(302, $client->getResponse()->getStatusCode());\n        /** @var RedirectResponse */\n        $response = $client->getResponse();\n        $this->assertSame('/dashboard', $response->getTargetUrl());\n\n        $message = self::getContainer()->get('session')->getFlashBag()->get('info');\n        $this->assertSame('Successfully logged in. Your starred repos will soon be synced!', $message[0]);\n\n        $transport = self::getContainer()->get('messenger.transport.sync_starred_repos');\n        $this->assertCount(1, $transport->get());\n\n        $messages = (array) $transport->get();\n        /** @var StarredReposSync */\n        $message = $messages[0]->getMessage();\n        $this->assertSame(456, $message->getUserId());\n    }\n}\n"
  },
  {
    "path": "tests/Twig/RepoVersionExtensionTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Twig;\n\nuse App\\Twig\\RepoVersionExtension;\nuse PHPUnit\\Framework\\TestCase;\n\nclass RepoVersionExtensionTest extends TestCase\n{\n    public function test(): void\n    {\n        $ext = new RepoVersionExtension();\n\n        $this->assertNull($ext->linkToVersion([]));\n        $this->assertNull($ext->linkToVersion(['fullName' => 'test/test']));\n        $this->assertNull($ext->linkToVersion(['tagName' => 'v1.0.0']));\n\n        $this->assertSame('https://github.com/test/test/releases/v1.0.0', $ext->linkToVersion(['fullName' => 'test/test', 'tagName' => 'v1.0.0']));\n    }\n\n    public function testEncodedTagName(): void\n    {\n        $ext = new RepoVersionExtension();\n\n        $this->assertNull($ext->linkToVersion([]));\n        $this->assertNull($ext->linkToVersion(['fullName' => 'test/test']));\n        $this->assertNull($ext->linkToVersion(['tagName' => '@1.0.0-alpha.1']));\n\n        $this->assertSame('https://github.com/test/test/releases/%401.0.0-alpha.1', $ext->linkToVersion(['fullName' => 'test/test', 'tagName' => '@1.0.0-alpha.1']));\n    }\n}\n"
  },
  {
    "path": "tests/Webfeeds/WebfeedsTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Webfeeds;\n\nuse App\\Webfeeds\\Webfeeds;\nuse PHPUnit\\Framework\\TestCase;\n\nclass WebfeedsTest extends TestCase\n{\n    public function test(): void\n    {\n        $webfeeds = new Webfeeds();\n        $webfeeds->setLogo('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png')\n            ->setIcon('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png')\n            ->setAccentColor('404040');\n\n        $this->assertSame('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png', $webfeeds->getLogo());\n        $this->assertSame('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png', $webfeeds->getIcon());\n        $this->assertSame('404040', $webfeeds->getAccentColor());\n    }\n}\n"
  },
  {
    "path": "tests/Webfeeds/WebfeedsWriterTest.php",
    "content": "<?php\n\nnamespace App\\Tests\\Webfeeds;\n\nuse App\\Webfeeds\\Webfeeds;\nuse App\\Webfeeds\\WebfeedsWriter;\nuse MarcW\\RssWriter\\RssWriter;\nuse PHPUnit\\Framework\\TestCase;\n\nclass WebfeedsWriterTest extends TestCase\n{\n    public function test(): void\n    {\n        $writer = new WebfeedsWriter();\n        $rssWriter = new RssWriter();\n\n        $webfeeds = new Webfeeds();\n        $webfeeds->setLogo('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png')\n            ->setIcon('https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png')\n            ->setAccentColor('404040');\n\n        $writer->write($rssWriter, $webfeeds);\n\n        $expected = <<<'EOF'\n<webfeeds:logo>https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png</webfeeds:logo><webfeeds:icon>https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo_TV_2015.png</webfeeds:icon><webfeeds:accentColor>404040</webfeeds:accentColor>\nEOF\n        ;\n\n        $this->assertSame(\n            $expected,\n            $rssWriter->getXmlWriter()->flush()\n        );\n    }\n}\n"
  },
  {
    "path": "tests/bootstrap.php",
    "content": "<?php\n\nuse Symfony\\Component\\Dotenv\\Dotenv;\n\nrequire dirname(__DIR__) . '/vendor/autoload.php';\n\nif (method_exists(Dotenv::class, 'bootEnv')) {\n    (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');\n}\n\nif ($_SERVER['APP_DEBUG']) {\n    umask(0000);\n}\n"
  },
  {
    "path": "tests/console-application.php",
    "content": "<?php\n\nuse App\\Kernel;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\n\n/**\n * @see https://github.com/phpstan/phpstan-symfony#console-command-analysis\n */\nrequire dirname(__DIR__) . '/tests/bootstrap.php';\n\n$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);\n\nreturn new Application($kernel);\n"
  },
  {
    "path": "tests/object-manager.php",
    "content": "<?php\n\nuse App\\Kernel;\n\nrequire dirname(__DIR__) . '/tests/bootstrap.php';\n\n$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);\n$kernel->boot();\n\nreturn $kernel->getContainer()->get('doctrine')->getManager();\n"
  }
]