#!/usr/bin/env php * * Run this from a branch which has an upstream remote branch, and an associated * pull request. * * The script will merge the branch into master, push master (which will * automatically close the pull request), and delete both the local and remote * branches. * * Based on a script by @christoomey. Translated into PHP. */ class ClosesPullRequests { private $targetBranch; private $localBranch; private $remoteBranch; private const RUN_TYPE_COMMAND = 'command'; private const RUN_TYPE_QUERY = 'query'; public function __construct() { $this->localBranch = $this->run( 'git rev-parse --abbrev-ref HEAD', self::RUN_TYPE_QUERY ); $this->targetBranch = $this->getTargetBranchFromArgs(); $this->remoteBranch = $this->run( 'git rev-parse --abbrev-ref --symbolic-full-name @{u}', self::RUN_TYPE_QUERY ); $this->remoteBranch = str_replace('origin/', '', $this->remoteBranch); } public function __invoke(): void { $this->confirmCiStatusIsPassing(); // TODO: Check that the current branch has a tracking branch. $this->ensureWorkingDirectoryAndIndexAreClean(); $this->fetchOrigin(); $this->ensureFeatureBranchInSync(); $this->ensureTargetBranchInSync(); $this->checkoutTargetBranch(); $this->mergeLocalBranch(); $this->pushTargetBranch(); $this->deleteRemoteBranch(); $this->deleteLocalBranch(); } private function ensureWorkingDirectoryAndIndexAreClean(): void { echo 'Ensuring that index and working directory are clean...' . PHP_EOL; $isIndexClean = $this->run('git diff --cached --exit-code', self::RUN_TYPE_COMMAND); $isWorkingDirClean = $this->run('git diff --exit-code', self::RUN_TYPE_COMMAND); if (!$isIndexClean || !$isWorkingDirClean) { $this->dieWithMessage('Index or working dir not clean. Aborting.'); } } private function getTargetBranchFromArgs(): string { if (!$targetBranchName = $this->getArg('t:', ['target:'])) { $this->dieWithMessage('Invalid target branch specified. Aborting.'); } return $targetBranchName; } private function confirmCiStatusIsPassing(): void { if ($this->isForce()) { echo 'Forced. Skipping ci-status check...' . PHP_EOL; return; } echo 'Confirming ci-status on PR is green...' . PHP_EOL; $passedCi = $this->run('gh pr checks', self::RUN_TYPE_COMMAND); // TODO: Check if there are no CI checks. Does this return `true` as well? if (!$passedCi) { $this->dieWithMessage('CI pending or failed.'); } } private function fetchOrigin(): void { print 'Fetching origin to confirm local and remote in sync...' . PHP_EOL; $this->run('git fetch origin', self::RUN_TYPE_COMMAND); } private function ensureTargetBranchInSync(): void { $this->ensureBranchInSyncWithUpstream( $this->targetBranch, $this->targetBranch ); } private function ensureFeatureBranchInSync(): void { $this->ensureBranchInSyncWithUpstream( $this->localBranch, $this->remoteBranch ); } private function ensureBranchInSyncWithUpstream( string $localBranch, string $remoteBranch ): void { echo sprintf( 'Ensuring that %s is in sync with its upstream...', $localBranch ) . PHP_EOL; $localCommitTip = $this->tipCommitOfBranch($localBranch); $remoteCommitTip = $this->tipCommitOfBranch(sprintf( 'origin/%s', $remoteBranch )); if ($localCommitTip != $remoteCommitTip) { $this->dieWithMessage(sprintf( 'Branch %s was out of date, needs rebasing. Aborting.', $localBranch )); } } private function tipCommitOfBranch(string $branchName): string { return $this->run( sprintf('git rev-parse %s', $branchName), self::RUN_TYPE_QUERY ); } private function checkoutTargetBranch(): void { echo sprintf('Checking out %s...' . PHP_EOL, $this->targetBranch); $this->run( sprintf('git checkout %s', $this->targetBranch), self::RUN_TYPE_COMMAND ); } private function mergeLocalBranch(): void { echo sprintf( 'Merging %s into %s...' . PHP_EOL, $this->localBranch, $this->targetBranch ); $mergeCommand = sprintf('git merge --ff-only %s', $this->localBranch); if (!$this->run($mergeCommand, self::RUN_TYPE_COMMAND)) { // Switch back to the previous branch. $this->run('git checkout -', self::RUN_TYPE_COMMAND); $this->dieWithMessage(sprintf( 'Branch %s is not fast-forwardable.', $this->localBranch )); } } public function pushTargetBranch(): void { print(sprintf('Pushing updated %s branch...', $this->targetBranch)); $this->run( sprintf('git push origin %s', $this->targetBranch), self::RUN_TYPE_COMMAND ); } public function deleteRemoteBranch(): void { echo 'Deleting remote branch...' . PHP_EOL; $this->run( sprintf('git push origin :%s', $this->remoteBranch), self::RUN_TYPE_COMMAND ); } public function deleteLocalBranch(): void { echo 'Deleting local branch...' . PHP_EOL; $this->run( sprintf('git branch -d %s', $this->localBranch), self::RUN_TYPE_COMMAND ); } private function getArg(string $shortOpts, array $longOpts = []): ?string { if (!$values = getopt($shortOpts, $longOpts)) { return NULL; } return current($values); } private function hasArg(string $shortOpts, array $longOpts = []): bool { return !empty(getopt($shortOpts, $longOpts)); } private function isForce(): bool { return $this->hasArg('f::', ['force::']); } /** * Run the command. * * @return bool|string * If the type is 'command', the method will return if there were any * errors when running the command based on its return code. * * If the type is 'query', then the output of the command will be returned * as a string. */ private function run(string $command, string $type) { switch ($type) { case self::RUN_TYPE_COMMAND: // Perform the command, hiding the original output and return // whether or not there were errors. @exec("$command", $output, $return); return $return == 0; case self::RUN_TYPE_QUERY: // Perform the command and return the output. return exec($command, $output); } } private function dieWithMessage(string $message): void { echo sprintf("\e[31m%s\e[0m", $message); exit(1); } private function exitWithWarning(string $message): void { echo sprintf("\e[33m%s\e[0m", $message); exit(2); } } (new ClosesPullRequests())->__invoke();