├── .gitignore ├── Application.php ├── Command ├── Apply.php ├── Cleanup.php ├── CommandBase.php ├── CreatePatch.php ├── Diff.php ├── LocalSetup.php ├── LocalUpdate.php ├── OpenIssue.php ├── Purge.php ├── Status.php └── SwitchMaster.php ├── Console ├── DefinitionList.php ├── ItemList.php └── ListBase.php ├── DependencyInjection └── ContainerAwareTrait.php ├── README.md ├── Service ├── Analyser.php ├── CommitMessageHandler.php ├── DrupalOrg.php ├── GitExecutor.php ├── GitInfo.php ├── GitLog.php ├── UserInput.php ├── WaypointManagerBranches.php └── WaypointManagerPatches.php ├── Testing └── repository │ ├── main.txt │ ├── patch-b.patch │ └── patch-c.patch ├── Waypoint ├── FeatureBranch.php ├── LocalPatch.php ├── MasterBranch.php └── Patch.php ├── composer.json ├── dorgflow ├── services.php └── tests ├── CommandCreatePatchTest.php ├── CommandLocalSetupTest.php ├── CommandLocalUpdateTest.php ├── CommandLocalUpdateUnitTest.php ├── CommandTestBase.php ├── CommitMessageHandlerTest.php ├── FeatureBranchTest.php ├── GitHandlerFileCheckout.php └── SetUpPatchesTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /Application.php: -------------------------------------------------------------------------------- 1 | get('setup'); 21 | } 22 | if (strpos($name, 'drupal.org') !== FALSE) { 23 | return $this->get('setup'); 24 | } 25 | 26 | return parent::find($name); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Command/Apply.php: -------------------------------------------------------------------------------- 1 | setName('apply') 24 | ->setDescription('Applies the current feature branch to the master branch.') 25 | ->setHelp('Applies the diff of the current feature branch to the master branch, so it can be committed.'); 26 | } 27 | 28 | protected function setServices() { 29 | $this->git_info = $this->container->get('git.info'); 30 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 31 | $this->git_executor = $this->container->get('git.executor'); 32 | $this->analyser = $this->container->get('analyser'); 33 | } 34 | 35 | protected function execute(InputInterface $input, OutputInterface $output): int { 36 | $this->setServices(); 37 | 38 | // Check git is clean. 39 | $clean = $this->git_info->gitIsClean(); 40 | if (!$clean) { 41 | throw new \Exception("Git repository is not clean. Aborting."); 42 | } 43 | 44 | // Create branches. 45 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 46 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 47 | 48 | // If the feature branch is not current, abort. 49 | if (!$feature_branch->exists()) { 50 | throw new \Exception("Could not find a feature branch. Aborting."); 51 | } 52 | if (!$feature_branch->isCurrentBranch()) { 53 | throw new \Exception(strtr("Detected feature branch !branch, but it is not the current branch. Aborting.", [ 54 | '!branch' => $feature_branch->getBranchName(), 55 | ])); 56 | } 57 | 58 | // @todo check that the feature branch tip is the same as the most recent patch 59 | // from d.org 60 | 61 | // Check out the master branch. 62 | $this->git_executor->checkOutBranch($master_branch->getBranchName()); 63 | // Perform a squash merge from the feature branch: in other words, all the 64 | // changes on the feature branch are now staged on master. 65 | $this->git_executor->squashMerge($feature_branch->getBranchName()); 66 | 67 | print strtr("Changes from feature branch !feature-branch are now applied and staged on branch !master-branch.\n", [ 68 | '!feature-branch' => $feature_branch->getBranchName(), 69 | '!master-branch' => $master_branch->getBranchName(), 70 | ]); 71 | print strtr("You should now commit this, using the command from the issue on drupal.org: https://www.drupal.org/node/!id#drupalorg-issue-credit-form.\n", [ 72 | '!id' => $this->analyser->deduceIssueNumber(), 73 | ]); 74 | 75 | return 0; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Command/Cleanup.php: -------------------------------------------------------------------------------- 1 | setName('cleanup') 24 | ->setDescription('Deletes the current feature branch.') 25 | ->setHelp('Deletes the current feature branch.'); 26 | } 27 | 28 | protected function setServices() { 29 | $this->git_info = $this->container->get('git.info'); 30 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int { 34 | $this->setServices(); 35 | 36 | // Check git is clean. 37 | $clean = $this->git_info->gitIsClean(); 38 | if (!$clean) { 39 | throw new \Exception("Git repository is not clean. Aborting."); 40 | } 41 | 42 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 43 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 44 | 45 | $master_branch_name = $master_branch->getBranchName(); 46 | $feature_branch_name = $feature_branch->getBranchName(); 47 | 48 | print "You are about to checkout branch $master_branch_name and DELETE branch $feature_branch_name!\n"; 49 | $confirmation = readline("Please enter 'delete' to confirm:"); 50 | 51 | if ($confirmation != 'delete') { 52 | print "Clean up aborted.\n"; 53 | return 0; 54 | } 55 | 56 | $master_branch_name = $master_branch->getBranchName(); 57 | shell_exec("git checkout $master_branch_name"); 58 | 59 | shell_exec("git branch -D $feature_branch_name"); 60 | 61 | // TODO: delete any patch files for this issue. 62 | 63 | return 0; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Command/CommandBase.php: -------------------------------------------------------------------------------- 1 | container = $container; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Command/CreatePatch.php: -------------------------------------------------------------------------------- 1 | setName('patch') 21 | ->setDescription('Creates a patch for the current feature branch.') 22 | ->setHelp('Creates a patch for the diff between the current feature branch and the master branch, and also an interdiff if a patch has previously been made.'); 23 | } 24 | 25 | protected function setServices() { 26 | $this->git_info = $this->container->get('git.info'); 27 | $this->git_log = $this->container->get('git.log'); 28 | $this->analyser = $this->container->get('analyser'); 29 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 30 | $this->waypoint_manager_patches = $this->container->get('waypoint_manager.patches'); 31 | $this->drupal_org = $this->container->get('drupal_org'); 32 | $this->git_executor = $this->container->get('git.executor'); 33 | $this->commit_message = $this->container->get('commit_message'); 34 | } 35 | 36 | protected function execute(InputInterface $input, OutputInterface $output): int { 37 | $this->setServices(); 38 | 39 | // Check git is clean. 40 | $clean = $this->git_info->gitIsClean(); 41 | if (!$clean) { 42 | throw new \Exception("Git repository is not clean. Aborting."); 43 | } 44 | 45 | // Create branches. 46 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 47 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 48 | 49 | // If the feature branch doesn't exist or is not current, abort. 50 | if (!$feature_branch->exists()) { 51 | throw new \Exception("Feature branch does not exist."); 52 | } 53 | if (!$feature_branch->isCurrentBranch()) { 54 | throw new \Exception("Feature branch is not the current branch."); 55 | } 56 | 57 | // TODO: get this from user input. 58 | $sequential = FALSE; 59 | 60 | $master_branch_name = $master_branch->getBranchName(); 61 | 62 | $local_patch = $this->waypoint_manager_patches->getLocalPatch(); 63 | 64 | $patch_name = $local_patch->getPatchFilename(); 65 | 66 | $this->git_executor->createPatch($master_branch_name, $patch_name, $sequential); 67 | 68 | print("Written patch $patch_name with diff from $master_branch_name to local branch.\n"); 69 | 70 | // Make an interdiff from the most recent patch. 71 | // (Before we make a recording patch, of course!) 72 | $last_patch = $this->waypoint_manager_patches->getMostRecentPatch(); 73 | if (!empty($last_patch)) { 74 | $interdiff_name = $this->getInterdiffName($feature_branch, $last_patch); 75 | $last_patch_sha = $last_patch->getSHA(); 76 | 77 | $this->git_executor->createPatch($last_patch_sha, $interdiff_name); 78 | 79 | print("Written interdiff $interdiff_name with diff from $last_patch_sha to local branch.\n"); 80 | } 81 | 82 | // Write out a log of changes since the last patch. 83 | print("The following may be useful for the comment on d.org:\n"); 84 | print("------------------------------------------------\n"); 85 | if (empty($last_patch)) { 86 | $log = $this->git_log->getPartialFeatureBranchLog($master_branch_name); 87 | print("Changes in this patch:\n"); 88 | } 89 | else { 90 | $log = $this->git_log->getPartialFeatureBranchLog($last_patch->getSHA()); 91 | print("Changes since the last patch:\n"); 92 | } 93 | foreach ($log as $log_item) { 94 | print("- {$log_item['message']}\n"); 95 | } 96 | // Blow our own trumpet ;) 97 | if (empty($last_patch)) { 98 | print("Patch created by Dorgflow.\n"); 99 | } 100 | else { 101 | print("Patch and interdiff created by Dorgflow.\n"); 102 | } 103 | print("------------------------------------------------\n"); 104 | 105 | // Make an empty commit to record the patch. 106 | $local_patch_commit_message = $this->commit_message->createLocalCommitMessage($local_patch); 107 | $this->git_executor->commit($local_patch_commit_message); 108 | 109 | return 0; 110 | } 111 | 112 | protected function getInterdiffName($feature_branch, $last_patch) { 113 | $issue_number = $this->analyser->deduceIssueNumber(); 114 | $last_patch_comment_number = $last_patch->getPatchFileIndex(); 115 | $next_comment_number = $this->drupal_org->getNextCommentIndex(); 116 | 117 | // Allow for local patches that won't have a comment index. 118 | if (empty($last_patch_comment_number)) { 119 | $interdiff_name = "interdiff.$issue_number.$next_comment_number.txt"; 120 | } 121 | else { 122 | $interdiff_name = "interdiff.$issue_number.$last_patch_comment_number-$next_comment_number.txt"; 123 | } 124 | return $interdiff_name; 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /Command/Diff.php: -------------------------------------------------------------------------------- 1 | setName('diff') 25 | ->setDescription('Shows a git diff to the master branch.') 26 | ->setHelp('Shows the changes made on the feature branch, compared to the master branch.'); 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | protected function setServices() { 33 | $this->git_info = $this->container->get('git.info'); 34 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | protected function execute(InputInterface $input, OutputInterface $output): int { 41 | $this->setServices(); 42 | 43 | $io = new SymfonyStyle($input, $output); 44 | 45 | // Check git is clean. 46 | $clean = $this->git_info->gitIsClean(); 47 | if (!$clean) { 48 | $io->note('Git is not clean: the diff will include your uncommitted changes.'); 49 | } 50 | 51 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 52 | 53 | $diff = $this->git_info->diffMasterBranch($master_branch->getBranchName()); 54 | 55 | $io->text($diff); 56 | 57 | return 0; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Command/LocalSetup.php: -------------------------------------------------------------------------------- 1 | setName('setup') 23 | ->setDescription('Sets up a feature branch.') 24 | ->setHelp('Sets up a feature branch based on a drupal.org issue node, and downloads and applies any patches.'); 25 | } 26 | 27 | protected function setServices() { 28 | $this->git_info = $this->container->get('git.info'); 29 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 30 | $this->waypoint_manager_patches = $this->container->get('waypoint_manager.patches'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int { 34 | $this->setServices(); 35 | 36 | // Check git is clean. 37 | $clean = $this->git_info->gitIsClean(); 38 | if (!$clean) { 39 | throw new \Exception("Git repository is not clean. Aborting."); 40 | } 41 | 42 | // Create branch objects. 43 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 44 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 45 | 46 | // Check whether feature branch exists (whether reachable or not). 47 | if ($feature_branch->exists()) { 48 | // If the feature branch already exists, check it out, and stop. 49 | $feature_branch->gitCheckout(); 50 | 51 | $output->writeln(strtr("The feature branch !branch already exists and has been checked out.", [ 52 | '!branch' => $feature_branch->getBranchName(), 53 | ])); 54 | 55 | if ($this->waypoint_manager_branches->featureBranchIsUpToDateWithMaster($feature_branch)) { 56 | $output->writeln("This branch is up to date with master."); 57 | $output->writeln("You should use the update command to get new patches from drupal.org."); 58 | } 59 | else { 60 | $output->writeln(strtr("This branch is not up to date with master. You should do 'git rebase !master --keep-empty'.", [ 61 | '!master' => $master_branch->getBranchName(), 62 | ])); 63 | $output->writeln("Afterwards, you should use the update command to get new patches from drupal.org."); 64 | } 65 | 66 | return 0; 67 | } 68 | 69 | // If the master branch is not current, abort. 70 | if (!$master_branch->isCurrentBranch()) { 71 | throw new \Exception(strtr("Detected master branch !branch, but it is not the current branch. Aborting.\n", [ 72 | '!branch' => $master_branch->getBranchName(), 73 | ])); 74 | } 75 | 76 | $output->writeln(strtr("Detected master branch !branch.", [ 77 | '!branch' => $master_branch->getBranchName(), 78 | ])); 79 | 80 | $feature_branch->gitCreate(); 81 | 82 | $output->writeln(strtr("Created feature branch !branch.", [ 83 | '!branch' => $feature_branch->getBranchName(), 84 | ])); 85 | 86 | // Get the patches and create them. 87 | $patches = $this->waypoint_manager_patches->setUpPatches(); 88 | 89 | // If no patches, we're done. 90 | if (empty($patches)) { 91 | $output->writeln("There are no patches to apply."); 92 | return 0; 93 | } 94 | 95 | // Output the patches. 96 | $list = new ItemList($output); 97 | $list->setProgressive(); 98 | foreach ($patches as $patch) { 99 | $patch_committed = $patch->commitPatch(); 100 | 101 | // Message. 102 | if ($patch_committed) { 103 | $list->addItem(strtr("Applied and committed patch !patchname.", [ 104 | '!patchname' => $patch->getPatchFilename(), 105 | ])); 106 | } 107 | else { 108 | $list->addItem(strtr("Patch !patchname did not apply.", [ 109 | '!patchname' => $patch->getPatchFilename(), 110 | ])); 111 | } 112 | } 113 | 114 | // If final patch didn't apply, then output a message: the latest patch 115 | // has rotted. Save the patch file to disk and give the filename in the 116 | // message. 117 | if (!$patch_committed) { 118 | // Save the file so the user can apply it manually. 119 | file_put_contents($patch->getPatchFilename(), $patch->getPatchFile()); 120 | 121 | $output->writeln(strtr("The most recent patch, !patchname, did not apply. You should attempt to apply it manually. The patch file has been saved to the working directory.", [ 122 | '!patchname' => $patch->getPatchFilename(), 123 | ])); 124 | } 125 | 126 | return 0; 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /Command/LocalUpdate.php: -------------------------------------------------------------------------------- 1 | setName('update') 23 | ->setDescription('Updates a feature branch.') 24 | ->setHelp('Updates an existing feature branch, and downloads and applies any new patches.'); 25 | // Does not work yet -- comment indexes are not reliable, e.g. see 26 | // the jump in index numbers at #28 on 27 | // https://www.drupal.org/project/drupal/issues/66183. 28 | // ->addOption( 29 | // 'start', 30 | // 's', 31 | // // this is the type of option (e.g. requires a value, can be passed more than once, etc.) 32 | // InputOption::VALUE_OPTIONAL, 33 | // 'The natural comment index at which to start taking patches.', 34 | // 0, 35 | // ); 36 | } 37 | 38 | protected function setServices() { 39 | $this->git_info = $this->container->get('git.info'); 40 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 41 | $this->waypoint_manager_patches = $this->container->get('waypoint_manager.patches'); 42 | $this->git_executor = $this->container->get('git.executor'); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int { 46 | $this->setServices(); 47 | 48 | // Check git is clean. 49 | $clean = $this->git_info->gitIsClean(); 50 | if (!$clean) { 51 | throw new \Exception("Git repository is not clean. Aborting."); 52 | } 53 | 54 | // Create branches. 55 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 56 | 57 | // If the feature branch is not current, abort. 58 | if (!$feature_branch->exists()) { 59 | throw new \Exception("Could not find a feature branch. Aborting."); 60 | } 61 | if (!$feature_branch->isCurrentBranch()) { 62 | throw new \Exception(strtr("Detected feature branch !branch, but it is not the current branch. Aborting.", [ 63 | '!branch' => $feature_branch->getBranchName(), 64 | ])); 65 | } 66 | 67 | // Get the patches and create them. 68 | // $first_comment = $input->getOption('start'); 69 | $first_comment = 0; 70 | $patches = $this->waypoint_manager_patches->setUpPatches($first_comment); 71 | //dump($patches); 72 | 73 | // If no patches, we're done. 74 | if (empty($patches)) { 75 | print "No patches to apply.\n"; 76 | return 0; 77 | } 78 | 79 | $patches_uncommitted = []; 80 | $last_committed_patch = NULL; 81 | 82 | // Find the first new, uncommitted patch. 83 | foreach ($patches as $patch) { 84 | if ($patch->hasCommit()) { 85 | // Any patches prior to a committed patch don't count as uncomitted: 86 | // they have presumably been examined before and a commit attempted and 87 | // failed. Hence, if we've found a committed patch, zap the array of 88 | // uncomitted patches, as what's come before should be ignored. 89 | $patches_uncommitted = []; 90 | 91 | // Keep updating this, so the last time it's set gives us the last 92 | // committed patch. 93 | $last_committed_patch = $patch; 94 | } 95 | else { 96 | $patches_uncommitted[] = $patch; 97 | } 98 | } 99 | 100 | // If no uncommitted patches, we're done. 101 | if (empty($patches_uncommitted)) { 102 | print "No patches to apply; existing patches are already applied to this feature branch.\n"; 103 | return 0; 104 | } 105 | 106 | // If the feature branch's SHA is not the same as the last committed patch 107 | // SHA, then that means there are local commits on the branch that are 108 | // newer than the patch. 109 | if (isset($last_committed_patch) && $last_committed_patch->getSHA() != $feature_branch->getSHA()) { 110 | // Create a new branch at the tip of the feature branch. 111 | $forked_branch_name = $feature_branch->createForkBranchName(); 112 | $this->git_executor->createNewBranch($forked_branch_name); 113 | 114 | // Reposition the FeatureBranch tip to the last committed patch. 115 | $this->git_executor->moveBranch($feature_branch->getBranchName(), $last_committed_patch->getSHA()); 116 | 117 | print strtr("Moved your work at the tip of the feature branch to new branch !forkedbranchname. You should manually merge this into the feature branch to preserve your work.\n", [ 118 | '!forkedbranchname' => $forked_branch_name, 119 | ]); 120 | 121 | // We're now ready to apply the patches. 122 | } 123 | 124 | // Output the patches. 125 | $patches_committed = []; 126 | $list = new ItemList($output); 127 | $list->setProgressive(); 128 | foreach ($patches_uncommitted as $patch) { 129 | // Commit the patch. 130 | $patch_committed = $patch->commitPatch(); 131 | 132 | // Message. 133 | if ($patch_committed) { 134 | // Keep a list of the patches that we commit. 135 | $patches_committed[] = $patch; 136 | 137 | $list->addItem(strtr("Applied and committed patch !patchname.", [ 138 | '!patchname' => $patch->getPatchFilename(), 139 | ])); 140 | } 141 | else { 142 | $list->addItem(strtr("Patch !patchname did not apply.", [ 143 | '!patchname' => $patch->getPatchFilename(), 144 | ])); 145 | } 146 | } 147 | 148 | // If all the patches were already committed, we're done. 149 | if (empty($patches_committed)) { 150 | print "No new patches to apply.\n"; 151 | return 0; 152 | } 153 | 154 | // If final patch didn't apply, then output a message: the latest patch 155 | // has rotted. Save the patch file to disk and give the filename in the 156 | // message. 157 | if (!$patch_committed) { 158 | // Save the file so the user can apply it manually. 159 | file_put_contents($patch->getPatchFilename(), $patch->getPatchFile()); 160 | 161 | print strtr("The most recent patch, !patchname, did not apply. You should attempt to apply it manually. " 162 | . "The patch file has been saved to the working directory.\n", [ 163 | '!patchname' => $patch->getPatchFilename(), 164 | ]); 165 | } 166 | 167 | return 0; 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /Command/OpenIssue.php: -------------------------------------------------------------------------------- 1 | setName('open') 28 | ->setDescription('Opens the issue for the current feature branch.') 29 | ->setHelp('Uses the system `open` command to open the issue for the current feature branch in the default browser.'); 30 | } 31 | 32 | protected function setServices() { 33 | $this->analyser = $this->container->get('analyser'); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | protected function execute(InputInterface $input, OutputInterface $output): int { 40 | $this->setServices(); 41 | 42 | $issue_number = $this->analyser->deduceIssueNumber(); 43 | 44 | exec('open https://www.drupal.org/node/' . $issue_number); 45 | 46 | return 0; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Command/Purge.php: -------------------------------------------------------------------------------- 1 | setName('purge') 28 | ->setDescription('Deletes all feature branches and remotes whose issues are committed to the master branch.') 29 | ->setHelp('Deletes all feature branches and remotes whose issues are committed to the master branch.'); 30 | } 31 | 32 | protected function setServices() { 33 | $this->git_info = $this->container->get('git.info'); 34 | $this->git_log = $this->container->get('git.log'); 35 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 36 | $this->analyser = $this->container->get('analyser'); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int { 40 | $this->setServices(); 41 | 42 | $this->master_branch_name = $this->waypoint_manager_branches->getMasterBranch()->getBranchName(); 43 | 44 | $branch_list = $this->git_info->getBranchList(); 45 | 46 | ProgressBar::setFormatDefinition('custom', ' %current%/%max% -- %message%'); 47 | $progressBar = new ProgressBar($output, count($branch_list)); 48 | $progressBar->setFormat('custom'); 49 | $progressBar->setMessage("Collecting branches..."); 50 | $progressBar->start(); 51 | 52 | $issues_to_clean_up = []; 53 | 54 | foreach ($branch_list as $branch_name => $sha) { 55 | $issue_number = $this->analyser->extractIssueNumberFromBranch($branch_name); 56 | 57 | // Skip the branch if it's not for an issue. 58 | if (empty($issue_number)) { 59 | $progressBar->advance(); 60 | continue; 61 | } 62 | 63 | $progressBar->setMessage("Analysing branch {$branch_name}..."); 64 | $issue_commit = $this->git_log->getIssueCommit($issue_number); 65 | 66 | // Skip the branch if we can't find a commit for it. 67 | if (empty($issue_commit)) { 68 | $progressBar->advance(); 69 | continue; 70 | } 71 | 72 | // TODO get patch and interdiff files too. 73 | 74 | // TODO! Bug! in the case of a follow-up, an issue can have more than one 75 | // commit! 76 | list($sha, $message) = explode(' ', rtrim($issue_commit), 2); 77 | 78 | $issues_to_clean_up[$issue_number] = [ 79 | 'branch' => $branch_name, 80 | 'message' => $message, 81 | 'sha' => $sha, 82 | ]; 83 | 84 | if ($remote = $this->git_info->getIssueRemote($issue_number)) { 85 | $issues_to_clean_up[$issue_number]['remote'] = $remote; 86 | } 87 | 88 | $progressBar->advance(); 89 | } 90 | 91 | $progressBar->finish(); 92 | $progressBar->clear(); 93 | 94 | if (empty($issues_to_clean_up)) { 95 | print "No branches to clean up.\n"; 96 | return 0; 97 | } 98 | 99 | // Sort by issue number. 100 | // (TODO: sort by date of commit?) 101 | ksort($issues_to_clean_up); 102 | 103 | print "You are about to DELETE the following branches!\n"; 104 | $list = new ItemList($output); 105 | $remote_count = 0; 106 | foreach ($issues_to_clean_up as $issue_number => $info) { 107 | $nested_list = $list->getNestedListItem(DefinitionList::class); 108 | $nested_list->setDefinitionFormatterStyle(new \Symfony\Component\Console\Formatter\OutputFormatterStyle( 109 | NULL, NULL, ['bold'] 110 | )); 111 | 112 | $nested_list->addItem("issue", $issue_number); 113 | $nested_list->addItem('branch name', $info['branch']); 114 | if (isset($info['remote'])) { 115 | $nested_list->addItem('remote name', $info['remote']); 116 | $remote_count++; 117 | } 118 | $nested_list->addItem('committed in', $info['message']); 119 | 120 | $list->addItem($nested_list); 121 | } 122 | $list->render(); 123 | 124 | $helper = $this->getHelper('question'); 125 | 126 | $count = count($issues_to_clean_up); 127 | $question = new Question("Please enter 'delete' to confirm DELETION of {$count} branches and {$remote_count} remotes:"); 128 | if ($helper->ask($input, $output, $question) != 'delete') { 129 | $output->writeln('Clean up aborted.'); 130 | return 0; 131 | } 132 | 133 | foreach ($issues_to_clean_up as $issue_number => $info) { 134 | shell_exec("git branch -D {$info['branch']}"); 135 | $output->writeln("Deleted branch {$info['branch']}."); 136 | 137 | if (isset($info['remote'])) { 138 | shell_exec("git remote remove {$info['remote']}"); 139 | $output->writeln("Deleted remote {$info['remote']}."); 140 | } 141 | } 142 | 143 | return 0; 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /Command/Status.php: -------------------------------------------------------------------------------- 1 | setName('status') 25 | ->setDescription('Shows a status summary.') 26 | ->setHelp('Shows the names of the detected master and feature branches.'); 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | protected function setServices() { 33 | $this->git_info = $this->container->get('git.info'); 34 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 35 | $this->waypoint_manager_patches = $this->container->get('waypoint_manager.patches'); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | protected function execute(InputInterface $input, OutputInterface $output): int { 42 | $this->setServices(); 43 | 44 | $io = new SymfonyStyle($input, $output); 45 | 46 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 47 | $feature_branch = $this->waypoint_manager_branches->getFeatureBranch(); 48 | 49 | $io->text( 50 | strtr("Master branch detected as @branch.", [ 51 | '@branch' => $master_branch->getBranchName(), 52 | ]) 53 | ); 54 | 55 | if ($feature_branch->exists()) { 56 | $io->text( 57 | strtr("Feature branch detected as @branch.", [ 58 | '@branch' => $feature_branch->getBranchName(), 59 | ]) 60 | ); 61 | } 62 | 63 | // TODO: show whether the branch is up to date with d.org. 64 | // $patch = $this->waypoint_manager_patches->getMostRecentPatch(); 65 | // if ($patch) { 66 | 67 | // } 68 | 69 | $clean = $this->git_info->gitIsClean(); 70 | if (!$clean) { 71 | $io->text('You have uncommitted changes.'); 72 | } 73 | 74 | return 0; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /Command/SwitchMaster.php: -------------------------------------------------------------------------------- 1 | setName('master') 24 | ->setDescription('Checks out the master branch.') 25 | ->setHelp('Checks out the master branch that the current feature branch is branched from.'); 26 | } 27 | 28 | protected function setServices() { 29 | $this->git_info = $this->container->get('git.info'); 30 | $this->waypoint_manager_branches = $this->container->get('waypoint_manager.branches'); 31 | $this->git_executor = $this->container->get('git.executor'); 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int { 35 | $this->setServices(); 36 | 37 | // Check git is clean. 38 | $clean = $this->git_info->gitIsClean(); 39 | if (!$clean) { 40 | throw new \Exception("Git repository is not clean. Aborting."); 41 | } 42 | 43 | $master_branch = $this->waypoint_manager_branches->getMasterBranch(); 44 | $master_branch->gitCheckout(); 45 | 46 | return 0; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /Console/DefinitionList.php: -------------------------------------------------------------------------------- 1 | output->getFormatter()->setStyle('definition', $style); 37 | $this->usesDefinitionStyle = TRUE; 38 | } 39 | 40 | /** 41 | * Adds an item to the list. 42 | * 43 | * TODO: support a nested list. 44 | * 45 | * @param string $definition 46 | * The definition. 47 | * @param string $term 48 | * The term. 49 | */ 50 | public function addItem($definition, $term) 51 | { 52 | $item = [$definition, $term]; 53 | 54 | $this->items[] = $item; 55 | 56 | if ($this->progressive) { 57 | $this->writeItem($item); 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | protected function formatSingleItem($item) 67 | { 68 | list($definition, $term) = $item; 69 | 70 | if ($this->usesDefinitionStyle) { 71 | return "{$definition}" . ': ' . $term; 72 | } 73 | else { 74 | return $definition . ': ' . $term; 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Console/ItemList.php: -------------------------------------------------------------------------------- 1 | items[] = $item; 25 | 26 | if ($this->progressive) { 27 | $this->writeItem($item); 28 | } 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | protected function formatSingleItem($item) { 37 | return $item; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Console/ListBase.php: -------------------------------------------------------------------------------- 1 | output = $output; 51 | } 52 | 53 | /** 54 | * Set the list to render items as soon as they are added. 55 | * 56 | * If this is set, calling render() will have no effect. 57 | * 58 | * @param bool $progressive 59 | * (optional) Whether to set the list as progressive. 60 | */ 61 | public function setProgressive($progressive = TRUE) 62 | { 63 | $this->progressive = $progressive; 64 | } 65 | 66 | /** 67 | * Sets the bullet for the list. 68 | * 69 | * @param string $bullet 70 | * The string to use as a bullet for each list item. Do not include any 71 | * trailing space or indentation. 72 | */ 73 | public function setBullet($bullet) 74 | { 75 | // Prevent changing of the bullet midway through output. 76 | // TODO: don't prevent this when output is non-progressive. 77 | if (!empty($this->items)) { 78 | throw new \Exception("The bullet may not be changed once output has started."); 79 | } 80 | 81 | $this->bullet = $bullet; 82 | } 83 | 84 | /** 85 | * Sets the indentation level of the list. 86 | * 87 | * @param int $indent_level 88 | * The indentation level. The list will be indented by double this number 89 | * of spaces. 90 | */ 91 | public function setIndentLevel($indent_level) { 92 | $this->indentLevel = $indent_level; 93 | } 94 | 95 | /** 96 | * Creates a list helper for a nested list. 97 | * 98 | * @param string $class 99 | * The class to use for the list. 100 | * 101 | * @return 102 | * The new list helper object. 103 | */ 104 | public function getNestedListItem($class) { 105 | // Give the nested list a buffered output, so we can collect its output 106 | // and indent it in the outer list. 107 | // The buffered output is given the same parameters as the output the parent 108 | // list has, so things such as formatting work. 109 | $buffered_output = new \Symfony\Component\Console\Output\BufferedOutput( 110 | $this->output->getVerbosity(), 111 | $this->output->isDecorated(), 112 | $this->output->getFormatter() 113 | ); 114 | 115 | $nested_list = new $class($buffered_output); 116 | 117 | $nested_list->setIndentLevel($this->indentLevel + 1); 118 | 119 | return $nested_list; 120 | } 121 | 122 | /** 123 | * Renders the list. 124 | * 125 | * This has no effect if the list is progressive. 126 | * 127 | * @return string|null 128 | * Returns the output if the output is buffered. 129 | */ 130 | public function render() 131 | { 132 | if (!$this->progressive) { 133 | foreach ($this->items as $item) { 134 | $this->writeItem($item); 135 | } 136 | 137 | // If the output is buffered, fetch the buffer. 138 | if ($this->output instanceof \Symfony\Component\Console\Output\BufferedOutput) { 139 | return $this->output->fetch(); 140 | } 141 | } 142 | } 143 | 144 | /** 145 | * Returns the bullet string. 146 | * 147 | * @return string 148 | * The bullet with a trailing space appended. 149 | */ 150 | protected function getBulletWithSpacing() { 151 | if (empty($this->bullet)) { 152 | $bullet = ''; 153 | } 154 | else { 155 | $bullet = $this->bullet . ' '; 156 | } 157 | 158 | return $this->getNestingIndent() . $bullet; 159 | } 160 | 161 | /** 162 | * Returns a blank string of the same length as the bullet string. 163 | * 164 | * @return string 165 | * A string composed of only spaces, whose length is the same as that 166 | * returned by getBulletWithSpacing(). 167 | */ 168 | protected function getBulletIndentString() { 169 | $bullet_width = strlen($this->bullet); 170 | 171 | if (empty($bullet_width)) { 172 | return $this->getNestingIndent(); 173 | } 174 | 175 | $bullet_width++; 176 | 177 | return $this->getNestingIndent() . str_repeat(' ', $bullet_width); 178 | } 179 | 180 | /** 181 | * Returns a blank string for the indent. 182 | * 183 | * @return string 184 | * A string composed of only spaces, whose length twice the indent level. 185 | */ 186 | protected function getNestingIndent() { 187 | return str_repeat(' ', $this->indentLevel); 188 | } 189 | 190 | /** 191 | * Gets the width to wrap lines to, taking the bullet into account. 192 | * 193 | * @return int 194 | * The number of characters to wrap by. 195 | */ 196 | protected function getWrapWidth() { 197 | // The indent consists of the nesting indent + the bullet + the bullet's 198 | // trailing space. 199 | $nesting_indent_width = $this->indentLevel * 2; 200 | $bullet_width = strlen($this->bullet); 201 | $total_indent_width = $nesting_indent_width + $bullet_width + 1; 202 | 203 | $terminal_width = (new Terminal())->getWidth(); 204 | $line_wrap_width = $terminal_width - $total_indent_width; 205 | 206 | return $line_wrap_width; 207 | } 208 | 209 | /** 210 | * Output a single item. 211 | * 212 | * @param mixed $item 213 | * The item. 214 | */ 215 | protected function writeItem($item) 216 | { 217 | if ($item instanceof ListBase) { 218 | //$this->output->writeln($this->getBulletWithSpacing() . $item); 219 | $output = $item->render(); 220 | 221 | // Splice a bullet at the front of the nested list. 222 | $bullet = $this->getBulletWithSpacing(); 223 | $output = substr_replace($output, $bullet, 0, strlen($bullet)); 224 | 225 | $this->output->write($output); 226 | } 227 | else { 228 | $formatted_item = $this->formatSingleItem($item); 229 | 230 | // Wrap the line if necessary. 231 | $line_wrap_width = $this->getWrapWidth(); 232 | if (strlen($formatted_item) > $line_wrap_width) { 233 | $bullet_indent = $this->getBulletIndentString(); 234 | $formatted_item = wordwrap($formatted_item, $line_wrap_width, "\n$bullet_indent", TRUE); 235 | } 236 | 237 | $this->output->writeln($this->getBulletWithSpacing() . $formatted_item); 238 | } 239 | } 240 | 241 | /** 242 | * Formats a single item. 243 | * 244 | * @var mixed $item 245 | * The item. 246 | * 247 | * @return string 248 | * The formatted item. This does not include the indent, bullet, or spacing 249 | * after the bullet. 250 | */ 251 | abstract protected function formatSingleItem($item); 252 | 253 | } 254 | -------------------------------------------------------------------------------- /DependencyInjection/ContainerAwareTrait.php: -------------------------------------------------------------------------------- 1 | \func_num_args()) { 27 | trigger_deprecation('symfony/dependency-injection', '6.2', 'Calling "%s::%s()" without any arguments is deprecated, pass null explicitly instead.', __CLASS__, __FUNCTION__); 28 | } 29 | 30 | $this->container = $container; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Dorgflow: git workflow for drupal.org patches 2 | ============================================= 3 | 4 | Dorgflow is a set of commands that streamline your work with patches for issues 5 | on drupal.org. With Dorgflow, you don't need to download and apply patches, and 6 | creating patches and interdiffs is simplified. The only thing that Dorgflow 7 | doesn't handle is posting your files back to an issue for review. 8 | 9 | ## Installation 10 | 11 | Install dependencies with Composer: 12 | 13 | $ composer install --no-dev 14 | 15 | (The --no-dev option omits the packages that are needed for testing.) 16 | 17 | Then either: 18 | - symlink the file dorgflow into a folder that's in your path. 19 | - set the root folder of this repository into your path. 20 | 21 | ### Optional: For composer driven Drupal projects 22 | 23 | If you're about to use Dorgflow in the context of a composer driver Drupal project, where you e.g. want to use version 8.x-3.1 of a particular module for staging and the production server, you may easily run into trouble setting up your environment for drupal.org contributions controlled by dorgflow. For those of you there is a composer plugin available which helps you manage those environments: https://packagist.org/packages/lakedrops/dorgflow 24 | 25 | ## Usage 26 | 27 | ### Starting work on an issue 28 | 29 | Start with your local git clone clean and up to date on a release branch, e.g. 30 | 8.3.x (for core) or 8.x-1.x (for contrib). We'll call this the *master branch*. 31 | 32 | To start working on an issue, simply do: 33 | 34 | $ dorgflow https://www.drupal.org/node/12345 35 | 36 | You can also just give the issue number. And it's fine to have anchor links from 37 | the URL you're copy-pasting too, thus https://www.drupal.org/node/12345#new. 38 | 39 | This creates a new git branch for you to work on. If there are patches on the 40 | issue, it will also download them and make commits for them. So you'll have 41 | something like this: 42 | 43 | * (12345-fix-bug) Patch from Drupal.org. Comment: 4; file: 12345-4.fix-bug.patch; fid 99999. Automatic commit by dorgflow. 44 | * Patch from Drupal.org. Comment: 2; file: 12345-1.fix-bug.permissions-author.patch; fid 88888. Automatic commit by dorgflow. 45 | / 46 | * (8.3.x) Issue 11111 by whoever. 47 | * Issue 22222 by whoever. 48 | 49 | The branch name is formed from the issue number and title: 12345-fix-bug. You 50 | may change this branch name if you wish, provided you keep the issue number and 51 | hyphen prefix. 52 | 53 | Each automatic patch commit gives you: 54 | - the index number of the comment the file was added with, 55 | - the URL of the comment, 56 | - the patch filename, 57 | - the patch file's entity ID. 58 | 59 | You can now start work on your own fix to the issue! 60 | 61 | ### Working on an issue 62 | 63 | Commit your work to the feature branch, as you would normally. Make as many 64 | commits as you want, with whatever message you want. 65 | 66 | You can see the whole of your work so far by doing: 67 | 68 | $ dorgflow diff 69 | 70 | This is just a shorthand for doing a git diff against the master branch. 71 | 72 | For a reminder of your feature branch and master branch, do: 73 | 74 | $ dorgflow status 75 | 76 | ### Updating your feature branch 77 | 78 | If there are new patches on the issue on drupal.org, do: 79 | 80 | $ dorgflow update 81 | 82 | This will create commits for the new patches on your branch. 83 | 84 | In the situation that the feature branch has your own commits at the tip of it, 85 | which have not been posted as a patch, these are moved to a separate branch. 86 | 87 | This situation: 88 | 89 | * My commit. 90 | * (12345-fix-bug) Patch 2 from Drupal.org. 91 | * Patch 1 from Drupal.org. 92 | / 93 | * (8.3.x) Issue 11111 by whoever. 94 | * Issue 22222 by whoever. 95 | 96 | becomes this: 97 | 98 | * (12345-fix-bug) Patch 3 from Drupal.org. 99 | | * (12345-fix-bug-forked-TIMESTAMP) My commit. 100 | |/ 101 | * Patch 2 from Drupal.org. 102 | * Patch 1 from Drupal.org. 103 | / 104 | * (8.3.x) Issue 11111 by whoever. 105 | * Issue 22222 by whoever. 106 | 107 | You should then manually merge the forked branch back into the feature branch to 108 | preserve your work. 109 | 110 | ### Contributing your work 111 | 112 | When you are ready to make a patch, just do: 113 | 114 | $ dorgflow 115 | 116 | This will create a patch with a systematic filename, and also an interdiff file. 117 | You can then upload these to the issue node on drupal.org. 118 | 119 | The commit messages from the git log are output, either since the start of the 120 | branch, or the last patch if there is one. You can copy-paste this to your 121 | comment on drupal.org to explain your changes. 122 | 123 | ### Committing a patch (maintainers only) 124 | 125 | If an issue is set to RTBC, and the corresponding feature branch is up to date 126 | with the most recent patch, you can apply the changes to the master branch ready 127 | to be committed: 128 | 129 | $ dorgflow apply 130 | 131 | This puts git back on the master branch, and performs a squash merge so that all 132 | the changes from the feature branch are applied and staged. 133 | 134 | All you now need to is perform the git commit, using the command suggested by 135 | the issue node's credit and committing section. 136 | 137 | ### Cleaning up 138 | 139 | When you're completely done with this branch, you can do: 140 | 141 | $ dorgflow cleanup 142 | 143 | This performs a checkout of the master branch, and deletes the feature branch. 144 | 145 | Alternatively, you can clean up ALL feature branches that have been committed 146 | with: 147 | 148 | $ dorgflow purge 149 | 150 | This looks at all branches whose name is of the form 'ISSUE-description', and 151 | deletes those where a master branch commit exists with that issue number in the 152 | commit message. 153 | -------------------------------------------------------------------------------- /Service/Analyser.php: -------------------------------------------------------------------------------- 1 | git_info = $git_info; 15 | $this->user_input = $user_input; 16 | } 17 | 18 | /** 19 | * Figures out the issue number in question, from input or current branch. 20 | * 21 | * A user input value, that is, given as a command line parameter, takes 22 | * precedence over the current branch. 23 | * 24 | * @return int 25 | * The issue number, which is the nid of the drupal.org issue node. 26 | * 27 | * @throws \Exception 28 | * Throws an exception if no issue number can be found from any input. 29 | */ 30 | public function deduceIssueNumber() { 31 | if (isset($this->issue_number)) { 32 | return $this->issue_number; 33 | } 34 | 35 | // Try to get an issue number from user input. 36 | // This comes first to allow commands to override the current branch with 37 | // input. 38 | $this->issue_number = $this->user_input->getIssueNumber(); 39 | 40 | if (!empty($this->issue_number)) { 41 | return $this->issue_number; 42 | } 43 | 44 | // Try to deduce an issue number from the current branch. 45 | $current_branch = $this->git_info->getCurrentBranch(); 46 | $this->issue_number = $this->extractIssueNumberFromBranch($current_branch); 47 | 48 | if (!empty($this->issue_number)) { 49 | return $this->issue_number; 50 | } 51 | 52 | // Dev mode. 53 | /* 54 | if ($this->devel_mode) { 55 | return 2801423; 56 | } 57 | */ 58 | 59 | throw new \Exception("Unable to find an issue number from command line parameter or current git branch."); 60 | } 61 | 62 | /** 63 | * Determine an issue number from a git branch name. 64 | * 65 | * @param string $branch_name 66 | * The branch name. 67 | * 68 | * @return 69 | * An issue number, or NULL if none is found. 70 | */ 71 | public function extractIssueNumberFromBranch($branch_name) { 72 | $matches = []; 73 | preg_match("@^(?P\d+)-@", $branch_name, $matches); 74 | if (!empty($matches['number'])) { 75 | $issue_number = $matches['number']; 76 | return $issue_number; 77 | } 78 | } 79 | 80 | public function getCurrentProjectName() { 81 | // @todo caching? 82 | $repo_base_dir = $this->getRepoBaseDir(); 83 | 84 | // Special cases for core; I for one have Drupal installed in lots of 85 | // funnily-named folders. 86 | // Drupal 8. 87 | if (file_exists($repo_base_dir . "/core/index.php")) { 88 | return 'drupal'; 89 | } 90 | // Drupal 8 installed with Composer. 91 | if (file_exists($repo_base_dir . "/core.api.php")) { 92 | return 'drupal'; 93 | } 94 | // Drupal 7 and prior. 95 | if (file_exists($repo_base_dir . "/index.php")) { 96 | return 'drupal'; 97 | } 98 | 99 | // Get the module name. 100 | $current_module = basename($repo_base_dir); 101 | 102 | // Allow for module folders to have a suffix. (E.g., I might have views-6 103 | // and views-7 in my sandbox folder.) 104 | $current_module = preg_replace("@-.*$@", '', $current_module); 105 | 106 | $this->current_project = $current_module; 107 | 108 | return $current_module; 109 | } 110 | 111 | /** 112 | * Determines whether the current project is Drupal core within Composer. 113 | * 114 | * This is needed because with Composer, Drupal core is checked out from a 115 | * subtree split with /core as its root, and so diffs must be taken with a 116 | * prefix. 117 | * 118 | * @return bool 119 | * TRUE if the current project is Drupal core, being managed as a Composer 120 | * package. FALSE otherwise. 121 | */ 122 | public function drupalCoreInComposer() { 123 | $repo_base_dir = $this->getRepoBaseDir(); 124 | 125 | if ($this->getCurrentProjectName() == 'drupal' && basename($repo_base_dir) == 'core') { 126 | return TRUE; 127 | } 128 | else { 129 | return FALSE; 130 | } 131 | } 132 | 133 | /** 134 | * Gets the base directory for the repository. 135 | * 136 | * @return string 137 | * The absolute path to the base directory of the repository. 138 | */ 139 | protected function getRepoBaseDir() { 140 | $dir = getcwd(); 141 | while (strlen($dir) > 1) { 142 | if (file_exists("$dir/.git/config")) { 143 | break; 144 | } 145 | $dir = dirname($dir); 146 | } 147 | return $dir; 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /Service/CommitMessageHandler.php: -------------------------------------------------------------------------------- 1 | analyser = $analyser; 19 | } 20 | 21 | /** 22 | * Creates a commit message to use for a patch from a d.org file. 23 | * 24 | * @param \Dorgflow\Waypoint\Patch $patch 25 | * The patch object. 26 | * 27 | * @return string 28 | * The commit message. 29 | */ 30 | public function createCommitMessage(Patch $patch) { 31 | // TODO: throw or bail if the patch object is already committed. 32 | $filename = $patch->getPatchFilename(); 33 | $fid = $patch->getPatchFileFid(); 34 | $index = $patch->getPatchFileIndex(); 35 | 36 | // Construct the anchor URL for the comment on the issue node where the 37 | // patch was added. 38 | if ($cid = $patch->getPatchFileCid()) { 39 | $url = strtr('https://www.drupal.org/node/:nid#comment-:cid', [ 40 | ':nid' => $this->analyser->deduceIssueNumber(), 41 | ':cid' => $cid, 42 | ]); 43 | } 44 | else { 45 | $url = strtr('https://www.drupal.org/node/:nid', [ 46 | ':nid' => $this->analyser->deduceIssueNumber(), 47 | ]); 48 | } 49 | 50 | return "Patch from Drupal.org. Comment: $index; URL: $url; file: $filename; fid: $fid. Automatic commit by dorgflow."; 51 | } 52 | 53 | /** 54 | * Creates a commit message to use for a local patch. 55 | * 56 | * @param \Dorgflow\Waypoint\LocalPatch $local_patch 57 | * The patch object. 58 | * 59 | * @return string 60 | * The commit message. 61 | */ 62 | public function createLocalCommitMessage(LocalPatch $local_patch) { 63 | $patch_name = $local_patch->getPatchFilename(); 64 | $index = $local_patch->getPatchFileIndex(); 65 | 66 | return "Patch for Drupal.org. Comment (expected): $index; file: $patch_name. Automatic commit by dorgflow."; 67 | } 68 | 69 | /** 70 | * Extract data from a commit message previously created by Dorgflow. 71 | * 72 | * @param $message 73 | * The message string. 74 | * 75 | * @return 76 | * Either FALSE if no data can be found in the message, or an array of data. 77 | * The following keys may be present: 78 | * - 'filename': The filename of the commit's patch. 79 | * - 'fid': The file entity ID. This will be absent in the case of a commit 80 | * made by the CreatePatch command, that is, for a patch the user is 81 | * creating to be uploaded to drupal.org. 82 | * - 'comment_index': The comment index. 83 | * - 'local': Is set and TRUE if the commit is for a local patch, i.e. one 84 | * that the user generated to upload themselves to Drupal.org. 85 | */ 86 | public function parseCommitMessage($message) { 87 | // Bail if not a dorgflow commit. 88 | if (!preg_match('@Automatic commit by dorgflow.$@', $message)) { 89 | return FALSE; 90 | } 91 | 92 | $return = []; 93 | 94 | $matches = []; 95 | // Allow for older format (pre-1.0.0 for d.org commits, pre-1.1.3 for local) 96 | // where the file is the first item in the list and has a capital letter. 97 | if (preg_match('@[Ff]ile: (?P.+\.patch)@', $message, $matches)) { 98 | $return['filename'] = $matches['filename']; 99 | } 100 | 101 | $matches = []; 102 | // Allow for pre-1.1.0 format, where the ':' after 'fid' is missing. 103 | if (preg_match('@fid:? (?P\d+)@', $message, $matches)) { 104 | $return['fid'] = $matches['fid']; 105 | } 106 | 107 | $matches = []; 108 | if (preg_match('@Comment( \(expected\))?: (?P\d+)@', $message, $matches)) { 109 | $return['comment_index'] = $matches['comment_index']; 110 | } 111 | 112 | if (preg_match('@Patch for Drupal.org@', $message)) { 113 | $return['local'] = TRUE; 114 | } 115 | 116 | // Handle pre-1.1.3 local commits without the index explicit in the message: 117 | // pick it out of the patch filename. 118 | if (!empty($return['local']) && empty($return['comment_index'])) { 119 | $matches = []; 120 | // Format is: ISSUE-COMMENT.PROJECT.DESCRIPTION.patch. 121 | if (preg_match('@^\d+-(?P\d+)\.@', $return['filename'], $matches)) { 122 | $return['comment_index'] = $matches['comment_index']; 123 | } 124 | } 125 | 126 | if (empty($return)) { 127 | // We shouldn't come here, but just in case, return the right thing. 128 | return FALSE; 129 | } 130 | 131 | return $return; 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /Service/DrupalOrg.php: -------------------------------------------------------------------------------- 1 | analyser = $analyser; 23 | 24 | // Set the user-agent for the request to drupal.org's API, to be polite. 25 | // See https://www.drupal.org/api 26 | ini_set('user_agent', "Dorgflow - https://github.com/joachim-n/dorgflow."); 27 | } 28 | 29 | /** 30 | * Returns the issue node title. 31 | * 32 | * @return string 33 | * The issue node title. 34 | */ 35 | public function getIssueNodeTitle() { 36 | if (!isset($this->node_data)) { 37 | $this->fetchIssueNode(); 38 | } 39 | 40 | return $this->node_data->title; 41 | } 42 | 43 | /** 44 | * Returns the expected index for the next comment. 45 | * 46 | * @return int 47 | * The comment index for the next comment (assuming nobody else posts a 48 | * comment in the meantime!). 49 | */ 50 | public function getNextCommentIndex() { 51 | if (!isset($this->node_data)) { 52 | $this->fetchIssueNode(); 53 | } 54 | 55 | $comment_count = $this->node_data->comment_count; 56 | $next_comment_index = $comment_count + 1; 57 | return $next_comment_index; 58 | } 59 | 60 | /** 61 | * Gets the field items for the issue files field, in order of creation. 62 | * 63 | * @return 64 | * An array of Drupal file field items. This has the structure: 65 | * - (delta): The key is the file field delta. Contains an array with these 66 | * properties: 67 | * - display: A boolean indicating whether the file is set to be displayed 68 | * in the node output. 69 | * - file: An object with these properties: 70 | * - uri: The file URI. 71 | * - id: The file entity ID. 72 | * - resource: The type of the resource, in this case, 'file'. 73 | * - cid: The comment entity ID of the comment at which this file was 74 | * added. 75 | * - index: The natural index of the comment this file was added with. 76 | */ 77 | public function getIssueFileFieldItems(int $starting_comment_index = 0) { 78 | if (!isset($this->node_data)) { 79 | $this->fetchIssueNode(); 80 | } 81 | 82 | $files = $this->node_data->field_issue_files; 83 | 84 | // Get the comment data from the node, so we can add the comment index 85 | // number to each file. 86 | // Create a lookup from comment ID to natural index. 87 | $comment_id_natural_indexes = []; 88 | foreach ($this->node_data->comments as $index => $comment_item) { 89 | // On d.org, the comments are output with a natural index starting from 1. 90 | $natural_index = $index + 1; 91 | 92 | $comment_id_natural_indexes[$comment_item->id] = $natural_index; 93 | } 94 | 95 | foreach ($files as $delta => &$file_item) { 96 | // A file won't have a comment ID if it was uploaded when the node was 97 | // created. 98 | // TODO: file a bug in the relevant Drupal module, following up my last 99 | // patch to add this data. 100 | if (isset($file_item->file->cid)) { 101 | $file_item_cid = $file_item->file->cid; 102 | $file_item->index = $comment_id_natural_indexes[$file_item_cid]; 103 | } 104 | else { 105 | $file_item->index = 0; 106 | } 107 | 108 | if ($starting_comment_index && $file_item->index < $starting_comment_index) { 109 | unset($files[$delta]); 110 | } 111 | } 112 | 113 | // Ensure these are in creation order by ordering them by the comment index. 114 | uasort($files, function($a, $b) { 115 | return ($a->index <=> $b->index); 116 | }); 117 | 118 | return $files; 119 | } 120 | 121 | /** 122 | * Fetches a file entity from drupal.org's REST API. 123 | * 124 | * @param $fid 125 | * The file entity ID. 126 | * 127 | * @return 128 | * The file entity data. 129 | */ 130 | public function getFileEntity($fid) { 131 | if (!isset($file_entities[$fid])) { 132 | $response = file_get_contents("https://www.drupal.org/api-d7/file/{$fid}.json"); 133 | 134 | if ($response === FALSE) { 135 | throw new \Exception("Failed getting file entity {$fid} from drupal.org."); 136 | } 137 | 138 | $file_entities[$fid] = json_decode($response); 139 | } 140 | 141 | return $file_entities[$fid]; 142 | } 143 | 144 | /** 145 | * Fetches a patch file from drupal.org. 146 | * 147 | * @param $url 148 | * The patch file URL. 149 | * 150 | * @return 151 | * The patch file contents. 152 | */ 153 | public function getPatchFile($url) { 154 | // @todo: this probably doesn't need any caching, but check! 155 | 156 | $file = file_get_contents($url); 157 | 158 | if ($file === FALSE) { 159 | throw new \Exception("Failed getting file {$url} from drupal.org."); 160 | } 161 | 162 | return $file; 163 | } 164 | 165 | /** 166 | * Fetches the issue node from drupal.org's REST API. 167 | */ 168 | protected function fetchIssueNode() { 169 | $issue_number = $this->analyser->deduceIssueNumber(); 170 | 171 | print "Fetching node $issue_number from drupal.org.\n"; 172 | 173 | $response = file_get_contents("https://www.drupal.org/api-d7/node/{$issue_number}.json"); 174 | 175 | if ($response === FALSE) { 176 | throw new \Exception("Failed getting node {$issue_number} from drupal.org."); 177 | } 178 | 179 | $this->node_data = json_decode($response); 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /Service/GitExecutor.php: -------------------------------------------------------------------------------- 1 | git_info = $git_info; 11 | $this->analyser = $analyser; 12 | } 13 | 14 | /** 15 | * Create a new git branch at the current commit. 16 | * 17 | * @param string $branch_short_name 18 | * The name of the branch, without the refs/heads/ prefix. 19 | * @param bool $checkout 20 | * Indicates whether to switch to the new branch. 21 | * 22 | * @throws \Exception 23 | * Throws an exception if a branch already exists with the given name. 24 | */ 25 | public function createNewBranch($branch_short_name, $checkout = FALSE) { 26 | // Check whether the branch already exist, as the plumbing command to create 27 | // a branch doesn't do so, and will simply move the HEAD of an existing one. 28 | $existing_ref = exec("git show-ref --heads {$branch_short_name}"); 29 | if (!empty($existing_ref)) { 30 | throw new \Exception("Attempted to create branch $branch_short_name, but it already exists."); 31 | } 32 | 33 | // Create a new branch at the given commit. 34 | exec("git update-ref refs/heads/{$branch_short_name} HEAD"); 35 | 36 | // Switch to the new branch if requested. 37 | if ($checkout) { 38 | exec("git symbolic-ref HEAD refs/heads/{$branch_short_name}"); 39 | 40 | $this->git_info->invalidateCurrentBranchCache(); 41 | } 42 | } 43 | 44 | /** 45 | * Checks out the given branch. 46 | * 47 | * @param $branch_name 48 | * The short name of a branch, e.g. 'master'. 49 | */ 50 | public function checkOutBranch($branch_name) { 51 | // @todo change this to use git plumbing command. 52 | exec("git checkout $branch_name"); 53 | } 54 | 55 | /** 56 | * Resets the tip of a given branch. 57 | * 58 | * @param $branch_name 59 | * The short name of a branch, e.g. 'master'. 60 | * @param $sha 61 | * An SHA to set the branch tip to. 62 | */ 63 | public function moveBranch($branch_name, $sha) { 64 | shell_exec("git update-ref refs/heads/$branch_name $sha"); 65 | } 66 | 67 | /** 68 | * Checks out the files of the given commit, without moving branches. 69 | * 70 | * This causes both the working directory and the staging to look like the 71 | * files in the commit given by $treeish, without changing the actual commit 72 | * or branch that git is currently on. 73 | * 74 | * The end result is that git has changes staged which take the current branch 75 | * back to $treeish. The point of this is a patch which is against $treeish 76 | * can now be applied. 77 | * 78 | * See http://stackoverflow.com/questions/13896246/reset-git-to-commit-without-changing-head-to-detached-state 79 | * 80 | * (The porcelain equivalent of this is: 81 | * $ git reset --hard $treeish; 82 | * $ git reset --soft $current_sha 83 | * ) 84 | * 85 | * @param $treeish 86 | * A valid commit identifier, such as an SHA or branch name. 87 | */ 88 | public function checkOutFiles($treeish) { 89 | // Read the tree for the given commit into the index. 90 | // Use the -m option as this might be what causes false negatives for the 91 | // git clean check on subsequent commands. 92 | shell_exec("git read-tree -m $treeish"); 93 | 94 | // Check out the index. 95 | shell_exec('git checkout-index -f -a -u'); 96 | 97 | // ARGH, we have to call this for weird git reasons that are weird, all the 98 | // more so that doing these commands manually doesn't require this, but when 99 | // run in this script, causes an error of 'does not match index' when trying 100 | // to apply patches. 101 | // See http://git.661346.n2.nabble.com/quot-git-apply-check-quot-successes-but-git-am-says-quot-does-not-match-index-quot-td6684646.html 102 | // for possible clues. 103 | shell_exec('git update-index -q --refresh'); 104 | } 105 | 106 | // Porcelain version. 107 | // TODO: remove in due course. 108 | public function checkOutFilesPorcelain($treeish) { 109 | $current_sha = shell_exec("git rev-parse HEAD"); 110 | 111 | // Reset the feature branch to the master branch tip commit. This puts the 112 | // files in the same state as the master branch. 113 | shell_exec("git reset --hard $treeish"); 114 | 115 | // Move the branch reference back to where it was, but without changing the 116 | // files. 117 | shell_exec("git reset --soft $current_sha"); 118 | } 119 | 120 | /** 121 | * Changes the current branch to the given one, without changing files. 122 | * 123 | * @param $branch_name 124 | * The short name of a branch, e.g. 'master'. 125 | */ 126 | public function moveToBranch($branch_name) { 127 | shell_exec("git symbolic-ref HEAD refs/heads/{$branch_name}"); 128 | 129 | $this->git_info->invalidateCurrentBranchCache(); 130 | } 131 | 132 | /** 133 | * Performs a squash merge of a given branch. 134 | */ 135 | public function squashMerge($branch_name) { 136 | // @todo change this to use git plumbing command. 137 | exec("git merge --squash $branch_name"); 138 | } 139 | 140 | /** 141 | * Apply a patch to the staging area. 142 | * 143 | * @param $patch_text 144 | * The text of the patch file. 145 | * 146 | * @return 147 | * TRUE if the patch applied, FALSE if it did not. 148 | */ 149 | public function applyPatch($patch_text) { 150 | // See https://www.sitepoint.com/proc-open-communicate-with-the-outside-world/ 151 | $desc = [ 152 | 0 => array('pipe', 'r'), // 0 is STDIN for process 153 | 1 => array('pipe', 'w'), // 1 is STDOUT for process 154 | 2 => array('pipe', 'w'), // 2 is STDERR for process 155 | ]; 156 | 157 | // The command. 158 | $cmd = "git apply --index -"; 159 | 160 | // Spawn the process. 161 | $pipes = []; 162 | $process = proc_open($cmd, $desc, $pipes); 163 | 164 | // Send the patch to command as input, the close the input pipe so the 165 | // command knows to start processing. 166 | fwrite($pipes[0], $patch_text); 167 | fclose($pipes[0]); 168 | 169 | 170 | $out = stream_get_contents($pipes[1]); 171 | //dump('OUT:'); 172 | //dump($out); 173 | 174 | $errors = stream_get_contents($pipes[2]); 175 | //dump('ERROR:'); 176 | //dump($errors); 177 | 178 | // all done! Clean up 179 | fclose($pipes[1]); 180 | fclose($pipes[2]); 181 | proc_close($process); 182 | 183 | if (empty($errors)) { 184 | return TRUE; 185 | } 186 | 187 | $error_lines = explode("\n", $errors); 188 | 189 | // Check the messages in STDERR. Not all error messages mean the patch 190 | // failed; for instance, git warns of file modes that don't match the patch. 191 | foreach ($error_lines as $error) { 192 | if (strpos($error, 'error: patch failed') !== FALSE) { 193 | return FALSE; 194 | } 195 | } 196 | 197 | // If no patch failing error was found, consider this a success. 198 | return TRUE; 199 | } 200 | 201 | /** 202 | * Commit the currently staged changes. 203 | * 204 | * @param $message 205 | * The message for the commit. 206 | */ 207 | public function commit($message) { 208 | // Allow empty commits, for local patches and also in case two sequential 209 | // patches are identical. 210 | shell_exec("git commit --allow-empty --message='$message'"); 211 | } 212 | 213 | /** 214 | * Writes a patch file based on a git diff. 215 | * 216 | * @param $treeish 217 | * The commit to take the diff from. 218 | * @param $patch_name 219 | * The filename to write for the patch. 220 | * @param $sequential 221 | * (optional) If TRUE, the patch is sequential: composed of multiple 222 | * changesets, one for each commit from $treeish to HEAD. Defaults to FALSE. 223 | */ 224 | public function createPatch($treeish, $patch_name, $sequential = FALSE) { 225 | // Select the diff command to use. 226 | if ($sequential) { 227 | $command = 'format-patch --stdout'; 228 | } 229 | else { 230 | $command = 'diff'; 231 | } 232 | 233 | // In case someone has git config --global diff.noprefix true. 234 | $prefixes = '--src-prefix=a/ --dst-prefix=b/'; 235 | 236 | // Detect whether the current repository is the Drupal core subtree split 237 | // that drupal-composer/drupal-project uses. 238 | if ($this->analyser->drupalCoreInComposer()) { 239 | // We are working in the core directory of drupal-composer/drupal-project. 240 | $prefixes = '--src-prefix=a/core/ --dst-prefix=b/core/'; 241 | } 242 | 243 | shell_exec("git $command $treeish $prefixes > $patch_name"); 244 | } 245 | 246 | } 247 | -------------------------------------------------------------------------------- /Service/GitInfo.php: -------------------------------------------------------------------------------- 1 | is_clean)) { 26 | // Unconfuse 'git diff-files', which sees a moved file as having a diff 27 | // even if the contents are the same. 28 | // See https://stackoverflow.com/questions/36367190/git-diff-files-output-changes-after-git-status 29 | shell_exec("git update-index --refresh"); 30 | 31 | $diff_files = shell_exec("git diff-files"); 32 | 33 | $this->is_clean = (empty($diff_files)); 34 | } 35 | 36 | return $this->is_clean; 37 | } 38 | 39 | /** 40 | * Returns the diff to the given branch. 41 | * 42 | * TODO: consider whether to move this to the branch object. 43 | * 44 | * @param string $branch 45 | * The branch name to diff against. 46 | * 47 | * @return string 48 | * The diff output, with colour. 49 | */ 50 | public function diffMasterBranch($branch) { 51 | $diff = shell_exec("git diff-index --color -p {$branch}"); 52 | 53 | return $diff; 54 | } 55 | 56 | public function getCurrentBranch() { 57 | if (!isset($this->current_branch)) { 58 | $this->current_branch = trim(shell_exec("git symbolic-ref --short -q HEAD")); 59 | } 60 | 61 | return $this->current_branch; 62 | } 63 | 64 | /** 65 | * Clears the cached value for the current branch. 66 | * 67 | * The Git Executor service must call this whenever it changes the branch. 68 | */ 69 | public function invalidateCurrentBranchCache() { 70 | unset($this->current_branch); 71 | } 72 | 73 | /** 74 | * Returns a list of all the git branches. 75 | * 76 | * TODO: this should filter when we want only main branches?? 77 | * 78 | * @return 79 | * An array whose keys are branch names, and values are the SHA of the tip. 80 | */ 81 | public function getBranchList() { 82 | if (!isset($this->branch_list)) { 83 | // TODO: check in right dir! 84 | 85 | $branch_list = []; 86 | 87 | // Get the list of local branches as 'SHA BRANCHNAME'. 88 | $refs = shell_exec("git for-each-ref refs/heads/ --format='%(objectname) %(refname:short)'"); 89 | foreach (explode("\n", trim($refs)) as $line) { 90 | list($sha, $branch_name) = explode(' ', $line); 91 | 92 | $branch_list[$branch_name] = $sha; 93 | } 94 | 95 | $this->branch_list = $branch_list; 96 | } 97 | 98 | return $this->branch_list; 99 | } 100 | 101 | /** 102 | * Gets the remote for an issue, if one exists. 103 | * 104 | * This assumes the default remote names proposed by drupal.org's git 105 | * instructions are used. 106 | * 107 | * @param int $issue_number 108 | * An issue number. 109 | * 110 | * @return string|null 111 | * The remote name, or NULL if none found. 112 | */ 113 | public function getIssueRemote(int $issue_number): ?string { 114 | // Use the -n flag so we don't hit drupal.org. 115 | $result = shell_exec("git remote show -n drupal-{$issue_number}"); 116 | 117 | if (!str_contains($result, 'git@git.drupal.org')) { 118 | return NULL; 119 | } 120 | 121 | return "drupal-{$issue_number}"; 122 | } 123 | 124 | /** 125 | * Returns a list of all the git branches which are currently reachable. 126 | * 127 | * @return 128 | * An array whose keys are branch names, and values are the SHA of the tip. 129 | */ 130 | public function getBranchListReachable() { 131 | if (!isset($this->branch_list_reachable)) { 132 | $branch_list_reachable = []; 133 | 134 | foreach ($this->getBranchList() as $branch_name => $sha) { 135 | // TODO: use isAncestor(). 136 | $output = ''; 137 | // Exit value is 0 if true, 1 if false. 138 | $return_var = ''; 139 | exec("git merge-base --is-ancestor $branch_name HEAD", $output, $return_var); 140 | 141 | if ($return_var === 0) { 142 | $branch_list_reachable[$branch_name] = $sha; 143 | } 144 | } 145 | 146 | $this->branch_list_reachable = $branch_list_reachable; 147 | } 148 | 149 | return $this->branch_list_reachable; 150 | } 151 | 152 | /** 153 | * Determines whether one commit is the ancestor of another. 154 | * 155 | * @param string $ancestor 156 | * The potential ancestor commit. 157 | * @param string $child 158 | * The potential child commit. 159 | * 160 | * @return bool 161 | * TRUE if $ancestor is reachable from $child, FALSE if not. 162 | */ 163 | public function isAncestor($ancestor, $child) { 164 | // Exit value is 0 if true, 1 if false. 165 | $return_var = ''; 166 | exec("git merge-base --is-ancestor $ancestor $child", $output, $return_var); 167 | 168 | if ($return_var === 0) { 169 | return TRUE; 170 | } 171 | else { 172 | return FALSE; 173 | } 174 | } 175 | 176 | } 177 | -------------------------------------------------------------------------------- /Service/GitLog.php: -------------------------------------------------------------------------------- 1 | waypoint_manager_branches = $waypoint_manager_branches; 15 | } 16 | 17 | /** 18 | * Get the log data of the feature branch from the branch point with master. 19 | * 20 | * @return 21 | * An array keyed by SHA, whose items are arrays with 'sha' and 'message'. 22 | * The items are arranged in progressing order, that is, older commits first. 23 | */ 24 | public function getFeatureBranchLog() { 25 | if (!isset($this->feature_branch_log)) { 26 | $master_branch_name = $this->waypoint_manager_branches->getMasterBranch()->getBranchName(); 27 | // TODO! Complain if $feature_branch_name doesn't exist yet! 28 | $feature_branch_name = $this->waypoint_manager_branches->getFeatureBranch()->getBranchName(); 29 | 30 | $log = $this->getLog($master_branch_name, $feature_branch_name); 31 | $this->feature_branch_log = $this->parseLog($log); 32 | } 33 | 34 | return $this->feature_branch_log; 35 | } 36 | 37 | /** 38 | * Get the log data of the feature branch from a given point. 39 | * 40 | * @param $commit 41 | * The older commit to start the log after. This is not included. 42 | * TODO: change this to be a Waypoint object. 43 | * 44 | * @return 45 | * An array keyed by SHA, whose items are arrays with 'sha' and 'message'. 46 | * The items are arranged in progressing order, that is, older commits first. 47 | */ 48 | public function getPartialFeatureBranchLog($commit) { 49 | // This only gets called once, no need to cache. 50 | 51 | // TODO! Complain if $feature_branch_name doesn't exist yet! 52 | $feature_branch_name = $this->waypoint_manager_branches->getFeatureBranch()->getBranchName(); 53 | 54 | $log = $this->getLog($commit, $feature_branch_name); 55 | 56 | return $this->parseLog($log); 57 | } 58 | 59 | /** 60 | * Gets the most recent master branch commit for an issue number, if any. 61 | * 62 | * @param int $issue_number 63 | * The issue number. 64 | * 65 | * @return string|null 66 | * The git log message, or NULL if none found, or if the most recent commit 67 | * that mentions the issue number appears to be a revert commit. 68 | */ 69 | public function getIssueCommit(int $issue_number): ?string { 70 | $master_branch_name = $this->waypoint_manager_branches->getMasterBranch()->getBranchName(); 71 | 72 | if (!isset($this->masterBranchLog)) { 73 | $log = shell_exec("git rev-list {$master_branch_name} --pretty=oneline"); 74 | $this->masterBranchLog = explode("\n", $log); 75 | } 76 | 77 | $log_filtered_to_issue = preg_grep("@$issue_number@", $this->masterBranchLog); 78 | 79 | if (empty($log_filtered_to_issue)) { 80 | return NULL; 81 | } 82 | 83 | $first_commit = reset($log_filtered_to_issue); 84 | 85 | if (str_contains($first_commit, 'Revert')) { 86 | return NULL; 87 | } 88 | 89 | return $first_commit; 90 | } 91 | 92 | /** 93 | * Gets the raw git log from one commit to another. 94 | * 95 | * @param $old 96 | * The older commit. This is not included in the log. 97 | * @param $new 98 | * The recent commit. This is included in the log. 99 | * 100 | * @return 101 | * The raw output from git rev-list. 102 | */ 103 | protected function getLog($old, $new) { 104 | $git_log = shell_exec("git rev-list {$new} ^{$old} --pretty=oneline --reverse"); 105 | 106 | return $git_log; 107 | } 108 | 109 | /** 110 | * Parse raw git log output into structured data. 111 | * 112 | * @param string $log 113 | * The log output, as given by 'git rev-list --pretty=oneline'. 114 | * 115 | * @return 116 | * An array keyed by SHA, where each item is an array with: 117 | * - 'sha': The SHA. 118 | * - 'message': The commit message. 119 | */ 120 | protected function parseLog($log) { 121 | $feature_branch_log = []; 122 | 123 | if (!empty($log)) { 124 | $git_log_lines = explode("\n", rtrim($log)); 125 | foreach ($git_log_lines as $line) { 126 | list($sha, $message) = explode(' ', $line, 2); 127 | //dump("$sha ::: $message"); 128 | 129 | // This gets used with array_shift(), so the key is mostly pointless. 130 | $feature_branch_log[$sha] = [ 131 | 'sha' => $sha, 132 | 'message' => $message, 133 | ]; 134 | } 135 | } 136 | 137 | return $feature_branch_log; 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /Service/UserInput.php: -------------------------------------------------------------------------------- 1 | issueNumber = $argv[1]; 17 | } 18 | else { 19 | // If the param is a URL, get the node ID from the end of it. 20 | // Allow an #anchor at the end of the URL so users can copy and paste it 21 | // when it has a #new or #ID link. 22 | $matches = []; 23 | // Built-in node URL. 24 | if (preg_match("@www\.drupal\.org/node/(?P\d+)(#.*)?$@", $argv[1], $matches)) { 25 | $this->issueNumber = $matches['number']; 26 | } 27 | // Issue node path alias with the project name in the path. 28 | if (preg_match("@www\.drupal\.org/project/(?:\w+)/issues/(?P\d+)(#.*)?$@", $argv[1], $matches)) { 29 | $this->issueNumber = $matches['number']; 30 | } 31 | } 32 | } 33 | 34 | // If nothing worked, set it to FALSE so we don't repeat the work here 35 | // another time. 36 | if (!isset($this->issueNumber)) { 37 | $this->issueNumber = FALSE; 38 | } 39 | 40 | return $this->issueNumber; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Service/WaypointManagerBranches.php: -------------------------------------------------------------------------------- 1 | git_info = $git_info; 17 | $this->drupal_org = $drupal_org; 18 | $this->git_executor = $git_executor; 19 | $this->analyser = $analyser; 20 | } 21 | 22 | public function getMasterBranch() { 23 | if (empty($this->masterBranch)) { 24 | $this->masterBranch = new MasterBranch( 25 | $this->git_info, 26 | $this->git_executor 27 | ); 28 | } 29 | 30 | return $this->masterBranch; 31 | } 32 | 33 | public function getFeatureBranch() { 34 | if (empty($this->feature_branch)) { 35 | $this->feature_branch = new FeatureBranch( 36 | $this->git_info, 37 | $this->analyser, 38 | $this->drupal_org, 39 | $this->git_executor 40 | ); 41 | } 42 | 43 | return $this->feature_branch; 44 | } 45 | 46 | /** 47 | * Determines whether the feature branch is fully merged with master. 48 | * 49 | * @param \Dorgflow\Waypoint\FeatureBranch $feature_branch 50 | * The feature branch. 51 | * 52 | * @return bool 53 | * TRUE if the feature descends from master, that is, the tip of master is 54 | * reachable from the feature branch. FALSE otherwise, that is, if the 55 | * feature branch needs to be rebased. 56 | */ 57 | public function featureBranchIsUpToDateWithMaster(FeatureBranch $feature_branch) { 58 | if (!$feature_branch->exists()) { 59 | // If the branch doesn't exist, then it's not up to date. 60 | return FALSE; 61 | } 62 | 63 | return $this->git_info->isAncestor( 64 | $this->getMasterBranch()->getBranchName(), 65 | $this->getFeatureBranch()->getBranchName() 66 | ); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Service/WaypointManagerPatches.php: -------------------------------------------------------------------------------- 1 | drupal_org->getIssueFileFieldItems($starting_comment_index); 32 | 33 | $feature_branch_log = $this->git_log->getFeatureBranchLog(); 34 | //dump($feature_branch_log); 35 | 36 | $patch_waypoints = []; 37 | 38 | 39 | // We work over the file field items from the node: 40 | // - see whether it already exists as a feature branch commit 41 | // - Yes: create a Patch object that records this. 42 | // - No: get the file entity so we can check the actual patch file URL. 43 | // - If it's a patch file, create a Patch object. 44 | // Issue file items are returned in creation order, earliest first. 45 | while ($issue_file_field_items) { 46 | // Get the next file item. 47 | $file_field_item = array_shift($issue_file_field_items); 48 | //dump($file_field_item); 49 | 50 | // Skip a file that is set to not be displayed. 51 | if (!$file_field_item->display) { 52 | continue; 53 | } 54 | 55 | $fid = $file_field_item->file->id; 56 | 57 | // Get a copy of the feature branch log array that we can search in 58 | // destructively, leaving the original to mark our place. 59 | $feature_branch_log_to_search_in = $feature_branch_log; 60 | 61 | // Work through the feature branch commit list until we find a commit 62 | // that matches. 63 | while ($feature_branch_log_to_search_in) { 64 | // Get the next commit. 65 | $commit = array_shift($feature_branch_log_to_search_in); 66 | $commit_message_data = $this->commit_message->parseCommitMessage($commit['message']); 67 | 68 | // If we find a commit from the feature branch that matches this patch, 69 | // then create a Patch waypoint and move on to the next file. 70 | if (!empty($commit_message_data['fid']) && $commit_message_data['fid'] == $fid) { 71 | // Create a patch waypoint for this patch. 72 | $patch = $this->getWaypoint(Patch::class, $file_field_item, $commit['sha'], $commit_message_data); 73 | $patch_waypoints[] = $patch; 74 | 75 | // Replace the original log with the search copy, as we've found a 76 | // commit that works, and we want to start from here the next time we 77 | // look for a patch in the log. 78 | $feature_branch_log = $feature_branch_log_to_search_in; 79 | 80 | // Done with this file item. 81 | continue 2; 82 | } 83 | } 84 | 85 | // We didn't find a commit, so now get the file entity to look at the 86 | // filename, to see if it's a patch file. 87 | $file_entity = $this->drupal_org->getFileEntity($fid); 88 | $file_url = $file_entity->url; 89 | 90 | // Skip a file that is not a patch. 91 | $patch_file_extension = pathinfo($file_url, PATHINFO_EXTENSION); 92 | if ($patch_file_extension != 'patch') { 93 | continue; 94 | } 95 | 96 | // Get a copy of the feature branch log array that we can search in 97 | // destructively, leaving the original to mark our place. 98 | $feature_branch_log_to_search_in = $feature_branch_log; 99 | 100 | // Work through the feature branch commit list to see whether this is a 101 | // patch we uploaded. 102 | while ($feature_branch_log_to_search_in) { 103 | // Get the next commit. 104 | $commit = array_shift($feature_branch_log_to_search_in); 105 | $commit_message_data = $this->commit_message->parseCommitMessage($commit['message']); 106 | 107 | // If we find a commit from the feature branch that matches this patch, 108 | // then create a Patch waypoint and move on to the next file. 109 | // We need to do more than check equality to compare two patch filenames 110 | // as drupal.org may rename files for security or uniqueness. 111 | $patch_filename = pathinfo($file_url, PATHINFO_BASENAME); 112 | if (!empty($commit_message_data['filename']) && $this->patchFilenamesAreEqual($commit_message_data['filename'], $patch_filename)) { 113 | // Create a patch waypoint for this patch. 114 | $patch = $this->getWaypoint(Patch::class, $file_field_item, $commit['sha'], $commit_message_data); 115 | $patch_waypoints[] = $patch; 116 | 117 | // Replace the original log with the search copy, as we've found a 118 | // commit that works, and we want to start from here the next time we 119 | // look for a patch in the log. 120 | $feature_branch_log = $feature_branch_log_to_search_in; 121 | 122 | // Done with this file item. 123 | continue 2; 124 | } 125 | } 126 | 127 | // We've not found a commit. 128 | // Create a patch waypoint for this patch. 129 | $patch = $this->getWaypoint(Patch::class, $file_field_item); 130 | $patch_waypoints[] = $patch; 131 | 132 | // TODO: 133 | // if $feature_branch_log still contains commits, that means that there 134 | // are local commits at the tip of the branch! 135 | } 136 | 137 | //dump($patch_waypoints); 138 | 139 | return $patch_waypoints; 140 | } 141 | 142 | /** 143 | * Determine whether two patch filenames count as equal. 144 | * 145 | * This is necessary because drupal.org can change the name of an uploaded 146 | * file in two ways: 147 | * - the filename is altered by file_munge_filename() for security reasons 148 | * - an numeric suffix is added to prevent a filename collision. 149 | * 150 | * @param $local_filename 151 | * The local filename. 152 | * @param $drupal_org_filename 153 | * The name of the file from drupal.org. 154 | * 155 | * @return bool 156 | * TRUE if the filenames are considered equal, FALSE if not. 157 | */ 158 | protected function patchFilenamesAreEqual($local_filename, $drupal_org_filename) { 159 | // Quick positive. 160 | if ($local_filename == $drupal_org_filename) { 161 | return TRUE; 162 | } 163 | 164 | // Redo the work of file_munge_filename() on the local filename. 165 | // The extensions whitelist is that of the files field instance, 166 | // node-project_issue-field_issue_files in file 167 | // features/drupalorg_issues/drupalorg_issues.features.field_instance.inc 168 | // of the 'drupalorg' project repository. 169 | $extensions = 'jpg jpeg gif png txt xls pdf ppt pps odt ods odp gz tgz patch diff zip test info po pot psd yml mov mp4 avi mkv'; 170 | // Remove any null bytes. See http://php.net/manual/security.filesystem.nullbytes.php 171 | $local_filename = str_replace(chr(0), '', $local_filename); 172 | 173 | $whitelist = array_unique(explode(' ', strtolower(trim($extensions)))); 174 | 175 | // Split the filename up by periods. The first part becomes the basename 176 | // the last part the final extension. 177 | $filename_parts = explode('.', $local_filename); 178 | $new_filename = array_shift($filename_parts); // Remove file basename. 179 | $final_extension = array_pop($filename_parts); // Remove final extension. 180 | 181 | // Loop through the middle parts of the name and add an underscore to the 182 | // end of each section that could be a file extension but isn't in the list 183 | // of allowed extensions. 184 | foreach ($filename_parts as $filename_part) { 185 | $new_filename .= '.' . $filename_part; 186 | if (!in_array(strtolower($filename_part), $whitelist) && preg_match("/^[a-zA-Z]{2,5}\d?$/", $filename_part)) { 187 | $new_filename .= '_'; 188 | } 189 | } 190 | $local_filename = $new_filename . '.' . $final_extension; 191 | 192 | // Check again. 193 | if ($local_filename == $drupal_org_filename) { 194 | return TRUE; 195 | } 196 | 197 | // Allow for a FILE_EXISTS_RENAME suffix on the drupal.org filename. 198 | $drupal_org_filename = preg_replace('@_\d+(?=\..+)@', '', $drupal_org_filename); 199 | 200 | // Final check. 201 | return ($local_filename == $drupal_org_filename); 202 | } 203 | 204 | /** 205 | * Creates a Waypoint object representing a patch about to be created. 206 | * 207 | * @return \Dorgflow\Waypoint\LocalPatch 208 | * A LocalPatch waypoint object. 209 | */ 210 | public function getLocalPatch() { 211 | // TODO: sanity checks. 212 | // if tip commit is a d.org patch, then bail. pointless 213 | // if tip commit is a local patch, then bail. pointless 214 | 215 | return $this->getWaypoint(LocalPatch::class); 216 | } 217 | 218 | /** 219 | * Finds the most recent commit on the feature branch that is for a patch. 220 | * 221 | * @return \Dorgflow\Waypoint\Patch 222 | * The patch object, or NULL of none was found. 223 | */ 224 | public function getMostRecentPatch() { 225 | $branch_log = $this->git_log->getFeatureBranchLog(); 226 | // Reverse this so we get the most recent first. 227 | foreach (array_reverse($branch_log) as $sha => $commit) { 228 | $commit_message_data = $this->commit_message->parseCommitMessage($commit['message']); 229 | 230 | if (empty($commit_message_data)) { 231 | // Skip a commit that isn't a patch. 232 | continue; 233 | } 234 | 235 | // If the patch is local, we might not want to diff against it, in the 236 | // case that it's a prior run at making the patch we're making now. 237 | // (The use case is the user makes the patch, fixes a typo, makes the 238 | // patch again.) 239 | // Compare the comment index of this patch with the expected comment index 240 | // of the next patch. 241 | // (Note that local patch commits written prior to version 1.1.3 won't 242 | // have the comment index. In this case, we play safe (and keep the old 243 | // buggy behaviour) and return this commit. 244 | if (!empty($commit_message_data['local']) && isset($commit_message_data['comment_index'])) { 245 | $next_comment_number = $this->drupal_org->getNextCommentIndex(); 246 | 247 | // If the comment numbers are the same, then skip this patch. 248 | if ($commit_message_data['comment_index'] == $next_comment_number) { 249 | continue; 250 | } 251 | 252 | // If the comment index is different, then there are two possibilities: 253 | // - it's an older patch that the user previously uploaded, and we are 254 | // right to return this. 255 | // - it is in fact a prior version of the patch we're making now, but 256 | // in the time since it was made, another drupal.org user posted a 257 | // comment to the node, causing getNextCommentIndex() to now return 258 | // a higher number. There is no simple way we can deal with this 259 | // scenario, short of checking for patches on any comments since 260 | // the commit's comment index, and then checking these patches to 261 | // see whether they match with the diff that the commit represents, 262 | // which is all a lot of work for an edge case. 263 | // So for now, we do return this, even though it could be incorrect. 264 | } 265 | 266 | // If we have commit data, then this is the most recent commit that is a 267 | // patch. 268 | // Create a patch object for this commit and we're done. 269 | $patch = $this->getWaypoint(Patch::class, NULL, $sha, $commit_message_data); 270 | return $patch; 271 | } 272 | } 273 | 274 | /** 275 | * Creates a waypoint object of the given class. 276 | * 277 | * This takes care of injecting the services. 278 | * 279 | * @param string $class_name 280 | * The fully-qualified name of the class to instantiate. 281 | * @param array $params 282 | * (optional) Further parameters to pass to the constructor after the 283 | * injected services. 284 | * 285 | * @return 286 | * The new object. 287 | */ 288 | protected function getWaypoint($class_name, ...$params) { 289 | $waypoint = new $class_name( 290 | $this->drupal_org, 291 | $this->waypoint_manager_branches, 292 | $this->git_executor, 293 | $this->commit_message, 294 | $this->analyser, 295 | // Splat operator! :) 296 | ...$params 297 | ); 298 | 299 | return $waypoint; 300 | } 301 | 302 | } 303 | -------------------------------------------------------------------------------- /Testing/repository/main.txt: -------------------------------------------------------------------------------- 1 | Arthur the aardvark 2 | -------------------------------------------------------------------------------- /Testing/repository/patch-b.patch: -------------------------------------------------------------------------------- 1 | diff --git a/main.txt b/main.txt 2 | index 221f676..d2077f5 100644 3 | --- a/main.txt 4 | +++ b/main.txt 5 | @@ -1 +1,2 @@ 6 | Arthur the aardvark 7 | +Betty the baboon 8 | -------------------------------------------------------------------------------- /Testing/repository/patch-c.patch: -------------------------------------------------------------------------------- 1 | diff --git a/main.txt b/main.txt 2 | index 221f676..b571f80 100644 3 | --- a/main.txt 4 | +++ b/main.txt 5 | @@ -1 +1,2 @@ 6 | Arthur the aardvark 7 | +Caroline the chameleon 8 | -------------------------------------------------------------------------------- /Waypoint/FeatureBranch.php: -------------------------------------------------------------------------------- 1 | git_info = $git_info; 21 | $this->analyser = $analyser; 22 | $this->drupal_org = $drupal_org; 23 | $this->git_exec = $git_exec; 24 | 25 | $issue_number = $this->analyser->deduceIssueNumber(); 26 | 27 | // Is there a branch for this issue number, that is not a tests branch? 28 | $branch_list = $this->git_info->getBranchList(); 29 | //dump($branch_list); 30 | 31 | // Work over branch list. 32 | foreach ($branch_list as $branch => $sha) { 33 | if (substr($branch, 0, strlen($issue_number)) == $issue_number) { 34 | // TODO: support tests-only branches? 35 | // substr($branch, -6) != '-XXXtests' 36 | 37 | $this->exists = TRUE; 38 | // Set the current branch as WHAT WE ARE. 39 | $this->sha = $sha; 40 | $this->branchName = $branch; 41 | //dump('found!'); 42 | //dump($this->branchName); 43 | 44 | break; 45 | } 46 | } 47 | 48 | if (empty($this->exists)) { 49 | // If we didn't find a branch for the issue number, then set ourselves 50 | // as not existing yet. 51 | $this->exists = FALSE; 52 | // Note we don't set the branch name at this point, as it incurs a call to 53 | // drupal.org, and it might be that we don't need it (such as if the 54 | // command fails for some reason). 55 | } 56 | 57 | // if current branch NOT feature branch, problem? 58 | // no, leave that to the command to determine. 59 | } 60 | 61 | /** 62 | * Returns the SHA for the commit for this patch, or NULL if not committed. 63 | * 64 | * @return string|null 65 | * The SHA, or NULL if this patch has no corresponding commit. 66 | */ 67 | public function getSHA() { 68 | return $this->sha; 69 | } 70 | 71 | public function createForkBranchName() { 72 | return $this->getBranchName() . '-forked-' . time(); 73 | } 74 | 75 | public function getBranchName() { 76 | // If the branch name hasn't been set yet, it's because the branch doesn't 77 | // exist, and we need to invent the name. 78 | if (empty($this->branchName)) { 79 | // Invent a branch name. 80 | $this->branchName = $this->createBranchName(); 81 | //dump($this->branchName); 82 | } 83 | 84 | return $this->branchName; 85 | } 86 | 87 | /** 88 | * Invents a name to give the branch if it does not actually exist yet. 89 | * 90 | * @return string 91 | * The proposed branch name. 92 | */ 93 | protected function createBranchName() { 94 | $issue_number = $this->analyser->deduceIssueNumber(); 95 | $issue_title = $this->drupal_org->getIssueNodeTitle(); 96 | 97 | $pieces = preg_split('/\s+/', $issue_title); 98 | $pieces = preg_replace('/[[:^alnum:]]/', '', $pieces); 99 | array_unshift($pieces, $issue_number); 100 | 101 | $branch_name = implode('-', $pieces); 102 | 103 | return $branch_name; 104 | } 105 | 106 | /** 107 | * Returns whether the branch exists in git. 108 | * 109 | * @return bool 110 | * TRUE if the branch exists, FALSE if not. 111 | */ 112 | public function exists() { 113 | return $this->exists; 114 | } 115 | 116 | /** 117 | * Returns whether the branch is currently checked out in git. 118 | * 119 | * @return bool 120 | * TRUE if the branch is current, FALSE if not. 121 | */ 122 | public function isCurrentBranch() { 123 | // If the branch doesn't exist, it can't be current. 124 | if (!$this->exists) { 125 | return FALSE; 126 | } 127 | 128 | // If the property hasn't been set yet, determine its value from git. 129 | if (!isset($this->isCurrentBranch)) { 130 | $this->isCurrentBranch = ($this->git_info->getCurrentBranch() == $this->branchName); 131 | } 132 | 133 | return $this->isCurrentBranch; 134 | } 135 | 136 | public function getBranchDescription() { 137 | $matches = []; 138 | preg_match("@\d+-?(?P.+)@", $this->branchName, $matches); 139 | 140 | if (!empty($matches['description'])) { 141 | $branch_description = $matches['description']; 142 | return $branch_description; 143 | } 144 | } 145 | 146 | public function gitCreate() { 147 | // Get the branch name from the method, so this lazy-loads. 148 | $branch_name = $this->getBranchName(); 149 | 150 | // Create a new branch and check it out. 151 | $this->git_exec->createNewBranch($branch_name, TRUE); 152 | 153 | // This now exists. 154 | $this->exists = TRUE; 155 | } 156 | 157 | /** 158 | * Checks out the branch. 159 | */ 160 | public function gitCheckout() { 161 | // No need to do anything if the branch is current. 162 | if ($this->isCurrentBranch()) { 163 | return; 164 | } 165 | 166 | $branch_name = $this->getBranchName(); 167 | $this->git_exec->checkOutBranch($branch_name); 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /Waypoint/LocalPatch.php: -------------------------------------------------------------------------------- 1 | drupal_org = $drupal_org; 23 | $this->waypoint_manager_branches = $waypoint_manager_branches; 24 | $this->git_executor = $git_executor; 25 | $this->commit_message = $commit_message; 26 | $this->analyser = $analyser; 27 | } 28 | 29 | /** 30 | * Gets the filename for this waypoint's patch file. 31 | * 32 | * @return string 33 | * The patch filename. 34 | */ 35 | public function getPatchFilename() { 36 | if (!isset($this->patchFile)) { 37 | $this->patchFile = $this->makePatchFilename(); 38 | } 39 | 40 | return $this->patchFile; 41 | } 42 | 43 | /** 44 | * Gets the (expected) comment index for the patch file. 45 | * 46 | * This is the number of the comment that will to the node when the file 47 | * was added. Comment numbers start at 1 and increment for each comment. 48 | * (Obviously, if another user comments on the issue in between this patch 49 | * being created and the Dorgflow user uploading it, the number will be 50 | * wrong.) 51 | * 52 | * @return int 53 | * The comment index. 54 | */ 55 | public function getPatchFileIndex() { 56 | if (!isset($this->index)) { 57 | $this->index = $this->drupal_org->getNextCommentIndex(); 58 | } 59 | return $this->index; 60 | } 61 | 62 | /** 63 | * Creates a filename for this waypoint's patch file. 64 | * 65 | * @return string 66 | * The patch filename. 67 | */ 68 | protected function makePatchFilename() { 69 | $issue_number = $this->analyser->deduceIssueNumber(); 70 | $comment_number = $this->getPatchFileIndex(); 71 | $patch_number = "$issue_number-$comment_number"; 72 | 73 | $current_project = $this->analyser->getCurrentProjectName(); 74 | 75 | $branch_description = $this->waypoint_manager_branches->getFeatureBranch()->getBranchDescription(); 76 | 77 | return "$patch_number.$current_project.$branch_description.patch"; 78 | } 79 | 80 | public function commitPatch() { 81 | $this->makeGitCommit(); 82 | } 83 | 84 | public function makeCommitMessage() { 85 | // TODO 86 | } 87 | 88 | protected function makeGitCommit() { 89 | $message = $this->makeCommitMessage(); 90 | $this->git_executor->commit($message); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /Waypoint/MasterBranch.php: -------------------------------------------------------------------------------- 1 | git_info = $git_info; 31 | $this->git_executor = $git_executor; 32 | 33 | // We require the master branch to be reachable. 34 | $branch_list = $this->git_info->getBranchListReachable(); 35 | 36 | // Sort the branches by version number. 37 | uksort($branch_list, function ($a, $b) { 38 | // A semver branch is always taken to be newer than a non-semver. We 39 | // can't use version_compare() for this, as in contrib, something like 40 | // 2.0.x is newer than 8.x-1.x. 41 | if (preg_match(self::SEMVER_BRANCH_NAME_REGEX, $a) && !preg_match(self::SEMVER_BRANCH_NAME_REGEX, $b)) { 42 | // Second is lower than first. 43 | return 1; 44 | } 45 | if (!preg_match(self::SEMVER_BRANCH_NAME_REGEX, $a) && preg_match(self::SEMVER_BRANCH_NAME_REGEX, $b)) { 46 | // First is lower than second. 47 | return -1; 48 | } 49 | 50 | return version_compare($a, $b); 51 | }); 52 | 53 | // Reverse so we have highest branch first. 54 | $branch_list = array_reverse($branch_list); 55 | 56 | $master_branch_regex = "@(" . implode('|', self::BRANCH_NAME_PATTERNS) . ')@'; 57 | 58 | foreach ($branch_list as $branch => $sha) { 59 | // Identify the main development branch, of one of the following forms: 60 | // - '7.x-1.x' 61 | // - '7.x' 62 | // - '8.0.x' 63 | if (preg_match($master_branch_regex, $branch)) { 64 | $this->branchName = trim($branch); 65 | 66 | $found = TRUE; 67 | 68 | break; 69 | } 70 | } 71 | 72 | if (empty($found)) { 73 | // This should trigger a complete failure -- throw an exception! 74 | throw new \Exception("Can't find a master branch in the reachable branches. Perhaps you need to rebase this branch?"); 75 | } 76 | 77 | $this->isCurrentBranch = ($this->git_info->getCurrentBranch() == $this->branchName); 78 | } 79 | 80 | public function getBranchName() { 81 | return $this->branchName; 82 | } 83 | 84 | public function isCurrentBranch() { 85 | return $this->isCurrentBranch; 86 | } 87 | 88 | public function checkOutFiles() { 89 | $original_branch = $this->git_info->getCurrentBranch(); 90 | 91 | if ($original_branch == $this->branchName) { 92 | throw new \Exception("On master branch {$original_branch}."); 93 | } 94 | 95 | // Check out the master branch. 96 | $this->git_executor->checkOutBranch($this->branchName); 97 | 98 | // Make the original branch current, but without changing the files. 99 | // This will allow us to apply a patch on the master branch, while ready to 100 | // make the commit for the patch on the feature branch. 101 | // We have to go the long way round, because the simply checking out the 102 | // master branch files while remaining on the feature branch will not take 103 | // into account any new files that are added by a patch. 104 | $this->git_executor->moveToBranch($original_branch); 105 | } 106 | 107 | /** 108 | * Checks out the branch. 109 | */ 110 | public function gitCheckout() { 111 | // No need to do anything if the branch is current. 112 | if ($this->isCurrentBranch()) { 113 | return; 114 | } 115 | 116 | $branch_name = $this->getBranchName(); 117 | $this->git_executor->checkOutBranch($branch_name); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /Waypoint/Patch.php: -------------------------------------------------------------------------------- 1 | becomes git commit -- PatchFile 31 | - getFileEntity 32 | - getPatchFile 33 | - getPatchFilename 34 | 35 | 36 | - file from dorg AND git commit --> doesn't do much -- PatchFileCommitted 37 | 38 | - git commit only --> becomes dorg file -- LocalPatch 39 | 40 | - local file for dorg AND git commit --> doesn't do much -- LocalPatchFile 41 | */ 42 | 43 | /** 44 | * The file entity ID for this patch. 45 | */ 46 | protected $fid; 47 | 48 | /** 49 | * The comment ID for this patch. 50 | */ 51 | protected $cid; 52 | 53 | /** 54 | * The comment index for this patch. 55 | * 56 | * This is the number of the comment that was added to the node when the file 57 | * was added. Comment numbers start at 1 and increment for each comment. 58 | */ 59 | protected $index; 60 | 61 | /** 62 | * The filename for this patch. 63 | */ 64 | protected $patchFile; 65 | 66 | /** 67 | * The SHA for this patch, if it is already committed to the feature branch. 68 | */ 69 | protected $sha; 70 | 71 | /** 72 | * Constructor. 73 | * 74 | * @param $file_field_item = NULL 75 | * The file field item from the issue node for the patch file, if there is 76 | * one. 77 | * @param $sha = NULL 78 | * The SHA for the patch's commit, if there is a commit. 79 | * @param $commit_message_data = NULL 80 | * The parsed commit message data, if there is a commit. 81 | */ 82 | function __construct($drupal_org, $waypoint_manager_branches, $git_executor, $commit_message, $analyser, $file_field_item = NULL, $sha = NULL, $commit_message_data = NULL) { 83 | $this->drupal_org = $drupal_org; 84 | $this->waypoint_manager_branches = $waypoint_manager_branches; 85 | $this->git_executor = $git_executor; 86 | $this->commit_message = $commit_message; 87 | 88 | // Set the file ID and index. 89 | if (isset($file_field_item)) { 90 | $this->fid = $file_field_item->file->id; 91 | $this->index = $file_field_item->index; 92 | $this->cid = $file_field_item->file->cid ?? NULL; 93 | } 94 | elseif (isset($commit_message_data)) { 95 | // If there is no file item, then try the commit message data. 96 | $this->fid = $commit_message_data['fid'] ?? NULL; 97 | $this->index = $commit_message_data['comment_index'] ?? NULL; 98 | } 99 | 100 | $this->sha = $sha; 101 | } 102 | 103 | /** 104 | * Returns the SHA for the commit for this patch, or NULL if not committed. 105 | * 106 | * @return string|null 107 | * The SHA, or NULL if this patch has no corresponding commit. 108 | */ 109 | public function getSHA() { 110 | return $this->sha; 111 | } 112 | 113 | /** 114 | * Returns the Drupal file entity for this patch. 115 | * 116 | * @return \StdClass 117 | * The Drupal file entity, downloaded from drupal.org. 118 | */ 119 | public function getFileEntity() { 120 | // Lazy fetch the file entity for the patch. 121 | if (empty($this->fileEntity)) { 122 | $this->fileEntity = $this->drupal_org->getFileEntity($this->fid); 123 | } 124 | return $this->fileEntity; 125 | } 126 | 127 | /** 128 | * Returns the patch file for this patch. 129 | * 130 | * @return string 131 | * The text of the patch file. 132 | */ 133 | public function getPatchFile() { 134 | // Lazy fetch the patch file. 135 | if (empty($this->patchFile)) { 136 | $file_entity = $this->getFileEntity(); 137 | 138 | $this->patchFile = $this->drupal_org->getPatchFile($file_entity->url); 139 | } 140 | return $this->patchFile; 141 | } 142 | 143 | public function getPatchFilename() { 144 | $file_entity = $this->getFileEntity(); 145 | $file_url = $file_entity->url; 146 | return pathinfo($file_url, PATHINFO_BASENAME); 147 | } 148 | 149 | public function getPatchFileFid() { 150 | return $this->fid; 151 | } 152 | 153 | /** 154 | * Gets the comment index for the patch file. 155 | * 156 | * This is the number of the comment that was added to the node when the file 157 | * was added. Comment numbers start at 1 and increment for each comment. 158 | * 159 | * @return int 160 | * The comment index. 161 | */ 162 | public function getPatchFileIndex() { 163 | return $this->index; 164 | } 165 | 166 | /** 167 | * Gets the comment ID for the patch file. 168 | * 169 | * @return int|null 170 | * The comment ID, or NULL if the file is on the node itself. 171 | */ 172 | public function getPatchFileCid(): ?int { 173 | return $this->cid; 174 | } 175 | 176 | /** 177 | * Returns whether this patch already has a feature branch commit. 178 | * 179 | * @return bool 180 | * TRUE if this patch has a commit; FALSE if not. 181 | */ 182 | public function hasCommit() { 183 | return !empty($this->sha); 184 | } 185 | 186 | /** 187 | * Create a commit for this patch on the current feature branch. 188 | * 189 | * @return bool 190 | * TRUE if git was able to apply the patch, FALSE if it was not. 191 | */ 192 | public function commitPatch() { 193 | // Set the files back to the master branch, without changing the current 194 | // commit. 195 | $this->waypoint_manager_branches->getMasterBranch()->checkOutFiles(); 196 | 197 | $patch_status = $this->applyPatchFile(); 198 | 199 | if ($patch_status) { 200 | $this->makeGitCommit(); 201 | return TRUE; 202 | } 203 | else { 204 | return FALSE; 205 | } 206 | } 207 | 208 | /** 209 | * Apply the file for this patch. 210 | * 211 | * This assumes that the filesystem is in a state that is ready to accept the 212 | * patch -- that is, the master branch files are checked out. 213 | * 214 | * @return 215 | * TRUE if the patch applied, FALSE if it did not. 216 | */ 217 | public function applyPatchFile() { 218 | $patch_file = $this->getPatchFile(); 219 | return $this->git_executor->applyPatch($patch_file); 220 | } 221 | 222 | /** 223 | * Creates a commit message to use for the patch. 224 | * 225 | * @return string 226 | * The commit message. 227 | */ 228 | protected function getCommitMessage() { 229 | return $this->commit_message->createCommitMessage($this); 230 | } 231 | 232 | protected function makeGitCommit() { 233 | $message = $this->getCommitMessage(); 234 | $this->git_executor->commit($message); 235 | } 236 | 237 | } 238 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joachim-n/dorgflow", 3 | "description": " Automated git workflow for drupal.org patches.", 4 | "homepage": "https://github.com/joachim-n/dorgflow", 5 | "license": "GPL-2.0+", 6 | "require": { 7 | "symfony/dependency-injection": "^7", 8 | "symfony/console": "^7" 9 | }, 10 | "require-dev": { 11 | "phpunit/phpunit": ">=6.0", 12 | "symfony/var-dumper": ">=2.8" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "Dorgflow\\": "" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /dorgflow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add($container->get('command.apply')); 21 | $application->add($container->get('command.cleanup')); 22 | $application->add($container->get('command.patch')); 23 | $application->add($container->get('command.status')); 24 | $application->add($container->get('command.open')); 25 | $application->add($container->get('command.diff')); 26 | $application->add($container->get('command.setup')); 27 | $application->add($container->get('command.update')); 28 | $application->add($container->get('command.master')); 29 | $application->add($container->get('command.purge')); 30 | 31 | $application->setDefaultCommand('patch'); 32 | 33 | $application->run(); 34 | -------------------------------------------------------------------------------- /services.php: -------------------------------------------------------------------------------- 1 | register('user_input', \Dorgflow\Service\UserInput::class); 12 | 13 | $container 14 | ->register('git.info', \Dorgflow\Service\GitInfo::class); 15 | 16 | $container 17 | ->register('analyser', \Dorgflow\Service\Analyser::class) 18 | ->addArgument(new Reference('git.info')) 19 | ->addArgument(new Reference('user_input')); 20 | 21 | $container 22 | ->register('git.executor', \Dorgflow\Service\GitExecutor::class) 23 | ->addArgument(new Reference('git.info')) 24 | ->addArgument(new Reference('analyser')); 25 | 26 | $container 27 | ->register('commit_message', \Dorgflow\Service\CommitMessageHandler::class) 28 | ->addArgument(new Reference('analyser')); 29 | 30 | $container 31 | ->register('drupal_org', \Dorgflow\Service\DrupalOrg::class) 32 | ->addArgument(new Reference('analyser')); 33 | 34 | $container 35 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 36 | ->addArgument(new Reference('git.info')) 37 | ->addArgument(new Reference('drupal_org')) 38 | ->addArgument(new Reference('git.executor')) 39 | ->addArgument(new Reference('analyser')); 40 | 41 | $container 42 | ->register('git.log', \Dorgflow\Service\GitLog::class) 43 | ->addArgument(new Reference('waypoint_manager.branches')); 44 | 45 | $container 46 | ->register('waypoint_manager.patches', \Dorgflow\Service\WaypointManagerPatches::class) 47 | ->addArgument(new Reference('commit_message')) 48 | ->addArgument(new Reference('drupal_org')) 49 | ->addArgument(new Reference('git.log')) 50 | ->addArgument(new Reference('git.executor')) 51 | ->addArgument(new Reference('analyser')) 52 | ->addArgument(new Reference('waypoint_manager.branches')); 53 | 54 | // Register commands as services. 55 | $container 56 | ->register('command.apply', \Dorgflow\Command\Apply::class) 57 | ->addMethodCall('setContainer', [new Reference('service_container')]); 58 | $container 59 | ->register('command.cleanup', \Dorgflow\Command\Cleanup::class) 60 | ->addMethodCall('setContainer', [new Reference('service_container')]); 61 | $container 62 | ->register('command.status', \Dorgflow\Command\Status::class) 63 | ->addMethodCall('setContainer', [new Reference('service_container')]); 64 | $container 65 | ->register('command.open', \Dorgflow\Command\OpenIssue::class) 66 | ->addMethodCall('setContainer', [new Reference('service_container')]); 67 | $container 68 | ->register('command.diff', \Dorgflow\Command\Diff::class) 69 | ->addMethodCall('setContainer', [new Reference('service_container')]); 70 | $container 71 | ->register('command.patch', \Dorgflow\Command\CreatePatch::class) 72 | ->addMethodCall('setContainer', [new Reference('service_container')]); 73 | $container 74 | ->register('command.setup', \Dorgflow\Command\LocalSetup::class) 75 | ->addMethodCall('setContainer', [new Reference('service_container')]); 76 | $container 77 | ->register('command.update', \Dorgflow\Command\LocalUpdate::class) 78 | ->addMethodCall('setContainer', [new Reference('service_container')]); 79 | $container 80 | ->register('command.master', \Dorgflow\Command\SwitchMaster::class) 81 | ->addMethodCall('setContainer', [new Reference('service_container')]); 82 | $container 83 | ->register('command.purge', \Dorgflow\Command\Purge::class) 84 | ->addMethodCall('setContainer', [new Reference('service_container')]); 85 | -------------------------------------------------------------------------------- /tests/CommandCreatePatchTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(\Dorgflow\Service\GitInfo::class) 26 | ->disableOriginalConstructor() 27 | ->setMethods(['gitIsClean']) 28 | ->getMock(); 29 | $git_info->method('gitIsClean') 30 | ->willReturn(FALSE); 31 | 32 | $container->set('git.info', $git_info); 33 | // These won't get called, so don't need to mock anything. 34 | $container->set('git.log', $this->getMockBuilder(StdClass::class)); 35 | $container->set('analyser', $this->getMockBuilder(StdClass::class)); 36 | $container->set('waypoint_manager.branches', $this->getMockBuilder(StdClass::class)); 37 | $container->set('waypoint_manager.patches', $this->getMockBuilder(StdClass::class)); 38 | $container->set('drupal_org', $this->getMockBuilder(StdClass::class)); 39 | $container->set('git.executor', $this->getMockBuilder(StdClass::class)); 40 | 41 | // Add real versions of any remaining services not yet registered. 42 | $this->completeServiceContainer($container); 43 | 44 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 45 | 46 | $this->expectException(\Exception::class); 47 | 48 | $command_tester->execute([ 49 | 'command' => 'patch', 50 | ]); 51 | } 52 | 53 | /** 54 | * Tests the case where the feature branch can't be found. 55 | */ 56 | public function testNoFeatureBranch() { 57 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 58 | 59 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 60 | // Git is clean so the command proceeds. 61 | $git_info->method('gitIsClean') 62 | ->willReturn(TRUE); 63 | $branch_list = [ 64 | // There is no feature branch. 65 | '8.x-2.x' => 'sha', 66 | 'some-branch-name' => 'sha', 67 | 'something-else' => 'sha', 68 | ]; 69 | $git_info->method('getBranchList') 70 | ->willReturn($branch_list); 71 | $git_info->method('getBranchListReachable') 72 | ->willReturn($branch_list); 73 | $container->set('git.info', $git_info); 74 | 75 | // The analyser returns an issue number. 76 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 77 | $analyser->method('deduceIssueNumber') 78 | ->willReturn(123456); 79 | $container->set('analyser', $analyser); 80 | 81 | // The git log should not be called at all. 82 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 83 | $git_log->expects($this->never())->method($this->anything()); 84 | $container->set('git.log', $git_log); 85 | 86 | // The git executor should not be called at all. 87 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 88 | $git_executor->expects($this->never())->method($this->anything()); 89 | $container->set('git.executor', $git_executor); 90 | 91 | // Drupal.org API should not be called at all. 92 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 93 | $drupal_org->expects($this->never())->method($this->anything()); 94 | $container->set('drupal_org', $drupal_org); 95 | 96 | // Need the real service for this, as we want the command to get the branch 97 | // object from it, based on the mocked git.info service. 98 | $container 99 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 100 | ->addArgument(new Reference('git.info')) 101 | ->addArgument(new Reference('drupal_org')) 102 | ->addArgument(new Reference('git.executor')) 103 | ->addArgument(new Reference('analyser')); 104 | 105 | $container->set('waypoint_manager.patches', $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class)); 106 | 107 | // Add real versions of any remaining services not yet registered. 108 | $this->completeServiceContainer($container); 109 | 110 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 111 | 112 | $this->expectException(\Exception::class); 113 | 114 | $command_tester->execute([ 115 | 'command' => 'patch', 116 | ]); 117 | } 118 | 119 | /** 120 | * Tests the case where the feature branch isn't current. 121 | */ 122 | public function testNotOnFeatureBranch() { 123 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 124 | 125 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 126 | // Git is clean so the command proceeds. 127 | $git_info->method('gitIsClean') 128 | ->willReturn(TRUE); 129 | $branch_list = [ 130 | // There is a feature branch. 131 | '123456-terrible-bug' => 'sha-feature', 132 | '8.x-2.x' => 'sha', 133 | 'some-branch-name' => 'sha', 134 | 'something-else' => 'sha', 135 | ]; 136 | $git_info->method('getBranchList') 137 | ->willReturn($branch_list); 138 | $git_info->method('getBranchListReachable') 139 | ->willReturn($branch_list); 140 | $git_info->method('getCurrentBranch') 141 | ->willReturn('8.x-2.x'); 142 | $container->set('git.info', $git_info); 143 | 144 | // The analyser returns an issue number. 145 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 146 | $analyser->method('deduceIssueNumber') 147 | ->willReturn(123456); 148 | $container->set('analyser', $analyser); 149 | 150 | // The git log should not be called at all. 151 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 152 | $git_log->expects($this->never())->method($this->anything()); 153 | $container->set('git.log', $git_log); 154 | 155 | // The git executor should not be called at all. 156 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 157 | $git_executor->expects($this->never())->method($this->anything()); 158 | $container->set('git.executor', $git_executor); 159 | 160 | // Drupal.org API should not be called at all. 161 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 162 | $drupal_org->expects($this->never())->method($this->anything()); 163 | $container->set('drupal_org', $drupal_org); 164 | 165 | // Need the real service for this, as we want the command to get the branch 166 | // object from it, based on the mocked git.info service. 167 | $container 168 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 169 | ->addArgument(new Reference('git.info')) 170 | ->addArgument(new Reference('drupal_org')) 171 | ->addArgument(new Reference('git.executor')) 172 | ->addArgument(new Reference('analyser')); 173 | 174 | $container->set('waypoint_manager.patches', $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class)); 175 | 176 | // Add real versions of any remaining services not yet registered. 177 | $this->completeServiceContainer($container); 178 | 179 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 180 | 181 | $this->expectException(\Exception::class); 182 | 183 | $command_tester->execute([ 184 | 'command' => 'patch', 185 | ]); 186 | } 187 | 188 | /** 189 | * Tests creating a patch with no previous patches. 190 | */ 191 | public function testCreatePatch() { 192 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 193 | 194 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 195 | // Git is clean so the command proceeds. 196 | $git_info->method('gitIsClean') 197 | ->willReturn(TRUE); 198 | $branch_list = [ 199 | // There is a feature branch. 200 | '123456-terrible-bug' => 'sha-feature', 201 | '8.x-2.x' => 'sha', 202 | 'some-branch-name' => 'sha', 203 | 'something-else' => 'sha', 204 | ]; 205 | $git_info->method('getBranchList') 206 | ->willReturn($branch_list); 207 | $git_info->method('getBranchListReachable') 208 | ->willReturn($branch_list); 209 | // Feature branch is current. 210 | $git_info->method('getCurrentBranch') 211 | ->willReturn('123456-terrible-bug'); 212 | $container->set('git.info', $git_info); 213 | 214 | // The analyser returns an issue number and project name. 215 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 216 | $analyser->method('deduceIssueNumber') 217 | ->willReturn(123456); 218 | $analyser->method('getCurrentProjectName') 219 | ->willReturn('my_project'); 220 | $container->set('analyser', $analyser); 221 | 222 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 223 | $drupal_org->method('getNextCommentIndex') 224 | ->willReturn(16); 225 | $container->set('drupal_org', $drupal_org); 226 | 227 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 228 | $git_log->method('getFeatureBranchLog') 229 | ->willReturn([ 230 | 'sha-feature' => [ 231 | 'sha' => 'sha-feature', 232 | 'message' => 'commit message', 233 | ], 234 | ]); 235 | // Not testing this output, so just return an empty array. 236 | $git_log->method('getPartialFeatureBranchLog') 237 | ->willReturn([]); 238 | $container->set('git.log', $git_log); 239 | 240 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 241 | // A patch will be created. 242 | $git_executor->expects($this->once()) 243 | ->method('createPatch') 244 | ->with( 245 | // Diff against the master branch. 246 | $this->equalTo('8.x-2.x'), 247 | // Patch name contains: 248 | // - issue number 249 | // - next comment number 250 | // - project name 251 | // - feature branch description 252 | $this->equalTo('123456-16.my_project.terrible-bug.patch') 253 | ); 254 | $git_executor->expects($this->never())->method('checkOutFiles'); 255 | $git_executor->expects($this->never())->method('applyPatch'); 256 | // An empty commit will be made to the feature branch to show the new patch. 257 | $git_executor->expects($this->once()) 258 | ->method('commit') 259 | ->with( 260 | $this->equalTo("Patch for Drupal.org. Comment (expected): 16; file: 123456-16.my_project.terrible-bug.patch. Automatic commit by dorgflow.") 261 | ); 262 | $container->set('git.executor', $git_executor); 263 | 264 | // Add real versions of any remaining services not yet registered. 265 | $this->completeServiceContainer($container); 266 | 267 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 268 | 269 | $command_tester->execute([ 270 | 'command' => 'patch', 271 | ]); 272 | } 273 | 274 | /** 275 | * Tests creating a patch with a previous patch. 276 | */ 277 | public function testCreatePatchWithInterdiff() { 278 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 279 | 280 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 281 | // Git is clean so the command proceeds. 282 | $git_info->method('gitIsClean') 283 | ->willReturn(TRUE); 284 | $branch_list = [ 285 | // There is a feature branch. 286 | '123456-terrible-bug' => 'sha-feature', 287 | '8.x-2.x' => 'sha', 288 | 'some-branch-name' => 'sha', 289 | 'something-else' => 'sha', 290 | ]; 291 | $git_info->method('getBranchList') 292 | ->willReturn($branch_list); 293 | $git_info->method('getBranchListReachable') 294 | ->willReturn($branch_list); 295 | // Feature branch is current. 296 | $git_info->method('getCurrentBranch') 297 | ->willReturn('123456-terrible-bug'); 298 | $container->set('git.info', $git_info); 299 | 300 | // The analyser returns an issue number and project name. 301 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 302 | $analyser->method('deduceIssueNumber') 303 | ->willReturn(123456); 304 | $analyser->method('getCurrentProjectName') 305 | ->willReturn('my_project'); 306 | $container->set('analyser', $analyser); 307 | 308 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 309 | $drupal_org->method('getNextCommentIndex') 310 | ->willReturn(16); 311 | $container->set('drupal_org', $drupal_org); 312 | 313 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 314 | $git_log->method('getFeatureBranchLog') 315 | ->willReturn([ 316 | // Previous patch from Drupal.org which has been applied. 317 | 'sha-patch-1' => [ 318 | 'sha' => 'sha-patch-1', 319 | 'message' => 'Patch from Drupal.org. Comment: 1; URL: https://www.drupal.org/node/123456#comment-111; file: patch-1.patch; fid: 11. Automatic commit by dorgflow.', 320 | ], 321 | 'sha-feature' => [ 322 | 'sha' => 'sha-feature', 323 | 'message' => 'commit message', 324 | ], 325 | ]); 326 | // Not testing this output, so just return an empty array. 327 | $git_log->method('getPartialFeatureBranchLog') 328 | ->willReturn([]); 329 | $container->set('git.log', $git_log); 330 | 331 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 332 | // A patch will be created. 333 | $git_executor->expects($this->exactly(2)) 334 | ->method('createPatch') 335 | ->withConsecutive( 336 | [ 337 | // Diff against the master branch. 338 | $this->equalTo('8.x-2.x'), 339 | // Patch name contains: 340 | // - issue number 341 | // - next comment number 342 | // - project name 343 | // - feature branch description 344 | $this->equalTo('123456-16.my_project.terrible-bug.patch'), 345 | ], 346 | [ 347 | // Diff against the most recent patch. 348 | 'sha-patch-1', 349 | // Interdiff file name 350 | 'interdiff.123456.1-16.txt', 351 | ] 352 | ); 353 | $git_executor->expects($this->never())->method('checkOutFiles'); 354 | $git_executor->expects($this->never())->method('applyPatch'); 355 | // An empty commit will be made to the feature branch to show the new patch. 356 | $git_executor->expects($this->once()) 357 | ->method('commit') 358 | ->with( 359 | $this->equalTo("Patch for Drupal.org. Comment (expected): 16; file: 123456-16.my_project.terrible-bug.patch. Automatic commit by dorgflow.") 360 | ); 361 | $container->set('git.executor', $git_executor); 362 | 363 | // Add real versions of any remaining services not yet registered. 364 | $this->completeServiceContainer($container); 365 | 366 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 367 | 368 | $command_tester->execute([ 369 | 'command' => 'patch', 370 | ]); 371 | } 372 | 373 | /** 374 | * Tests creating a patch with a previous patch after an earlier attempt. 375 | * 376 | * This covers the case when the user has done this: 377 | * - $ dorgflow [creates a patch] 378 | * - spots something they missed, doesn't upload the patch. 379 | * - $ git commit ... [makes more commits] 380 | * The user is now about to make the patch again. The interdiff should ignore 381 | * the commit that the previous CreatePatch command made. 382 | */ 383 | public function testCreatePatchWithInterdiffAndPriorAttempt() { 384 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 385 | 386 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 387 | // Git is clean so the command proceeds. 388 | $git_info->method('gitIsClean') 389 | ->willReturn(TRUE); 390 | $branch_list = [ 391 | // There is a feature branch. 392 | '123456-terrible-bug' => 'sha-feature', 393 | '8.x-2.x' => 'sha', 394 | 'some-branch-name' => 'sha', 395 | 'something-else' => 'sha', 396 | ]; 397 | $git_info->method('getBranchList') 398 | ->willReturn($branch_list); 399 | $git_info->method('getBranchListReachable') 400 | ->willReturn($branch_list); 401 | // Feature branch is current. 402 | $git_info->method('getCurrentBranch') 403 | ->willReturn('123456-terrible-bug'); 404 | $container->set('git.info', $git_info); 405 | 406 | // The analyser returns an issue number and project name. 407 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 408 | $analyser->method('deduceIssueNumber') 409 | ->willReturn(123456); 410 | $analyser->method('getCurrentProjectName') 411 | ->willReturn('my_project'); 412 | $container->set('analyser', $analyser); 413 | 414 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 415 | $drupal_org->method('getNextCommentIndex') 416 | ->willReturn(16); 417 | $container->set('drupal_org', $drupal_org); 418 | 419 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 420 | $git_log->method('getFeatureBranchLog') 421 | ->willReturn([ 422 | // Previous patch from Drupal.org which has been applied. 423 | 'sha-patch-1' => [ 424 | 'sha' => 'sha-patch-1', 425 | 'message' => 'Patch from Drupal.org. Comment: 1; URL: https://www.drupal.org/node/123456#comment-111; file: patch-1.patch; fid: 11. Automatic commit by dorgflow.', 426 | ], 427 | 'sha-1' => [ 428 | 'sha' => 'sha-1', 429 | 'message' => 'my own commit', 430 | ], 431 | 'sha-patch-local' => [ 432 | 'sha' => 'sha-patch-local', 433 | 'message' => "Patch for Drupal.org. Comment (expected): 16; file: 123456-16.my_project.terrible-bug.patch. Automatic commit by dorgflow.", 434 | ], 435 | 'sha-2' => [ 436 | 'sha' => 'sha-2', 437 | 'message' => 'another commit', 438 | ], 439 | 'sha-feature' => [ 440 | 'sha' => 'sha-feature', 441 | 'message' => 'commit message', 442 | ], 443 | ]); 444 | // Not testing this output, so just return an empty array. 445 | $git_log->method('getPartialFeatureBranchLog') 446 | ->willReturn([]); 447 | $container->set('git.log', $git_log); 448 | 449 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 450 | // A patch will be created. 451 | $git_executor->expects($this->exactly(2)) 452 | ->method('createPatch') 453 | ->withConsecutive( 454 | [ 455 | // Diff against the master branch. 456 | $this->equalTo('8.x-2.x'), 457 | // Patch name contains: 458 | // - issue number 459 | // - next comment number 460 | // - project name 461 | // - feature branch description 462 | $this->equalTo('123456-16.my_project.terrible-bug.patch'), 463 | ], 464 | [ 465 | // Diff against the most recent patch. 466 | 'sha-patch-1', 467 | // Interdiff file name 468 | 'interdiff.123456.1-16.txt', 469 | ] 470 | ); 471 | $git_executor->expects($this->never())->method('checkOutFiles'); 472 | $git_executor->expects($this->never())->method('applyPatch'); 473 | // An empty commit will be made to the feature branch to show the new patch. 474 | $git_executor->expects($this->once()) 475 | ->method('commit') 476 | ->with( 477 | $this->equalTo("Patch for Drupal.org. Comment (expected): 16; file: 123456-16.my_project.terrible-bug.patch. Automatic commit by dorgflow.") 478 | ); 479 | $container->set('git.executor', $git_executor); 480 | 481 | // Add real versions of any remaining services not yet registered. 482 | $this->completeServiceContainer($container); 483 | 484 | $command_tester = $this->setUpCommandTester($container, 'patch', \Dorgflow\Command\CreatePatch::class); 485 | 486 | $command_tester->execute([ 487 | 'command' => 'patch', 488 | ]); 489 | } 490 | 491 | } 492 | -------------------------------------------------------------------------------- /tests/CommandLocalSetupTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(\Dorgflow\Service\GitInfo::class) 28 | ->disableOriginalConstructor() 29 | ->setMethods(['gitIsClean']) 30 | ->getMock(); 31 | $git_info->method('gitIsClean') 32 | ->willReturn(FALSE); 33 | 34 | $container->set('git.info', $git_info); 35 | // These won't get called, so don't need to mock anything. 36 | $container->set('waypoint_manager.branches', $this->getMockBuilder(StdClass::class)); 37 | $container->set('waypoint_manager.patches', $this->getMockBuilder(StdClass::class)); 38 | 39 | $command_tester = $this->setUpCommandTester($container, 'setup', \Dorgflow\Command\LocalSetup::class); 40 | 41 | $this->expectException(\Exception::class); 42 | 43 | $command_tester->execute([ 44 | 'command' => 'setup', 45 | ]); 46 | } 47 | 48 | /** 49 | * Test the command bails when the master branch is not current. 50 | */ 51 | public function testNotOnMasterBranch() { 52 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 53 | 54 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 55 | // Git is clean so the command proceeds. 56 | $git_info->method('gitIsClean') 57 | ->willReturn(TRUE); 58 | $branch_list = [ 59 | '8.x-2.x' => 'sha', 60 | 'some-branch-name' => 'sha', 61 | 'something-else' => 'sha', 62 | ]; 63 | $git_info->method('getBranchList') 64 | ->willReturn($branch_list); 65 | $git_info->method('getBranchListReachable') 66 | ->willReturn($branch_list); 67 | // The master branch is not current -- we're on some other branch that's not 68 | // for an issue. 69 | $git_info->method('getCurrentBranch') 70 | ->willReturn('some-branch-name'); 71 | $container->set('git.info', $git_info); 72 | 73 | // The git executor should not be called at all. 74 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 75 | $git_executor->expects($this->never())->method($this->anything()); 76 | $container->set('git.executor', $git_executor); 77 | 78 | // Drupal.org API should not be called at all. 79 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 80 | $drupal_org->expects($this->never())->method($this->anything()); 81 | $container->set('drupal_org', $drupal_org); 82 | 83 | // The requested issue number comes from user input. 84 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 85 | $analyser->method('deduceIssueNumber') 86 | ->willReturn(123456); 87 | $container->set('analyser', $analyser); 88 | 89 | // Need the real service for this, as we want the command to get the branch 90 | // object from it, based on the mocked git.info service. 91 | $container 92 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 93 | ->addArgument(new Reference('git.info')) 94 | ->addArgument(new Reference('drupal_org')) 95 | ->addArgument(new Reference('git.executor')) 96 | ->addArgument(new Reference('analyser')); 97 | 98 | $container->set('waypoint_manager.patches', $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class)); 99 | 100 | $command_tester = $this->setUpCommandTester($container, 'setup', \Dorgflow\Command\LocalSetup::class); 101 | 102 | $this->expectException(\Exception::class); 103 | 104 | $command_tester->execute([ 105 | 'command' => 'setup', 106 | ]); 107 | } 108 | 109 | /** 110 | * Test the command bails when the feature branch exists. 111 | */ 112 | public function testFeatureBranchExists() { 113 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 114 | 115 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 116 | // Git is clean so the command proceeds past this check. 117 | $git_info->method('gitIsClean') 118 | ->willReturn(TRUE); 119 | $branch_list = [ 120 | '8.x-2.x' => 'sha', 121 | // Feature branch already exists. 122 | // Only the issue number part counts to determine this; the rest of the 123 | // branch name should not matter, so this is intentionally different 124 | // from the issue node title. 125 | '123456-some-branch-name' => 'sha', 126 | 'some-other-branch' => 'sha', 127 | ]; 128 | $git_info->method('getBranchList') 129 | ->willReturn($branch_list); 130 | $git_info->method('getBranchListReachable') 131 | ->willReturn($branch_list); 132 | // The master branch is current so we proceed past master branch discovery. 133 | $git_info->method('getCurrentBranch') 134 | ->willReturn('8.x-2.x'); 135 | $container->set('git.info', $git_info); 136 | 137 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 138 | $analyser->method('deduceIssueNumber') 139 | ->willReturn(123456); 140 | $container->set('analyser', $analyser); 141 | 142 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 143 | $drupal_org->method('getIssueNodeTitle') 144 | ->willReturn('Terribly awful bug'); 145 | // Issue file fields should not be requested. 146 | $drupal_org->expects($this->never())->method('getIssueFileFieldItems'); 147 | $container->set('drupal_org', $drupal_org); 148 | 149 | // The git executor be called only to check out the feature branch. 150 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 151 | $git_executor->expects($this->once()) 152 | ->method('checkOutBranch') 153 | ->with($this->equalTo('123456-some-branch-name')); 154 | // No branches will be created or patches applied. 155 | $git_executor->expects($this->never())->method('createNewBranch'); 156 | $git_executor->expects($this->never())->method('checkOutFiles'); 157 | $git_executor->expects($this->never())->method('applyPatch'); 158 | $git_executor->expects($this->never())->method('commit'); 159 | $container->set('git.executor', $git_executor); 160 | 161 | $container 162 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 163 | ->addArgument(new Reference('git.info')) 164 | ->addArgument(new Reference('drupal_org')) 165 | ->addArgument(new Reference('git.executor')) 166 | ->addArgument(new Reference('analyser')); 167 | 168 | $waypoint_manager_patches = $this->createMock(\Dorgflow\Service\WaypointManagerPatches::class); 169 | $container->set('waypoint_manager.patches', $waypoint_manager_patches); 170 | 171 | $command_tester = $this->setUpCommandTester($container, 'setup', \Dorgflow\Command\LocalSetup::class); 172 | 173 | $command_tester->execute([ 174 | 'command' => 'setup', 175 | ]); 176 | } 177 | 178 | /** 179 | * Test setup on an issue with no patches. 180 | */ 181 | public function testIssueNoPatches() { 182 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 183 | 184 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 185 | // Git is clean so the command proceeds. 186 | $git_info->method('gitIsClean') 187 | ->willReturn(TRUE); 188 | // Master branch is current. 189 | $git_info->method('getCurrentBranch') 190 | ->willReturn('8.3.x'); 191 | $branch_list = [ 192 | '8.3.x' => 'sha', 193 | ]; 194 | $git_info->method('getBranchList') 195 | ->willReturn($branch_list); 196 | $git_info->method('getBranchListReachable') 197 | ->willReturn($branch_list); 198 | $container->set('git.info', $git_info); 199 | 200 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 201 | $analyser->method('deduceIssueNumber') 202 | ->willReturn(123456); 203 | $container->set('analyser', $analyser); 204 | 205 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 206 | $drupal_org->method('getIssueNodeTitle') 207 | ->willReturn('Terribly awful bug'); 208 | // No issue file fields. 209 | $drupal_org->method('getIssueFileFieldItems') 210 | ->willReturn([]); 211 | // Patch files will not be requested. 212 | $drupal_org->expects($this->never())->method('getFileEntity'); 213 | $drupal_org->expects($this->never())->method('getPatchFile'); 214 | $container->set('drupal_org', $drupal_org); 215 | 216 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 217 | // A new branch will be created. 218 | $git_executor->expects($this->once()) 219 | ->method('createNewBranch') 220 | ->with($this->equalTo('123456-Terribly-awful-bug'), $this->equalTo(TRUE)); 221 | // No patches will be applied. 222 | $git_executor->expects($this->never())->method('checkOutFiles'); 223 | $git_executor->expects($this->never())->method('applyPatch'); 224 | $git_executor->expects($this->never())->method('commit'); 225 | 226 | $container->set('git.executor', $git_executor); 227 | 228 | $container 229 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 230 | ->addArgument(new Reference('git.info')) 231 | ->addArgument(new Reference('drupal_org')) 232 | ->addArgument(new Reference('git.executor')) 233 | ->addArgument(new Reference('analyser')); 234 | 235 | $waypoint_manager_patches = $this->createMock(\Dorgflow\Service\WaypointManagerPatches::class); 236 | $container->set('waypoint_manager.patches', $waypoint_manager_patches); 237 | 238 | $command_tester = $this->setUpCommandTester($container, 'setup', \Dorgflow\Command\LocalSetup::class); 239 | 240 | $command_tester->execute([ 241 | 'command' => 'setup', 242 | ]); 243 | } 244 | 245 | /** 246 | * Test setup on an issue with patches. 247 | */ 248 | public function testIssueWithPatches() { 249 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 250 | 251 | $prophet = new \Prophecy\Prophet; 252 | $git_info = $prophet->prophesize(); 253 | $git_info->willExtend(\Dorgflow\Service\GitInfo::class); 254 | 255 | // Git is clean so the command proceeds. 256 | $git_info->gitIsClean()->willReturn(TRUE); 257 | 258 | $git_info->getCurrentBranch()->willReturn('8.3.x'); 259 | 260 | $branch_list = [ 261 | '8.3.x' => 'sha', 262 | ]; 263 | $git_info->getBranchList()->willReturn($branch_list); 264 | $git_info->getBranchListReachable()->willReturn($branch_list); 265 | 266 | $container->set('git.info', $git_info->reveal()); 267 | 268 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 269 | $analyser->method('deduceIssueNumber') 270 | ->willReturn(123456); 271 | $container->set('analyser', $analyser); 272 | 273 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 274 | $drupal_org->method('getIssueNodeTitle') 275 | ->willReturn('Terribly awful bug'); 276 | $patch_file_data = [ 277 | 0 => [ 278 | 'fid' => 200, 279 | 'cid' => 400, 280 | 'index' => 1, 281 | 'filename' => 'fix-1.patch', 282 | 'display' => TRUE, 283 | 'applies' => TRUE, 284 | 'expected' => 'apply', 285 | ], 286 | // Not displayed; will be skipped. 287 | 1 => [ 288 | 'fid' => 205, 289 | 'cid' => 405, 290 | 'index' => 5, 291 | 'filename' => 'fix-5.patch', 292 | 'display' => FALSE, 293 | 'expected' => 'skip', 294 | ], 295 | // Not a patch; will be skipped. 296 | 2 => [ 297 | 'fid' => 206, 298 | 'cid' => 406, 299 | 'index' => 6, 300 | 'filename' => 'fix-5.not.patch.txt', 301 | 'display' => TRUE, 302 | 'expected' => 'skip', 303 | ], 304 | 3 => [ 305 | 'fid' => 210, 306 | 'cid' => 410, 307 | 'index' => 10, 308 | 'filename' => 'fix-10.patch', 309 | 'display' => TRUE, 310 | 'applies' => TRUE, 311 | 'expected' => 'apply', 312 | ], 313 | ]; 314 | $this->setUpDrupalOrgExpectations($drupal_org, $patch_file_data); 315 | $container->set('drupal_org', $drupal_org); 316 | 317 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 318 | // A new branch will be created. 319 | $git_executor->expects($this->once()) 320 | ->method('createNewBranch') 321 | ->with( 322 | $this->callback(function($subject) use ($git_info) { 323 | // Creating a new branch changes what git_info will return as the 324 | // current branch. 325 | // (Yes, horrible mishmash of two mocking systems.) 326 | $git_info->getCurrentBranch()->willReturn($subject); 327 | 328 | return ($subject == '123456-Terribly-awful-bug'); 329 | }), 330 | $this->equalTo(TRUE) 331 | ); 332 | 333 | $this->setUpGitExecutorPatchExpectations($git_executor, $patch_file_data); 334 | $container->set('git.executor', $git_executor); 335 | 336 | $container->set('commit_message', $this->createMock(\Dorgflow\Service\CommitMessageHandler::class)); 337 | $container->set('git.log', $this->createMock(\Dorgflow\Service\GitLog::class)); 338 | 339 | // Add real versions of any remaining services not yet registered. 340 | $this->completeServiceContainer($container); 341 | 342 | $command_tester = $this->setUpCommandTester($container, 'setup', \Dorgflow\Command\LocalSetup::class); 343 | 344 | $command_tester->execute([ 345 | 'command' => 'setup', 346 | ]); 347 | } 348 | 349 | } 350 | -------------------------------------------------------------------------------- /tests/CommandLocalUpdateTest.php: -------------------------------------------------------------------------------- 1 | createMock(\Dorgflow\Service\GitInfo::class); 41 | $git_info->method('gitIsClean') 42 | ->willReturn(FALSE); 43 | 44 | $container->set('git.info', $git_info); 45 | // These won't get called, so don't need to mock anything. 46 | $container->set('waypoint_manager.branches', $this->getMockBuilder(StdClass::class)); 47 | $container->set('waypoint_manager.patches', $this->getMockBuilder(StdClass::class)); 48 | $container->set('git.executor', $this->getMockBuilder(StdClass::class)); 49 | 50 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 51 | 52 | $this->expectException(\Exception::class); 53 | 54 | $command_tester->execute([ 55 | 'command' => 'update', 56 | ]); 57 | } 58 | 59 | /** 60 | * Tests the case where the feature branch can't be found. 61 | */ 62 | public function testNoFeatureBranch() { 63 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 64 | 65 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 66 | // Git is clean so the command proceeds. 67 | $git_info->method('gitIsClean') 68 | ->willReturn(TRUE); 69 | $branch_list = [ 70 | // There is no feature branch. 71 | '8.x-2.x' => 'sha', 72 | 'some-branch-name' => 'sha', 73 | 'something-else' => 'sha', 74 | ]; 75 | $git_info->method('getBranchList') 76 | ->willReturn($branch_list); 77 | $git_info->method('getBranchListReachable') 78 | ->willReturn($branch_list); 79 | $container->set('git.info', $git_info); 80 | 81 | // The analyser returns an issue number. 82 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 83 | $analyser->method('deduceIssueNumber') 84 | ->willReturn(self::ISSUE_NUMBER); 85 | $container->set('analyser', $analyser); 86 | 87 | // The git executor should not be called at all. 88 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 89 | $git_executor->expects($this->never())->method($this->anything()); 90 | $container->set('git.executor', $git_executor); 91 | 92 | // Drupal.org API should not be called at all. 93 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 94 | $drupal_org->expects($this->never())->method($this->anything()); 95 | $container->set('drupal_org', $drupal_org); 96 | 97 | // Need the real service for this, as we want the command to get the branch 98 | // object from it, based on the mocked git.info service. 99 | $container 100 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 101 | ->addArgument(new Reference('git.info')) 102 | ->addArgument(new Reference('drupal_org')) 103 | ->addArgument(new Reference('git.executor')) 104 | ->addArgument(new Reference('analyser')); 105 | 106 | $container->set('waypoint_manager.patches', $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class)); 107 | 108 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 109 | 110 | $this->expectException(\Exception::class); 111 | 112 | $command_tester->execute([ 113 | 'command' => 'update', 114 | ]); 115 | } 116 | 117 | /** 118 | * Tests the case where the feature branch isn't current. 119 | */ 120 | public function testNotOnFeatureBranch() { 121 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 122 | 123 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 124 | // Git is clean so the command proceeds. 125 | $git_info->method('gitIsClean') 126 | ->willReturn(TRUE); 127 | $branch_list = [ 128 | // There is a feature branch. 129 | self::FEATURE_BRANCH_NAME => self::FEATURE_BRANCH_SHA, 130 | '8.x-2.x' => 'sha', 131 | 'some-branch-name' => 'sha', 132 | 'something-else' => 'sha', 133 | ]; 134 | $git_info->method('getBranchList') 135 | ->willReturn($branch_list); 136 | $git_info->method('getBranchListReachable') 137 | ->willReturn($branch_list); 138 | // Master branch is current rather than the feature branch. 139 | $git_info->method('getCurrentBranch') 140 | ->willReturn('8.x-2.x'); 141 | $container->set('git.info', $git_info); 142 | 143 | // The analyser returns an issue number. 144 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 145 | $analyser->method('deduceIssueNumber') 146 | ->willReturn(self::ISSUE_NUMBER); 147 | $container->set('analyser', $analyser); 148 | 149 | // The git executor should not be called at all. 150 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 151 | $git_executor->expects($this->never())->method($this->anything()); 152 | $container->set('git.executor', $git_executor); 153 | 154 | // Drupal.org API should not be called at all. 155 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 156 | $drupal_org->expects($this->never())->method($this->anything()); 157 | $container->set('drupal_org', $drupal_org); 158 | 159 | // Need the real service for this, as we want the command to get the branch 160 | // object from it, based on the mocked git.info service. 161 | $container 162 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 163 | ->addArgument(new Reference('git.info')) 164 | ->addArgument(new Reference('drupal_org')) 165 | ->addArgument(new Reference('git.executor')) 166 | ->addArgument(new Reference('analyser')); 167 | 168 | $container->set('waypoint_manager.patches', $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class)); 169 | 170 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 171 | 172 | $this->expectException(\Exception::class); 173 | 174 | $command_tester->execute([ 175 | 'command' => 'update', 176 | ]); 177 | } 178 | 179 | /** 180 | * Tests the case the feature branch has nothing and there are new patches. 181 | */ 182 | public function testEmptyFeatureBranchNewPatches() { 183 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 184 | 185 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 186 | // Git is clean so the command proceeds. 187 | $git_info->method('gitIsClean') 188 | ->willReturn(TRUE); 189 | $branch_list = [ 190 | // There is a feature branch, and its SHA is the same as the master 191 | // branch. 192 | self::FEATURE_BRANCH_NAME => 'sha-master', 193 | '8.3.x' => 'sha-master', 194 | 'some-branch-name' => 'sha', 195 | 'something-else' => 'sha', 196 | ]; 197 | $git_info->method('getBranchList') 198 | ->willReturn($branch_list); 199 | $git_info->method('getBranchListReachable') 200 | ->willReturn($branch_list); 201 | // Feature branch is current. 202 | $git_info->method('getCurrentBranch') 203 | ->willReturn(self::FEATURE_BRANCH_NAME); 204 | $container->set('git.info', $git_info); 205 | 206 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 207 | // Feature branch log is empty. 208 | $git_log->method('getFeatureBranchLog') 209 | ->willReturn([]); 210 | $container->set('git.log', $git_log); 211 | 212 | // The analyser returns an issue number. 213 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 214 | $analyser->method('deduceIssueNumber') 215 | ->willReturn(self::ISSUE_NUMBER); 216 | $container->set('analyser', $analyser); 217 | 218 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 219 | $drupal_org->method('getIssueNodeTitle') 220 | ->willReturn('Terribly awful bug'); 221 | $patch_file_data = [ 222 | 0 => [ 223 | 'fid' => 200, 224 | 'cid' => 400, 225 | 'index' => 1, 226 | 'filename' => 'fix-1.patch', 227 | 'display' => TRUE, 228 | 'applies' => TRUE, 229 | 'expected' => 'apply', 230 | ], 231 | // Not displayed; will be skipped. 232 | 1 => [ 233 | 'fid' => 205, 234 | 'cid' => 405, 235 | 'index' => 5, 236 | 'filename' => 'fix-5.patch', 237 | 'display' => FALSE, 238 | 'expected' => 'skip', 239 | ], 240 | // Not a patch; will be skipped. 241 | 2 => [ 242 | 'fid' => 206, 243 | 'cid' => 406, 244 | 'index' => 6, 245 | 'filename' => 'fix-5.not.patch.txt', 246 | 'display' => TRUE, 247 | 'expected' => 'skip', 248 | ], 249 | 3 => [ 250 | 'fid' => 210, 251 | 'cid' => 410, 252 | 'index' => 10, 253 | 'filename' => 'fix-10.patch', 254 | 'display' => TRUE, 255 | 'applies' => TRUE, 256 | 'expected' => 'apply', 257 | ], 258 | ]; 259 | $this->setUpDrupalOrgExpectations($drupal_org, $patch_file_data); 260 | $container->set('drupal_org', $drupal_org); 261 | 262 | $container->set('commit_message', $this->createMock(\Dorgflow\Service\CommitMessageHandler::class)); 263 | 264 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 265 | // No new branches will be created. 266 | $git_executor->expects($this->never()) 267 | ->method('createNewBranch'); 268 | 269 | // Both patches will be applied. 270 | $this->setUpGitExecutorPatchExpectations($git_executor, $patch_file_data); 271 | $container->set('git.executor', $git_executor); 272 | 273 | // Add real versions of any remaining services not yet registered. 274 | $this->completeServiceContainer($container); 275 | 276 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 277 | 278 | $command_tester->execute([ 279 | 'command' => 'update', 280 | ]); 281 | } 282 | 283 | /** 284 | * Tests a feature branch that has patches, with new patches on the issue. 285 | */ 286 | public function testFeatureBranchFurtherPatches() { 287 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 288 | 289 | $git_info = $this->getGitInfoCleanWithFeatureBranch(); 290 | $container->set('git.info', $git_info); 291 | 292 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 293 | // Feature branch log: two patches have previously been committed. 294 | $git_log->method('getFeatureBranchLog') 295 | ->willReturn([ 296 | 'sha-patch-1' => [ 297 | 'sha' => 'sha-patch-1', 298 | 'message' => "Patch from Drupal.org. Comment: 1; URL: http://url.com/1234; file: applied.patch; fid: 11. Automatic commit by dorgflow.", 299 | ], 300 | self::FEATURE_BRANCH_SHA => [ 301 | 'sha' => self::FEATURE_BRANCH_SHA, 302 | 'message' => "Patch from Drupal.org. Comment: 2; URL: http://url.com/1234; file: applied.patch; fid: 12. Automatic commit by dorgflow.", 303 | ], 304 | ]); 305 | $container->set('git.log', $git_log); 306 | 307 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 308 | $drupal_org->method('getIssueNodeTitle') 309 | ->willReturn('Terribly awful bug'); 310 | $patch_file_data = [ 311 | 0 => [ 312 | // Patch has previously been applied. 313 | 'fid' => 11, 314 | 'cid' => 21, 315 | 'index' => 1, 316 | 'filename' => 'patch-1.patch', 317 | 'display' => TRUE, 318 | // We expect that this will not be attempted again. 319 | 'expected' => 'skip', 320 | ], 321 | 1 => [ 322 | // Patch has previously been applied. 323 | 'fid' => 12, 324 | 'cid' => 22, 325 | 'index' => 2, 326 | 'filename' => 'patch-2.patch', 327 | 'display' => TRUE, 328 | // We expect that this will not be attempted again. 329 | 'expected' => 'skip', 330 | ], 331 | 2 => [ 332 | // New patch. 333 | 'fid' => 13, 334 | 'cid' => 23, 335 | 'index' => 3, 336 | 'filename' => 'new.patch', 337 | 'display' => TRUE, 338 | 'applies' => TRUE, 339 | // The new patch will be attempted to be applied. 340 | 'expected' => 'apply', 341 | ], 342 | ]; 343 | $this->setUpDrupalOrgExpectations($drupal_org, $patch_file_data); 344 | $container->set('drupal_org', $drupal_org); 345 | 346 | // The analyser returns an issue number. 347 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 348 | $analyser->method('deduceIssueNumber') 349 | ->willReturn(self::ISSUE_NUMBER); 350 | $container->set('analyser', $analyser); 351 | 352 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 353 | // No new branches will be created. 354 | $git_executor->expects($this->never()) 355 | ->method('createNewBranch'); 356 | // Only the new patch file will be applied. 357 | $this->setUpGitExecutorPatchExpectations($git_executor, $patch_file_data); 358 | $container->set('git.executor', $git_executor); 359 | 360 | // Add real versions of any remaining services not yet registered. 361 | $this->completeServiceContainer($container); 362 | 363 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 364 | 365 | $command_tester->execute([ 366 | 'command' => 'update', 367 | ]); 368 | } 369 | 370 | /** 371 | * Tests a prior patch that failed to apply is not applied again. 372 | */ 373 | public function testFeatureBranchFailingPatch() { 374 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 375 | 376 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 377 | // Git is clean so the command proceeds. 378 | $git_info->method('gitIsClean') 379 | ->willReturn(TRUE); 380 | $branch_list = [ 381 | // There is a feature branch. 382 | self::FEATURE_BRANCH_NAME => self::FEATURE_BRANCH_SHA, 383 | '8.3.x' => 'sha-master', 384 | 'some-branch-name' => 'sha', 385 | 'something-else' => 'sha', 386 | ]; 387 | $git_info->method('getBranchList') 388 | ->willReturn($branch_list); 389 | $git_info->method('getBranchListReachable') 390 | ->willReturn($branch_list); 391 | // Feature branch is current. 392 | $git_info->method('getCurrentBranch') 393 | ->willReturn(self::FEATURE_BRANCH_NAME); 394 | $container->set('git.info', $git_info); 395 | 396 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 397 | // Feature branch log has a commit for the second patch on the issue, since 398 | // the first one failed. 399 | $git_log->method('getFeatureBranchLog') 400 | ->willReturn([ 401 | self::FEATURE_BRANCH_SHA => [ 402 | 'sha' => self::FEATURE_BRANCH_SHA, 403 | 'message' => "Patch from Drupal.org. Comment: 10; URL: http://url.com/1234; file: applied.patch; fid: 210. Automatic commit by dorgflow.", 404 | ], 405 | ]); 406 | $container->set('git.log', $git_log); 407 | 408 | // The analyser returns an issue number. 409 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 410 | $analyser->method('deduceIssueNumber') 411 | ->willReturn(self::ISSUE_NUMBER); 412 | $container->set('analyser', $analyser); 413 | 414 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 415 | $drupal_org->method('getIssueNodeTitle') 416 | ->willReturn('Terribly awful bug'); 417 | $patch_file_data = [ 418 | 0 => [ 419 | // A patch that previously failed to apply. 420 | 'fid' => 200, 421 | 'cid' => 400, 422 | 'index' => 1, 423 | 'filename' => 'failing.patch', 424 | 'display' => TRUE, 425 | // We expect that this will not be attempted again. 426 | 'expected' => 'skip', 427 | ], 428 | 1 => [ 429 | // Patch has previously been applied. 430 | // This is the condition for the prior failing patch to not be attempted 431 | // again. 432 | 'fid' => 210, 433 | 'cid' => 410, 434 | 'index' => 10, 435 | 'filename' => 'applied.patch', 436 | 'display' => TRUE, 437 | 'expected' => 'skip', 438 | ], 439 | 2 => [ 440 | // New patch. 441 | 'fid' => 220, 442 | 'cid' => 420, 443 | 'index' => 20, 444 | 'filename' => 'new.patch', 445 | 'display' => TRUE, 446 | 'applies' => TRUE, 447 | 'expected' => 'apply', 448 | ], 449 | ]; 450 | $this->setUpDrupalOrgExpectations($drupal_org, $patch_file_data); 451 | $container->set('drupal_org', $drupal_org); 452 | 453 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 454 | // No new branches will be created. 455 | $git_executor->expects($this->never()) 456 | ->method('createNewBranch'); 457 | // Only the new patch file will be applied. 458 | $this->setUpGitExecutorPatchExpectations($git_executor, $patch_file_data); 459 | $container->set('git.executor', $git_executor); 460 | 461 | // Add real versions of any remaining services not yet registered. 462 | $this->completeServiceContainer($container); 463 | 464 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 465 | 466 | $command_tester->execute([ 467 | 'command' => 'update', 468 | ]); 469 | } 470 | 471 | /** 472 | * Tests a feature branch ending in a local patch, new patches on the issue. 473 | * 474 | * The scenario is as follows: 475 | * - user set up the issue with a patch from d.org 476 | * - user created a patch and uploaded it 477 | * - another user posted a patch to d.org 478 | * - the user is now updating. 479 | */ 480 | public function testFeatureBranchLocalPatchHeadFurtherPatches() { 481 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 482 | 483 | // Create a standard git.info service for the command to proceed. 484 | $git_info = $this->getGitInfoCleanWithFeatureBranch(); 485 | $container->set('git.info', $git_info); 486 | 487 | $git_log = $this->createMock(\Dorgflow\Service\GitLog::class); 488 | // Feature branch log: one d.org patch, a work commit, then one local patch. 489 | $git_log->method('getFeatureBranchLog') 490 | ->willReturn([ 491 | 'sha-patch-1' => [ 492 | 'sha' => 'sha-patch-1', 493 | 'message' => "Patch from Drupal.org. Comment: 21; URL: http://url.com/1234; file: patch-1.patch; fid: 11. Automatic commit by dorgflow.", 494 | ], 495 | 'sha-work' => [ 496 | 'sha' => 'sha-work', 497 | 'message' => "Fixing the bug.", 498 | ], 499 | self::FEATURE_BRANCH_SHA => [ 500 | // This is the tip of the feature branch. 501 | 'sha' => self::FEATURE_BRANCH_SHA, 502 | 'message' => "Patch for Drupal.org. Comment (expected): 22; file: 123456-22.project.bug-description.patch. Automatic commit by dorgflow.", 503 | ], 504 | ]); 505 | $container->set('git.log', $git_log); 506 | 507 | $drupal_org = $this->createMock(\Dorgflow\Service\DrupalOrg::class); 508 | $drupal_org->method('getIssueNodeTitle') 509 | ->willReturn('Terribly awful bug'); 510 | $patch_file_data = [ 511 | 0 => [ 512 | // Patch has previously been applied. 513 | 'fid' => 11, 514 | 'cid' => 21, 515 | 'index' => 1, 516 | 'filename' => 'patch-1.patch', 517 | 'display' => TRUE, 518 | // We expect that this will not be attempted again. 519 | 'expected' => 'skip', 520 | ], 521 | 1 => [ 522 | // Patch came from us. 523 | 'fid' => 12, 524 | 'cid' => 22, 525 | 'index' => 2, 526 | 'filename' => '123456-22.project.bug-description.patch', 527 | 'display' => TRUE, 528 | // We expect that this will not be attempted again. 529 | 'expected' => 'skip', 530 | ], 531 | 2 => [ 532 | // New patch. 533 | 'fid' => 13, 534 | 'cid' => 23, 535 | 'index' => 3, 536 | 'filename' => 'patch-23.patch', 537 | 'display' => TRUE, 538 | 'applies' => TRUE, 539 | // The new patch will be attempted to be applied. 540 | 'expected' => 'apply', 541 | ], 542 | ]; 543 | $this->setUpDrupalOrgExpectations($drupal_org, $patch_file_data); 544 | $container->set('drupal_org', $drupal_org); 545 | 546 | // The analyser returns an issue number. 547 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 548 | $analyser->method('deduceIssueNumber') 549 | ->willReturn(self::ISSUE_NUMBER); 550 | $container->set('analyser', $analyser); 551 | 552 | $git_executor = $this->createMock(\Dorgflow\Service\GitExecutor::class); 553 | // No new branches will be created. 554 | $git_executor->expects($this->never()) 555 | ->method('createNewBranch'); 556 | // Only the new patch file will be applied. 557 | $this->setUpGitExecutorPatchExpectations($git_executor, $patch_file_data); 558 | $container->set('git.executor', $git_executor); 559 | 560 | // Add real versions of any remaining services not yet registered. 561 | $this->completeServiceContainer($container); 562 | 563 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 564 | 565 | $command_tester->execute([ 566 | 'command' => 'update', 567 | ]); 568 | } 569 | 570 | /** 571 | * Creates a git info service with typical data for the command to proceed. 572 | * 573 | * - git is clean 574 | * - the feature branch exists, is current, and is ahead of the master branch. 575 | * 576 | * @return 577 | * The mocked git.info object. 578 | */ 579 | protected function getGitInfoCleanWithFeatureBranch() { 580 | $git_info = $this->createMock(\Dorgflow\Service\GitInfo::class); 581 | // Git is clean so the command proceeds. 582 | $git_info->method('gitIsClean') 583 | ->willReturn(TRUE); 584 | $branch_list = [ 585 | // There is a feature branch, which is further ahead than the master 586 | // branch. 587 | self::FEATURE_BRANCH_NAME => self::FEATURE_BRANCH_SHA, 588 | '8.3.x' => 'sha-master', 589 | 'some-branch-name' => 'sha', 590 | 'something-else' => 'sha', 591 | ]; 592 | $git_info->method('getBranchList') 593 | ->willReturn($branch_list); 594 | $git_info->method('getBranchListReachable') 595 | ->willReturn($branch_list); 596 | // Feature branch is current. 597 | $git_info->method('getCurrentBranch') 598 | ->willReturn(self::FEATURE_BRANCH_NAME); 599 | 600 | return $git_info; 601 | } 602 | 603 | } 604 | -------------------------------------------------------------------------------- /tests/CommandLocalUpdateUnitTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(\Dorgflow\Waypoint\FeatureBranch::class) 33 | ->disableOriginalConstructor() 34 | ->setMethods(['exists']) 35 | ->getMock(); 36 | // Feature branch reports it doesn't exist. 37 | $feature_branch->method('exists') 38 | ->willReturn(FALSE); 39 | 40 | // Branch manager to provide the feature branch. 41 | $waypoint_manager_branches = $this->getMockBuilder(\Dorgflow\Service\WaypointManagerBranches::class) 42 | ->disableOriginalConstructor() 43 | ->setMethods(['getFeatureBranch']) 44 | ->getMock(); 45 | $waypoint_manager_branches->method('getFeatureBranch') 46 | ->willReturn($feature_branch); 47 | 48 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 49 | 50 | $container->set('git.info', $this->getMockGitInfoClean()); 51 | $container->set('waypoint_manager.branches', $waypoint_manager_branches); 52 | // These won't get called, so don't need to mock anything. 53 | $container->set('waypoint_manager.patches', $this->getMockBuilder(StdClass::class)); 54 | $container->set('git.executor', $this->getMockBuilder(StdClass::class)); 55 | 56 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 57 | 58 | $this->expectException(\Exception::class); 59 | 60 | $command_tester->execute([ 61 | 'command' => 'update', 62 | ]); 63 | } 64 | 65 | /** 66 | * Tests a new patch that applies on an empty feature branch. 67 | */ 68 | public function testNewPatchEmptyBranch() { 69 | // Set up one new patch on the issue. 70 | $patches = []; 71 | 72 | $patch = $this->getMockBuilder(\Dorgflow\Waypoint\Patch::class) 73 | ->disableOriginalConstructor() 74 | ->setMethods(['hasCommit', 'commitPatch', 'getPatchFilename']) 75 | ->getMock(); 76 | // Patch is new: it has no commit. 77 | $patch->method('hasCommit') 78 | ->willReturn(FALSE); 79 | // We expect the patch will get committed (and that it will apply OK). 80 | $patch->expects($this->once()) 81 | ->method('commitPatch') 82 | ->willReturn(TRUE); 83 | // The patch filename; needed for output message. 84 | $patch->method('getPatchFilename') 85 | ->willReturn('file-patch-0.patch'); 86 | $patches[] = $patch; 87 | 88 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 89 | 90 | $container->set('git.info', $this->getMockGitInfoClean()); 91 | $container->set('waypoint_manager.branches', $this->getMockWaypointManagerFeatureBranchCurrent()); 92 | $container->set('waypoint_manager.patches', $this->getMockWaypointManagerWithPatches($patches)); 93 | $container->set('git.executor', $this->getMockBuilder(StdClass::class)); 94 | 95 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 96 | 97 | $command_tester->execute([ 98 | 'command' => 'update', 99 | ]); 100 | 101 | // TODO: test user output 102 | } 103 | 104 | /** 105 | * Tests a new patch that applies on a feature branch with a patch. 106 | */ 107 | public function testNewPatchBranchWithPatch() { 108 | // Set up two new patch on the issue, one which is already committed. 109 | $patches = []; 110 | 111 | // Already committed patch. 112 | $patch = $this->getMockBuilder(\Dorgflow\Waypoint\Patch::class) 113 | ->disableOriginalConstructor() 114 | ->setMethods(['hasCommit', 'commitPatch', 'getSHA']) 115 | ->getMock(); 116 | // Patch is new: it has no commit. 117 | $patch->method('hasCommit') 118 | ->willReturn(TRUE); 119 | $patch->method('hasCommit') 120 | ->willReturn(TRUE); 121 | // The branch has no local commits, so the last committed patch is at the 122 | // feature branch tip. 123 | $patch->method('getSHA') 124 | ->willReturn('sha-feature'); 125 | // We expect the patch will not get committed. 126 | $patch->expects($this->never()) 127 | ->method('commitPatch'); 128 | $patches[] = $patch; 129 | 130 | // New patch. 131 | $patch = $this->getMockBuilder(\Dorgflow\Waypoint\Patch::class) 132 | ->disableOriginalConstructor() 133 | ->setMethods(['hasCommit', 'commitPatch', 'getPatchFilename']) 134 | ->getMock(); 135 | // Patch is new: it has no commit. 136 | $patch->method('hasCommit') 137 | ->willReturn(FALSE); 138 | // We expect the patch will get committed (and that it will apply OK). 139 | $patch->expects($this->once()) 140 | ->method('commitPatch') 141 | ->willReturn(TRUE); 142 | // The patch filename; needed for output message. 143 | $patch->method('getPatchFilename') 144 | ->willReturn('file-patch-1.patch'); 145 | $patches[] = $patch; 146 | 147 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 148 | 149 | $container->set('git.info', $this->getMockGitInfoClean()); 150 | $container->set('waypoint_manager.branches', $this->getMockWaypointManagerFeatureBranchCurrent()); 151 | $container->set('waypoint_manager.patches', $this->getMockWaypointManagerWithPatches($patches)); 152 | $container->set('git.executor', $this->getMockBuilder(StdClass::class)); 153 | 154 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 155 | 156 | $command_tester->execute([ 157 | 'command' => 'update', 158 | ]); 159 | } 160 | 161 | /** 162 | * Tests a new patch that applies on a feature branch with local commits. 163 | */ 164 | public function testNewPatchBranchWithLocalCommits() { 165 | // Set up two new patch on the issue, one which is already committed. 166 | $patches = []; 167 | 168 | // Already committed patch. 169 | $patch = $this->getMockBuilder(\Dorgflow\Waypoint\Patch::class) 170 | ->disableOriginalConstructor() 171 | ->setMethods(['hasCommit', 'commitPatch', 'getSHA']) 172 | ->getMock(); 173 | // Patch is new: it has no commit. 174 | $patch->method('hasCommit') 175 | ->willReturn(TRUE); 176 | $patch->method('hasCommit') 177 | ->willReturn(TRUE); 178 | // The branch has local commits, so the last committed patch has an SHA 179 | // different from the feature branch tip. 180 | $patch->method('getSHA') 181 | ->willReturn('sha-patch-0'); 182 | // We expect the patch will not get committed. 183 | $patch->expects($this->never()) 184 | ->method('commitPatch'); 185 | $patches[] = $patch; 186 | 187 | // New patch. 188 | $patch = $this->getMockBuilder(\Dorgflow\Waypoint\Patch::class) 189 | ->disableOriginalConstructor() 190 | ->setMethods(['hasCommit', 'commitPatch', 'getPatchFilename']) 191 | ->getMock(); 192 | // Patch is new: it has no commit. 193 | $patch->method('hasCommit') 194 | ->willReturn(FALSE); 195 | // We expect the patch will get committed (and that it will apply OK). 196 | $patch->expects($this->once()) 197 | ->method('commitPatch') 198 | ->willReturn(TRUE); 199 | // The patch filename; needed for output message. 200 | $patch->method('getPatchFilename') 201 | ->willReturn('file-patch-1.patch'); 202 | $patches[] = $patch; 203 | 204 | // Git executor. 205 | $git_executor = $this->getMockBuilder(\Dorgflow\Service\GitExecutor::class) 206 | ->disableOriginalConstructor() 207 | ->setMethods(['createNewBranch', 'moveBranch']) 208 | ->getMock(); 209 | // We expect the Git Exec to create a new branch with a forked branch name. 210 | $git_executor->expects($this->once()) 211 | ->method('createNewBranch') 212 | ->with($this->matchesRegularExpression('/^123456-feature-forked-/')); 213 | // We expect the Git Exec to move the feature branch to the SHA of the last 214 | // committed patch. 215 | $git_executor->expects($this->once()) 216 | ->method('moveBranch') 217 | ->with($this->isType('string'), 'sha-patch-0'); 218 | 219 | $container = new \Symfony\Component\DependencyInjection\ContainerBuilder(); 220 | 221 | $container->set('git.info', $this->getMockGitInfoClean()); 222 | $container->set('waypoint_manager.branches', $this->getMockWaypointManagerFeatureBranchCurrent()); 223 | $container->set('waypoint_manager.patches', $this->getMockWaypointManagerWithPatches($patches)); 224 | $container->set('git.executor', $git_executor); 225 | 226 | $command_tester = $this->setUpCommandTester($container, 'update', \Dorgflow\Command\LocalUpdate::class); 227 | 228 | $command_tester->execute([ 229 | 'command' => 'update', 230 | ]); 231 | } 232 | 233 | /** 234 | * Creates a mock git.info service that will state that git is clean. 235 | * 236 | * @return 237 | * The mocked git.info service object. 238 | */ 239 | protected function getMockGitInfoClean() { 240 | $git_info = $this->getMockBuilder(\Dorgflow\Service\GitInfo::class) 241 | ->disableOriginalConstructor() 242 | ->setMethods(['gitIsClean']) 243 | ->getMock(); 244 | $git_info->method('gitIsClean') 245 | ->willReturn(TRUE); 246 | 247 | return $git_info; 248 | } 249 | 250 | /** 251 | * Creates a mock branch service whose feature branch is current. 252 | * 253 | * @return 254 | * The mocked waypoint_manager.branches service object. It will provide a 255 | * feature branch which: 256 | * - reports it exists 257 | * - reports it is current 258 | * - has a branch name of '123456-feature' 259 | * - has an SHA of 'sha-feature' 260 | */ 261 | protected function getMockWaypointManagerFeatureBranchCurrent() { 262 | $feature_branch = $this->getMockBuilder(\Dorgflow\Waypoint\FeatureBranch::class) 263 | ->disableOriginalConstructor() 264 | ->setMethods(['exists', 'isCurrentBranch', 'getSHA', 'getBranchName']) 265 | ->getMock(); 266 | $feature_branch->method('exists') 267 | ->willReturn(TRUE); 268 | $feature_branch->method('isCurrentBranch') 269 | ->willReturn(TRUE); 270 | $feature_branch->method('getBranchName') 271 | ->willReturn('123456-feature'); 272 | $feature_branch->method('getSHA') 273 | ->willReturn('sha-feature'); 274 | 275 | $waypoint_manager_branches = $this->getMockBuilder(\Dorgflow\Service\WaypointManagerBranches::class) 276 | ->disableOriginalConstructor() 277 | ->setMethods(['getFeatureBranch']) 278 | ->getMock(); 279 | $waypoint_manager_branches->method('getFeatureBranch') 280 | ->willReturn($feature_branch); 281 | 282 | return $waypoint_manager_branches; 283 | } 284 | 285 | /** 286 | * Creates a mock patch manager that will provide the given array of patches. 287 | * 288 | * @param $patches 289 | * An array of mock patch objects. 290 | * 291 | * @return 292 | * The mocked waypoint_manager.patches service object. 293 | */ 294 | protected function getMockWaypointManagerWithPatches($patches) { 295 | $waypoint_manager_patches = $this->getMockBuilder(\Dorgflow\Service\WaypointManagerPatches::class) 296 | ->disableOriginalConstructor() 297 | ->setMethods(['setUpPatches']) 298 | ->getMock(); 299 | $waypoint_manager_patches->method('setUpPatches') 300 | ->willReturn($patches); 301 | 302 | return $waypoint_manager_patches; 303 | } 304 | 305 | } 306 | -------------------------------------------------------------------------------- /tests/CommandTestBase.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(\Dorgflow\Service\GitInfo::class) 23 | ->disableOriginalConstructor() 24 | ->setMethods(['gitIsClean']) 25 | ->getMock(); 26 | $git_info->method('gitIsClean') 27 | ->willReturn(TRUE); 28 | 29 | return $git_info; 30 | } 31 | 32 | /** 33 | * Sets up the mock drupal_org service with the given patch file data. 34 | * 35 | * @param $drupal_org 36 | * The mock drupal_org service. 37 | * @param $patch_file_data 38 | * An array of data for the patch files. The key is the filefield delta; each 39 | * item is an array with the following properties: 40 | * - 'fid': The file entity ID. 41 | * - 'cid': The comment entity ID for this file. 42 | * - 'index': The comment index. 43 | * - 'filename': The patch filename. 44 | * - 'display': Boolean indicating whether the file is displayed. 45 | */ 46 | protected function setUpDrupalOrgExpectations($drupal_org, $patch_file_data) { 47 | $getIssueFileFieldItems_return = []; 48 | $getFileEntity_value_map = []; 49 | $getPatchFile_value_map = []; 50 | 51 | foreach ($patch_file_data as $patch_file_data_item) { 52 | $file_field_item = (object) [ 53 | 'file' => (object) [ 54 | 'uri' => 'https://www.drupal.org/api-d7/file/' . $patch_file_data_item['fid'], 55 | 'id' => $patch_file_data_item['fid'], 56 | 'resource' => 'file', 57 | 'cid' => $patch_file_data_item['cid'], 58 | ], 59 | 'display' => $patch_file_data_item['display'], 60 | 'index' => $patch_file_data_item['index'], 61 | ]; 62 | $getIssueFileFieldItems_return[] = $file_field_item; 63 | 64 | $getFileEntity_value_map[] = [ 65 | $patch_file_data_item['fid'], 66 | // For dummy file entities, we only need the url property. 67 | (object) ['url' => $patch_file_data_item['filename']] 68 | ]; 69 | 70 | $getPatchFile_value_map[] = [ 71 | $patch_file_data_item['filename'], 72 | // The contents of the patch file. 73 | 'patch-file-data-' . $patch_file_data_item['fid'] 74 | ]; 75 | } 76 | 77 | $drupal_org->method('getIssueFileFieldItems') 78 | ->willReturn($getIssueFileFieldItems_return); 79 | $drupal_org->expects($this->any()) 80 | ->method('getFileEntity') 81 | ->will($this->returnValueMap($getFileEntity_value_map)); 82 | $drupal_org->expects($this->any()) 83 | ->method('getPatchFile') 84 | ->will($this->returnValueMap($getPatchFile_value_map)); 85 | } 86 | 87 | /** 88 | * Sets up the mock git.executor service with the given patch file data. 89 | * 90 | * @param $git_executor 91 | * The mock git.executor service. 92 | * @param $patch_file_data 93 | * An array of data for the patch files. The key is the filefield delta; each 94 | * item is an array with the following properties: 95 | * - 'fid': The file entity ID. 96 | * - 'cid': The comment entity ID for this file. 97 | * - 'index': The comment index. 98 | * - 'filename': The patch filename. 99 | * - 'display': Boolean indicating whether the file is displayed. 100 | * - 'applies': (optional) Boolean indicating whether the patch should 101 | * cause the git executor to report it applies. Only needed if the value 102 | * of 'expected' is 'apply'. 103 | * - 'expected': The expected outcome for this patch. One of: 104 | * - 'skip': The patch will not be downloaded or applied. 105 | * - 'apply': The git exectutor will attempt to apply the patch. 106 | */ 107 | protected function setUpGitExecutorPatchExpectations($git_executor, $patch_file_data) { 108 | // The total number of patches. 109 | $patch_count = 0; 110 | // The number of patches that will apply. 111 | $applies_count = 0; 112 | // A map of the dummy patch file contents (that is, the parameter that will 113 | // be passed to applyPatch()) to a boolean indicating whether the patch 114 | // applies or not. 115 | $applyPatch_map = []; 116 | 117 | foreach ($patch_file_data as $patch_file_data_item) { 118 | // If the patch is expected to skip, do so. 119 | if ($patch_file_data_item['expected'] == 'skip') { 120 | continue; 121 | } 122 | 123 | $patch_count++; 124 | 125 | $patch_file_contents = 'patch-file-data-' . $patch_file_data_item['fid']; 126 | 127 | if (!empty($patch_file_data_item['applies'])) { 128 | $applies_count++; 129 | 130 | $applyPatch_map[$patch_file_contents] = TRUE; 131 | } 132 | else { 133 | $applyPatch_map[$patch_file_contents] = FALSE; 134 | } 135 | } 136 | 137 | // For each patch, the master branch files will be checked out. 138 | // (Technically we should verify this checks out the right branch, but that 139 | // would mean an extra faffy parameter to this helper method.) 140 | $git_executor 141 | ->expects($this->exactly($patch_count)) 142 | ->method('checkOutBranch'); 143 | $git_executor 144 | ->expects($this->exactly($patch_count)) 145 | ->method('moveToBranch'); 146 | 147 | // For each patch we expect to be attempted to apply, the patch file 148 | // contents will be applied. 149 | $git_executor 150 | ->expects($this->exactly($patch_count)) 151 | ->method('applyPatch') 152 | ->with($this->callback(function($subject) use ($applyPatch_map) { 153 | // Statics inside closures are apparently iffy, but this appears to 154 | // work...! 155 | static $patch_contents; 156 | if (!isset($patch_contents)) { 157 | $patch_contents = array_keys($applyPatch_map); 158 | } 159 | 160 | $expected_parameter = array_shift($patch_contents); 161 | return $expected_parameter == $subject; 162 | })) 163 | ->will($this->returnCallback(function ($patch_file_data) use ($applyPatch_map) { 164 | return $applyPatch_map[$patch_file_data]; 165 | })); 166 | // Each patch that applies will be committed. 167 | $git_executor 168 | ->expects($this->exactly($applies_count)) 169 | ->method('commit'); 170 | } 171 | 172 | /** 173 | * Add any services to the container that are not yet registered on it. 174 | * 175 | * NOTE: currently only takes care of commit_message and the waypoint 176 | * managers. 177 | * 178 | * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container 179 | * The service container. 180 | */ 181 | protected function completeServiceContainer(ContainerBuilder $container) { 182 | // TODO: add all the other services, but so far these are always mocked, to 183 | // YAGNI. 184 | 185 | if (!$container->has('commit_message')) { 186 | $container 187 | ->register('commit_message', \Dorgflow\Service\CommitMessageHandler::class) 188 | ->addArgument(new Reference('analyser')); 189 | } 190 | 191 | if (!$container->has('waypoint_manager.branches')) { 192 | $container 193 | ->register('waypoint_manager.branches', \Dorgflow\Service\WaypointManagerBranches::class) 194 | ->addArgument(new Reference('git.info')) 195 | ->addArgument(new Reference('drupal_org')) 196 | ->addArgument(new Reference('git.executor')) 197 | ->addArgument(new Reference('analyser')); 198 | } 199 | 200 | if (!$container->has('waypoint_manager.patches')) { 201 | $container 202 | ->register('waypoint_manager.patches', \Dorgflow\Service\WaypointManagerPatches::class) 203 | ->addArgument(new Reference('commit_message')) 204 | ->addArgument(new Reference('drupal_org')) 205 | ->addArgument(new Reference('git.log')) 206 | ->addArgument(new Reference('git.executor')) 207 | ->addArgument(new Reference('analyser')) 208 | ->addArgument(new Reference('waypoint_manager.branches')); 209 | } 210 | } 211 | 212 | /** 213 | * Gets the CommandTester object to run a given command. 214 | * 215 | * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container 216 | * The DI container. 217 | * @param $name 218 | * The name of the command as registered in the console application. 219 | * @param $class 220 | * The Command class. 221 | * 222 | * @return \Symfony\Component\Console\Tester\CommandTester 223 | * The command tester object. 224 | */ 225 | protected function setUpCommandTester(ContainerBuilder $container, $name, $class) { 226 | $container 227 | ->register("command.{$name}", $class) 228 | ->addMethodCall('setContainer', [new Reference('service_container')]); 229 | 230 | $application = new Application(); 231 | $application->add($container->get("command.{$name}")); 232 | 233 | $command = $application->find($name); 234 | $command_tester = new CommandTester($command); 235 | 236 | return $command_tester; 237 | } 238 | 239 | } 240 | -------------------------------------------------------------------------------- /tests/CommitMessageHandlerTest.php: -------------------------------------------------------------------------------- 1 | createMock(\Dorgflow\Service\Analyser::class); 22 | 23 | $commit_message_handler = new \Dorgflow\Service\CommitMessageHandler($analyser); 24 | 25 | $commit_data = $commit_message_handler->parseCommitMessage($message); 26 | 27 | // For ease of debugging failing tests, check each array item individually. 28 | if (is_array($expected_data)) { 29 | if (isset($expected_data['filename'])) { 30 | $this->assertEquals($expected_data['filename'], $commit_data['filename']); 31 | } 32 | if (isset($expected_data['fid'])) { 33 | $this->assertEquals($expected_data['fid'], $commit_data['fid']); 34 | } 35 | } 36 | 37 | 38 | // Check the complete expected data matches what we got, for return values 39 | // which are not arrays, and for completeness. 40 | $this->assertEquals($expected_data, $commit_data); 41 | } 42 | 43 | /** 44 | * Data provider for testCommitMessageParser(). 45 | */ 46 | public function providerCommitMessages() { 47 | return [ 48 | 'nothing' => [ 49 | // Message. 50 | 'Some other commit message.', 51 | // Expected data. 52 | FALSE, 53 | ], 54 | // 1.1.3 format. 55 | 'd.org patch 1.1.3' => [ 56 | // Message. 57 | 'Patch from Drupal.org. Comment: 10; URL: https://www.drupal.org/node/12345#comment-67890; file: myfile.patch; fid: 16. Automatic commit by dorgflow.', 58 | // Expected data. 59 | [ 60 | 'filename' => 'myfile.patch', 61 | 'fid' => 16, 62 | 'comment_index' => 10, 63 | ], 64 | ], 65 | 'local commit 1.1.3' => [ 66 | // Message. 67 | 'Patch for Drupal.org. Comment (expected): 10; file: 12345-10.project.bug-description.patch. Automatic commit by dorgflow.', 68 | // Expected data. 69 | [ 70 | 'filename' => '12345-10.project.bug-description.patch', 71 | 'comment_index' => 10, 72 | 'local' => TRUE, 73 | ], 74 | ], 75 | // 1.1.0 format. 76 | 'local commit 1.1.0' => [ 77 | // Message. 78 | 'Patch for Drupal.org. File: 12345-10.project.bug-description.patch. Automatic commit by dorgflow.', 79 | // Expected data. 80 | [ 81 | 'filename' => '12345-10.project.bug-description.patch', 82 | 'local' => TRUE, 83 | // The parser extracts this from the filename for the 1.1.0 format. 84 | 'comment_index' => 10, 85 | ], 86 | ], 87 | // 1.0.0 format. 88 | 'd.org patch 1.0.0' => [ 89 | // Message. 90 | 'Patch from Drupal.org. Comment: 10; file: myfile.patch; fid 16. Automatic commit by dorgflow.', 91 | // Expected data. 92 | [ 93 | 'filename' => 'myfile.patch', 94 | 'fid' => 16, 95 | 'comment_index' => 10, 96 | ], 97 | ], 98 | ]; 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /tests/FeatureBranchTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(\Dorgflow\Service\GitInfo::class) 17 | ->disableOriginalConstructor() 18 | ->setMethods(['getBranchList', 'getBranchListReachable', 'getCurrentBranch']) 19 | ->getMock(); 20 | $git_info->method('getBranchList') 21 | ->willReturn([]); 22 | $git_info->method('getBranchListReachable') 23 | ->willReturn([]); 24 | $git_info->method('getCurrentBranch') 25 | ->willReturn('notthebranchyouseek'); 26 | 27 | $analyser = $this->getMockBuilder(\Dorgflow\Service\Analyser::class) 28 | ->disableOriginalConstructor() 29 | ->setMethods(['deduceIssueNumber']) 30 | ->getMock(); 31 | $analyser->method('deduceIssueNumber') 32 | ->willReturn(123456); 33 | 34 | $drupal_org = $this->getMockBuilder(\Dorgflow\Service\DrupalOrg::class) 35 | ->disableOriginalConstructor() 36 | ->setMethods(['getIssueNodeTitle']) 37 | ->getMock(); 38 | $drupal_org->method('getIssueNodeTitle') 39 | ->willReturn('the title of the issue'); 40 | 41 | $git_exec = $this->getMockBuilder(\Dorgflow\Service\GitExecutor::class) 42 | ->disableOriginalConstructor() 43 | ->setMethods([]); 44 | 45 | $feature_branch = new \Dorgflow\Waypoint\FeatureBranch($git_info, $analyser, $drupal_org, $git_exec); 46 | 47 | $exists = $feature_branch->exists(); 48 | $this->assertFalse($exists); 49 | 50 | $branch_name = $feature_branch->getBranchName(); 51 | $this->assertEquals($branch_name, '123456-the-title-of-the-issue'); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /tests/GitHandlerFileCheckout.php: -------------------------------------------------------------------------------- 1 | test_file_original_contents = file_get_contents('main.txt'); 27 | } 28 | 29 | public function testFileCheckout() { 30 | $git = new \Dorgflow\Service\GitExecutor; 31 | 32 | $initial_sha = shell_exec("git rev-parse HEAD"); 33 | 34 | // Apply a sequence of patches. 35 | $patch_filenames = [ 36 | 'patch-b.patch', 37 | 'patch-c.patch', 38 | ]; 39 | foreach ($patch_filenames as $patch_filename) { 40 | // Put the files back to the initial commit so that the patch applies. 41 | $git->checkOutFiles($initial_sha); 42 | 43 | $patch_text = file_get_contents($patch_filename); 44 | $applied = $git->applyPatch($patch_text); 45 | 46 | if (!$applied) { 47 | $this->fail("Patch $patch_filename failed to apply"); 48 | } 49 | 50 | // Make the commit. 51 | // TODO: use Git handler when this gains the ability. 52 | shell_exec("git commit --allow-empty --message='Commit for $patch_filename.'"); 53 | } 54 | 55 | $log = shell_exec("git rev-list master --pretty=oneline"); 56 | $log_lines = explode("\n", trim($log)); 57 | $this->assertEquals(3, count($log_lines), 'The git log has the expected number of commits.'); 58 | } 59 | 60 | /** 61 | * Remove the testing git repository created in setUp(). 62 | */ 63 | protected function tearDown() { 64 | // Restore the file we've changed. 65 | file_put_contents('main.txt', $this->test_file_original_contents); 66 | 67 | // Remove the git repo. Be very careful it's the right one! 68 | $current_dir = getcwd(); 69 | if (basename($current_dir) != 'repository') { 70 | throw new \Exception("Trying to delete wrong git repository!"); 71 | } 72 | exec('rm -rf .git'); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /tests/SetUpPatchesTest.php: -------------------------------------------------------------------------------- 1 | 24 | (object) (array( 25 | 'file' => 26 | (object) (array( 27 | 'uri' => 'https://www.drupal.org/api-d7/file/5755031', 28 | 'id' => '100', 29 | 'resource' => 'file', 30 | 'cid' => '400', 31 | )), 32 | 'display' => '0', 33 | 'index' => 1, 34 | )), 35 | // Not a patch file: should be omitted once the file entity has been seen. 36 | 1 => 37 | (object) (array( 38 | 'file' => 39 | (object) (array( 40 | 'uri' => 'https://www.drupal.org/api-d7/file/5755137', 41 | 'id' => '101', 42 | 'resource' => 'file', 43 | 'cid' => '401', 44 | )), 45 | 'display' => '1', 46 | 'index' => 2, 47 | )), 48 | 2 => 49 | (object) (array( 50 | 'file' => 51 | (object) (array( 52 | 'uri' => 'https://www.drupal.org/api-d7/file/5755185', 53 | 'id' => '102', 54 | 'resource' => 'file', 55 | 'cid' => '402', 56 | )), 57 | 'display' => '1', 58 | 'index' => 4, 59 | )), 60 | 3 => 61 | (object) (array( 62 | 'file' => 63 | (object) (array( 64 | 'uri' => 'https://www.drupal.org/api-d7/file/5755421', 65 | 'id' => '103', 66 | 'resource' => 'file', 67 | 'cid' => '403', 68 | )), 69 | 'display' => '1', 70 | 'index' => 10, 71 | )), 72 | ); 73 | 74 | $file_urls = [ 75 | 101 => 'foobar.notapatch.txt', 76 | 102 => 'fix-102.patch', 77 | 103 => 'fix-103.patch', 78 | ]; 79 | 80 | $drupal_org = $this->getMockBuilder(\Dorgflow\Service\DrupalOrg::class) 81 | ->disableOriginalConstructor() 82 | ->setMethods(['getIssueFileFieldItems', 'getFileEntity']) 83 | ->getMock(); 84 | $drupal_org->method('getIssueFileFieldItems') 85 | ->willReturn($issue_file_field_items); 86 | $drupal_org->expects($this->any()) 87 | ->method('getFileEntity') 88 | ->will($this->returnValueMap([ 89 | // Note the params have to be strings, not numeric! 90 | // For dummy file entities, we only need the url property. 91 | ['101', (object) ['url' => $file_urls[101]]], 92 | ['102', (object) ['url' => $file_urls[102]]], 93 | ['103', (object) ['url' => $file_urls[103]]], 94 | ])); 95 | 96 | $git_log = $this->getMockBuilder(\Dorgflow\Service\GitLog::class) 97 | ->disableOriginalConstructor() 98 | ->setMethods(['getFeatureBranchLog']) 99 | ->getMock(); 100 | $git_log->method('getFeatureBranchLog') 101 | ->willReturn([]); 102 | 103 | $wmp = new \Dorgflow\Service\WaypointManagerPatches( 104 | NULL, 105 | $drupal_org, 106 | $git_log, 107 | NULL, 108 | NULL, 109 | NULL, 110 | NULL 111 | ); 112 | 113 | $patches = $wmp->setUpPatches(); 114 | 115 | $analyser = $this->createMock(\Dorgflow\Service\Analyser::class); 116 | $analyser->method('deduceIssueNumber') 117 | ->willReturn(123456); 118 | 119 | $commit_message_handler = new \Dorgflow\Service\CommitMessageHandler($analyser); 120 | 121 | $this->assertCount(2, $patches); 122 | 123 | $patch_102 = $patches[0]; 124 | $this->assertEquals($file_urls[102], $patch_102->getPatchFilename()); 125 | $this->assertEquals("Patch from Drupal.org. Comment: 4; URL: https://www.drupal.org/node/123456#comment-402; file: fix-102.patch; fid: 102. Automatic commit by dorgflow.", 126 | $commit_message_handler->createCommitMessage($patch_102)); 127 | 128 | $patch_103 = $patches[1]; 129 | $this->assertEquals($file_urls[103], $patch_103->getPatchFilename()); 130 | $this->assertEquals("Patch from Drupal.org. Comment: 10; URL: https://www.drupal.org/node/123456#comment-403; file: fix-103.patch; fid: 103. Automatic commit by dorgflow.", 131 | $commit_message_handler->createCommitMessage($patch_103)); 132 | 133 | return; 134 | } 135 | 136 | /** 137 | * Test the patchFilenamesAreEqual() helper method. 138 | * 139 | * @dataProvider providerPatchFileComparison 140 | * 141 | * @todo Move this somewhere better. 142 | */ 143 | public function testPatchFileComparison($local_filename, $drupal_org_filename, $expected_result) { 144 | $wmp = new \Dorgflow\Service\WaypointManagerPatches( 145 | NULL, 146 | NULL, 147 | NULL, 148 | NULL, 149 | NULL, 150 | NULL 151 | ); 152 | 153 | $reflection = new \ReflectionClass(\Dorgflow\Service\WaypointManagerPatches::class); 154 | $method = $reflection->getMethod('patchFilenamesAreEqual'); 155 | $method->setAccessible(TRUE); 156 | 157 | $result = $method->invokeArgs($wmp, [$local_filename, $drupal_org_filename]); 158 | 159 | $this->assertEquals($expected_result, $result, "The filenames $local_filename and $drupal_org_filename did not produce the expected result."); 160 | } 161 | 162 | /** 163 | * Data provider for testCommitMessageParser(). 164 | */ 165 | public function providerPatchFileComparison() { 166 | return [ 167 | 'equal' => [ 168 | 'foo.patch', 169 | 'foo.patch', 170 | // Expected result. 171 | TRUE, 172 | ], 173 | 'munged' => [ 174 | 'foo.flag.patch', 175 | 'foo.flag_.patch', 176 | TRUE, 177 | ], 178 | 'renamed' => [ 179 | 'foo.longer-piece.patch', 180 | 'foo.longer-piece_12.patch', 181 | TRUE, 182 | ], 183 | 'munged and renamed' => [ 184 | 'foo.flag.longer-piece.patch', 185 | 'foo.flag_.longer-piece_12.patch', 186 | TRUE, 187 | ], 188 | 'different' => [ 189 | 'foo.flag.patch', 190 | 'totally.different.flag.patch', 191 | FALSE, 192 | ], 193 | ]; 194 | } 195 | 196 | } 197 | --------------------------------------------------------------------------------