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 @@
+{blocktrans}This issue {$verb}{/blocktrans}
+ {foreach $rel_issues as $rel_issue}
+
+
+ {$rel_issue.other_issue} - {$rel_issue.other_summary|shorten:30}
+
+
+ {/foreach}
+