├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Command ├── FundCommand.php └── ThanksCommand.php ├── GitHubClient.php └── Thanks.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2019 Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Give thanks (in the form of a [GitHub ★ ](https://help.github.com/articles/about-stars/)) to your fellow PHP package maintainers (not limited to Symfony components)! 6 | 7 | Install 8 | ------- 9 | 10 | Install this as any other (dev) Composer package: 11 | ```sh 12 | composer require --dev symfony/thanks 13 | ``` 14 | 15 | You can also install it once for all your local projects: 16 | ```sh 17 | composer global require symfony/thanks 18 | ``` 19 | 20 | Usage 21 | ----- 22 | 23 | ```sh 24 | composer thanks 25 | ``` 26 | 27 | This will find all of your Composer dependencies, find their github.com repository, and star their GitHub repositories. This was inspired by `cargo thanks`, which was inspired in part by Medium's clapping button as a way to show thanks for someone else's work you've found enjoyment in. 28 | 29 | If you're wondering why did some dependencies get thanked and not others, the answer is that this plugin only supports github.com at the moment. Pull requests are welcome to add support for thanking packages hosted on other services. 30 | 31 | Original idea by Doug Tangren (softprops) 2017 for Rust (thanks!) 32 | 33 | Implemented by Nicolas Grekas (SensioLabs & Blackfire.io) 2017 for PHP. 34 | 35 | Forwarding stars 36 | ---------------- 37 | 38 | Package authors can *send* a star to another package that they would like to thank. 39 | 40 | If you are a package author and want to thank another repository, you can add a `thanks` entry in the `extra` section of your `composer.json` file. 41 | 42 | For example, `symfony/webpack-encore-pack` sends a star to `symfony/webpack-encore`: 43 | 44 | ```json 45 | { 46 | "extra": { 47 | "thanks": { 48 | "name": "symfony/webpack-encore", 49 | "url": "https://github.com/symfony/webpack-encore" 50 | } 51 | } 52 | } 53 | ``` 54 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/thanks", 3 | "description": "Encourages sending ⭐ and 💵 to fellow PHP package maintainers (not limited to Symfony components)!", 4 | "type": "composer-plugin", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Nicolas Grekas", 9 | "email": "p@tchwork.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "composer-plugin-api": "^1.0|^2.0" 15 | }, 16 | "autoload": { 17 | "psr-4": { 18 | "Symfony\\Thanks\\": "src" 19 | } 20 | }, 21 | "extra": { 22 | "branch-alias": { 23 | "dev-main": "1.4-dev" 24 | }, 25 | "class": "Symfony\\Thanks\\Thanks" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Command/FundCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Thanks\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Symfony\Component\Console\Input\InputInterface; 16 | use Symfony\Component\Console\Input\InputOption; 17 | use Symfony\Component\Console\Output\OutputInterface; 18 | use Symfony\Thanks\GitHubClient; 19 | 20 | /** 21 | * @author Nicolas Grekas 22 | */ 23 | class FundCommand extends BaseCommand 24 | { 25 | private $star = '★ '; 26 | private $love = '💖 '; 27 | private $cash = '💵 '; 28 | 29 | protected function configure(): void 30 | { 31 | if ('Hyper' === getenv('TERM_PROGRAM')) { 32 | $this->star = '⭐ '; 33 | } elseif ('\\' === \DIRECTORY_SEPARATOR) { 34 | $this->star = '*'; 35 | $this->love = '<3'; 36 | $this->cash = '$$$'; 37 | } 38 | 39 | $this->setName('fund') 40 | ->setDescription(sprintf('Discover the funding links that fellow PHP package maintainers publish %s.', $this->cash)) 41 | ; 42 | } 43 | 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $composer = $this->getComposer(); 47 | $gitHub = new GitHubClient($composer, $this->getIO()); 48 | 49 | $repos = $gitHub->getRepositories($failures, true); 50 | $fundings = []; 51 | $notStarred = 0; 52 | 53 | foreach ($repos as $alias => $repo) { 54 | $notStarred += (int) !$repo['viewerHasStarred']; 55 | 56 | foreach ($repo['fundingLinks'] as $link) { 57 | list($owner, $package) = explode('/', $repo['package'], 2); 58 | $fundings[$owner][$link['url']][] = $package; 59 | } 60 | } 61 | 62 | if ($fundings) { 63 | $prev = null; 64 | 65 | $output->writeln('The following packages were found in your dependencies and publish sponsoring links on their GitHub page:'); 66 | 67 | foreach ($fundings as $owner => $links) { 68 | $output->writeln(sprintf("\n%s", $owner)); 69 | foreach ($links as $url => $packages) { 70 | $line = sprintf(' %s/%s', $owner, implode(', ', $packages)); 71 | 72 | if ($prev !== $line) { 73 | $output->writeln($line); 74 | $prev = $line; 75 | } 76 | $output->writeln(sprintf(' %s %s', $this->cash, $url)); 77 | } 78 | } 79 | 80 | $output->writeln("\nPlease consider following these links and sponsoring the work of package authors!"); 81 | $output->writeln(sprintf("\nThank you! %s", $this->love)); 82 | } else { 83 | $output->writeln("No funding links were found in your package dependencies. This doesn't mean they don't need your support!"); 84 | } 85 | 86 | if ($notStarred) { 87 | $output->writeln(sprintf("\nRun composer thanks to send a %s to %d GitHub repositor%s of your fellow package maintainers.", $this->star, $notStarred, 1 < $notStarred ? 'ies' : 'y')); 88 | } 89 | 90 | return 0; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Command/ThanksCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Thanks\Command; 13 | 14 | use Composer\Command\BaseCommand; 15 | use Symfony\Component\Console\Input\InputInterface; 16 | use Symfony\Component\Console\Input\InputOption; 17 | use Symfony\Component\Console\Output\OutputInterface; 18 | use Symfony\Thanks\GitHubClient; 19 | 20 | /** 21 | * @author Nicolas Grekas 22 | */ 23 | class ThanksCommand extends BaseCommand 24 | { 25 | private $star = '★ '; 26 | private $love = '💖 '; 27 | private $cash = '💵 '; 28 | 29 | protected function configure(): void 30 | { 31 | if ('Hyper' === getenv('TERM_PROGRAM')) { 32 | $this->star = '⭐ '; 33 | } elseif ('\\' === \DIRECTORY_SEPARATOR) { 34 | $this->star = '*'; 35 | $this->love = '<3'; 36 | $this->cash = '$$$'; 37 | } 38 | 39 | $this->setName('thanks') 40 | ->setDescription(sprintf('Give thanks (in the form of a GitHub %s) to your fellow PHP package maintainers.', $this->star)) 41 | ->setDefinition([ 42 | new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Don\'t actually send the stars'), 43 | ]) 44 | ; 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | $composer = $this->getComposer(); 50 | $gitHub = new GitHubClient($composer, $this->getIO()); 51 | 52 | $repos = $gitHub->getRepositories($failures); 53 | 54 | $template = '%1$s: addStar(input:{clientMutationId:"%s",starrableId:"%s"}){clientMutationId}'."\n"; 55 | $graphql = ''; 56 | $notStarred = []; 57 | 58 | foreach ($repos as $alias => $repo) { 59 | if (!$repo['viewerHasStarred']) { 60 | $graphql .= sprintf($template, $alias, $repo['id']); 61 | $notStarred[$alias] = $repo; 62 | } 63 | } 64 | 65 | if (!$notStarred) { 66 | $output->writeln('You already starred all your GitHub dependencies.'); 67 | } else { 68 | if (!$input->getOption('dry-run')) { 69 | $notStarred = $gitHub->call(sprintf("mutation{\n%s}", $graphql)); 70 | } 71 | 72 | $output->writeln('Stars sent to:'); 73 | foreach ($repos as $alias => $repo) { 74 | $output->writeln(sprintf(' %s %s - %s', $this->star, sprintf(isset($notStarred[$alias]) ? '%s' : '%s', $repo['package']), $repo['url'])); 75 | } 76 | } 77 | 78 | if ($failures) { 79 | $output->writeln(''); 80 | $output->writeln('Some repositories could not be starred, please run composer update and try again:'); 81 | 82 | foreach ($failures as $alias => $failure) { 83 | foreach ((array) $failure['messages'] as $message) { 84 | $output->writeln(sprintf(' * %s - %s', $failure['url'], $message)); 85 | } 86 | } 87 | } 88 | 89 | $output->writeln("\nPlease consider contributing back in any way if you can!"); 90 | $output->writeln(sprintf("\nRun composer fund to discover how you can sponsor your fellow PHP package maintainers %s", $this->cash)); 91 | $output->writeln(sprintf("\nThank you! %s", $this->love)); 92 | 93 | return 0; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/GitHubClient.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Thanks; 13 | 14 | use Composer\Composer; 15 | use Composer\Downloader\TransportException; 16 | use Composer\Factory; 17 | use Composer\IO\IOInterface; 18 | use Composer\Json\JsonFile; 19 | use Composer\Util\HttpDownloader; 20 | 21 | /** 22 | * @author Nicolas Grekas 23 | */ 24 | class GitHubClient 25 | { 26 | // This is a list of projects that should get a star on their main repository 27 | // (when there is one) whenever you use any of their other repositories. 28 | // When a project's main repo is also a dependency of their other repos (like amphp/amp), 29 | // there is no need to list it here, as starring will transitively happen anyway. 30 | private static $mainRepositories = [ 31 | 'api-platform' => [ 32 | 'name' => 'api-platform/api-platform', 33 | 'url' => 'https://github.com/api-platform/api-platform', 34 | ], 35 | 'cakephp' => [ 36 | 'name' => 'cakephp/cakephp', 37 | 'url' => 'https://github.com/cakephp/cakephp', 38 | ], 39 | 'drupal' => [ 40 | 'name' => 'drupal/drupal', 41 | 'url' => 'https://github.com/drupal/drupal', 42 | ], 43 | 'laravel' => [ 44 | 'name' => 'laravel/laravel', 45 | 'url' => 'https://github.com/laravel/laravel', 46 | ], 47 | 'illuminate' => [ 48 | 'name' => 'laravel/laravel', 49 | 'url' => 'https://github.com/laravel/laravel', 50 | ], 51 | 'nette' => [ 52 | 'name' => 'nette/nette', 53 | 'url' => 'https://github.com/nette/nette', 54 | ], 55 | 'phpDocumentor' => [ 56 | 'name' => 'phpDocumentor/phpDocumentor2', 57 | 'url' => 'https://github.com/phpDocumentor/phpDocumentor2', 58 | ], 59 | 'matomo' => [ 60 | 'name' => 'piwik/piwik', 61 | 'url' => 'https://github.com/matomo-org/matomo', 62 | ], 63 | 'reactphp' => [ 64 | 'name' => 'reactphp/react', 65 | 'url' => 'https://github.com/reactphp/react', 66 | ], 67 | 'sebastianbergmann' => [ 68 | 'name' => 'phpunit/phpunit', 69 | 'url' => 'https://github.com/sebastianbergmann/phpunit', 70 | ], 71 | 'slimphp' => [ 72 | 'name' => 'slimphp/Slim', 73 | 'url' => 'https://github.com/slimphp/Slim', 74 | ], 75 | 'Sylius' => [ 76 | 'name' => 'Sylius/Sylius', 77 | 'url' => 'https://github.com/Sylius/Sylius', 78 | ], 79 | 'symfony' => [ 80 | 'name' => 'symfony/symfony', 81 | 'url' => 'https://github.com/symfony/symfony', 82 | ], 83 | 'yiisoft' => [ 84 | 'name' => 'yiisoft/yii2', 85 | 'url' => 'https://github.com/yiisoft/yii2', 86 | ], 87 | 'zendframework' => [ 88 | 'name' => 'zendframework/zendframework', 89 | 'url' => 'https://github.com/zendframework/zendframework', 90 | ], 91 | ]; 92 | 93 | private $composer; 94 | private $io; 95 | private $rfs; 96 | 97 | public function __construct(Composer $composer, IOInterface $io) 98 | { 99 | $this->composer = $composer; 100 | $this->io = $io; 101 | 102 | if (class_exists(HttpDownloader::class)) { 103 | $this->rfs = new HttpDownloader($io, $composer->getConfig()); 104 | } else { 105 | $this->rfs = Factory::createRemoteFilesystem($io, $composer->getConfig()); 106 | } 107 | } 108 | 109 | public function getRepositories(?array &$failures = null, bool $withFundingLinks = false): array 110 | { 111 | $repo = $this->composer->getRepositoryManager()->getLocalRepository(); 112 | 113 | $urls = [ 114 | 'composer/composer' => 'https://github.com/composer/composer', 115 | 'php/php-src' => 'https://github.com/php/php-src', 116 | 'symfony/thanks' => 'https://github.com/symfony/thanks', 117 | ]; 118 | 119 | $directPackages = $this->getDirectlyRequiredPackageNames(); 120 | // symfony/thanks shouldn't trigger thanking symfony/symfony 121 | unset($directPackages['symfony/thanks']); 122 | foreach ($repo->getPackages() as $package) { 123 | $extra = $package->getExtra(); 124 | 125 | if (isset($extra['thanks']['name'], $extra['thanks']['url'])) { 126 | $urls += [$extra['thanks']['name'] => $extra['thanks']['url']]; 127 | } 128 | 129 | if (!$url = $package->getSourceUrl()) { 130 | continue; 131 | } 132 | 133 | $urls[$package->getName()] = $url; 134 | 135 | if (!preg_match('#^https://github.com/([^/]++)#', $url, $url)) { 136 | continue; 137 | } 138 | $owner = $url[1]; 139 | 140 | // star the main repository, but only if this package is directly 141 | // being required by the user's composer.json 142 | if (isset(self::$mainRepositories[$owner], $directPackages[$package->getName()])) { 143 | $urls[self::$mainRepositories[$owner]['name']] = self::$mainRepositories[$owner]['url']; 144 | } 145 | } 146 | 147 | ksort($urls); 148 | 149 | $i = 0; 150 | $template = $withFundingLinks 151 | ? '_%d: repository(owner:"%s",name:"%s"){id,viewerHasStarred,fundingLinks{platform,url}}'."\n" 152 | : '_%d: repository(owner:"%s",name:"%s"){id,viewerHasStarred}'."\n"; 153 | $graphql = ''; 154 | 155 | foreach ($urls as $package => $url) { 156 | if (preg_match('#^https://github.com/([^/]++)/(.*?)(?:\.git)?$#i', $url, $url)) { 157 | $graphql .= sprintf($template, ++$i, $url[1], $url[2]); 158 | $aliases['_'.$i] = [$package, sprintf('https://github.com/%s/%s', $url[1], $url[2])]; 159 | } 160 | } 161 | 162 | $failures = []; 163 | $repos = []; 164 | 165 | foreach ($this->call(sprintf("query{\n%s}", $graphql), $failures) as $alias => $repo) { 166 | $repo['package'] = $aliases[$alias][0]; 167 | $repo['url'] = $aliases[$alias][1]; 168 | $repos[$alias] = $repo; 169 | } 170 | 171 | foreach ($failures as $alias => $messages) { 172 | $failures[$alias] = [ 173 | 'messages' => $messages, 174 | 'package' => $aliases[$alias][0], 175 | 'url' => $aliases[$alias][1], 176 | ]; 177 | } 178 | 179 | return $repos; 180 | } 181 | 182 | public function call($graphql, array &$failures = []): mixed 183 | { 184 | $options = [ 185 | 'http' => [ 186 | 'method' => 'POST', 187 | 'content' => json_encode(['query' => $graphql]), 188 | 'header' => ['Content-Type: application/json'], 189 | ], 190 | ]; 191 | 192 | if ($this->rfs instanceof HttpDownloader) { 193 | $result = $this->rfs->get('https://api.github.com/graphql', $options)->getBody(); 194 | } else { 195 | $result = $this->rfs->getContents('github.com', 'https://api.github.com/graphql', false, $options); 196 | } 197 | 198 | $result = json_decode($result, true); 199 | 200 | if (isset($result['errors'][0]['message'])) { 201 | if (!isset($result['data'])) { 202 | throw new TransportException($result['errors'][0]['message']); 203 | } 204 | 205 | foreach ($result['errors'] as $error) { 206 | if (!isset($error['path'])) { 207 | $failures[isset($error['type']) ? $error['type'] : $error['message']] = $error['message']; 208 | continue; 209 | } 210 | 211 | foreach ($error['path'] as $path) { 212 | $failures += [$path => $error['message']]; 213 | unset($result['data'][$path]); 214 | } 215 | } 216 | } 217 | 218 | return isset($result['data']) ? $result['data'] : []; 219 | } 220 | 221 | private function getDirectlyRequiredPackageNames(): array 222 | { 223 | $file = new JsonFile(Factory::getComposerFile(), null, $this->io); 224 | 225 | if (!$file->exists()) { 226 | throw new \Exception('Could not find your composer.json file!'); 227 | } 228 | 229 | $data = $file->read() + ['require' => [], 'require-dev' => []]; 230 | $data = array_keys($data['require'] + $data['require-dev']); 231 | 232 | return array_combine($data, $data); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Thanks.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Thanks; 13 | 14 | use Composer\Composer; 15 | use Composer\Console\Application; 16 | use Composer\EventDispatcher\EventSubscriberInterface; 17 | use Composer\Installer\PackageEvents; 18 | use Composer\IO\IOInterface; 19 | use Composer\Plugin\PluginInterface; 20 | use Composer\Script\Event as ScriptEvent; 21 | use Composer\Script\ScriptEvents; 22 | use Symfony\Component\Console\Input\ArgvInput; 23 | 24 | /** 25 | * @author Nicolas Grekas 26 | */ 27 | class Thanks implements EventSubscriberInterface, PluginInterface 28 | { 29 | private $io; 30 | private $displayReminder = 0; 31 | 32 | public function activate(Composer $composer, IOInterface $io): void 33 | { 34 | $this->io = $io; 35 | 36 | foreach (debug_backtrace() as $trace) { 37 | if (!isset($trace['object']) || !isset($trace['args'][0])) { 38 | continue; 39 | } 40 | 41 | if (!$trace['object'] instanceof Application || !$trace['args'][0] instanceof ArgvInput) { 42 | continue; 43 | } 44 | 45 | $input = $trace['args'][0]; 46 | $app = $trace['object']; 47 | 48 | try { 49 | $command = $input->getFirstArgument(); 50 | $command = $command ? $app->find($command)->getName() : null; 51 | } catch (\InvalidArgumentException $e) { 52 | } 53 | 54 | if ('update' === $command) { 55 | $this->displayReminder = 1; 56 | } 57 | 58 | $app->add(new Command\ThanksCommand()); 59 | 60 | if (!$app->has('fund')) { 61 | $app->add(new Command\FundCommand()); 62 | } 63 | 64 | break; 65 | } 66 | } 67 | 68 | public function enableReminder(): void 69 | { 70 | if (1 === $this->displayReminder) { 71 | $this->displayReminder = version_compare('1.1.0', PluginInterface::PLUGIN_API_VERSION, '<=') ? 2 : 0; 72 | } 73 | } 74 | 75 | public function displayReminder(ScriptEvent $event): void 76 | { 77 | if (2 !== $this->displayReminder) { 78 | return; 79 | } 80 | 81 | $gitHub = new GitHubClient($event->getComposer(), $event->getIO()); 82 | 83 | $notStarred = 0; 84 | foreach ($gitHub->getRepositories() as $repo) { 85 | $notStarred += (int) !$repo['viewerHasStarred']; 86 | } 87 | 88 | if (!$notStarred) { 89 | return; 90 | } 91 | 92 | $love = '💖 '; 93 | $star = '★ '; 94 | $cash = '💵 '; 95 | 96 | if ('Hyper' === getenv('TERM_PROGRAM')) { 97 | $star = '⭐ '; 98 | } elseif ('\\' === \DIRECTORY_SEPARATOR) { 99 | $love = 'love'; 100 | $star = 'star'; 101 | $cash = 'cash.'; 102 | } 103 | 104 | $this->io->writeError(''); 105 | $this->io->writeError('What about running composer thanks now?'); 106 | $this->io->writeError(sprintf('This will spread some %s by sending a %s to %d GitHub repositor%s of your fellow package maintainers.', $love, $star, $notStarred, 1 < $notStarred ? 'ies' : 'y')); 107 | $this->io->writeError(sprintf('You can also run composer fund to discover how you can sponsor their work with some %s', $cash)); 108 | $this->io->writeError(''); 109 | } 110 | 111 | public static function getSubscribedEvents(): array 112 | { 113 | return [ 114 | PackageEvents::POST_PACKAGE_UPDATE => 'enableReminder', 115 | ScriptEvents::POST_UPDATE_CMD => 'displayReminder', 116 | ]; 117 | } 118 | 119 | /** 120 | * {@inheritdoc} 121 | */ 122 | public function deactivate(Composer $composer, IOInterface $io): void 123 | { 124 | } 125 | 126 | /** 127 | * {@inheritdoc} 128 | */ 129 | public function uninstall(Composer $composer, IOInterface $io): void 130 | { 131 | } 132 | } 133 | --------------------------------------------------------------------------------