├── .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 |
--------------------------------------------------------------------------------