Make site selection more robust
[project/drush_iq.git] / iq.drush.inc
1 <?php
2
3 /**
4 * @file
5 * The drush Issue Queue manager
6 */
7
8
9
10 /**
11 * Implementation of hook_drush_command().
12 */
13 function iq_drush_command() {
14 $items['iq-info'] = array(
15 'description' => 'Show information about an issue from the queue on drupal.org.',
16 'examples' => array(
17 'drush iq-info 1234' => 'Get info on issue 1234.',
18 'drush iq-info http://drupal.org/node/1234' => 'Get info on an issue identified by its URL.',
19 'drush iq-info 1234-#3' => 'Get info on the patch on the third comment of issue 1234.',
20 'drush iq-info 1234-5678' => 'Get info on the patch on comment id 5678 of issue 1234.',
21 ),
22 'arguments' => array(
23 'number' => 'The issue number.',
24 ),
25 'required-arguments' => TRUE,
26 'options' => array(
27 'pipe' => 'Print the full issue info data structure.',
28 ),
29 'aliases' => array('iqi'),
30 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
31 'topics' => array('docs-iq-commands'),
32 );
33 $items['iq-create-commit-comment'] = array(
34 'description' => 'Create a commit comment for the specified issue number.',
35 'examples' => array(
36 'drush iq-create-commit-comment 1234' => 'Generate a commit comment using the title from issue 1234, and crediting every user who provided attachments.',
37 ),
38 'arguments' => array(
39 'number' => 'The issue number.',
40 ),
41 'aliases' => array('iqccc', 'ccc'),
42 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
43 'topics' => array('docs-iq-commands'),
44 );
45 $items['iq-apply-patch'] = array(
46 'description' => 'Look up the most recent patch attached to the specified issue, and apply it to its project.',
47 'examples' => array(
48 'drush iq-apply-patch 1234' => 'Apply the newest patch attached to issue 1234.',
49 'drush iq-apply-patch 1234-#5' => 'Apply the patch attached to the fifth comment of issue 1234.',
50 'drush iq-apply-patch 1234-5678' => 'Apply the patch attached to comment id 5678 of issue 1234.',
51 'drush iq-apply-patch 1234 --select' => 'Show all patches attached to issue 1234, and prompt for the one to apply.',
52 ),
53 'arguments' => array(
54 'number' => 'The issue number.',
55 ),
56 'required-arguments' => TRUE,
57 'options' => array(
58 'no-prefix' => 'Patch was created with --no-prefix, and therefore should be applied with -Np0 instead of -Np1. Optional; default is to try both.',
59 'no-git' => 'Do not execute any git commands. Default is to create a new branch for the issue and commit the patch.',
60 'no-commit' => 'Create a new branch, but do not commit the patch to the issue working branch after applying it. Optional. Patches created by git format-patch are always committed.',
61 'keep-patch' => 'Keep the patchfile after applying it. Default is to delete the patchfile.',
62 'select' => 'Prompt for which patch to apply. Optional; default is newest patch.',
63 ),
64 'aliases' => array('patch', 'am'),
65 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
66 'topics' => array('docs-iq-commands'),
67 );
68 $items['iq-diff'] = array(
69 'description' => 'Create a diff. Uses `git format-patch`, so author information is included.',
70 'examples' => array(
71 'drush iq-diff' => 'Create a diff between the current branch and its upstream branch.',
72 'drush iq-diff --commit' => 'Commit the changes to the current branch after creating the diff.',
73 ),
74 'arguments' => array(
75 'number' => 'The issue number.',
76 ),
77 'options' => array(
78 'no-prefix' => 'Create patch with no prefix. Not recommended; patch will have to be applied with -Np0 instead of -Np1.',
79 'no-git' => 'Do not execute any git commands; just run diff. Default is to use git format-patch.',
80 'commit' => 'Commit change to current patch review branch. Optional; default is to leave changes unstaged.',
81 'no-squash' => 'Show all commits in the branch, like a standard format-patch. Default is to squash into a single commit.',
82 'rebase' => 'Rebase before creating patch.',
83 'message' => 'Commit message. Optional; default is to use `drush iq-create-commit-comment`.',
84 ),
85 'aliases' => array('diff', 'iqd'),
86 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
87 'topics' => array('docs-iq-commands'),
88 );
89 $items['iq-reset'] = array(
90 'description' => 'Stop working on a patch, and return to the original branch.',
91 'examples' => array(
92 'drush iq-reset' => 'Safely go back to the original branch. Work on the patch may resume later by returning to the working branch.',
93 'drush iq-reset --hard' => 'Permanently delete all changes, and go back to the original branch.',
94 ),
95 'options' => array(
96 'hard' => 'Also delete the working branch.',
97 ),
98 'aliases' => array('reset', 'iqr'),
99 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
100 'topics' => array('docs-iq-commands'),
101 );
102 $items['iq-merge'] = array(
103 'description' => 'Merge patch working branch into the original branch.',
104 'examples' => array(
105 'drush iq-merge' => 'Go back to the original branch, bringing all commits along.',
106 'drush iq-merge --squash' => 'Merge multiple commits into a single commit when merging. May discard some author credit, as only the last commit is recorded.',
107 ),
108 'options' => array(
109 'squash' => 'Merge all commits into one, keeping only the last.',
110 'no-commit' => 'Skip committing unstaged changes. Default is to commit everything.',
111 ),
112 'aliases' => array('merge', 'iqm'),
113 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
114 'topics' => array('docs-iq-commands'),
115 );
116 $items['docs-iq-commands'] = array(
117 'description' => 'Issue queue commands for applying and creating patches from drupal.org.',
118 'hidden' => TRUE,
119 'topic' => TRUE,
120 'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
121 'callback' => 'drush_print_file',
122 'callback arguments' => array(dirname(__FILE__) . '/docs/iq-commands.html'),
123 );
124
125 return $items;
126 }
127
128 function drush_iq_diff($number = NULL) {
129 $branch_merges_with = FALSE;
130 $issue_info = array();
131 $remote = FALSE;
132 if (isset($number)) {
133 $issue_info = drush_iq_get_info($number);
134 if (!$issue_info) {
135 return FALSE;
136 }
137 $branch = _drush_iq_get_branch($issue_info);
138 $dir = _drush_iq_project_dir($issue_info);
139 if (!$dir) {
140 return drush_set_error('DRUSH_IQ_DIFF_NO_PROJECT', dt('Could not find the project directory for !n', array('!n' => $number)));
141 }
142 }
143 else {
144 $dir = _drush_iq_git_root_dir_from_cwd();
145 $branch = _drush_iq_get_branch_at_dir($dir);
146 $branch_merges_with = _drush_iq_get_branch_merges_with($dir);
147 if ($branch_merges_with === FALSE) {
148 return FALSE;
149 }
150 if (!empty($branch_merges_with)) {
151 $remote = _drush_iq_get_best_remote($branch_merges_with);
152 $branch_merges_with = _drush_iq_merge_branch_with_remote($remote, $branch_merges_with);
153 }
154 }
155 if (!$branch_merges_with && ($branch != "(no branch)")) {
156 $branch_merges_with = 'master';
157 }
158
159 // Not under git revision control is an error
160 if (empty($branch)) {
161 return drush_set_error('DRUSH_NO_VCS', dt("Error: drush can only produce diffs of projects under git version control"));
162 }
163
164 // If the user did not provide an issue number, but we can find
165 // that a git branch has been made at the current working directory,
166 // then we will expect that the branch tag is in the format of
167 // "arbitrary-prequel-ISSUENUMBER". If it is in this form, drush_iq_get_info
168 // will be able to find the issue number.
169 if (isset($branch) && ($branch != "master") && empty($issue_info)) {
170 $issue_info_id = drush_iq_issue_number($branch);
171 if (!empty($issue_info_id)) {
172 $number = $issue_info_id['id'];
173 $issue_info = drush_iq_download_info($issue_info_id);
174 }
175 }
176 // Add on extra flags
177 $extra = "";
178 if (drush_get_option('no-prefix', FALSE)) {
179 $extra .= ' --no-prefix';
180 }
181
182 $cwd = getcwd();
183 $committed = FALSE;
184 $squashed = FALSE;
185 drush_op('chdir', $dir);
186 $patch_description = "drush-iq";
187 if (!empty($issue_info)) {
188 $comment_number = array_key_exists('comment-number', $issue_info) ? $issue_info['comment-number'] : 1 + count($issue_info['comments']);
189 $patch_description = _drush_iq_make_description($issue_info['title']) . '-' . $issue_info['id'] . '-' . $comment_number . '.patch';
190 }
191 drush_log(dt("# Create patch: !patch_description (branch: !b merge: !m)", array('!patch_description' => $patch_description, '!b' => $branch, '!m' => $branch_merges_with)), 'notice');
192
193 // If we have modified the master branch (not recommended), just run git diff
194 if (($branch == "master") || ($branch == "(no branch)") || ($branch == $branch_merges_with) || drush_get_option('no-git', FALSE)) {
195 $result = drush_shell_exec_interactive("git diff $extra %s", $branch_merges_with);
196 }
197 // Changes are being made under a local branch. Do the recommended procedure for creating the diff.
198 else {
199 $commit_comment = _drush_iq_create_commit_comment($issue_info, drush_get_option('committer', TRUE), drush_get_option('message', FALSE));
200 $result = drush_shell_exec("git status -s");
201 $status_output = drush_shell_exec_output();
202 // If there are unstaged changes, commit them
203 if (!empty($status_output)) {
204 // TODO: can we easily ignore *.patch, etc., or easily add only modified files?
205 $result = drush_shell_exec("git add .");
206 $result = drush_shell_exec("git commit -m %s", $commit_comment);
207 if (!$result) {
208 return drush_set_error('DRUSH_IQ_DIFF_CANNOT_COMMIT', dt("Commit failed."));
209 }
210 $committed = TRUE;
211 }
212 // If the branch to merge with is remote, then fetch and rebase
213 if (drush_get_option('rebase', FALSE)) {
214 if ($remote) {
215 drush_shell_exec("git fetch %s %s && git rebase %s", $remote, $branch, $branch_merges_with);
216 }
217 else {
218 drush_log(dt('--rebase ignored because !branch has no upstream branch.', array('!branch' => $branch)), 'warning');
219 }
220 }
221
222 // Check to see if there are multiple commits on this branch. If there
223 // are, make a new temporary branch and squash-merge all commits there.
224 if (!drush_get_option('no-squash', FALSE)) {
225 $result = drush_shell_exec("git log --oneline %s..HEAD", $branch_merges_with);
226 $commits_output = drush_shell_exec_output();
227 if (count($commits_output) > 1) {
228 drush_shell_exec('git branch -D drush-iq-temp 2>/dev/null');
229 drush_shell_exec('git checkout %s', $branch_merges_with);
230 drush_shell_exec('git checkout -b drush-iq-temp');
231 drush_shell_exec('git merge --squash %s', $branch);
232 drush_shell_exec("git commit -m %s", $commit_comment);
233 $squashed = TRUE;
234 }
235 }
236
237 $result = drush_shell_exec_interactive("git format-patch %s -k $extra --stdout", $branch_merges_with);
238 if ($squashed) {
239 drush_shell_exec('git checkout %s', $branch);
240 drush_shell_exec('git branch -D drush-iq-temp 2>/dev/null');
241 }
242 if ($committed && !drush_get_option('commit', FALSE)) {
243 // Get rid of our commit
244 drush_shell_exec("git reset HEAD~1");
245 }
246 }
247 drush_op('chdir', $cwd);
248 return $issue_info;
249 }
250
251 /**
252 * iq-info command callback
253 */
254 function drush_iq_info($number = NULL) {
255 if (isset($number)) {
256 $issue_info = drush_iq_get_info($number);
257 }
258 else {
259 $dir = _drush_iq_git_root_dir_from_cwd();
260 $branch = _drush_iq_get_branch_at_dir($dir);
261 $issue_info = drush_iq_get_info($branch);
262 }
263 if ($issue_info === FALSE) {
264 return FALSE;
265 }
266 $issue_info = _drush_iq_select_patch($issue_info);
267
268 if (drush_get_option('pipe')) {
269 drush_print_pipe(var_export($issue_info, TRUE));
270 }
271 else {
272 $label_map = _drush_iq_label_map();
273 $rows = array();
274
275 foreach($label_map as $label => $key) {
276 if (array_key_exists($key, $issue_info)) {
277 $rows[] = array($label, ':', $issue_info[$key]);
278 }
279 }
280
281 drush_print_table($rows);
282 }
283 return $issue_info;
284 }
285
286 /**
287 * iq-create-commit-comment command callback
288 */
289 function drush_iq_create_commit_comment($number = NULL) {
290 if (isset($number)) {
291 $issue_info = drush_iq_get_info($number);
292 }
293 else {
294 $dir = _drush_iq_git_root_dir_from_cwd();
295 $branch = _drush_iq_get_branch_at_dir($dir);
296 $issue_info = drush_iq_get_info($branch);
297 }
298 if ($issue_info === FALSE) {
299 return FALSE;
300 }
301
302 $result = _drush_iq_create_commit_comment($issue_info, drush_get_option('committer', FALSE));
303 drush_print($result);
304 return $result;
305 }
306
307 /**
308 * Abandon the current branch
309 */
310 function drush_iq_reset($number = NULL) {
311 $cwd = getcwd();
312 if (isset($number)) {
313 $issue_info = drush_iq_get_info($number);
314 if (!$issue_info) {
315 return FALSE;
316 }
317 $dir = _drush_iq_project_dir($issue_info);
318 if (!$dir) {
319 return drush_set_error('DRUSH_IQ_RESET_NO_PROJECT', dt('Could not find the project directory for !n', array('!n' => $number)));
320 }
321 }
322 else {
323 $dir = _drush_iq_git_root_dir_from_cwd();
324 }
325 $result = drush_shell_cd_and_exec($dir, "git status -s");
326 $status_output = drush_shell_exec_output();
327 $unstaged_changes = (!empty($status_output));
328 $branch = _drush_iq_get_branch_at_dir($dir);
329 $branch_merges_with = _drush_iq_get_branch_merges_with($dir);
330 if ($branch_merges_with === FALSE) {
331 return FALSE;
332 }
333 if (empty($branch_merges_with)) {
334 $branch_merges_with = 'master';
335 }
336 $delete = drush_get_option('hard', FALSE);
337 $issue_info['branch'] = $branch;
338 $issue_info['mergeBranch'] = $branch_merges_with;
339
340 $reset_message = dt("Would you like to reset to the branch !merges_with? ", array('!merges_with' => $branch_merges_with, '!branch' => $branch));
341
342 if (($branch == '(no branch)') || ($branch_merges_with == $branch)) {
343 $reset_message = "";
344 if (!$unstaged_changes) {
345 return drush_set_error('DRUSH_IQ_NO_UNSTAGED_CHANGES', dt("No unstaged changes, and not on a branch; Drush cannot reset. If you have commits that you would like to get rid of, try:\n git reset HEAD~1\nReplace '1' with the number of commits you have made. Use 'git log' and 'git status' for information."));
346 }
347 if (!$delete) {
348 return drush_set_error('DRUSH_WILL_NOT_DELETE', dt("Not on a branch; Drush will not delete your unstaged changes unless you use the --hard flag."));
349 }
350 $action_message = dt("Your unstaged changes will be permanently deleted.");
351 }
352 elseif ($unstaged_changes) {
353 $action_message = $delete ? dt("Your working branch !branch will be deleted, along with all unstaged changes.", array('!merges_with' => $branch_merges_with, '!branch' => $branch)) : dt("Your work will be preserved; you can return to it by typing:\n git checkout !branch\nYour unstaged changes will be re-applied on top of the branch !merges_with", array('!merges_with' => $branch_merges_with, '!branch' => $branch));
354 }
355 else {
356 $action_message = $delete ? dt("Your working branch !branch will be deleted.", array('!merges_with' => $branch_merges_with, '!branch' => $branch)) : dt("Your work will be preserved; you can return to it by typing:\n git checkout !branch", array('!merges_with' => $branch_merges_with, '!branch' => $branch));
357 }
358 drush_print($reset_message . $action_message);
359 $confirm = drush_confirm(dt("Is it okay to continue?"));
360 if (!$confirm) {
361 return drush_user_abort();
362 }
363
364 if ($delete) {
365 drush_shell_exec_interactive("git reset --hard HEAD");
366 }
367 if (($branch_merges_with != $branch) && ($branch != "(no branch)")) {
368 drush_op('chdir', $dir);
369 $result = drush_shell_exec_interactive("git checkout %s", $branch_merges_with);
370 if ($delete && ($branch != '(no branch)')) {
371 $result = drush_shell_exec_interactive("git branch -D %s", $branch);
372 }
373 }
374 drush_op('chdir', $cwd);
375 return $issue_info;
376 }
377
378 /**
379 * Merge the current branch in with its upstream branch.
380 */
381 function drush_iq_merge($number = NULL) {
382 $issue_info = array();
383 $cwd = getcwd();
384 if (isset($number)) {
385 $issue_info = drush_iq_get_info($number);
386 if (!$issue_info) {
387 return FALSE;
388 }
389 $dir = _drush_iq_project_dir($issue_info);
390 if (!$dir) {
391 return drush_set_error('DRUSH_IQ_MERGE_NO_PROJECT', dt('Could not find the project directory for !n', array('!n' => $number)));
392 }
393 }
394 else {
395 $dir = _drush_iq_git_root_dir_from_cwd();
396 }
397 $result = drush_shell_cd_and_exec($dir, "git status -s");
398 $status_output = drush_shell_exec_output();
399 $unstaged_changes = (!empty($status_output));
400 $branch = _drush_iq_get_branch_at_dir($dir);
401 if (empty($issue_info)) {
402 $issue_info = drush_iq_get_info($branch);
403 }
404 $branch_merges_with = _drush_iq_get_branch_merges_with($dir);
405 if ($branch_merges_with === FALSE) {
406 return FALSE;
407 }
408 if (empty($branch_merges_with)) {
409 $branch_merges_with = 'master';
410 }
411 $issue_info['branch'] = $branch;
412 $issue_info['mergeBranch'] = $branch_merges_with;
413
414 if (($branch == '(no branch)') || ($branch_merges_with == $branch)) {
415 return drush_set_error('DRUSH_IQ_CANNOT_MERGE', dt("Not on a branch; cannot merge."));
416 }
417 elseif ($unstaged_changes) {
418 if (!drush_get_option('no-commit', FALSE)) {
419 $commit_comment = _drush_iq_create_commit_comment($issue_info, drush_get_option('committer', TRUE), drush_get_option('message', FALSE));
420 // TODO: can we easily ignore *.patch, etc., or easily add only modified files?
421 $result = drush_shell_exec("git add .");
422 $result = drush_shell_exec("git commit -m %s", $commit_comment);
423 if (!$result) {
424 return drush_set_error('DRUSH_IQ_DIFF_CANNOT_COMMIT', dt("Commit failed."));
425 }
426 }
427 else {
428 return drush_set_error('DRUSH_IQ_CANNOT_MERGE', dt("Please commit your unstaged changes before merging, or run command again without the --no-commit option."));
429 }
430 }
431 $confirm = drush_confirm(dt("Would you like to merge the branch !branch with its upstream branch !merges_with? ", array('!merges_with' => $branch_merges_with, '!branch' => $branch)));
432 if (!$confirm) {
433 return drush_user_abort();
434 }
435 $squash = drush_get_option('squash', FALSE) ? '--squash' : '';
436
437 drush_op('chdir', $dir);
438 $result = drush_shell_exec_interactive("git checkout %s", $branch_merges_with);
439 $result = drush_shell_exec_interactive("git merge %s %s", $squash, $branch);
440 $result = drush_shell_exec_interactive("git branch -D %s", $branch);
441 drush_op('chdir', $cwd);
442 return $issue_info;
443 }
444
445 /**
446 * iq-apply-patch command callback
447 *
448 * Given an issue number, find the most recent patch file
449 * attached to it
450 */
451 function drush_iq_apply_patch($number) {
452 $issue_info = drush_iq_get_info($number);
453 $do_git_operations = !drush_get_option('no-git', FALSE);
454 $result = FALSE;
455 if (!$issue_info) {
456 return FALSE;
457 }
458 $project_dir = _drush_iq_project_dir($issue_info);
459 if (!$project_dir) {
460 return drush_set_error('DRUSH_IQ_APPLY_PATCH_NO_PROJECT', dt('You are not working in a site, and the current directory is not named after the project. Please cd to the project !project and try again.', array('!project' => $issue_info['projectName'])));
461 }
462 $issue_info = _drush_iq_select_patch($issue_info, drush_get_option('select', FALSE));
463 if (!$issue_info) {
464 return FALSE;
465 }
466 if (!array_key_exists('patchId', $issue_info)) {
467 return drush_set_error('DRUSH_NO_PATCHES', dt("Could not find a suitable patch in !issue", array('!issue' => _drush_iq_create_commit_comment($issue_info))));
468 }
469 $patch_name = $issue_info['patchName'];
470 $patch_id = $issue_info['patchId'];
471 $patch = $issue_info['patchUrl'];
472 drush_log(dt("Drush iq-apply-patch !id: downloading patchfile !patch for project !project", array('!patch' => $patch_name, '!id' => $patch_id, '!project' => $issue_info['projectName'])), 'ok');
473 // n.b. the file returned by drush_download_file is either in the cache,
474 // or is registered for deletion
475 $filename = drush_download_file($patch);
476 if (!file_exists($filename)) {
477 return drush_set_error('DRUSH_PATCH_NOT_DOWNLOADED', dt("Patch could not be downloaded."));
478 }
479 $filename = realpath($filename);
480 $handle = @fopen($filename, "r");
481 if (!$handle) {
482 return drush_set_error('DRUSH_PATCH_NOT_READABLE', dt("Patch could not be read."));
483 }
484 // Get the first 80 characters or so from the file; we really only
485 // care whether it starts 'diff' or 'from'.
486 $line = fgets($handle, 80);
487 $from_format_patch = (strncasecmp('from ', $line, 5) == 0);
488 $from_diff = (strncasecmp('diff ', $line, 5) == 0);
489 if (!$from_format_patch && !$from_diff) {
490 drush_log("Could not tell if patch was created by diff or git format-patch; will try both.");
491 $from_format_patch = $from_diff = TRUE;
492 }
493 fclose($handle);
494
495 $keep_patch = FALSE;
496 if (drush_get_option('keep-patch', FALSE)) {
497 $keep_patch = dirname($filename) . '/iq-' . basename($filename);
498 copy($filename, $keep_patch);
499 }
500
501 $cwd = getcwd();
502 drush_op('chdir', $project_dir);
503
504 $try_patch_tool = TRUE;
505 if ($do_git_operations) {
506 $branch_merges_with = _drush_iq_get_branch_merges_with($project_dir);
507 if (!$branch_merges_with) {
508 $branch_merges_with = _drush_iq_get_branch_at_dir($project_dir);
509 if (empty($branch_merges_with)) {
510 $result = drush_shell_cd_and_exec($project_dir, "git init");
511 $result = drush_shell_cd_and_exec($project_dir, "git add .");
512 if (dirname($filename) == $project_dir) {
513 $result = drush_shell_cd_and_exec($project_dir, "git rm --cached -- %s", basename($filename));
514 }
515 if ($keep_patch) {
516 $result = drush_shell_cd_and_exec($project_dir, "git rm --cached -- %s", basename($keep_patch));
517 }
518 $result = drush_shell_cd_and_exec($project_dir, "git commit -m 'Git repository created by drush apply-patch command.'");
519 }
520 }
521 else {
522 $branch_merges_with = 'origin/' . $branch_merges_with;
523 }
524 if ($branch_merges_with != "(no branch)") {
525 $issue_info['mergeBranch'] = $branch_merges_with;
526 drush_log(dt("Starting at branch !branchlabel", array('!branchlabel' => $branch_merges_with)), 'notice');
527
528 // Make a branch via 'git checkout -b [description]-[issue]'
529 $description = $issue_info['title'];
530 $comment_number = array_key_exists('patchIndex', $issue_info) ? $issue_info['patchIndex'] : ('#' . 1 + count($issue_info['comments']));
531 $branchlabel = 'drush-iq-' . _drush_iq_make_description($description) . '-' . $issue_info['id'] . '-' . $comment_number;
532 $issue_info['branch'] = $branchlabel;
533 drush_log(dt("Switching to branch !branchlabel", array('!branchlabel' => $branchlabel)), 'ok');
534 $result = drush_shell_exec_interactive("git checkout -b %s %s", $branchlabel, $branch_merges_with);
535 if (!$result) {
536 return drush_set_error('DRUSH_IQ_BRANCH_CREATE_ERROR', dt("Could not create branch !branch", array('!branch' => $branchlabel)));
537 }
538 }
539 }
540
541 if ($do_git_operations && $from_format_patch) {
542 $result = drush_shell_exec_interactive('git apply -v --directory=%s %s', $project_dir, $filename);
543 if ($result === FALSE) {
544 drush_log(dt("git apply failed; falling back to 'patch' tool"), 'warning');
545 }
546 else {
547 $try_patch_tool = FALSE;
548 }
549 }
550
551 if ($try_patch_tool) {
552 // Try -Np1 first, then -Np0, unless the user selected --no-prefix,
553 // in which case we'll try -Np0 followed by -Np1 if that does not work.
554 $strip_count = 1;
555 if (drush_get_option('no-prefix', FALSE)) {
556 $strip_count = 0;
557 }
558
559 $result = drush_shell_exec_interactive('patch -Np%d --dry-run --batch -d %s -i %s', $strip_count, $project_dir, $filename);
560 if (!$result) {
561 $strip_count = !$strip_count;
562 $result = drush_shell_exec_interactive('patch -Np%d --dry-run --batch -d %s -i %s', $strip_count, $project_dir, $filename);
563 if (!$result) {
564 return drush_set_error('DRUSH_IQ_PATCH_DID_NOT_APPLY', dt("Could not apply the patch with either -Np0 or -Np1; perhaps the patch was rolled for a different version of the project."));
565 }
566 }
567 $result = drush_shell_exec_interactive('patch -Np%d --batch -d %s -i %s', $strip_count, $project_dir, $filename);
568 }
569 if (!drush_get_option('no-commit', FALSE)) {
570 $commit_comment = _drush_iq_create_commit_comment($issue_info, drush_get_option('committer', FALSE), drush_get_option('message', FALSE));
571 // Add the modified files
572 $result = drush_shell_exec("git add -A");
573 if (dirname($filename) == $project_dir) {
574 $result = drush_shell_exec("git rm --cached -- %s", basename($filename));
575 if ($keep_patch) {
576 $result = drush_shell_exec("git rm --cached -- %s", basename($keep_patch));
577 }
578 }
579 if (array_key_exists('patchAuthorCredit', $issue_info)) {
580 $result = drush_shell_exec("git commit --author=%s -m %s", $issue_info['patchAuthorCredit'], $commit_comment);
581 }
582 else {
583 $result = drush_shell_exec("git commit -m %s", $commit_comment);
584 }
585 }
586 drush_op('chdir', $cwd);
587 return $issue_info;
588 }
589
590 function _drush_iq_select_patch($issue_info, $prompt = FALSE) {
591 $patch_list = _drush_iq_get_patch_list($issue_info);
592 $patches = _drush_iq_collate_patches($issue_info, $patch_list);
593
594 if (!empty($patches)) {
595 // If --select, then prompt the user
596 if ($prompt) {
597 $choices = array();
598
599 foreach ($patch_list as $patch => $info) {
600 $index = $info['index'] . $info['suffix'];
601 $choices[$patch] = array("$index", ":", $patch);
602 }
603 $patch = drush_choice($choices, dt("Select a patch to apply:"));
604 if ($patch === FALSE) {
605 return drush_user_abort();
606 }
607 }
608 // If the issue specification included a comment number, e.g. #1078108-1, then select the patch attached to the specified comment
609 elseif (array_key_exists('comment-number', $issue_info)) {
610 $index = $issue_info['comment-number'];
611 if (!array_key_exists($index, $patches)) {
612 return drush_set_error('DRUSH_IQ_NO_PATCH', dt("Error: comment #!index does not exist or does not have a patch.", array('!index' => $index)));
613 }
614 $patch = $patches[$index];
615 }
616 else {
617 // TODO: See http://drupal.org/node/1078108#comment-5659936 and
618 // http://drupal.org/node/1078108#comment-5663932 for additional
619 // heuristics we might apply here.
620 $patch = array_pop($patches);
621 }
622
623 $patch_name = basename($patch);
624 $patch_id = $issue_info['id'];
625 if (array_key_exists($patch, $patch_list)) {
626 $issue_info['patchUrl'] = $patch;
627 $issue_info['patchIndex'] = $patch_list[$patch]['index'] . $patch_list[$patch]['suffix'];
628 $patch_id .= '-' . $issue_info['patchIndex'];
629 $issue_info['patchAuthorId'] = $issue_info['comments'][$patch_list[$patch]['id']]['contributorId'];
630 $issue_info['patchAuthorCredit'] = $issue_info['contributors'][$issue_info['patchAuthorId']]['authorCredit'];
631 }
632 $issue_info['patchName'] = $patch_name;
633 $issue_info['patchId'] = $patch_id;
634 }
635 return $issue_info;
636 }
637
638 /**
639 * This lookup table is used to map items from
640 * the issue queue json to the human-readable
641 * labels used in the output of iq-info.
642 */
643 function _drush_iq_label_map() {
644 return array(
645 'Title' => 'title',
646 'ID' => 'id',
647 'URL' => 'url',
648 'Project' => 'projectTitle',
649 'Project URL' => 'projectUrl',
650 'Version' => 'version',
651 'Component' => 'component',
652 'Category' => 'category',
653 'Priority' => 'priority',
654 'Assigned' => 'assignedName',
655 'Status' => 'status',
656 'Patch URL' => 'patchUrl',
657 'Patch Index' => 'patchIndex',
658 'Patch ID' => 'patchId',
659 );
660 }
661
662 /**
663 * Create a commit comment
664 *
665 * @param $issue_info array describing an issue; @see drush_iq_get_info()
666 *
667 * @returns string "#id by contributor1, contributor2: issue title"
668 */
669 function _drush_iq_create_commit_comment($issue_info, $committer = FALSE, $message = FALSE) {
670 $contributors = array();
671
672 // If this function is being called because a patch is being
673 // created right now, then add the committer to the head of the
674 // credits list.
675 if ($committer !== FALSE) {
676 // If 'TRUE' is passed for the committer, then look up
677 // the current user name from git config --list
678 if ($committer === TRUE) {
679 drush_shell_exec("git config --list | grep '^user.name=' | sed -e 's|[^=]*=||'");
680 $output = drush_shell_exec_output();
681 $committer = array_pop($output);
682 }
683
684 if (!empty($committer)) {
685 $contributors[$committer] = $committer;
686 }
687 }
688 // Gather up commit credits, listing most recent contributors first.
689 // Everyone who added an attachment gets credit.
690 if (!empty($issue_info)) {
691 foreach (array_reverse($issue_info['attachments']) as $comment_number => $attachment) {
692 if (array_key_exists($attachment['contributorId'], $issue_info['contributors'])) {
693 $contributor = $issue_info['contributors'][$attachment['contributorId']]['name'];
694 if (!in_array($contributor, $contributors)) {
695 $contributors[$contributor] = $contributor;
696 }
697 }
698 }
699 }
700 $credits = "";
701 $credits_prequel = "";
702 if (!empty($contributors)) {
703 $credits_prequel = " by";
704 $credits = " " . implode(', ', $contributors);
705 }
706
707 $prequel = "";
708 if ($issue_info) {
709 $issue_number = $issue_info['id'];
710 $issue_title = $issue_info['title'];
711 if (!$message) {
712 $message = $issue_title;
713 }
714 $prequel = "Issue #$issue_number$credits_prequel";
715 }
716 if (!$message) {
717 $message = dt("Generated with Drush iq");
718 }
719 return "$prequel$credits: $message";
720 }
721
722 /**
723 * Get information about an issue
724 *
725 * @param $number integer containing the issue number, or string beginning with a "#" and the issue number
726 *
727 * @returns array
728 * - title Title of the issue
729 * - id Issue number
730 * - url URL to issue page on drupal.org
731 * - projectTitle Title of the project issue belongs to (e.g. Drush)
732 * - projectName Name of the project (e.g. drush) - http://drupal.org/project/{projectName}
733 * - projectId
734 * - projectUrl
735 * - version Project version
736 * - versionId
737 * - authorName Name of the user who submitted the issue
738 * - authorId
739 * - authorUrl
740 * - assignedName Name of the user the issue is assigned to
741 * - assignedId
742 * - assignedUrl
743 * - component Code, documentation, etc.
744 * - category Bug, feature request, etc.
745 * - priority minor, normal, major, critical
746 * - priorityId
747 * - status active, needs work, etc.
748 * - statusId
749 * - created
750 * - changed
751 * - comments A list (array with numeric keys) of URLs
752 * - attachments A list (array with numeric keys) of attachments
753 * Array
754 * - contributorId uid of user submitting the patch
755 * - urls A list of strings pointing to the attachments
756 * - contributors An associative array keyed by uid of contributors
757 * Array
758 * - name Name of contributor
759 * - uid uid of contributor (same as key for this item)
760 * - profile url to user profile on drupal.org
761 */
762 function drush_iq_get_info($issue_spec) {
763 $issue_info_id = drush_iq_issue_number($issue_spec);
764 if (empty($issue_info_id)) {
765 return drush_set_error('DRUSH_ISSUE_NOT_FOUND', dt("Could not find the issue !issue", array('!issue' => $issue_spec)));
766 }
767 $issue_info = drush_iq_download_info($issue_info_id);
768 return $issue_info;
769 }
770
771 /**
772 * Given an issue specification,
773 */
774 function drush_iq_issue_number($issue_spec) {
775 $issue_info_id = array();
776 $number = FALSE;
777 $issue_site_domain = drush_get_option('issue-site', 'drupal.org');
778 $comment_number = FALSE;
779 // #1234
780 if (substr($issue_spec, 0, 1) == '#') {
781 $issue_info_id['id'] = substr($issue_spec, 1);
782 }
783 // http://drupal.org/node/1234
784 elseif (preg_match("#^http://([^/]*)/node/([0-9]*)/*#", $issue_spec, $matches, PREG_OFFSET_CAPTURE)) {
785 $issue_site_domain = $matches[1][0];
786 $issue_info_id['id'] = $matches[2][0];
787 }
788 // 1234
789 elseif (is_numeric($issue_spec)) {
790 $issue_info_id['id'] = $issue_spec;
791 }
792 // description-of-issue-1234 or description-of-issue-1234-8 (description-issue or description-issue-comment)
793 elseif (strpos($issue_spec, ' ') === FALSE) {
794 if (preg_match('/-*([0-9]+)(-*(#*[0-9]*[a-z]*))$/', $issue_spec, $matches)) {
795 $issue_info_id['id'] = $matches[1];
796 if (!empty($matches[3])) {
797 $issue_info_id['comment-number'] = $matches[3];
798 }
799 }
800 }
801 return $issue_info_id;
802 }
803
804 /**
805 * Given a node id, fetch and decode the issue info json from drupal.org.
806 */
807 function drush_iq_download_info($issue_info_id) {
808 $number = $issue_info_id['id'];
809 $comment_number = array_key_exists('comment-number', $issue_info_id) ? $issue_info_id['comment-number'] : FALSE;
810 $issue_site_domain = drush_get_option('issue-site', 'drupal.org');
811 $url = "http://$issue_site_domain/node/$number";
812 // Get the json data from d.o.
813 $filename = drush_find_tmp() . '/project-issue-' . $number . '.json';
814 $filename = _drush_download_file($url . "/project-issue/json", $filename, TRUE);
815 if (!empty($filename)) {
816 $data = file_get_contents($filename);
817 }
818 if (!empty($data)) {
819 $issue_info = json_decode($data, TRUE);
820 // Fixup: if d.o claims that attachment #0 has a contributor, change it to contributor-id
821 if (array_key_exists(0, $issue_info['attachments']) && array_key_exists('contributor', $issue_info['attachments'][0]) && !array_key_exists('contributorId', $issue_info['attachments'][0])) {
822 $issue_info['attachments'][0]['contributorId'] = $issue_info['attachments'][0]['contributor'];
823 }
824 // Copy the comment number (in 'subject') from the comment structure
825 // to the attachments structure.
826 foreach ($issue_info['comments'] as $commentId => $comment) {
827 if (array_key_exists($commentId, $issue_info['attachments'])) {
828 if (!array_key_exists('commentNumber', $issue_info['attachments'][$commentId])) {
829 $issue_info['attachments'][$commentId]['commentNumber'] = $issue_info['comments'][$commentId]['subject'];
830 }
831 }
832 }
833 // Add in the author credit info
834 foreach ($issue_info['contributors'] as $uid => $info) {
835 if ($info['name'] != 'System Message') {
836 $issue_info['contributors'][$uid]['authorCredit'] = sprintf("%s <%s@%s.no-reply.drupal.org>", $info['name'], $info['name'], $uid);
837 }
838 }
839 if (!empty($issue_info)) {
840 if ($comment_number) {
841 $issue_info['comment-number'] = $comment_number;
842 }
843 return $issue_info;
844 }
845 }
846 return drush_set_error('DRUSH_PM_ISSUE_FAILED', dt('Could not fetch issue data from !url', array('!url' => $url)));
847 }
848
849 function _drush_iq_get_patch_list($issue_info) {
850 $patch_list = array();
851
852 foreach ($issue_info['attachments'] as $comment_number => $attachment) {
853 $comment_patches = array();
854 $index = array_key_exists('commentNumber', $attachment) ? $attachment['commentNumber'] : '0';
855 foreach ($attachment['urls'] as $url) {
856 if (substr($url, -6) == ".patch" || substr($url, -5) == ".diff") {
857 $comment_patches[] = $url;
858 }
859 }
860 if (count($comment_patches) == 1) {
861 $patch_list[$comment_patches[0]] = array('index' => $index, 'id' => $comment_number, 'suffix' => '');
862 }
863 else {
864 $label = 'a';
865 foreach ($comment_patches as $url) {
866 $patch_list[$url] = array('index' => $index, 'id' => $comment_number, 'suffix' => $label);
867 $label = chr(ord($label) + 1);
868 }
869 }
870 }
871 return $patch_list;
872 }
873
874 function _drush_iq_collate_patches($issue_info, $patch_list) {
875 $patches = array();
876
877 foreach ($patch_list as $url => $info) {
878 $patches[$info['index'] . $info['suffix']] = $url;
879 $patches[$info['id'] . $info['suffix']] = $url;
880 }
881
882 return $patches;
883 }
884
885 /**
886 * Find the project directory associated with the project
887 * the specified issue is associated with.
888 */
889 function _drush_iq_project_dir(&$issue_info) {
890 $project_name = $issue_info['projectName'];
891 $result = FALSE;
892
893 if (array_key_exists('project-dir', $issue_info)) {
894 $result = $issue_info['project-dir'];
895 }
896 else {
897 // If our cwd is at the root of the project, then prefer that project over
898 // one in some other location.
899 $dir = _drush_iq_git_root_dir_from_cwd();
900 if (basename($dir) == $project_name) {
901 $result = $dir;
902 }
903 // TODO: Find drush extensions such as drush_extras, drush_make, drubuntu, etc.
904 elseif ($project_name == 'drush') {
905 $result = DRUSH_BASE_PATH;
906 }
907 else {
908 $phase = drush_bootstrap_max();
909 drush_log("bootstrapped to phase $phase");
910 if ($phase >= DRUSH_BOOTSTRAP_DRUPAL_CONFIGURATION) {
911 $extension_info = drush_get_extensions();
912 // TODO: offer to download the project if it is not found?
913 if (array_key_exists($project_name, $extension_info)) {
914 $result = drush_get_context('DRUSH_DRUPAL_ROOT', '') . '/' . dirname($extension_info[$project_name]->filename);
915 }
916 }
917 if ($phase >= DRUSH_BOOTSTRAP_DRUPAL_ROOT) {
918 $root = drush_get_context('DRUSH_DRUPAL_ROOT', FALSE);
919 if ($root) {
920 if ($project_name == 'drupal') {
921 $result = $root;
922 }
923 else {
924 foreach (array('modules', 'sites/all/modules', 'sites/default/modules') as $loc) {
925 $path = $root . '/' . $loc . '/' . $project_name;
926 if (is_dir($path)) {
927 $result = $path;
928 }
929 }
930 }
931 }
932 }
933 }
934 if ($result) {
935 $issue_info['project-dir'] = $result;
936 }
937 else {
938 drush_log(dt('Could not find the project directory under the bootstrapped site'));
939 }
940 }
941
942 return $result;
943 }
944
945 function _drush_iq_get_branch(&$issue_info) {
946 $branch = FALSE;
947 if (array_key_exists('branch', $issue_info)) {
948 $branch = $issue_info['branch'];
949 }
950 else {
951 $project_dir = _drush_iq_project_dir($issue_info);
952 if ($project_dir) {
953 $branch = _drush_iq_get_branch_at_dir($project_dir);
954 }
955 }
956 $issue_info['branch'] = $branch;
957 return $branch;
958 }
959
960 function _drush_iq_get_branch_at_dir($dir) {
961 $result = drush_shell_cd_and_exec($dir, "git branch");
962 $branch_output = drush_shell_exec_output();
963
964 // Return the last non-empty line
965 $branch = FALSE;
966 while (($branch === FALSE) && !empty($branch_output)) {
967 $line = array_shift($branch_output);
968 if (!empty($line) && ($line[0] == '*')) {
969 $branch_components = explode(' ', $line, 2);
970 $branch = $branch_components[1];
971 }
972 }
973 return $branch;
974 }
975
976 function _drush_iq_get_merge_branch_with_remote($merges_with) {
977 $remote = _drush_iq_get_best_remote($merges_with);
978 return _drush_iq_merge_branch_with_remote($remote, $merges_with);
979 }
980
981 function _drush_iq_merge_branch_with_remote($remote, $merges_with) {
982 if (empty($remote)) {
983 return $merges_with;
984 }
985 else {
986 return $remote . '/' . $merges_with;
987 }
988 }
989
990 function _drush_iq_get_best_remote($merges_with) {
991 $remotes = _drush_iq_get_remotes($merges_with);
992 if (empty($remotes)) {
993 return "";
994 }
995 elseif (count($remotes) == 1) {
996 return $remotes[0];
997 }
998 elseif (in_array('origin', $remotes)) {
999 return 'origin';
1000 }
1001 return $remotes[0];
1002 }
1003
1004 function _drush_iq_get_remotes($merges_with) {
1005 // Check the output of `git branch -r`; we are looking
1006 // for output "remote/branchname"
1007 $result = drush_shell_exec("git branch -r");
1008 $git_branch_output = drush_shell_exec_output();
1009
1010 $remotes = array();
1011 foreach($git_branch_output as $line) {
1012 $line = trim($line);
1013 if (preg_match("#([^/]+)/(.*)#", $line, $matches)) {
1014 if ($matches[2] == $merges_with) {
1015 $remotes[] = $matches[1];
1016 }
1017 }
1018 }
1019 return $remotes;
1020 }
1021
1022 function _drush_iq_get_branch_merges_with($dir, $branch_label = FALSE) {
1023 $merges_with = "";
1024 $default_merges_with = FALSE;
1025 $alternate_remotes = array();
1026
1027 if ($branch_label === FALSE) {
1028 $branch_label = _drush_iq_get_branch_at_dir($dir);
1029 }
1030 if ($branch_label == "(no branch)") {
1031 return $merges_with;
1032 }
1033 // We expect that the upstream remote has been configured via
1034 // `git branch --set-upstream drush-iq-x master`
1035 $result = drush_shell_cd_and_exec($dir, "git config branch.%s.merge", $branch_label);
1036 $branch_merge_output = drush_shell_exec_output();
1037 if (!empty($branch_merge_output)) {
1038 // Convert the branch name into its abbreviated form.
1039 $result = drush_shell_exec("git rev-parse --abbrev-ref %s", $branch_merge_output[0]);
1040 $rev_parse_output = drush_shell_exec_output();
1041 return $rev_parse_output[0];
1042 }
1043
1044 // If the upstream remote has not been configured, then we will look
1045 // at the output of `git remote show origin` and see if we can find
1046 // a fallback branch to use.
1047 $result = drush_shell_cd_and_exec($dir, "git remote show origin -n");
1048 $show_orgin_output = drush_shell_exec_output();
1049
1050 foreach ($show_orgin_output as $line) {
1051 $line = trim($line);
1052 // Find all of the other branches 'master merges with remote master', etc.
1053 if (preg_match("/([^ ]+).* with remote (.*)/", $line, $matches)) {
1054 if ($matches[1] == $matches[2]) {
1055 $alternate_remotes[] = $matches[2];
1056 }
1057 }
1058 }
1059 // If there is only one remote, that must be the one we merge with!
1060 if (count($alternate_remotes) == 1) {
1061 $merges_with = array_shift($alternate_remotes);
1062 }
1063 elseif (!empty($alternate_remotes)) {
1064 // We could figure out which of these branches was the correct
1065 // one to use by walking the commit log. See:
1066 // http://drupal.org/node/1078108#comment-6335376
1067 $setupstream = "";
1068 foreach ($alternate_remotes as $alternate) {
1069 $setupstream .= "\n " . dt("git branch --set-upstream !branch !merge", array('!branch' => $branch_label, '!merge' => $alternate));
1070 }
1071 return drush_set_error('DRUSH_IQ_CANNOT_FIND_MERGE_BRANCH', dt("Could not determine which branch of !alternatives was the correct merge branch. Use one of the following commands to select the correct one: !setupstream", array('!alternatives' => implode(',', $alternate_remotes), '!setupstream' => $setupstream)));
1072 }
1073 return $merges_with;
1074 }
1075
1076 function _drush_iq_make_description($title) {
1077 $description = preg_replace('/[^a-z._-]/', '', str_replace(' ', '-', strtolower($title)));
1078
1079 // Clip the description off at the next dash after the 30th position
1080 if (strlen($description) > 30) {
1081 $find_dash = strpos($description, '-', 30);
1082 if ($find_dash !== FALSE) {
1083 $description = substr($description, 0, $find_dash);
1084 }
1085 }
1086
1087 return $description;
1088 }
1089
1090 /**
1091 * Determine the location of the git root directory from the
1092 * location of the user's current working directory. If the
1093 * cwd is somewhere inside the git repo, pop up to the top
1094 * by using git rev-parse.
1095 */
1096 function _drush_iq_git_root_dir_from_cwd() {
1097 $dir = drush_get_context('DRUSH_OLDCWD', drush_cwd());
1098 exec('git rev-parse --show-toplevel 2> /dev/null', $output);
1099 if (!empty($output)) {
1100 $dir = $output[0];
1101 }
1102 return $dir;
1103 }