Browse Source

Merge pull request #8453 from naderman/funding

Add funding field to composer.json and composer fund command
Jordi Boggiano 5 years ago
parent
commit
055a179cc5

+ 5 - 0
doc/03-cli.md

@@ -420,6 +420,11 @@ This implies `--by-package --by-suggestion`, showing both lists.
 * **--by-suggestion:** Groups output by suggested package.
 * **--no-dev:** Excludes suggestions from `require-dev` packages.
 
+## fund
+
+Discover how to help fund the maintenance of your dependencies. This lists
+all funding links from the installed dependencies.
+
 ## depends (why)
 
 The `depends` command tells you which other packages depend on a certain

+ 33 - 0
doc/04-schema.md

@@ -258,6 +258,39 @@ An example:
 
 Optional.
 
+### funding
+
+A list of URLs to provide funding to the package authors for maintenance and
+development of new functionality.
+
+Each entry consists of the following
+
+* **type:** The type of funding or the platform through which funding can be provided, e.g. patreon, opencollective, tidelift or github.
+* **url:** URL to a website with details and a way to fund the package.
+
+An example:
+
+```json
+{
+    "funding": [
+        {
+            "type": "patreon",
+            "url": "https://www.patreon.com/phpdoctrine"
+        },
+        {
+            "type": "tidelift",
+            "url": "https://tidelift.com/subscription/pkg/packagist-doctrine_doctrine-bundle"
+        },
+        {
+            "type": "other",
+            "url": "https://www.doctrine-project.org/sponsorship.html"
+        }
+    ]
+}
+```
+
+Optional.
+
 ### Package links
 
 All of the following take an object which maps package names to

+ 18 - 0
res/composer-schema.json

@@ -522,6 +522,24 @@
                 }
             }
         },
+        "funding": {
+            "type": "array",
+            "description": "A list of options to fund the development and maintenance of the package.",
+            "items": {
+                "type": "object",
+                "properties": {
+                    "type": {
+                        "type": "string",
+                        "description": "Type of funding or platform through which funding is possible."
+                    },
+                    "url": {
+                        "type": "string",
+                        "description": "URL to a website with details on funding and a way to fund the package.",
+                        "format": "uri"
+                    }
+                }
+            }
+        },
         "non-feature-branches": {
             "type": ["array"],
             "description": "A set of string or regex patterns for non-numeric branch names that will not be handled as feature branches.",

+ 89 - 0
src/Composer/Command/FundCommand.php

@@ -0,0 +1,89 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Command;
+
+use Composer\Package\CompletePackageInterface;
+use Composer\Package\AliasPackage;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * @author Nicolas Grekas <p@tchwork.com>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class FundCommand extends BaseCommand
+{
+    protected function configure()
+    {
+        $this->setName('fund')
+            ->setDescription('Discover how to help fund the maintenance of your dependencies.')
+        ;
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $composer = $this->getComposer();
+
+        $repo = $composer->getRepositoryManager()->getLocalRepository();
+        $fundings = array();
+        foreach ($repo->getPackages() as $package) {
+            if ($package instanceof AliasPackage) {
+                continue;
+            }
+            if ($package instanceof CompletePackageInterface && $funding = $package->getFunding()) {
+                foreach ($funding as $fundingOption) {
+                    list($vendor, $packageName) = explode('/', $package->getPrettyName());
+                    $url = $fundingOption['url'];
+                    if (!empty($fundingOption['type']) && $fundingOption['type'] === 'github' && preg_match('{^https://github.com/([^/]+)$}', $url, $match)) {
+                        $url = 'https://github.com/sponsors/'.$match[1];
+                    }
+                    $fundings[$vendor][$url][] = $packageName;
+                }
+            }
+        }
+
+        ksort($fundings);
+
+        $io = $this->getIO();
+
+        if ($fundings) {
+            $prev = null;
+
+            $io->write('The following packages were found in your dependencies which publish funding information:');
+
+            foreach ($fundings as $vendor => $links) {
+                $io->write('');
+                $io->write(sprintf("<comment>%s</comment>", $vendor));
+                foreach ($links as $url => $packages) {
+                    $line = sprintf('  <info>%s</info>', implode(', ', $packages));
+
+                    if ($prev !== $line) {
+                        $io->write($line);
+                        $prev = $line;
+                    }
+
+                    $io->write(sprintf('    %s', $url));
+                }
+            }
+
+            $io->write("");
+            $io->write("Please consider following these links and sponsoring the work of package authors!");
+            $io->write("Thank you!");
+        } else {
+            $io->write("No funding links were found in your package dependencies. This doesn't mean they don't need your support!");
+        }
+
+        return 0;
+    }
+}

+ 1 - 0
src/Composer/Console/Application.php

@@ -436,6 +436,7 @@ class Application extends BaseApplication
             new Command\ExecCommand(),
             new Command\OutdatedCommand(),
             new Command\CheckPlatformReqsCommand(),
+            new Command\FundCommand(),
         ));
 
         if ('phar:' === substr(__FILE__, 0, 5)) {

+ 19 - 0
src/Composer/Installer.php

@@ -35,6 +35,7 @@ use Composer\IO\IOInterface;
 use Composer\Package\AliasPackage;
 use Composer\Package\BasePackage;
 use Composer\Package\CompletePackage;
+use Composer\Package\CompletePackageInterface;
 use Composer\Package\Link;
 use Composer\Package\Loader\ArrayLoader;
 use Composer\Package\Dumper\ArrayDumper;
@@ -313,6 +314,24 @@ class Installer
             }
         }
 
+        $fundingCount = 0;
+        foreach ($localRepo->getPackages() as $package) {
+            if ($package instanceof CompletePackageInterface && !$package instanceof AliasPackage && $package->getFunding()) {
+                $fundingCount++;
+            }
+        }
+        if ($fundingCount) {
+            $this->io->writeError(array(
+                sprintf(
+                    "<info>%d package%s you are using %s looking for funding.</info>",
+                    $fundingCount,
+                    1 === $fundingCount ? '' : 's',
+                    1 === $fundingCount ? 'is' : 'are'
+                ),
+                '<info>Use the composer fund command to find out more!</info>',
+            ));
+        }
+
         if ($this->runScripts) {
             // dispatch post event
             $eventName = $this->update ? ScriptEvents::POST_UPDATE_CMD : ScriptEvents::POST_INSTALL_CMD;

+ 5 - 0
src/Composer/Package/AliasPackage.php

@@ -377,6 +377,11 @@ class AliasPackage extends BasePackage implements CompletePackageInterface
         return $this->aliasOf->getSupport();
     }
 
+    public function getFunding()
+    {
+        return $this->aliasOf->getFunding();
+    }
+
     public function getNotificationUrl()
     {
         return $this->aliasOf->getNotificationUrl();

+ 19 - 0
src/Composer/Package/CompletePackage.php

@@ -27,6 +27,7 @@ class CompletePackage extends Package implements CompletePackageInterface
     protected $homepage;
     protected $scripts = array();
     protected $support = array();
+    protected $funding = array();
     protected $abandoned = false;
 
     /**
@@ -171,6 +172,24 @@ class CompletePackage extends Package implements CompletePackageInterface
         return $this->support;
     }
 
+    /**
+     * Set the funding
+     *
+     * @param array $funding
+     */
+    public function setFunding(array $funding)
+    {
+        $this->funding = $funding;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getFunding()
+    {
+        return $this->funding;
+    }
+
     /**
      * @return bool
      */

+ 9 - 0
src/Composer/Package/CompletePackageInterface.php

@@ -79,6 +79,15 @@ interface CompletePackageInterface extends PackageInterface
      */
     public function getSupport();
 
+    /**
+     * Returns an array of funding options for the package
+     *
+     * Each item will contain type and url keys
+     *
+     * @return array
+     */
+    public function getFunding();
+
     /**
      * Returns if the package is abandoned or not
      *

+ 1 - 0
src/Composer/Package/Dumper/ArrayDumper.php

@@ -104,6 +104,7 @@ class ArrayDumper
                 'keywords',
                 'repositories',
                 'support',
+                'funding',
             );
 
             $data = $this->dumpValues($package, $keys, $data);

+ 4 - 0
src/Composer/Package/Loader/ArrayLoader.php

@@ -198,6 +198,10 @@ class ArrayLoader implements LoaderInterface
                 $package->setSupport($config['support']);
             }
 
+            if (!empty($config['funding']) && is_array($config['funding'])) {
+                $package->setFunding($config['funding']);
+            }
+
             if (isset($config['abandoned'])) {
                 $package->setAbandoned($config['abandoned']);
             }

+ 26 - 0
src/Composer/Package/Loader/ValidatingArrayLoader.php

@@ -193,6 +193,32 @@ class ValidatingArrayLoader implements LoaderInterface
             }
         }
 
+        if ($this->validateArray('funding') && !empty($this->config['funding'])) {
+            foreach ($this->config['funding'] as $key => $fundingOption) {
+                if (!is_array($fundingOption)) {
+                    $this->errors[] = 'funding.'.$key.' : should be an array, '.gettype($fundingOption).' given';
+                    unset($this->config['funding'][$key]);
+                    continue;
+                }
+                foreach (array('type', 'url') as $fundingData) {
+                    if (isset($fundingOption[$fundingData]) && !is_string($fundingOption[$fundingData])) {
+                        $this->errors[] = 'funding.'.$key.'.'.$fundingData.' : invalid value, must be a string';
+                        unset($this->config['funding'][$key][$fundingData]);
+                    }
+                }
+                if (isset($fundingOption['url']) && !$this->filterUrl($fundingOption['url'])) {
+                    $this->warnings[] = 'funding.'.$key.'.url : invalid value ('.$fundingOption['url'].'), must be an http/https URL';
+                    unset($this->config['funding'][$key]['url']);
+                }
+                if (empty($this->config['funding'][$key])) {
+                    unset($this->config['funding'][$key]);
+                }
+            }
+            if (empty($this->config['funding'])) {
+                unset($this->config['funding']);
+            }
+        }
+
         $unboundConstraint = new Constraint('=', $this->versionParser->normalize('dev-master'));
         $stableConstraint = new Constraint('=', '1.0.0');
 

+ 39 - 0
src/Composer/Repository/Vcs/GitHubDriver.php

@@ -35,6 +35,7 @@ class GitHubDriver extends VcsDriver
     protected $infoCache = array();
     protected $isPrivate = false;
     private $isArchived = false;
+    private $fundingInfo;
 
     /**
      * Git Driver
@@ -166,6 +167,10 @@ class GitHubDriver extends VcsDriver
                 if (!isset($composer['abandoned']) && $this->isArchived) {
                     $composer['abandoned'] = true;
                 }
+
+                if (!isset($composer['funding']) && $funding = $this->getFundingInfo()) {
+                    $composer['funding'] = $funding;
+                }
             }
 
             if ($this->shouldCache($identifier)) {
@@ -178,6 +183,40 @@ class GitHubDriver extends VcsDriver
         return $this->infoCache[$identifier];
     }
 
+    private function getFundingInfo()
+    {
+        if (null !== $this->fundingInfo) {
+            return $this->fundingInfo;
+        }
+
+        if ($this->originUrl !== 'github.com') {
+            return $this->fundingInfo = false;
+        }
+
+        $graphql = 'query{repository(owner:"'.$this->owner.'",name:"'.$this->repository.'"){fundingLinks{platform,url}}}';
+        try {
+            $result = $this->remoteFilesystem->getContents($this->originUrl, 'https://api.github.com/graphql', false, array(
+                'http' => array(
+                    'method' => 'POST',
+                    'content' => json_encode(array('query' => $graphql)),
+                    'header' => array('Content-Type: application/json'),
+                ),
+                'retry-auth-failure' => false,
+            ));
+        } catch (\TransportException $e) {
+            return $this->fundingInfo = false;
+        }
+        $result = json_decode($result, true);
+
+        if (empty($result['data']['repository']['fundingLinks'])) {
+            return $this->fundingInfo = false;
+        }
+
+        return $this->fundingInfo = array_map(function ($link) {
+            return array('type' => strtolower($link['platform']), 'url' => $link['url']);
+        }, $result['data']['repository']['fundingLinks']);
+    }
+
     /**
      * {@inheritdoc}
      */

+ 54 - 0
tests/Composer/Test/Fixtures/installer/install-funding-notice.test

@@ -0,0 +1,54 @@
+--TEST--
+Installs a simple package with exact match requirement
+--COMPOSER--
+{
+    "repositories": [
+        {
+            "type": "package",
+            "package": [
+                {
+                    "name": "a/a",
+                    "version": "1.0.0",
+                    "funding": [{ "type": "example", "url": "http://example.org/fund" }],
+                    "require": {
+                        "d/d": "^1.0"
+                    }
+                },
+                {
+                    "name": "b/b",
+                    "version": "1.0.0",
+                    "funding": [{ "type": "example", "url": "http://example.org/fund" }]
+                },
+                {
+                    "name": "c/c",
+                    "version": "1.0.0",
+                    "funding": [{ "type": "example", "url": "http://example.org/fund" }]
+                },
+                {
+                    "name": "d/d",
+                    "version": "1.0.0",
+                    "require": {
+                        "b/b": "^1.0"
+                    }
+                }
+            ]
+        }
+    ],
+    "require": {
+        "a/a": "1.0.0"
+    }
+}
+--RUN--
+install
+--EXPECT-OUTPUT--
+Loading composer repositories with package information
+Updating dependencies (including require-dev)
+Package operations: 3 installs, 0 updates, 0 removals
+Writing lock file
+Generating autoload files
+2 packages you are using are looking for funding.
+Use the composer fund command to find out more!
+--EXPECT--
+Installing b/b (1.0.0)
+Installing d/d (1.0.0)
+Installing a/a (1.0.0)

+ 6 - 0
tests/Composer/Test/Json/Fixtures/composer.json

@@ -21,6 +21,12 @@
         "irc": "irc://irc.freenode.org/composer",
         "issues": "https://github.com/composer/composer/issues"
     },
+    "funding": [
+        {
+            "type": "service-subscription",
+            "url": "https://packagist.com"
+        }
+    ],
     "require": {
         "php": ">=5.3.2",
         "justinrainbow/json-schema": "~1.4",

+ 4 - 0
tests/Composer/Test/Package/Dumper/ArrayDumperTest.php

@@ -191,6 +191,10 @@ class ArrayDumperTest extends TestCase
                 'support',
                 array('foo' => 'bar'),
             ),
+            array(
+                'funding',
+                array('type' => 'foo', 'url' => 'https://example.com'),
+            ),
             array(
                 'require',
                 array(new Link('foo', 'foo/bar', new Constraint('=', '1.0.0.0'), 'requires', '1.0.0'), new Link('bar', 'bar/baz', new Constraint('=', '1.0.0.0'), 'requires', '1.0.0')),

+ 3 - 0
tests/Composer/Test/Package/Loader/ArrayLoaderTest.php

@@ -97,6 +97,9 @@ class ArrayLoaderTest extends TestCase
             'authors' => array(
                 array('name' => 'Bob', 'email' => 'bob@example.org', 'homepage' => 'example.org', 'role' => 'Developer'),
             ),
+            'funding' => array(
+                array('type' => 'example', 'url' => 'https://example.org/fund'),
+            ),
             'require' => array(
                 'foo/bar' => '1.0',
             ),

+ 9 - 0
tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php

@@ -73,6 +73,15 @@ class ValidatingArrayLoaderTest extends TestCase
                         'rss' => 'http://example.org/rss',
                         'chat' => 'http://example.org/chat',
                     ),
+                    'funding' => array(
+                        array(
+                            'type' => 'example',
+                            'url' => 'https://example.org/fund'
+                        ),
+                        array(
+                            'url' => 'https://example.org/fund'
+                        ),
+                    ),
                     'require' => array(
                         'a/b' => '1.*',
                         'b/c' => '~2',