diff --git a/Makefile b/Makefile index 942b144..8ec8daa 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ help: @printf "\tpo-push - Send the all PO files to the transifex server.\n" @printf "\tpo-pull - Get all PO files from the transifex server.\n" @printf "\tpo-stats - Show translation statistics of all PO files.\n" + @printf "\nMisc Rules :\n"; + @printf "\tdb-install - Install the database schema.\n" + @printf "\tdb-update - Update the database schema.\n" + # # Internationalization rule, POT & PO file manipulation @@ -139,3 +143,8 @@ po-stats: > indefero-$(@:-zipfile=)-`git log $(@:-zipfile=) -n 1 \ --pretty=format:%h`.zip +db-install: + @cd src && php $(PLUF_PATH)/migrate.php --conf=IDF/conf/idf.php -a -d -i + +db-update: + @cd src && php $(PLUF_PATH)/migrate.php --conf=IDF/conf/idf.php -a -d diff --git a/NEWS.mdtext b/NEWS.mdtext index 80830f3..bb50f63 100644 --- a/NEWS.mdtext +++ b/NEWS.mdtext @@ -1,7 +1,12 @@ # InDefero 1.2 - xxx xxx xx xx:xx 2011 UTC +ATTENTION: You need Pluf [324ae60b](http://projects.ceondo.com/p/pluf/source/commit/324ae60b) +or newer to properly run this version of Indefero! + ## New Features +- Indefero's issue tracker can now bi-directionally link issues with variable, configurable + terms, such as "is related to", "is blocked by" or "is duplicated by" (issue 638) - Mercurial source views now show parent revisions (if any) and detailed change information - File download URLs now contain the file name rather than the upload id; old links still work though (issues 559 and 686) - Display monotone file and directory attributes in the tree and file view diff --git a/src/IDF/Form/Admin/ProjectCreate.php b/src/IDF/Form/Admin/ProjectCreate.php index f111654..c39a028 100644 --- a/src/IDF/Form/Admin/ProjectCreate.php +++ b/src/IDF/Form/Admin/ProjectCreate.php @@ -319,6 +319,7 @@ class IDF_Form_Admin_ProjectCreate extends Pluf_Form 'labels_issue_closed' => IDF_Form_IssueTrackingConf::init_closed, 'labels_issue_predefined' => IDF_Form_IssueTrackingConf::init_predefined, 'labels_issue_one_max' => IDF_Form_IssueTrackingConf::init_one_max, + 'issue_relations' => IDF_Form_IssueTrackingConf::init_relations, 'webhook_url' => '', 'downloads_access_rights' => 'all', 'review_access_rights' => 'all', diff --git a/src/IDF/Form/IssueCreate.php b/src/IDF/Form/IssueCreate.php index b52987c..3f89988 100644 --- a/src/IDF/Form/IssueCreate.php +++ b/src/IDF/Form/IssueCreate.php @@ -36,6 +36,7 @@ class IDF_Form_IssueCreate extends Pluf_Form public $user = null; public $project = null; public $show_full = false; + public $relation_types = null; public function initFields($extra=array()) { @@ -45,9 +46,12 @@ class IDF_Form_IssueCreate extends Pluf_Form or $this->user->hasPerm('IDF.project-member', $this->project)) { $this->show_full = true; } + $this->relation_types = $this->project->getRelationsFromConfig(); + $contentTemplate = $this->project->getConf()->getVal( 'labels_issue_template', IDF_Form_IssueTrackingConf::init_template ); + $this->fields['summary'] = new Pluf_Form_Field_Varchar( array('required' => true, 'label' => __('Summary'), @@ -76,11 +80,11 @@ class IDF_Form_IssueCreate extends Pluf_Form // case of someone allowing the upload path to be accessible // to everybody. for ($i=1;$i<4;$i++) { - $filename = substr($md5, 0, 2).'/'.substr($md5, 2, 2).'/'.substr($md5, 4).'/%s.dummy'; + $filename = substr($md5, 0, 2).'/'.substr($md5, 2, 2).'/'.substr($md5, 4).'/%s.dummy'; $this->fields['attachment'.$i] = new Pluf_Form_Field_File( array('required' => false, 'label' => __('Attach a file'), - 'move_function_params' => + 'move_function_params' => array('upload_path' => $upload_path, 'upload_path_create' => true, 'file_name' => $filename, @@ -109,6 +113,20 @@ class IDF_Form_IssueCreate extends Pluf_Form ), )); + $this->fields['relation_type0'] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => __('This issue'), + 'initial' => current($this->relation_types), + 'widget_attrs' => array('size' => 15), + )); + + $this->fields['relation_issue0'] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => null, + 'initial' => '', + 'widget_attrs' => array('size' => 10), + )); + /* * get predefined tags for issues from current project * @@ -181,7 +199,7 @@ class IDF_Form_IssueCreate extends Pluf_Form $this->cleaned_data['label'.$i] = trim($this->cleaned_data['label'.$i]); if (strpos($this->cleaned_data['label'.$i], ':') !== false) { list($class, $name) = explode(':', $this->cleaned_data['label'.$i], 2); - list($class, $name) = array(mb_strtolower(trim($class)), + list($class, $name) = array(mb_strtolower(trim($class)), trim($name)); } else { $class = 'other'; @@ -215,10 +233,10 @@ class IDF_Form_IssueCreate extends Pluf_Form function clean_status() { // Check that the status is in the list of official status - $tags = $this->project->getTagsFromConfig('labels_issue_open', + $tags = $this->project->getTagsFromConfig('labels_issue_open', IDF_Form_IssueTrackingConf::init_open, 'Status'); - $tags = array_merge($this->project->getTagsFromConfig('labels_issue_closed', + $tags = array_merge($this->project->getTagsFromConfig('labels_issue_closed', IDF_Form_IssueTrackingConf::init_closed, 'Status') , $tags); @@ -235,6 +253,63 @@ class IDF_Form_IssueCreate extends Pluf_Form return $this->cleaned_data['status']; } + // this method is not called from Pluf_Form directly, but shared for + // among all similar fields + function clean_relation_type($value) + { + $relation_type = trim($value); + if (empty($relation_type)) + return ''; + + $found = false; + foreach ($this->relation_types as $type) { + if ($type == $relation_type) { + $found = true; + break; + } + } + if (!$found) { + throw new Pluf_Form_Invalid(__('You provided an invalid relation type.')); + } + return $relation_type; + } + + function clean_relation_type0() + { + return $this->clean_relation_type($this->cleaned_data['relation_type0']); + } + + // this method is not called from Pluf_Form directly, but shared for + // among all similar fields + function clean_relation_issue($value) + { + $issues = trim($value); + if (empty($issues)) + return ''; + + $issue_ids = preg_split('/\s*,\s*/', $issues, -1, PREG_SPLIT_NO_EMPTY); + foreach ($issue_ids as $issue_id) { + if (!ctype_digit($issue_id) || (int)$issue_id < 1) { + throw new Pluf_Form_Invalid(sprintf( + __('The value "%s" is not a valid issue id.'), $issue_id + )); + } + $issue = new IDF_Issue($issue_id); + if ($issue->id != $issue_id || $issue->project != $this->project->id) { + throw new Pluf_Form_Invalid(sprintf( + __('The issue "%s" does not exist.'), $issue_id + )); + } + } + + return implode(', ', $issue_ids); + } + + function clean_relation_issue0() + { + return $this->clean_relation_issue($this->cleaned_data['relation_issue0']); + } + /** * Clean the attachments post failure. */ @@ -298,6 +373,30 @@ class IDF_Form_IssueCreate extends Pluf_Form foreach ($tags as $tag) { $issue->setAssoc($tag); } + // add relations (if any) + if (!empty($this->cleaned_data['relation_type0'])) { + $verb = $this->cleaned_data['relation_type0']; + $other_verb = $this->relation_types[$verb]; + $related_issues = preg_split('/\s*,\s*/', $this->cleaned_data['relation_issue0'], -1, PREG_SPLIT_NO_EMPTY); + + foreach ($related_issues as $related_issue_id) { + $related_issue = new IDF_Issue($related_issue_id); + $rel = new IDF_IssueRelation(); + $rel->issue = $issue; + $rel->verb = $verb; + $rel->other_issue = $related_issue; + $rel->submitter = $this->user; + $rel->create(); + + $other_rel = new IDF_IssueRelation(); + $other_rel->issue = $related_issue; + $other_rel->verb = $other_verb; + $other_rel->other_issue = $issue; + $other_rel->submitter = $this->user; + $other_rel->create(); + } + } + // add the first comment $comment = new IDF_IssueComment(); $comment->issue = $issue; diff --git a/src/IDF/Form/IssueTrackingConf.php b/src/IDF/Form/IssueTrackingConf.php index 34dce61..738c3e4 100644 --- a/src/IDF/Form/IssueTrackingConf.php +++ b/src/IDF/Form/IssueTrackingConf.php @@ -72,6 +72,23 @@ Performance = Performance issue Usability = Affects program usability Maintainability = Hinders future changes'; const init_one_max = 'Type, Priority, Milestone'; + // ATTENTION: if you change something here, change the values below as well! + const init_relations = 'is related to +blocks, is blocked by +duplicates, is duplicated by'; + + // These are actually all noop's, but we have no other chance to + // tell IDF's translation mechanism to mark the strings as translatable + // FIXME: IDF should get a internal translation system for strings like + // that, that can also be easily expanded by users + private function noop() + { + __('is related to'); + __('blocks'); + __('is blocked by'); + __('duplicates'); + __('is duplicated by'); + } public function initFields($extra=array()) { @@ -114,10 +131,19 @@ Maintainability = Hinders future changes'; $this->fields['labels_issue_one_max'] = new Pluf_Form_Field_Varchar( array('required' => false, 'label' => __('Each issue may have at most one label with each of these classes'), - 'initial' => self::init_one_max, + 'initial' => self::init_one_max, 'widget_attrs' => array('size' => 60), )); + $this->fields['issue_relations'] = new Pluf_Form_Field_Varchar( + array('required' => true, + 'label' => __('Issue relations'), + 'initial' => self::init_relations, + 'help_text' => __('You can define bidirectional relations like "is related to" or "blocks, is blocked by".'), + 'widget_attrs' => array('rows' => 7, + 'cols' => 75), + 'widget' => 'Pluf_Form_Widget_TextareaInput', + )); } } diff --git a/src/IDF/Form/IssueUpdate.php b/src/IDF/Form/IssueUpdate.php index b453b9a..0362eb3 100644 --- a/src/IDF/Form/IssueUpdate.php +++ b/src/IDF/Form/IssueUpdate.php @@ -39,6 +39,7 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate or $this->user->hasPerm('IDF.project-member', $this->project)) { $this->show_full = true; } + $this->relation_types = $this->project->getRelationsFromConfig(); if ($this->show_full) { $this->fields['summary'] = new Pluf_Form_Field_Varchar( array('required' => true, @@ -69,11 +70,11 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate // case of someone allowing the upload path to be accessible // to everybody. for ($i=1;$i<4;$i++) { - $filename = substr($md5, 0, 2).'/'.substr($md5, 2, 2).'/'.substr($md5, 4).'/%s.dummy'; + $filename = substr($md5, 0, 2).'/'.substr($md5, 2, 2).'/'.substr($md5, 4).'/%s.dummy'; $this->fields['attachment'.$i] = new Pluf_Form_Field_File( array('required' => false, 'label' => __('Attach a file'), - 'move_function_params' => + 'move_function_params' => array('upload_path' => $upload_path, 'upload_path_create' => true, 'file_name' => $filename, @@ -102,6 +103,52 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate 'size' => 15, ), )); + + $idx = 0; + // note: clean_relation_type0 and clean_relation_issue0 already + // exist in the base class + $this->fields['relation_type'.$idx] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => __('This issue'), + 'initial' => current($this->relation_types), + 'widget_attrs' => array('size' => 15), + )); + + $this->fields['relation_issue'.$idx] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => null, + 'initial' => '', + 'widget_attrs' => array('size' => 10), + )); + + ++$idx; + $relatedIssues = $this->issue->getGroupedRelatedIssues(array(), true); + foreach ($relatedIssues as $verb => $ids) { + $this->fields['relation_type'.$idx] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => __('This issue'), + 'initial' => $verb, + 'widget_attrs' => array('size' => 15), + )); + $m = 'clean_relation_type'.$idx; + $this->$m = create_function('$form', ' + return $form->clean_relation_type($form->cleaned_data["relation_type'.$idx.'"]); + '); + + $this->fields['relation_issue'.$idx] = new Pluf_Form_Field_Varchar( + array('required' => false, + 'label' => null, + 'initial' => implode(', ', $ids), + 'widget_attrs' => array('size' => 10), + )); + $m = 'clean_relation_issue'.$idx; + $this->$m = create_function('$form', ' + return $form->clean_relation_issue($form->cleaned_data["relation_issue'.$idx.'"]); + '); + + ++$idx; + } + $tags = $this->issue->get_tags_list(); for ($i=1;$i<7;$i++) { $initial = ''; @@ -155,6 +202,51 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate public function clean() { $this->cleaned_data = parent::clean(); + + // normalize the user's input by removing dublettes and by combining + // ids from identical verbs in different input fields into one array + $normRelatedIssues = array(); + for ($idx = 0; isset($this->cleaned_data['relation_type'.$idx]); ++$idx) { + $verb = $this->cleaned_data['relation_type'.$idx]; + if (empty($verb)) + continue; + + $ids = preg_split('/\s*,\s*/', $this->cleaned_data['relation_issue'.$idx], + -1, PREG_SPLIT_NO_EMPTY); + if (count($ids) == 0) + continue; + + if (!array_key_exists($verb, $normRelatedIssues)) + $normRelatedIssues[$verb] = array(); + foreach ($ids as $id) { + if (!in_array($id, $normRelatedIssues[$verb])) + $normRelatedIssues[$verb][] = $id; + } + } + + // now look at any added / removed ids + $added = $removed = array(); + $relatedIssues = $this->issue->getGroupedRelatedIssues(array(), true); + $added = array_diff_key($normRelatedIssues, $relatedIssues); + $removed = array_diff_key($relatedIssues, $normRelatedIssues); + + $keysToLookAt = array_keys( + array_intersect_key($relatedIssues, $normRelatedIssues) + ); + foreach ($keysToLookAt as $key) { + $a = array_diff($normRelatedIssues[$key], $relatedIssues[$key]); + if (count($a) > 0) + $added[$key] = $a; + $r = array_diff($relatedIssues[$key], $normRelatedIssues[$key]); + if (count($r) > 0) + $removed[$key] = $r; + } + + // cache the added / removed data, so we do not have to + // calculate that again + $this->cleaned_data['_added_issue_relations'] = $added; + $this->cleaned_data['_removed_issue_relations'] = $removed; + // As soon as we know that at least one change was done, we // return the cleaned data and do not go further. if (strlen(trim($this->cleaned_data['content']))) { @@ -214,6 +306,11 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate return $this->cleaned_data; } } + + if (count($this->cleaned_data['_added_issue_relations']) != 0 || + count($this->cleaned_data['_removed_issue_relations']) != 0) { + return $this->cleaned_data; + } } // no changes! throw new Pluf_Form_Invalid(__('No changes were entered.')); @@ -255,20 +352,22 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate foreach ($tags as $tag) { if (!Pluf_Model_InArray($tag, $oldtags)) { if (!isset($changes['lb'])) $changes['lb'] = array(); + if (!isset($changes['lb']['add'])) $changes['lb']['add'] = array(); if ($tag->class != 'Other') { - $changes['lb'][] = (string) $tag; //new tag + $changes['lb']['add'][] = (string) $tag; //new tag } else { - $changes['lb'][] = (string) $tag->name; + $changes['lb']['add'][] = (string) $tag->name; } } } foreach ($oldtags as $tag) { if (!Pluf_Model_InArray($tag, $tags)) { if (!isset($changes['lb'])) $changes['lb'] = array(); + if (!isset($changes['lb']['rem'])) $changes['lb']['rem'] = array(); if ($tag->class != 'Other') { - $changes['lb'][] = '-'.(string) $tag; //new tag + $changes['lb']['rem'][] = (string) $tag; //new tag } else { - $changes['lb'][] = '-'.(string) $tag->name; + $changes['lb']['rem'][] = (string) $tag->name; } } } @@ -286,6 +385,47 @@ class IDF_Form_IssueUpdate extends IDF_Form_IssueCreate or ((!is_null($owner) and !is_null($this->issue->get_owner())) and $owner->id != $this->issue->get_owner()->id)) { $changes['ow'] = (is_null($owner)) ? '---' : $owner->login; } + // Issue relations - additions + foreach ($this->cleaned_data['_added_issue_relations'] as $verb => $ids) { + $other_verb = $this->relation_types[$verb]; + foreach ($ids as $id) { + $related_issue = new IDF_Issue($id); + $rel = new IDF_IssueRelation(); + $rel->issue = $this->issue; + $rel->verb = $verb; + $rel->other_issue = $related_issue; + $rel->submitter = $this->user; + $rel->create(); + + $other_rel = new IDF_IssueRelation(); + $other_rel->issue = $related_issue; + $other_rel->verb = $other_verb; + $other_rel->other_issue = $this->issue; + $other_rel->submitter = $this->user; + $other_rel->create(); + } + if (!isset($changes['rel'])) $changes['rel'] = array(); + if (!isset($changes['rel']['add'])) $changes['rel']['add'] = array(); + $changes['rel']['add'][] = $verb.' '.implode(', ', $ids); + } + // Issue relations - removals + foreach ($this->cleaned_data['_removed_issue_relations'] as $verb => $ids) { + foreach ($ids as $id) { + $db = &Pluf::db(); + $table = Pluf::factory('IDF_IssueRelation')->getSqlTable(); + $sql = new Pluf_SQL('verb=%s AND ( + (issue=%s AND other_issue=%s) OR + (other_issue=%s AND issue=%s))', + array($verb, + $this->issue->id, $id, + $this->issue->id, $id)); + $db->execute('DELETE FROM '.$table.' WHERE '.$sql->gen()); + } + + if (!isset($changes['rel'])) $changes['rel'] = array(); + if (!isset($changes['rel']['rem'])) $changes['rel']['rem'] = array(); + $changes['rel']['rem'][] = $verb.' '.implode(', ', $ids); + } // Update the issue $this->issue->batchAssoc('IDF_Tag', $tagids); $this->issue->summary = trim($this->cleaned_data['summary']); diff --git a/src/IDF/Issue.php b/src/IDF/Issue.php index b173209..8d1ed34 100644 --- a/src/IDF/Issue.php +++ b/src/IDF/Issue.php @@ -169,6 +169,24 @@ class IDF_Issue extends Pluf_Model } } + function getGroupedRelatedIssues($opts = array(), $idsOnly = false) + { + $rels = $this->get_related_issues_list(array_merge($opts, array( + 'view' => 'with_other_issue', + ))); + + $res = array(); + foreach ($rels as $rel) { + $verb = $rel->verb; + if (!array_key_exists($verb, $res)) { + $res[$verb] = array(); + } + $res[$verb][] = $idsOnly ? $rel->other_issue : $rel; + } + + return $res; + } + /** * Returns an HTML fragment used to display this issue in the * timeline. diff --git a/src/IDF/IssueComment.php b/src/IDF/IssueComment.php index 366b3e2..dd4edd8 100644 --- a/src/IDF/IssueComment.php +++ b/src/IDF/IssueComment.php @@ -155,10 +155,19 @@ class IDF_IssueComment extends Pluf_Model $out .= __('Owner:'); break; case 'lb': $out .= __('Labels:'); break; + case 'rel': + $out .= __('Relations:'); break; } $out .= ' '; - if ($w == 'lb') { - $out .= Pluf_esc(implode(', ', $v)); + if ($w == 'lb' || $w == 'rel') { + foreach ($v as $t => $ls) { + foreach ($ls as $l) { + if ($t == 'rem') $out .= ''; + $out .= Pluf_esc($l); + if ($t == 'rem') $out .= ''; + $out .= ' '; + } + } } else { $out .= Pluf_esc($v); } diff --git a/src/IDF/IssueRelation.php b/src/IDF/IssueRelation.php new file mode 100644 index 0000000..b0048dd --- /dev/null +++ b/src/IDF/IssueRelation.php @@ -0,0 +1,100 @@ +_a['table'] = 'idf_issuerelations'; + $this->_a['model'] = __CLASS__; + $this->_a['cols'] = array( + // It is mandatory to have an "id" column. + 'id' => + array( + 'type' => 'Pluf_DB_Field_Sequence', + 'blank' => true, + ), + 'issue' => + array( + 'type' => 'Pluf_DB_Field_Foreignkey', + 'model' => 'IDF_Issue', + 'blank' => false, + 'verbose' => __('issue'), + 'relate_name' => 'related_issues', + ), + 'verb' => + array( + 'type' => 'Pluf_DB_Field_Text', + 'blank' => false, + 'verbose' => __('verb'), + ), + 'other_issue' => + array( + 'type' => 'Pluf_DB_Field_Foreignkey', + 'model' => 'IDF_Issue', + 'blank' => false, + 'verbose' => __('other issue'), + 'relate_name' => 'related_other_issues', + ), + 'submitter' => + array( + 'type' => 'Pluf_DB_Field_Foreignkey', + 'model' => 'Pluf_User', + 'blank' => false, + 'verbose' => __('submitter'), + ), + 'creation_dtime' => + array( + 'type' => 'Pluf_DB_Field_Datetime', + 'blank' => true, + 'verbose' => __('creation date'), + ), + ); + $this->_a['idx'] = array( + 'creation_dtime_idx' => + array( + 'col' => 'creation_dtime', + 'type' => 'normal', + ), + ); + $issuetbl = $this->_con->pfx.'idf_issues'; + $this->_a['views'] = array( + 'with_other_issue' => array( + 'join' => 'INNER JOIN '.$issuetbl.' ON other_issue='.$issuetbl.'.id', + 'select' => $this->getSelect().', summary', + 'props' => array('summary' => 'other_summary'), + )); + } + + function preSave($create=false) + { + if ($this->id == '') { + $this->creation_dtime = gmdate('Y-m-d H:i:s'); + } + } +} diff --git a/src/IDF/Migrations/17AddIssueRelations.php b/src/IDF/Migrations/17AddIssueRelations.php new file mode 100644 index 0000000..e9f3636 --- /dev/null +++ b/src/IDF/Migrations/17AddIssueRelations.php @@ -0,0 +1,90 @@ +model = new IDF_IssueRelation(); + $schema->createTables(); + + // change the serialization format for added / removed labels in IDF_IssueComment + $comments = Pluf::factory('IDF_IssueComment')->getList(); + foreach ($comments as $comment) { + if (!isset($comment->changes['lb'])) continue; + $changes = $comment->changes; + $adds = $removals = array(); + foreach ($comment->changes['lb'] as $lb) { + if (substr($lb, 0, 1) == '-') + $removals[] = substr($lb, 1); + else + $adds[] = $lb; + } + $changes['lb'] = array(); + if (count($adds) > 0) + $changes['lb']['add'] = $adds; + if (count($removals) > 0) + $changes['lb']['rem'] = $removals; + $comment->changes = $changes; + $comment->update(); + } +} + +function IDF_Migrations_17AddIssueRelations_down($params=null) +{ + $db = Pluf::db(); + $schema = new Pluf_DB_Schema($db); + $schema->model = new IDF_IssueRelation(); + $schema->dropTables(); + + // change the serialization format for added / removed labels in IDF_IssueComment + $comments = Pluf::factory('IDF_IssueComment')->getList(); + foreach ($comments as $comment) { + $changes = $comment->changes; + if (empty($changes)) + continue; + if (isset($changes['lb'])) { + $labels = array(); + foreach ($changes['lb'] as $type => $lbs) { + if (!is_array($lbs)) { + $labels[] = $lbs; + continue; + } + foreach ($lbs as $lb) { + $labels[] = ($type == 'rem' ? '-' : '') . $lb; + } + } + $changes['lb'] = $labels; + } + // while we're at it, remove any 'rel' changes + unset($changes['rel']); + $comment->changes = $changes; + $comment->update(); + } +} + diff --git a/src/IDF/Migrations/Backup.php b/src/IDF/Migrations/Backup.php index 406b086..32fcb9e 100644 --- a/src/IDF/Migrations/Backup.php +++ b/src/IDF/Migrations/Backup.php @@ -54,6 +54,7 @@ function IDF_Migrations_Backup_run($folder, $name=null) 'IDF_Queue', 'IDF_Gconf', 'IDF_EmailAddress', + 'IDF_IssueRelation', ); $db = Pluf::db(); // Now, for each table, we dump the content in json, this is a @@ -100,6 +101,7 @@ function IDF_Migrations_Backup_restore($folder, $name) 'IDF_Queue', 'IDF_Gconf', 'IDF_EmailAddress', + 'IDF_IssueRelation', ); $db = Pluf::db(); $schema = new Pluf_DB_Schema($db); diff --git a/src/IDF/Migrations/Install.php b/src/IDF/Migrations/Install.php index 98c62fc..aea5f00 100644 --- a/src/IDF/Migrations/Install.php +++ b/src/IDF/Migrations/Install.php @@ -51,6 +51,7 @@ function IDF_Migrations_Install_setup($params=null) 'IDF_Queue', 'IDF_Gconf', 'IDF_EmailAddress', + 'IDF_IssueRelation', ); $db = Pluf::db(); $schema = new Pluf_DB_Schema($db); @@ -109,6 +110,7 @@ function IDF_Migrations_Install_teardown($params=null) 'IDF_Commit', 'IDF_Project', 'IDF_EmailAddress', + 'IDF_IssueRelation', ); $db = Pluf::db(); $schema = new Pluf_DB_Schema($db); diff --git a/src/IDF/Project.php b/src/IDF/Project.php index 3a542c6..2f09219 100644 --- a/src/IDF/Project.php +++ b/src/IDF/Project.php @@ -233,6 +233,29 @@ class IDF_Project extends Pluf_Model return $tags; } + /** + * Returns a list of relations which are available in this project as + * associative array. Each key-value pair marks a set of orthogonal + * relations. To ease processing, each of these pairs is included twice + * in the array, once as key1 => key2 and once as key2 => key1. + * + * @return array List of relation names + */ + public function getRelationsFromConfig() + { + $conf = $this->getConf(); + $rel = $conf->getVal('issue_relations', IDF_Form_IssueTrackingConf::init_relations); + $relations = array(); + foreach (preg_split("/\015\012|\015|\012/", $rel, -1, PREG_SPLIT_NO_EMPTY) as $s) { + $verbs = preg_split("/\s*,\s*/", $s, 2); + if (count($verbs) == 1) + $relations += array($verbs[0] => $verbs[0]); + else + $relations += array($verbs[0] => $verbs[1], $verbs[1] => $verbs[0]); + } + return $relations; + } + /** * Return membership data. * diff --git a/src/IDF/Views/Issue.php b/src/IDF/Views/Issue.php index e2b121c..91fdc40 100644 --- a/src/IDF/Views/Issue.php +++ b/src/IDF/Views/Issue.php @@ -90,37 +90,37 @@ class IDF_Views_Issue $ctags = $prj->getTagIdsByStatus('closed'); if (count($otags) == 0) $otags[] = 0; if (count($ctags) == 0) $ctags[] = 0; - + // Get the id list of issue in the user watch list (for all projects !) $db =& Pluf::db(); $sql_results = $db->select('SELECT idf_issue_id as id FROM '.Pluf::f('db_table_prefix', '').'idf_issue_pluf_user_assoc WHERE pluf_user_id='.$request->user->id); $issue_ids = array(0); foreach ($sql_results as $id) { $issue_ids[] = $id['id']; - } + } $issue_ids = implode (',', $issue_ids); - + // Count open and close issues $sql = new Pluf_SQL('project=%s AND id IN ('.$issue_ids.') AND status IN ('.implode(', ', $otags).')', array($prj->id)); $nb_open = Pluf::factory('IDF_Issue')->getCount(array('filter'=>$sql->gen())); $sql = new Pluf_SQL('project=%s AND id IN ('.$issue_ids.') AND status IN ('.implode(', ', $ctags).')', array($prj->id)); $nb_closed = Pluf::factory('IDF_Issue')->getCount(array('filter'=>$sql->gen())); - + // Generate a filter for the paginator switch ($match[2]) { case 'closed': $title = sprintf(__('Watch List: Closed Issues for %s'), (string) $prj); $summary = __('This table shows the closed issues in your watch list for %s project.', (string) $prj); - $f_sql = new Pluf_SQL('project=%s AND id IN ('.$issue_ids.') AND status IN ('.implode(', ', $ctags).')', array($prj->id)); - break; + $f_sql = new Pluf_SQL('project=%s AND id IN ('.$issue_ids.') AND status IN ('.implode(', ', $ctags).')', array($prj->id)); + break; case 'open': default: $title = sprintf(__('Watch List: Open Issues for %s'), (string) $prj); $summary = __('This table shows the open issues in your watch list for %s project.', (string) $prj); $f_sql = new Pluf_SQL('project=%s AND id IN ('.$issue_ids.') AND status IN ('.implode(', ', $otags).')', array($prj->id)); - break; + break; } - + // Paginator to paginate the issues $pag = new Pluf_Paginator(new IDF_Issue()); $pag->class = 'recent-issues'; @@ -170,17 +170,17 @@ class IDF_Views_Issue } foreach (IDF_Views::getProjects($request->user) as $project) { $ctags = array_merge($ctags, $project->getTagIdsByStatus('closed')); - } + } if (count($otags) == 0) $otags[] = 0; if (count($ctags) == 0) $ctags[] = 0; - + // Get the id list of issue in the user watch list (for all projects !) $db =& Pluf::db(); $sql_results = $db->select('SELECT idf_issue_id as id FROM '.Pluf::f('db_table_prefix', '').'idf_issue_pluf_user_assoc WHERE pluf_user_id='.$request->user->id); $issue_ids = array(0); foreach ($sql_results as $id) { $issue_ids[] = $id['id']; - } + } $issue_ids = implode (',', $issue_ids); // Count open and close issues @@ -194,16 +194,16 @@ class IDF_Views_Issue case 'closed': $title = sprintf(__('Watch List: Closed Issues')); $summary = __('This table shows the closed issues in your watch list.'); - $f_sql = new Pluf_SQL('id IN ('.$issue_ids.') AND status IN ('.implode(', ', $ctags).')', array()); - break; + $f_sql = new Pluf_SQL('id IN ('.$issue_ids.') AND status IN ('.implode(', ', $ctags).')', array()); + break; case 'open': default: $title = sprintf(__('Watch List: Open Issues')); $summary = __('This table shows the open issues in your watch list.'); $f_sql = new Pluf_SQL('id IN ('.$issue_ids.') AND status IN ('.implode(', ', $otags).')', array()); - break; + break; } - + // Paginator to paginate the issues $pag = new Pluf_Paginator(new IDF_Issue()); $pag->class = 'recent-issues'; @@ -345,6 +345,7 @@ class IDF_Views_Issue 'form' => $form, 'page_title' => $title, 'preview' => $preview, + 'issue' => new IDF_Issue(), ), self::autoCompleteArrays($prj) ); @@ -403,6 +404,8 @@ class IDF_Views_Issue $issue = Pluf_Shortcuts_GetObjectOr404('IDF_Issue', $match[2]); $prj->inOr404($issue); $comments = $issue->get_comments_list(array('order' => 'id ASC')); + $related_issues = $issue->getGroupedRelatedIssues(array('order' => 'other_issue ASC')); + $url = Pluf_HTTP_URL_urlForView('IDF_Views_Issue::view', array($prj->shortname, $issue->id)); $title = Pluf_Template::markSafe(sprintf(__('Issue %d: %s'), $url, $issue->id, $issue->summary)); @@ -453,7 +456,7 @@ class IDF_Views_Issue $next_issue = Pluf::factory('IDF_Issue')->getList(array('filter' => $sql_next->gen(), 'order' => 'id ASC', 'nb' => 1 - )); + )); $previous_issue_id = (isset($previous_issue[0])) ? $previous_issue[0]->id : 0; $next_issue_id = (isset($next_issue[0])) ? $next_issue[0]->id : 0; @@ -470,7 +473,8 @@ class IDF_Views_Issue 'preview' => $preview, 'interested' => $interested->count(), 'previous_issue_id' => $previous_issue_id, - 'next_issue_id' => $next_issue_id + 'next_issue_id' => $next_issue_id, + 'related_issues' => $related_issues, ), $arrays), $request); @@ -643,6 +647,73 @@ class IDF_Views_Issue $request); } + /** + * Renders a JSON string containing completed issue information + * based on the queried / partial string + */ + public $autoCompleteIssueList_precond = array('IDF_Precondition::accessIssues'); + public function autoCompleteIssueList($request, $match) + { + $prj = $request->project; + $issue_id = !empty($match[2]) ? intval($match[2]) : 0; + $query = trim($request->REQUEST['q']); + $limit = !empty($request->REQUEST['limit']) ? intval($request->REQUEST['limit']) : 0; + $limit = max(10, $limit); + + $issues = array(); + + // empty search, return the most recently updated issues + if (empty($query)) { + $sql = new Pluf_SQL('project=%s', array($prj->id)); + $tmp = Pluf::factory('IDF_Issue')->getList(array( + 'filter' => $sql->gen(), + 'order' => 'modif_dtime DESC' + )); + $issues += $tmp->getArrayCopy(); + } + else { + // ID-based search + if (is_numeric($query)) { + $sql = new Pluf_SQL('project=%s AND id LIKE %s', array($prj->id, $query.'%')); + $tmp = Pluf::factory('IDF_Issue')->getList(array( + 'filter' => $sql->gen(), + 'order' => 'id ASC' + )); + $issues += $tmp->getArrayCopy(); + } + + // text-based search + $res = new Pluf_Search_ResultSet( + IDF_Search::mySearch($query, $prj, 'IDF_Issue') + ); + foreach ($res as $issue) + $issues[] = $issue; + } + + // Autocomplete from jQuery UI works with JSON, this old one still + // expects a parsable string; since we'd need to bump jQuery beyond + // 1.2.6 for this to use as well, we're trying to cope with the old format. + // see http://www.learningjquery.com/2010/06/autocomplete-migration-guide + $out = ''; + $ids = array(); + foreach ($issues as $issue) + { + if ($issue->id == $issue_id) + continue; + + if (in_array($issue->id, $ids)) + continue; + + if (--$limit < 0) + break; + + $out .= str_replace('|', '|', $issue->summary) .'|'.$issue->id."\n"; + $ids[] = $issue->id; + } + + return new Pluf_HTTP_Response($out); + } + /** * Star/Unstar an issue. */ @@ -715,6 +786,15 @@ class IDF_Views_Issue } $auto['auto_owner'] = substr($auto['auto_owner'], 0, -2); unset($auto['_auto_owner']); + // Get issue relations + $r = $project->getRelationsFromConfig(); + $auto['auto_relation_types'] = ''; + foreach ($r as $rt) { + $esc = Pluf_esc($rt); + $auto['auto_relation_types'] .= sprintf('{ name: "%s", to: "%s" }, ', + $esc, $esc); + } + $auto['auto_relation_types'] = substr($auto['auto_relation_types'], 0, -2); return $auto; } } diff --git a/src/IDF/Views/Project.php b/src/IDF/Views/Project.php index 70e79a4..11c08f4 100644 --- a/src/IDF/Views/Project.php +++ b/src/IDF/Views/Project.php @@ -38,18 +38,18 @@ class IDF_Views_Project public function logo($request, $match) { $prj = $request->project; - + $logo = $prj->getConf()->getVal('logo'); if (empty($logo)) { $url = Pluf::f('url_media') . '/idf/img/no_logo.png'; return new Pluf_HTTP_Response_Redirect($url); } - + $info = IDF_FileUtil::getMimeType($logo); return new Pluf_HTTP_Response_File(Pluf::f('upload_path') . '/' . $prj->shortname . $logo, $info[0]); } - + /** * Home page of a project. */ @@ -291,12 +291,12 @@ class IDF_Views_Project public function admin($request, $match) { $prj = $request->project; - $title = sprintf(__('%s Project Summary'), (string) $prj); - $extra = array('project' => $prj); + $title = sprintf(__('%s Project Summary'), (string) $prj); + $extra = array('project' => $prj); if ($request->method == 'POST') { $form = new IDF_Form_ProjectConf(array_merge($request->POST, $request->FILES), - $extra); + $extra); if ($form->isValid()) { $form->save(); $request->user->setMessage(__('The project has been updated.')); @@ -305,9 +305,9 @@ class IDF_Views_Project return new Pluf_HTTP_Response_Redirect($url); } } else { - $form = new IDF_Form_ProjectConf($prj->getData(), $extra); + $form = new IDF_Form_ProjectConf($prj->getData(), $extra); } - + $logo = $prj->getConf()->getVal('logo'); return Pluf_Shortcuts_RenderToResponse('idf/admin/summary.html', array( @@ -316,7 +316,7 @@ class IDF_Views_Project 'project' => $prj, 'logo' => $logo, ), - $request); + $request); } /** @@ -344,7 +344,8 @@ class IDF_Views_Project $params = array(); $keys = array('labels_issue_template', 'labels_issue_open', 'labels_issue_closed', - 'labels_issue_predefined', 'labels_issue_one_max'); + 'labels_issue_predefined', 'labels_issue_one_max', + 'issue_relations'); foreach ($keys as $key) { $_val = $conf->getVal($key, false); if ($_val !== false) { diff --git a/src/IDF/conf/urls.php b/src/IDF/conf/urls.php index 7eea70e..55c47fa 100644 --- a/src/IDF/conf/urls.php +++ b/src/IDF/conf/urls.php @@ -173,6 +173,11 @@ $ctl[] = array('regex' => '#^/watchlist/(\w+)$#', 'model' => 'IDF_Views_Issue', 'method' => 'forgeWatchList'); +$ctl[] = array('regex' => '#^/p/([\-\w]+)/issues/autocomplete/(\d*)$#', + 'base' => $base, + 'model' => 'IDF_Views_Issue', + 'method' => 'autoCompleteIssueList'); + // ---------- SCM ---------------------------------------- $ctl[] = array('regex' => '#^/p/([\-\w]+)/source/help/$#', diff --git a/src/IDF/relations.php b/src/IDF/relations.php index dc951e7..38f0ea1 100644 --- a/src/IDF/relations.php +++ b/src/IDF/relations.php @@ -45,6 +45,7 @@ $m['IDF_Scm_Cache_Git'] = array('relate_to' => array('IDF_Project')); $m['IDF_UserData'] = array('relate_to' => array('Pluf_User')); $m['IDF_EmailAddress'] = array('relate_to' => array('Pluf_User')); +$m['IDF_IssueRelation'] = array('relate_to' => array('IDF_Issue', 'Pluf_User')); Pluf_Signal::connect('Pluf_Template_Compiler::construct_template_tags_modifiers', array('IDF_Middleware', 'updateTemplateTagsModifiers')); diff --git a/src/IDF/templates/idf/admin/issue-tracking.html b/src/IDF/templates/idf/admin/issue-tracking.html index 057b3c7..0059130 100644 --- a/src/IDF/templates/idf/admin/issue-tracking.html +++ b/src/IDF/templates/idf/admin/issue-tracking.html @@ -35,8 +35,15 @@ +{$form.f.issue_relations.labelTag}:
+{if $form.f.issue_relations.errors}{$form.f.issue_relations.fieldErrors}{/if} +{$form.f.issue_relations|unsafe}
+{$form.f.issue_relations.help_text} + + + - + diff --git a/src/IDF/templates/idf/issues/create.html b/src/IDF/templates/idf/issues/create.html index c2dba27..9d681a7 100644 --- a/src/IDF/templates/idf/issues/create.html +++ b/src/IDF/templates/idf/issues/create.html @@ -63,6 +63,15 @@ +{$form.f.relation_type0.labelTag}: + +{if $form.f.relation_type0.errors}{$form.f.relation_type0.fieldErrors}{/if} +{if $form.f.relation_issue0.errors}{$form.f.relation_issue0.fieldErrors}{/if} +{$form.f.relation_type0|unsafe} +{$form.f.relation_issue0|unsafe} + + + {$form.f.label1.labelTag}: {if $form.f.label1.errors}{$form.f.label1.fieldErrors}{/if}{$form.f.label1|unsafe} @@ -76,8 +85,8 @@   - - | + + | {trans 'Cancel'} @@ -123,11 +132,11 @@ $(document).ready(function(){ }); var j=0; for (j=1;j<4;j=j+1) { - if($("tr#form-attachment-"+j+" > td > ul.errorlist").length == 0){ - $("#form-attachment-"+j).hide(); - }else{ - $("#form-block-"+(j-1)).remove(); - } + if($("tr#form-attachment-"+j+" > td > ul.errorlist").length == 0){ + $("#form-attachment-"+j).hide(); + }else{ + $("#form-block-"+(j-1)).remove(); + } } }); diff --git a/src/IDF/templates/idf/issues/js-autocomplete.html b/src/IDF/templates/idf/issues/js-autocomplete.html index 589d178..575e1a5 100644 --- a/src/IDF/templates/idf/issues/js-autocomplete.html +++ b/src/IDF/templates/idf/issues/js-autocomplete.html @@ -2,54 +2,88 @@ diff --git a/src/IDF/templates/idf/issues/view.html b/src/IDF/templates/idf/issues/view.html index 050460d..488819c 100644 --- a/src/IDF/templates/idf/issues/view.html +++ b/src/IDF/templates/idf/issues/view.html @@ -40,7 +40,16 @@ {if $i> 0 and $c.changedIssue()}
{foreach $c.changes as $w => $v} -{if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if} {if $w == 'lb'}{assign $l = implode(', ', $v)}{$l}{else}{$v}{/if}
+{if $w == 'su'}{trans 'Summary:'}{/if}{if $w == 'st'}{trans 'Status:'}{/if}{if $w == 'ow'}{trans 'Owner:'}{/if}{if $w == 'lb'}{trans 'Labels:'}{/if}{if $w == 'rel'}{trans 'Relations:'}{/if} +{if $w == 'lb' or $w == 'rel'} + {foreach $v as $t => $ls} + {foreach $ls as $l} + {if $t == 'rem'}{/if}{$l}{if $t == 'rem'}{/if} + {/foreach} + {/foreach} +{else} + {$v} +{/if}
{/foreach}
{/if} @@ -120,6 +129,23 @@ +{$form.f.relation_type0.labelTag}: + +{assign $prevField} +{foreach $form as $field} + {if strpos($field.name, 'relation_type') === 0} + {$field|unsafe} + {assign $prevField = $field} + {/if} + {if strpos($field.name, 'relation_issue') === 0} + {$field|unsafe}
+ {if $prevField.errors}{$prevField.fieldErrors}{/if} + {if $field.errors}{$field.fieldErrors}{/if} + {/if} +{/foreach} + + + {$form.f.label1.labelTag}: {if $form.f.label1.errors}{$form.f.label1.fieldErrors}{/if}{$form.f.label1|unsafe} @@ -161,6 +187,21 @@ {$tag.class}:{$tag.name}
{/foreach}

{/if} +{if count($related_issues) > 0} +{foreach $related_issues as $verb => $rel_issues} +

+{blocktrans}This issue {$verb}{/blocktrans}
+ {foreach $rel_issues as $rel_issue} + + + {$rel_issue.other_issue} - {$rel_issue.other_summary|shorten:30} + +
+ {/foreach} +

+{/foreach} +{/if} {/block} {block javascript}{if $form}{include 'idf/issues/js-autocomplete.html'}