Trac 是我目前正在用的 issue tracking system,由於整合了 wiki 與 subversion,故幾乎可以拿來當作 project management system 來用了。不過,目前的 Trac 即使是進展到了 1.0 版,也仍然將是 single project 的系統。然而,實際工作上,多個 project 一併進行,設置互有牽扯的情況非常普遍。因此,我寫了一個簡單的 PHP 網頁,剛好可以簡單地當作 multi-project trac portal 來用。

使用前,請先依照 TracMultiProjects 的說明,設定好 multi-project 的環境。然後,修改這個 PHP 網頁最前面的 define()TRAC_DIR 是指你放那堆 trac environment 的目錄,而 TRAC_USR 則是你登入 trac 用的帳號。最後,把這個網頁放在你覺得最適合的地方即可。

<?php

define('TRAC_DIR', '/trac');
define('TRAC_USR', 'jeffhung');

$tracagg_errors = array();
function tracagg_raise_error($errmsg)
{
    array_push($tracagg_errors, $errmsg);
}

function wrap_html_text($text)
{
    return (($text == '') ? '&nbsp;' : htmlspecialchars($text));
}

function ticket_priority_cmp($lhs, $rhs)
{
    static $TRAC_PRIORITY_LIST = array(
        'lowest',
        'low',
        'normal',
        'high',
        'highest'
    );
    $lhs_code = array_search($lhs['priority'], $TRAC_PRIORITY_LIST);
    $rhs_code = array_search($rhs['priority'], $TRAC_PRIORITY_LIST);
    if ($lhs_code == $rhs_code) {
        return 0;
    }
    return ($lhs_code > $rhs_code) ? -1 : 1;
}

function ticket_severity_cmp($lhs, $rhs)
{
    static $TRAC_SEVERITY_LIST = array(
        'enhancement',
        'trivial',
        'minor',
        'normal',
        'major',
        'critical',
        'blocker'
    );
    $lhs_code = array_search($lhs['severity'], $TRAC_SEVERITY_LIST);
    $rhs_code = array_search($rhs['severity'], $TRAC_SEVERITY_LIST);
    if ($lhs_code == $rhs_code) {
        return 0;
    }
    return ($lhs_code > $rhs_code) ? -1 : 1;
}

function ticket_project_cmp($lhs, $rhs)
{
    $lhs_code = $lhs['__project__'];
    $rhs_code = $rhs['__project__'];
    return strcasecmp($lhs_code, $rhs_code);
}

function ticket_milestone_cmp($lhs, $rhs)
{
    $lhs_code = $lhs['milestone'];
    $rhs_code = $rhs['milestone'];
    return strcasecmp($lhs_code, $rhs_code);
}

function ticket_changetime_cmp($lhs, $rhs)
{
    $lhs_code = $lhs['_changetime'];
    $rhs_code = $rhs['_changetime'];
    return ($lhs_code < $rhs_code);
}

function composite_cmp_(&$block, $func_specs)
{
    if (count($func_specs)) {
        $block .= '$r = ';
        $block .= $func_specs[0]['func'];
        if ($func_specs[0]['rev']) {
            $block .= '($rhs, $lhs);';
        }
        else {
            $block .= '($lhs, $rhs);';
        }
        $block .= 'if ($r == 0) {';
        array_shift($func_specs);
        composite_cmp_($block, $func_specs);
        $block .= '}';
    }
}

function create_composite_cmp($func_specs)
{
    $func_block = '';
    composite_cmp_($func_block, $func_specs);
    $func_block .= 'return $r;';
    return create_function('$lhs, $rhs', $func_block);
}

function GetTracTickets($trac_dir, $project, $user)
{
    $tickets = false;
    $env = sprintf('%s/%s', $trac_dir, $project);

    $db = sqlite_open(
        sprintf('%s/db/trac.db', $env),
        0666,
        $db_errmsg
    );
    if ($db === false) {
        tracagg_raise_error($db_errmsg);
    }
    else {
        $sql = sprintf(
            "SELECT ".
            "    p.value AS __color__, ".
            "    ( ".
            "        CASE owner WHEN '$user' THEN ".
            "            ( milestone || ' (mine)' ) ".
            "        ELSE ".
            "            ( milestone || ' (others)' ) ".
            "        END ".
            "    ) AS __group__, ".
            "    ( ".
            "        CASE status WHEN 'closed' THEN ".
            "            'color: #777; background: #ddd; border-color: #ccc;' ".
            "        ELSE  ".
            "            ( ".
            "                CASE owner WHEN '$user' THEN ".
            "                    'font-weight: normal' ".
            "                END ".
            "            ) ".
            "        END ".
            "    ) AS __style__, ".
            "    t.id AS ticket, ".
            "    t.summary AS summary, ".
            "    t.component AS component, ".
            "    t.version AS version, ".
            "    t.milestone AS milestone, ".
            "    t.severity AS severity,  ".
            "    t.priority AS priority, ".
            "    ( ".
            "        CASE t.status WHEN 'assigned' THEN t.owner || ' *' ".
            "        ELSE t.owner ".
            "        END ".
            "    ) AS owner, ".
            "    t.time AS created, ".
            "    t.changetime AS _changetime, ".
            "    t.description AS _description, ".
            "    t.reporter AS _reporter ".
            "FROM ".
            "    ticket t, ".
            "    enum p ".
            "WHERE ".
            "    p.name = t.priority AND ".
            "    p.type = 'priority' AND ".
            "    status IN ('new', 'assigned', 'reopened') AND ".
            "    1 "
//          "ORDER BY ".
//          "    (t.milestone = ''), ".
//          "    t.milestone, ".
//          "    (t.status = 'closed'), ".
//          "    (t.owner = '$user') DESC, ".
//          "    p.value, ".
//          "    t.severity, ".
//          "    t.time "
        );
        $res = sqlite_query($db, $sql);
        if ($res === false) {
            tracagg_raise_error("Failed to do SQL query: $sql");
        }
        else {
            $tickets = array();
            while ($row = sqlite_fetch_array($res, SQLITE_ASSOC)) {
                $row['__project__'] = $project;
                array_push($tickets, $row);
            }
        }

        sqlite_close($db);
    }

    return $tickets;
}

$TotalProjectList = array();
if (is_dir(TRAC_DIR)) {
    if ($dh = opendir(TRAC_DIR)) {
        while (($entry = readdir($dh)) != false) {
            if (substr($entry, 0, 1) != '.') {
                if (is_dir(TRAC_DIR . "/$entry")) {
                    array_push($TotalProjectList, $entry);
                }
            }
        }
        closedir($dh);
    }
}
sort($TotalProjectList);

$TicketAggregation = array();
foreach ($TotalProjectList as $prj) {
    if (in_array($prj, array_keys($_REQUEST))) {
        $tickets = GetTracTickets(TRAC_DIR, $prj, TRAC_USR);
        $TicketAggregation = array_merge($TicketAggregation, $tickets);
    }
}

usort($TicketAggregation, create_composite_cmp(array(
    array('func' => 'ticket_changetime_cmp', 'rev' => false ),
    array('func' => 'ticket_priority_cmp',   'rev' => false ),
    array('func' => 'ticket_severity_cmp',   'rev' => false ),
    array('func' => 'ticket_project_cmp',    'rev' => false ),
    array('func' => 'ticket_milestone_cmp',  'rev' => false ),
)));

?><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<title>TracAggregator</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="Author" content="">
<meta name="Keywords" content="">
<meta name="Description" content="">
<link rel="stylesheet" href="trac-agg.css" />
</head>

<body>

<?php //print_r($_REQUEST); ?>

<div id="project_list">
    <form name="PrjListForm" action="<?php
        echo htmlspecialchars($_SERVER['PHP_SELF']);
    ?>" method="GET">
        <fieldset>
            <legend>Select Projects</legend>
            <?php
            foreach ($TotalProjectList as $prj) {
                printf("<span>");
                printf("<input type=\"checkbox\" id=\"%s\" name=\"%s\"".
                       " onChange=\"document.PrjListForm.submit();\" %s/>",
                       htmlspecialchars($prj),
                       htmlspecialchars($prj),
                       (in_array($prj, array_keys($_REQUEST)) ? 'checked' : '')
                );
                printf(" <a href=\"/projects/%s\" target=\"_blank\">%s</a>",
                       htmlspecialchars($prj),
                       htmlspecialchars($prj)
                );
                printf(" [<a href=\"/projects/%s/newticket\" target=\"_blank\">+</a>]",
                       htmlspecialchars($prj)
                );
                printf("</span>\n");
            }
            ?>
        </fieldset>
    </form>
</div>

Total <?php echo count($TicketAggregation); ?> ticket(s):
<table id="ticket_list" width="100%">
<thead>
<tr>
    <th>project</th>
    <th>ticket</th>
    <th>summary</th>
    <th>component</th>
    <th>version</th>
    <th>milestone</th>
    <th>severity</th>
    <th>priority</th>
    <th>owner</th>
    <th>created</th>
    <th>changed</th>
</tr>
</thead>
<tbody>
<?php

foreach ($TicketAggregation as $ta) {
    printf("<tr style=\"%s\">\n", htmlspecialchars($ta['__style__']));
    printf("<td>%s</td>\n", wrap_html_text($ta['__project__']));
    printf("<td>%s</td>\n", wrap_html_text('#' . $ta['ticket']));
    printf(
        "<td><a href=\"/projects/%s/ticket/%d\" target=\"_blank\">%s</a></td>\n",
        $ta['__project__'],
        $ta['ticket'],
        wrap_html_text($ta['summary'])
    );
    printf("<td>%s</td>\n", wrap_html_text($ta['component']));
    printf("<td>%s</td>\n", wrap_html_text($ta['version']));
    printf("<td>%s</td>\n", wrap_html_text($ta['milestone']));
    printf("<td>%s</td>\n", wrap_html_text($ta['severity']));
    printf("<td>%s</td>\n", wrap_html_text($ta['priority']));
    printf("<td>%s</td>\n", wrap_html_text($ta['owner']));
    printf("<td>%s</td>\n", wrap_html_text(strftime('%Y-%m-%d', $ta['created'])));
    printf("<td>%s</td>\n", wrap_html_text(strftime('%Y-%m-%d %H:%M:%S', $ta['_changetime'])));
    printf("</tr>\n");
}

?>
</tbody>
</table>

</body>
</html>

程式還會再繼續修改,本文將隨時更新。


Screenshot: