浏览代码

Auto-configure github hook and remove deprecated service, fixes #907

Jordi Boggiano 6 年之前
父节点
当前提交
8bfc1b5222

+ 1 - 0
app/AppKernel.php

@@ -17,6 +17,7 @@ class AppKernel extends Kernel
             new Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle(),
             new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
             new FOS\UserBundle\FOSUserBundle(),
+            new Http\HttplugBundle\HttplugBundle(),
             new HWI\Bundle\OAuthBundle\HWIOAuthBundle(),
             new Snc\RedisBundle\SncRedisBundle(),
             new WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle(),

+ 11 - 0
app/config/config.yml

@@ -111,6 +111,7 @@ hwi_oauth:
             type:          github
             client_id:     '%github.client_id%'
             client_secret: '%github.client_secret%'
+            scope: admin:repo_hook,read:org
             options:
                 csrf: true
 
@@ -128,3 +129,13 @@ nelmio_cors:
         '^/packages/[^/]+/[^/]+\.json$':
             allow_methods: ['GET']
             forced_allow_origin_value: '*'
+
+httplug:
+    plugins:
+        logger: ~
+    clients:
+        default:
+            factory: 'httplug.factory.guzzle6'
+            plugins: ['httplug.plugin.logger']
+            config:
+                timeout: 2

+ 0 - 4
app/config/config_dev.yml

@@ -42,9 +42,5 @@ monolog:
                 VERBOSITY_DEBUG: DEBUG
             channels: ["doctrine"]
 
-hwi_oauth:
-    http_client:
-        verify_peer: false
-
 #assetic:
 #    use_controller: true

+ 2 - 0
app/config/parameters.yml.dist

@@ -31,6 +31,8 @@ parameters:
     # set those to values obtained by creating an application at https://github.com/settings/applications
     github.client_id: CHANGE_ME_IN_PROD
     github.client_secret: CHANGE_ME_IN_PROD
+    # set to a random value
+    github.webhook_secret: CHANGE_ME_IN_PROD
 
     # -- performance features --
     # set both to apc to optimize things if it is available

+ 4 - 2
composer.json

@@ -41,7 +41,7 @@
 
         "composer/composer": "^1.3@dev",
         "friendsofsymfony/user-bundle": "^2.0@dev",
-        "hwi/oauth-bundle": "^0.4",
+        "hwi/oauth-bundle": "^0.6",
         "nelmio/security-bundle": "^2.4",
         "predis/predis": "^1.0",
         "snc/redis-bundle": "^2.0",
@@ -55,7 +55,9 @@
         "nelmio/cors-bundle": "^1.4",
         "cebe/markdown": "^1.1",
         "algolia/algoliasearch-client-php": "^1.18",
-        "seld/signal-handler": "^1.1"
+        "seld/signal-handler": "^1.1",
+        "php-http/httplug-bundle": "^1.11",
+        "php-http/guzzle6-adapter": "^1.1"
     },
     "_comment": ["fos user bundle 2.0.0 tag needed"],
     "require-dev": {

文件差异内容过多而无法显示
+ 995 - 91
composer.lock


+ 35 - 6
src/Packagist/WebBundle/Controller/ApiController.php

@@ -278,19 +278,35 @@ class ApiController extends Controller
     protected function receivePost(Request $request, $url, $urlRegex)
     {
         // try to parse the URL first to avoid the DB lookup on malformed requests
-        if (!preg_match($urlRegex, $url)) {
+        if (!preg_match($urlRegex, $url, $match)) {
             return new Response(json_encode(array('status' => 'error', 'message' => 'Could not parse payload repository URL')), 406);
         }
 
         // find the user
         $user = $this->findUser($request);
 
-        if (!$user) {
-            return new Response(json_encode(array('status' => 'error', 'message' => 'Invalid credentials')), 403);
+        $packages = null;
+        $autoUpdated = Package::AUTO_MANUAL_HOOK;
+        if (!$user && $match['host'] === 'github.com' && $request->getContent()) {
+            $sig = $request->headers->get('X-Hub-Signature');
+            if ($sig) {
+                list($algo, $sig) = explode('=', $sig);
+                $expected = hash_hmac($algo, $request->getContent(), $this->container->getParameter('github.webhook_secret'));
+                if (hash_equals($expected, $sig)) {
+                    $packages = $this->findPackagesByRepository('https://github.com/'.$match['path']);
+                    $autoUpdated = Package::AUTO_GITHUB_HOOK;
+                }
+            }
         }
 
-        // try to find the user package
-        $packages = $this->findPackagesByUrl($user, $url, $urlRegex);
+        if (!$packages) {
+            if (!$user) {
+                return new Response(json_encode(array('status' => 'error', 'message' => 'Invalid credentials')), 403);
+            }
+
+            // try to find the user package
+            $packages = $this->findPackagesByUrl($user, $url, $urlRegex);
+        }
 
         if (!$packages) {
             return new Response(json_encode(array('status' => 'error', 'message' => 'Could not find a package that matches this request (does user maintain the package?)')), 404);
@@ -302,7 +318,7 @@ class ApiController extends Controller
 
         /** @var Package $package */
         foreach ($packages as $package) {
-            $package->setAutoUpdated(true);
+            $package->setAutoUpdated($autoUpdated);
             $em->flush($package);
 
             $job = $this->get('scheduler')->scheduleUpdate($package);
@@ -328,6 +344,10 @@ class ApiController extends Controller
             $request->request->get('apiToken') :
             $request->query->get('apiToken');
 
+        if (!$apiToken || !$username) {
+            return null;
+        }
+
         $user = $this->get('packagist.user_repository')
             ->findOneBy(array('username' => $username, 'apiToken' => $apiToken));
 
@@ -364,4 +384,13 @@ class ApiController extends Controller
 
         return $packages;
     }
+
+    /**
+     * @param string $url
+     * @return array the packages found
+     */
+    protected function findPackagesByRepository(string $url): array
+    {
+        return $this->getDoctrine()->getRepository('PackagistWebBundle:Package')->findBy(['repository' => $url]);
+    }
 }

+ 4 - 1
src/Packagist/WebBundle/Controller/PackageController.php

@@ -108,6 +108,9 @@ class PackageController extends Controller
                 $em->flush();
 
                 $this->get('packagist.provider_manager')->insertPackage($package);
+                if ($user->getGithubToken()) {
+                    $this->get('github_user_migration_worker')->setupWebHook($user->getGithubToken(), $package);
+                }
 
                 $this->get('session')->getFlashBag()->set('success', $package->getName().' has been added to the package list, the repository will now be crawled.');
 
@@ -559,7 +562,7 @@ class PackageController extends Controller
 
         if ($package->getMaintainers()->contains($user) || $this->isGranted('ROLE_UPDATE_PACKAGES')) {
             if (null !== $autoUpdated) {
-                $package->setAutoUpdated((bool) $autoUpdated);
+                $package->setAutoUpdated($autoUpdated ? Package::AUTO_MANUAL_HOOK : 0);
                 $doctrine->getManager()->flush();
             }
 

+ 31 - 0
src/Packagist/WebBundle/Controller/UserController.php

@@ -49,6 +49,37 @@ class UserController extends Controller
         );
     }
 
+    /**
+     * @Route("/trigger-github-sync/", name="user_github_sync")
+     */
+    public function triggerGitHubSyncAction(Request $req)
+    {
+        $user = $this->getUser();
+        if (!$user) {
+            throw new \AccessDeniedException();
+        }
+
+        if (!$user->getGithubToken()) {
+            $this->get('session')->getFlashBag()->set('error', 'You must connect your user account to github to sync packages.');
+
+            return $this->redirectToRoute('fos_user_profile_show');
+        }
+
+        if (!$user->getGithubScope()) {
+            $this->get('session')->getFlashBag()->set('error', 'Please log out and log in with GitHub again to make sure the correct GitHub permissions are granted.');
+
+            return $this->redirectToRoute('fos_user_profile_show');
+        }
+
+        $this->get('scheduler')->scheduleUserScopeMigration($user->getId(), '', $user->getGithubScope());
+
+        sleep(5);
+
+        $this->get('session')->getFlashBag()->set('success', 'User sync scheduled. It might take a few seconds to run through, make sure you refresh then to check if any packages still need sync.');
+
+        return $this->redirectToRoute('fos_user_profile_show');
+    }
+
     /**
      * @Route("/spammers/{name}/", name="mark_spammer")
      * @ParamConverter("user", options={"mapping": {"name": "username"}})

+ 19 - 5
src/Packagist/WebBundle/Entity/Package.php

@@ -29,7 +29,8 @@ use Composer\Repository\Vcs\GitHubDriver;
  *     indexes={
  *         @ORM\Index(name="indexed_idx",columns={"indexedAt"}),
  *         @ORM\Index(name="crawled_idx",columns={"crawledAt"}),
- *         @ORM\Index(name="dumped_idx",columns={"dumpedAt"})
+ *         @ORM\Index(name="dumped_idx",columns={"dumpedAt"}),
+ *         @ORM\Index(name="repository_idx",columns={"repository"})
  *     }
  * )
  * @Assert\Callback(callback="isPackageUnique")
@@ -39,6 +40,9 @@ use Composer\Repository\Vcs\GitHubDriver;
  */
 class Package
 {
+    const AUTO_MANUAL_HOOK = 1;
+    const AUTO_GITHUB_HOOK = 2;
+
     /**
      * @ORM\Id
      * @ORM\Column(type="integer")
@@ -143,9 +147,9 @@ class Package
     private $downloads;
 
     /**
-     * @ORM\Column(type="boolean")
+     * @ORM\Column(type="smallint")
      */
-    private $autoUpdated = false;
+    private $autoUpdated = 0;
 
     /**
      * @var bool
@@ -813,13 +817,23 @@ class Package
     /**
      * Set autoUpdated
      *
-     * @param Boolean $autoUpdated
+     * @param int $autoUpdated
      */
     public function setAutoUpdated($autoUpdated)
     {
         $this->autoUpdated = $autoUpdated;
     }
 
+    /**
+     * Get autoUpdated
+     *
+     * @return int
+     */
+    public function getAutoUpdated()
+    {
+        return $this->autoUpdated;
+    }
+
     /**
      * Get autoUpdated
      *
@@ -827,7 +841,7 @@ class Package
      */
     public function isAutoUpdated()
     {
-        return $this->autoUpdated;
+        return $this->autoUpdated > 0;
     }
 
     /**

+ 14 - 0
src/Packagist/WebBundle/Entity/PackageRepository.php

@@ -78,6 +78,20 @@ class PackageRepository extends EntityRepository
         return $this->getPackageNamesForQuery($query);
     }
 
+    public function getGitHubPackagesByMaintainer(int $userId)
+    {
+        $query = $this->createQueryBuilder('p')
+            ->select('p')
+            ->leftJoin('p.maintainers', 'm')
+            ->where('m.id = :userId')
+            ->andWhere('p.repository LIKE :repoUrl')
+            ->andWhere('p.autoUpdated != :synced')
+            ->getQuery()
+            ->setParameters(['userId' => $userId, 'repoUrl' => 'https://github.com/%', 'synced' => Package::AUTO_GITHUB_HOOK]);
+
+        return $query->getResult();
+    }
+
     public function getPackagesWithFields($filters, $fields)
     {
         $selector = '';

+ 27 - 1
src/Packagist/WebBundle/Entity/User.php

@@ -94,6 +94,12 @@ class User extends BaseUser
      */
     private $githubToken;
 
+    /**
+     * @ORM\Column(type="string", length=255, nullable=true)
+     * @var string
+     */
+    private $githubScope;
+
     /**
      * @ORM\Column(type="boolean", options={"default"=true})
      * @var string
@@ -217,7 +223,7 @@ class User extends BaseUser
     }
 
     /**
-     * Get githubId.
+     * Get githubToken.
      *
      * @return string
      */
@@ -236,6 +242,26 @@ class User extends BaseUser
         $this->githubToken = $githubToken;
     }
 
+    /**
+     * Get githubScope.
+     *
+     * @return string
+     */
+    public function getGithubScope()
+    {
+        return $this->githubScope;
+    }
+
+    /**
+     * Set githubScope.
+     *
+     * @param string $githubScope
+     */
+    public function setGithubScope($githubScope)
+    {
+        $this->githubScope = $githubScope;
+    }
+
     /**
      * Set failureNotifications
      *

+ 13 - 2
src/Packagist/WebBundle/Model/PackageManager.php

@@ -16,7 +16,7 @@ use Symfony\Bridge\Doctrine\RegistryInterface;
 use Packagist\WebBundle\Entity\Package;
 use Psr\Log\LoggerInterface;
 use AlgoliaSearch\Client as AlgoliaClient;
-
+use Packagist\WebBundle\Service\GitHubUserMigrationWorker;
 
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>
@@ -31,8 +31,9 @@ class PackageManager
     protected $providerManager;
     protected $algoliaClient;
     protected $algoliaIndexName;
+    protected $githubWorker;
 
-    public function __construct(RegistryInterface $doctrine, \Swift_Mailer $mailer, \Twig_Environment $twig, LoggerInterface $logger, array $options, ProviderManager $providerManager, AlgoliaClient $algoliaClient, string $algoliaIndexName)
+    public function __construct(RegistryInterface $doctrine, \Swift_Mailer $mailer, \Twig_Environment $twig, LoggerInterface $logger, array $options, ProviderManager $providerManager, AlgoliaClient $algoliaClient, string $algoliaIndexName, GitHubUserMigrationWorker $githubWorker)
     {
         $this->doctrine = $doctrine;
         $this->mailer = $mailer;
@@ -42,6 +43,7 @@ class PackageManager
         $this->providerManager = $providerManager;
         $this->algoliaClient = $algoliaClient;
         $this->algoliaIndexName  = $algoliaIndexName;
+        $this->githubWorker  = $githubWorker;
     }
 
     public function deletePackage(Package $package)
@@ -52,6 +54,15 @@ class PackageManager
             $versionRepo->remove($version);
         }
 
+        if ($package->getAutoUpdated() === Package::AUTO_GITHUB_HOOK) {
+            foreach ($package->getMaintainers() as $maintainer) {
+                $token = $maintainer->getGithubToken();
+                if ($token && $this->githubWorker->deleteWebHook($token, $package)) {
+                    break;
+                }
+            }
+        }
+
         $downloadRepo = $this->doctrine->getRepository('PackagistWebBundle:Download');
         $downloadRepo->deletePackageDownloads($package);
 

+ 11 - 1
src/Packagist/WebBundle/Resources/config/services.yml

@@ -29,6 +29,10 @@ services:
         tags:
             - { name: kernel.event_subscriber }
 
+    guzzle_client:
+        class: GuzzleHttp\Client
+        arguments: [{timeout: 3}]
+
     packagist.package_dumper:
         class: Packagist\WebBundle\Package\SymlinkDumper
         arguments: [ '@doctrine', '@filesystem', '@router', '%kernel.root_dir%/../web/', '%packagist_metadata_dir%', '%packagist_dumper_compress%' ]
@@ -36,7 +40,7 @@ services:
     packagist.user_provider:
         class: Packagist\WebBundle\Security\Provider\UserProvider
         public: false
-        arguments: ['@fos_user.user_manager', '@fos_user.user_provider.username_email']
+        arguments: ['@fos_user.user_manager', '@fos_user.user_provider.username_email', '@scheduler']
 
     packagist.user_repository:
         class: Packagist\WebBundle\Entity\UserRepository
@@ -112,6 +116,7 @@ services:
             - '@packagist.provider_manager'
             - '@packagist.algolia.client'
             - '%algolia.index_name%'
+            - '@github_user_migration_worker'
 
     packagist.profile.form.type:
         class: Packagist\WebBundle\Form\Type\ProfileFormType
@@ -147,6 +152,7 @@ services:
             - "@doctrine"
             - "@logger"
             - 'package:updates': '@updater_worker'
+              'githubuser:migrate': '@github_user_migration_worker'
 
     scheduler:
         class: Packagist\WebBundle\Service\Scheduler
@@ -160,6 +166,10 @@ services:
         class: Packagist\WebBundle\Service\UpdaterWorker
         arguments: ["@logger", "@doctrine", "@packagist.package_updater", "@locker", "@scheduler", "@packagist.package_manager", "@packagist.download_manager"]
 
+    github_user_migration_worker:
+        class: Packagist\WebBundle\Service\GitHubUserMigrationWorker
+        arguments: ["@logger", "@doctrine", "@guzzle_client", "%github.webhook_secret%"]
+
     packagist.log_resetter:
         class: Packagist\WebBundle\Service\LogResetter
         arguments: ['@service_container', '%fingers_crossed_handlers%']

+ 5 - 7
src/Packagist/WebBundle/Resources/views/About/about.html.twig

@@ -101,17 +101,15 @@ v2.0.4-p1</code></pre>
     <h2 class="title" id="how-to-update-packages">How to update packages?</h2>
     <section class="row">
         <section class="col-md-6">
-            <h3>GitHub Service Hook</h3>
+            <h3>GitHub Hook</h3>
             <p>Enabling the Packagist service hook ensures that your package will always be updated instantly when you push to GitHub.</p>
             <p>To do so you can:</p>
             <ul>
-                <li>Go to your GitHub repository</li>
-                <li>Click the "Settings" button</li>
-                <li>Click "Integrations &amp; services"</li>
-                <li>Add a "Packagist" service, and configure it with your API token, plus your Packagist username</li>
-                <li>Check the "Active" box and submit the form</li>
+                <li>Make sure you login via GitHub (if you already have an account not connected to GitHub, you can <a href="https://packagist.org/profile/edit">connect it on your profile</a>).</li>
+                <li>Make sure <a href="https://github.com/settings/connections/applications/a059f127e1c09c04aa5a">the Packagist application</a> has access to all the GitHub organizations you need to publish packages from.</li>
+                <li>Check <a href="https://packagist.org/profile/">your package list</a> to see if any has a warning about not being automatically synced.</li>
+                <li>If you still need to setup sync on some packages, try <a rel="nofollow noindex" href="{{ path('user_github_sync') }}">triggering a manual account sync</a> to have Packagist try to set up hooks on your account again.</li>
             </ul>
-            <p>You can then hit the "Test Service" button to trigger it and check if Packagist removes the warning about the package not being auto-updated.</p>
         </section>
 
         <section class="col-md-6">

+ 5 - 2
src/Packagist/WebBundle/Resources/views/Package/viewPackage.html.twig

@@ -45,13 +45,16 @@
                 <div class="col-md-8">
                     <p class="requireme"><i class="glyphicon glyphicon-save"></i> <input type="text" readonly="readonly" value="composer {% if package.type == 'project' %}create-project{% else %}require{% endif %} {{ "#{package.vendor}/#{package.packageName}" }}" /></p>
 
-                    {% if not package.autoUpdated and app.user and (package.maintainers.contains(app.user) or is_granted('ROLE_UPDATE_PACKAGES')) %}
+                    {% if not package.isAutoUpdated() and app.user and (package.maintainers.contains(app.user) or is_granted('ROLE_UPDATE_PACKAGES')) %}
                         {% if "github.com" in package.repository %}
-                            <div class="alert alert-danger">This package is not auto-updated. Please set up the <a href="{{ path('about') }}#how-to-update-packages">GitHub Service Hook</a> for Packagist so that it gets updated whenever you push!</div>
+                            <div class="alert alert-danger">This package is not auto-updated. Please set up the <a href="{{ path('about') }}#how-to-update-packages">GitHub Hook</a> for Packagist so that it gets updated whenever you push!</div>
                         {% elseif "bitbucket.org" in package.repository %}
                             <div class="alert alert-danger">This package is not auto-updated. Please set up the <a href="{{ path('about') }}#how-to-update-packages">BitBucket Webhooks</a> for Packagist so that it gets updated whenever you push!</div>
                         {% endif %}
                     {% endif %}
+                    {% if "github.com" in package.repository and package.getAutoUpdated() == 1 and app.user and (package.maintainers.contains(app.user) or is_granted('ROLE_UPDATE_PACKAGES')) %}
+                        <div class="alert alert-danger">This package is using the legacy GitHub service and will stop being auto-updated in early 2019. Please set up the new <a href="{{ path('about') }}#how-to-update-packages">GitHub Hook</a> for Packagist so that it keeps working in the future.</div>
+                    {% endif %}
 
                     {% if package.abandoned %}
                         <div class="alert alert-danger">

+ 5 - 1
src/Packagist/WebBundle/Resources/views/macros.html.twig

@@ -16,7 +16,11 @@
                                 {% if package.id is not numeric %}
                                     <small>(Virtual Package)</small>
                                 {% endif %}
-                                {% if showAutoUpdateWarning and not package.autoUpdated %}
+                                {% if showAutoUpdateWarning and '://github.com/' in package.repository and package.getAutoUpdated() == 1 %}
+                                    <small>(Legacy Auto-Update, Needs Attention)</small>
+                                {% elseif showAutoUpdateWarning and '://github.com/' in package.repository and package.getAutoUpdated() == 0 %}
+                                    <small>(Not Auto-Updated, Needs Attention)</small>
+                                {% elseif showAutoUpdateWarning and not package.isAutoUpdated() %}
                                     <small>(Not Auto-Updated)</small>
                                 {% endif %}
                             </h4>

+ 15 - 1
src/Packagist/WebBundle/Security/Provider/UserProvider.php

@@ -19,6 +19,7 @@ use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface;
 use Packagist\WebBundle\Entity\User;
 use Symfony\Component\Security\Core\User\UserInterface;
 use Symfony\Component\Security\Core\User\UserProviderInterface;
+use Packagist\WebBundle\Service\Scheduler;
 
 class UserProvider implements OAuthAwareUserProviderInterface, UserProviderInterface
 {
@@ -32,14 +33,20 @@ class UserProvider implements OAuthAwareUserProviderInterface, UserProviderInter
      */
     private $userProvider;
 
+    /**
+     * @var Scheduler
+     */
+    private $scheduler;
+
     /**
      * @param UserManagerInterface  $userManager
      * @param UserProviderInterface $userProvider
      */
-    public function __construct(UserManagerInterface $userManager, UserProviderInterface $userProvider)
+    public function __construct(UserManagerInterface $userManager, UserProviderInterface $userProvider, Scheduler $scheduler)
     {
         $this->userManager = $userManager;
         $this->userProvider = $userProvider;
+        $this->scheduler = $scheduler;
     }
 
     /**
@@ -55,6 +62,8 @@ class UserProvider implements OAuthAwareUserProviderInterface, UserProviderInter
         /** @var User $user */
         $user->setGithubId($username);
         $user->setGithubToken($response->getAccessToken());
+        $user->setGithubScope($response->getOAuthToken()->getRawToken()['scope']);
+        $this->scheduler->scheduleUserScopeMigration($user->getId(), '', $user->getGithubScope());
 
         // The account is already connected. Do nothing
         if ($previousUser === $user) {
@@ -86,7 +95,12 @@ class UserProvider implements OAuthAwareUserProviderInterface, UserProviderInter
 
         if ($user->getGithubToken() !== $response->getAccessToken()) {
             $user->setGithubToken($response->getAccessToken());
+            $oldScope = $user->getGithubScope();
+            $user->setGithubScope($response->getOAuthToken()->getRawToken()['scope']);
             $this->userManager->updateUser($user);
+            if ($oldScope !== $user->getGithubScope()) {
+                $this->scheduler->scheduleUserScopeMigration($user->getId(), $oldScope ?: '', $user->getGithubScope());
+            }
         }
 
         return $user;

+ 237 - 0
src/Packagist/WebBundle/Service/GitHubUserMigrationWorker.php

@@ -0,0 +1,237 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Service;
+
+use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Entity\User;
+use Packagist\WebBundle\Entity\Job;
+use Seld\Signal\SignalHandler;
+use GuzzleHttp\Client;
+use GuzzleHttp\Psr7\Response;
+
+class GitHubUserMigrationWorker
+{
+    const HOOK_URL = 'https://packagist.org/api/github';
+
+    private $logger;
+    private $doctrine;
+    private $guzzle;
+    private $webhookSecret;
+
+    public function __construct(LoggerInterface $logger, RegistryInterface $doctrine, Client $guzzle, string $webhookSecret)
+    {
+        $this->logger = $logger;
+        $this->doctrine = $doctrine;
+        $this->guzzle = $guzzle;
+        $this->webhookSecret = $webhookSecret;
+    }
+
+    public function process(Job $job, SignalHandler $signal): array
+    {
+        $em = $this->doctrine->getEntityManager();
+        $id = $job->getPayload()['id'];
+        $packageRepository = $em->getRepository(Package::class);
+        $userRepository = $em->getRepository(User::class);
+
+        /** @var User $user */
+        $user = $userRepository->findOneById($id);
+
+        if (!$user) {
+            $this->logger->info('User is gone, skipping', ['id' => $id]);
+
+            return ['status' => Job::STATUS_COMPLETED, 'message' => 'User was deleted, skipped'];
+        }
+
+        try {
+            $hookChanges = 0;
+            foreach ($packageRepository->getGitHubPackagesByMaintainer($id) as $package) {
+                $hookChanges += $this->setupWebHook($user->getGithubToken(), $package);
+            }
+        } catch (\GuzzleHttp\Exception\ServerException $e) {
+            return [
+                'status' => Job::STATUS_RESCHEDULE,
+                'message' => 'Got error, rescheduling: '.$e->getMessage(),
+                'after' => new \DateTime('+5 minutes'),
+            ];
+        } catch (\GuzzleHttp\Exception\ConnectException $e) {
+            return [
+                'status' => Job::STATUS_RESCHEDULE,
+                'message' => 'Got error, rescheduling: '.$e->getMessage(),
+                'after' => new \DateTime('+5 minutes'),
+            ];
+        }
+
+        return [
+            'status' => Job::STATUS_COMPLETED,
+            'message' => 'Hooks updated for user '.$user->getUsername(),
+            'hookChanges' => $hookChanges,
+        ];
+    }
+
+    public function setupWebHook(string $token, Package $package): int
+    {
+        if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
+            return 0;
+        }
+
+        $this->logger->debug('Updating hooks for package '.$package->getName());
+
+        $repoKey = $match['owner'].'/'.$match['repo'];
+
+        try {
+            $hooks = $this->getHooks($token, $repoKey);
+
+            $legacyHooks = array_values(array_filter(
+                $hooks,
+                function ($hook) {
+                    return $hook['name'] === 'packagist' && $hook['active'] === true;
+                }
+            ));
+            $currentHooks = array_values(array_filter(
+                $hooks,
+                function ($hook) {
+                    return $hook['name'] === 'web' && strpos($hook['config']['url'], self::HOOK_URL) === 0;
+                }
+            ));
+
+            $hookData = $this->getGitHubHookData();
+            $hasValidHook = false;
+            foreach ($currentHooks as $index => $hook) {
+                $expectedConfigWithoutSecret = $hookData['config'];
+                $configWithoutSecret = $hook['config'];
+                unset($configWithoutSecret['secret'], $expectedConfigWithoutSecret['secret']);
+
+                if ($hook['updated_at'] < '2018-09-04T13:00:00' || $hook['events'] != $hookData['events'] || $configWithoutSecret != $expectedConfigWithoutSecret || !$hook['active']) {
+                    $this->logger->debug('Updating hook '.$hook['id']);
+                    $this->request($token, 'PATCH', 'repos/'.$repoKey.'/hooks/'.$hook['id'], $hookData);
+                    $hasValidHook = true;
+                }
+                unset($currentHooks[$index]);
+            }
+
+            foreach (array_merge(array_values($currentHooks), $legacyHooks) as $hook) {
+                $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
+                $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
+            }
+
+            if (!$hasValidHook) {
+                $this->logger->debug('Creating hook');
+                $this->request($token, 'POST', 'repos/'.$repoKey.'/hooks', $hookData);
+            }
+        } catch (\GuzzleHttp\Exception\ClientException $e) {
+            // repo not found probably means the user does not have admin access to it on github
+            if ($msg = $this->isAcceptableException($e)) {
+                $this->logger->debug($msg);
+
+                return 0;
+            }
+
+            throw $e;
+        }
+
+        return 1;
+    }
+
+    public function deleteWebHook(string $token, Package $package): bool
+    {
+        if (!preg_match('#^(?:(?:https?|git)://([^/]+)/|git@([^:]+):)(?P<owner>[^/]+)/(?P<repo>.+?)(?:\.git|/)?$#', $package->getRepository(), $match)) {
+            return true;
+        }
+
+        $this->logger->debug('Deleting hooks for package '.$package->getName());
+
+        $repoKey = $match['owner'].'/'.$match['repo'];
+
+        try {
+            $hooks = $this->getHooks($token, $repoKey);
+
+            foreach ($hooks as $hook) {
+                if ($hook['name'] === 'web' && strpos($hook['config']['url'], self::HOOK_URL) === 0) {
+                    $this->logger->debug('Deleting hook '.$hook['id'], ['hook' => $hook]);
+                    $this->request($token, 'DELETE', 'repos/'.$repoKey.'/hooks/'.$hook['id']);
+                }
+            }
+        } catch (\GuzzleHttp\Exception\ClientException $e) {
+            if ($msg = $this->isAcceptableException($e)) {
+                $this->logger->debug($msg);
+
+                return false;
+            }
+
+            throw $e;
+        }
+
+        return true;
+    }
+
+    private function getHooks(string $token, string $repoKey): array
+    {
+        $hooks = [];
+        $page = '';
+
+        do {
+            $resp = $this->request($token, 'GET', 'repos/'.$repoKey.'/hooks'.$page);
+            $hooks = array_merge($hooks, json_decode((string) $resp->getBody(), true));
+            $hasNext = false;
+            foreach ($resp->getHeader('Link') as $header) {
+                if (preg_match('{<https://api.github.com/resource?page=(?P<page>\d+)>; rel="next"}', $header, $match)) {
+                    $hasNext = true;
+                    $page = '?page='.$match['page'];
+                }
+            }
+        } while ($hasNext);
+
+        return $hooks;
+    }
+
+    private function request(string $token, string $method, string $url, array $json = null): Response
+    {
+        if (strpos($url, '?')) {
+            $url .= '&access_token='.$token;
+        } else {
+            $url .= '?access_token='.$token;
+        }
+
+        $opts = [
+            'headers' => ['Accept' => 'application/vnd.github.v3+json'],
+        ];
+
+        if ($json) {
+            $opts['json'] = $json;
+        }
+
+        return $this->guzzle->request($method, 'https://api.github.com/' . $url, $opts);
+    }
+
+    private function getGitHubHookData(): array
+    {
+        return [
+            'name' => 'web',
+            'config' => [
+                'url' => self::HOOK_URL,
+                'content_type' => 'json',
+                'secret' => $this->webhookSecret,
+            ],
+            'events' => [
+                'push',
+            ],
+            'active' => true,
+        ];
+    }
+
+    private function isAcceptableException(\Throwable $e)
+    {
+        // repo not found probably means the user does not have admin access to it on github
+        if ($e->getCode() === 404) {
+            return 'User has no access, skipping';
+        }
+
+        if ($e->getCode() === 403 && strpos($e->getMessage(), 'Repository was archived so is read-only') !== false) {
+            return 'Repository was archived';
+        }
+
+        return false;
+    }
+}

+ 0 - 1
src/Packagist/WebBundle/Service/QueueWorker.php

@@ -56,7 +56,6 @@ class QueueWorker
 
             $result = $this->redis->brpop('jobs', 10);
             if (!$result) {
-                $this->logger->debug('No message in queue');
                 continue;
             }
 

+ 5 - 0
src/Packagist/WebBundle/Service/Scheduler.php

@@ -53,6 +53,11 @@ class Scheduler
         return $this->createJob('package:updates', ['id' => $packageOrId, 'update_equal_refs' => $updateEqualRefs, 'delete_before' => $deleteBefore], $packageOrId, $executeAfter);
     }
 
+    public function scheduleUserScopeMigration(int $userId, string $oldScope, string $newScope): Job
+    {
+        return $this->createJob('githubuser:migrate', ['id' => $userId, 'old_scope' => $oldScope, 'new_scope' => $newScope]);
+    }
+
     private function getPendingUpdateJob(int $packageId, $updateEqualRefs = false, $deleteBefore = false)
     {
         $result = $this->doctrine->getManager()->getConnection()->fetchAssoc(

+ 1 - 1
src/Packagist/WebBundle/Tests/Controller/ApiControllerTest.php

@@ -17,7 +17,7 @@ class ApiControllerTest extends WebTestCase
 
         $payload = json_encode(array('repository' => array('url' => 'git://github.com/composer/composer',)));
         $client->request('POST', '/api/github?username=INVALID_USER&apiToken=INVALID_TOKEN', array('payload' => $payload,));
-        $this->assertEquals(403, $client->getResponse()->getStatusCode(), 'POST method should return 403 "Forbidden" if invalid username and API Token are sent');
+        $this->assertEquals(403, $client->getResponse()->getStatusCode(), 'POST method should return 403 "Forbidden" if invalid username and API Token are sent: '.$client->getResponse()->getContent());
     }
 
     /**

部分文件因为文件数量过多而无法显示