blob: 7757ec5aab5aa0fbf59ce4c088b84c23833406f2 [file] [log] [blame]
<?php
/**
* Copyright (c) Eclipse Foundation and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
require_once dirname ( __FILE__ ) . '/Project.class.php';
require_once dirname ( __FILE__ ) . '/License.class.inc';
require_once dirname ( __FILE__ ) . '/common.php';
require_once dirname ( __FILE__ ) . '/debug.php';
/**
* The ProjectStatusReporter class provides behaviour to load
* information about some subset of the projects, and then
* extract and provide that information in various ways.
* Several "alerts" and "columns" are defined, each of which knows how
* to extract and present some piece of the data.
*
* An instance is loaded with various "collectors", "alerts" and "values".
*
* Collectors are a means to load an instance with data; the primary
* intent being to preload and cache data rather than compute/load (and
* potentially recompute/load) as it is needed.
*
* Alerts are, essentially, a message and a callback that determines
* whether or not to display the message. Alters may be used, for
* example, to identify whether or not the project uses and old version
* of a license and so must be updated. The callback associated with
* an alert is a callable that takes an instance of Project as a parameter.
*
* Values provide a means of extracting values to display. A function
* may be provided to compute the value. In the absense of a function,
* the array key is used as an index into the values cached for the
* project.
*/
class ProjectStatusReporter {
var $projects;
var $alerts;
var $values;
var $charts;
function __construct($ids) {
$this->collectors = array(
);
$this->alerts = array(
'since' => array(
'message' => 'The project has not been provisioned',
'when' => function($project) {
return $project->getProvisionedDate() == null;
}
),
'provisioned' => array(
'message' => 'The project was provisioned in the last three months',
'when' => function($project) {
if (!$project->getProvisionedDate()) return false;
return $project->getProvisionedDate() > strtotime('-3 months');
}
),
'epl-1.0' => array(
'message' => 'The project uses the EPL-1.0',
'when' => function ($project) {
$licenses = License::getLicensesForProject($project->getId());
foreach ($licenses as $license) {
if ($license->getId() == 'EPL-1.0') return true;
}
return false;
}
),
'dormant' => array(
'message' => 'The project appears to be dormant',
'when' => function ($project) {
$latest = $this->getProjectData($project, 'commits_latest');
return strtotime($latest) < strtotime('-1 year');
}
),
'no_initial_cq' => array(
'message' => 'The project has no initial contribution',
'when' => function ($project) {
return empty($this->getProjectData($project, 'initial_cq'));
}
),
'no_project_lead' => array(
'message' => 'The project has no project leads',
'when' => function ($project) {
return !$project->hasProjectLeads();
}
),
'no_committers' => array(
'message' => 'The project has no committers',
'when' => function ($project) {
return !$project->hasCommitters();
}
)
);
/*
* Note that some of the "values" have a "sort" entry. This is from a
* previous use of the ProjectStatusReporter that rendered values in
* a table with the ability to sort the table on any column that provides
* a "sort" entry. The basic idea being that the function answers the
* sort value of a particular column from a particular project.
* The values are currently ignored; I decided to leave them in for
* now as I may opt to do something similar again.
*/
$this->values = array (
'name' => array (
'label' => 'Project Name'
),
'license' => array (
'label' => 'License',
'function' => function($value, $key, $project) {
$licenses = License::getLicensesForProject($project['id']);
$spdx = License::getSPDXExpression($licenses);
return $spdx;
}
),
'specification' => array (
'label' => 'Specification Project',
'function' => function ($value, $key, $project) {
return $project['project']->getSpecificationWorkingGroupName();
}
),
'url' => array (
'label' => 'PMI Page',
'function' => function ($value, $key, $project) {
return "<a href=\"$value\"><img src=\"/projects/images/external.gif\"/></a>";
}
),
'id' => array (
'label' => 'Project Id'
),
'top' => array (
'label' => 'Top-Level'
),
'phase' => array (
'label' => 'Phase'
),
'committers' => array (
'label' => 'Active Committers Count'
),
'organizations' => array (
'label' => 'Active Orgs Count'
),
'cqs' => array(
'label' => 'CQs',
'function' => function ($value, $key, $project) {
if (isset ( $project['id']) ) {
$link = "https://dev.eclipse.org/ipzilla/buglist.cgi?component={$project['id']}";
return "<a href=\"{$link}\"><img src=\"/projects/images/external.gif\"/></a>";
}
return "--";
}
),
'initial_cq' => array (
'label' => 'Initial Contribution',
'function' => function ($value, $key, $project) {
if (! isset ( $value ))
return "&nbsp;";
return "<a href=\"$value\"><img src=\"/projects/images/external.gif\"/></a>";
}
),
'initial_cq_created' => array (
'label' => 'Initial Contribution Created'
),
'initial_cq_pmc' => array (
'label' => 'Initial Contribution PMC Approved'
),
'initial_cq_checkin' => array (
'label' => 'Initial Contribution Check-in'
),
'initial_cq_approved' => array (
'label' => 'Initial Contribution Approved'
),
'initial_cq_latency' => array (
'label' => 'Time Since IC Change',
'function' => function ($value, $key, $project) {
if (@$project ['initial_cq_approved'])
return '--';
if ($checkin = @$project ['initial_cq_checkin'])
return self::getTimeSince ( $checkin );
if ($created = @$project ['initial_cq_created'])
return self::getTimeSince ( $created );
return '--';
},
'sort' => function ($value, $key, $project) {
if (@$project ['initial_cq_approved'])
return 0;
if ($checkin = @$project ['initial_cq_checkin'])
return ( int ) (time () - strtotime ( $checkin ));
if ($created = @$project ['initial_cq_created'])
return ( int ) (time () - strtotime ( $created ));
return 0;
},
'class' => 'column-align-right'
),
'commits_initial' => array (
'label' => 'Initial Commit'
),
'commits_latest' => array (
'label' => 'Latest Commit'
),
'commits' => array (
'label' => 'Commits Count',
'function' => function ($value, $key, $project) {
if ($value !== null)
return number_format ( $value );
return '--';
},
'sort' => function ($value, $key, $project) {
return ( int ) $value;
},
'class' => 'column-align-right'
),
'commits_latency' => array (
'label' => 'Time Since Last Commit',
'function' => function ($value, $key, $project) {
if ($latest = @$project ['commits_latest'])
return self::getTimeSince ( $latest );
return '--';
},
'sort' => function ($value, $key, $project) {
if ($latest = @$project ['commits_latest'])
return ( int ) (time () - strtotime ( $latest ));
return 0;
},
'class' => 'column-align-right'
),
'downloads_first' => array (
'label' => 'Oldest Download'
)
);
$this->charts = array(
'orgByYear' => function (Project $project) {
return ChartBuilder::named('org_yearly' . rand())
->title("Member Company Contributions :year")
->description("Contributions (commits) made to this project and its subprojects
by member companies in :year.")
->query('dashboard', "
select name, sum(commits) as commits
from ProjectRollup as p
join OrganizationProjectYearlyContributionSummary as s
on p.subproject=s.project
where p.project=':id' and s.year=:year
group by name
order by sum(commits) desc")
->column('Organization', 'name', 'string')
->column('Commits', 'commits', 'number')
->substitute ( ':id', $project->getId())
->substitute ( ':year', date('Y', strtotime('-1 year')))
->pieChart()
->height ( 300 )
->width ( 640 );
},
'project_activity' => function(Project $project) {
return ChartBuilder::named ( "project_activity_" . rand () )
->title ( "Commit Activity" )
->description ( "Overall committer activity by month based on commits made
against project repositories from :start to :end." )
->query ( 'dashboard', "
select
periods.period as period,
if (commits.count is null, 0, commits.count) as count
from (
select distinct period
from ProjectCommitActivity
where
period
between (
select
if(max(period) < date_format(':start','%Y%m'),
date_format(':end','%Y%m'),
greatest(min(period), date_format(':start','%Y%m')))
from ProjectCommitActivity
where project=':id')
and date_format(':end','%Y%m')) as periods
left join
ProjectCommitActivity as commits
on (periods.period = commits.period and commits.project=':id')
order by period
" )
->substitute ( ':id', $project->getId () )
->substitute ( ':start', (new DateTime())->modify('-4 Years')->format('Y-m-d') )
->substitute ( ':end', (new DateTime())->modify('-1 Months')->format('Y-m-d') )
->column ( 'Month', 'period', 'string', function ($value) {
return asYearMonth ( $value );
} )
->column ( 'Commits', 'count', 'number' )
->columnChart ()
->height ( 300 )
->width ( 640 )
->option ( 'legend', 'none' );
},
'project_contributors' => function(Project $project) {
return ChartBuilder::named('project_contributors_' . rand())
->title("Project Contributors by Quarter")
->description(
"Quarterly statistics on contribution (distinct contributors).")
->query('dashboard', "
select
quarter,
contributors,
committers,
companies
from ProjectQuarterlyContributionSummary
where project=':id'
and quarter
between concat(year(':start'),'Q', quarter(':start'))
and concat(year(':end'),'Q', quarter(':end'))
")
->column('Quarter', 'quarter', 'string', function($value) {return asYearMonth($value);})
->column('Contributors', 'contributors', 'number')
->column('Committers', 'committers', 'number')
->column('Companies', 'companies', 'number')
->substitute ( ':id', $project->getId () )
->substitute ( ':start', '2000-01-01')
->substitute ( ':end', (new DateTime())->modify('-3 Months')->format('Y-m-d'))
->height ( 300 )
->width ( 640 );
}
);
// FIXME We can't add collectors to the instance
$this->projects = $this->loadProjects ( $ids );
}
function values($id, $function) {
if ($project = $this->projects[$id]) {
foreach ( $this->values as $id => $config ) {
call_user_func($function, new ProjectStatusReporterColumn ( $id, $config, $project ) );
}
}
}
function charts(Project $project, $function) {
foreach ( $this->charts as $callable ) {
$builder = call_user_func($callable, $project);
$builder->titleString("<h4>:title</h4>");
call_user_func($function, $builder);
}
}
/**
* Generate a list of alerts for a particular project.
*
* @param Project $project
* @return string[]
*/
function alerts(Project $project) {
$alerts = array();
foreach ( $this->alerts as $config ) {
if (call_user_func ($config['when'], $project)) {
$alerts[] = $config['message'];
}
}
return $alerts;
}
public function getColumnDefinition($key) {
return $this->values [$key];
}
public static function getTimeSince($date) {
if (! $date)
return 0;
$milliseconds = date ( time () - strtotime ( $date ) );
$days = number_format ( ceil ( $milliseconds / (60 * 60 * 24) ) );
return "$days days";
}
public function filter($info) {
foreach ( $_GET as $key => $value ) {
if (isset ( $info [$key] )) {
if ($info [$key] != $value)
return true;
}
}
return false;
}
/**
* This function answers the value (if any) with a named
* index associated with a particular instance of Project.
* The values that are available are dependent on the data
* that is loaded into the instance.
*
* @param Project $project
* @param string $key
* @return mixed|NULL
*/
private function getProjectData(Project $project, $key) {
return @$this->projects[$project->getId()][$key];
}
private function loadProjects($ids) {
$projects = array ();
foreach ( Project::getAllProjects ( $ids ) as $project ) {
$id = $project->getId();
$projects[$id] = array (
'project' => $project,
'id' => $id,
'name' => $project->getName (),
'top' => $project->getTopLevelProject ()->getId (),
'topName' => preg_replace ( '/ Root$/', '', $project->getTopLevelProject ()->getName () ),
'phase' => $project->isInIncubationPhase () ? 'incubating' : 'regular',
'url' => $project->getUrl (),
'liveliness' => $project->getLiveliness ()
);
}
$this->addDashData ( $projects );
$this->addInitialContributionStats ( $projects );
// FIXME Experimental: call_user_func does pass-by-value.
// Need to sort out pass-by-reference.
foreach($this->collectors as $collector) {
call_user_func($collector, $projects);
}
return $projects;
}
/**
* Add stats that are tracked by Dash.
* This generally refers to values
* that are calculated in batch and cached on the dashboard database.
*
* @param mixed $projects
*/
private function addDashData(&$projects) {
$ids = $this->getProjectIds ( $projects );
$sql = "
select distinct
p.subproject as id,
date(ps.initialCommit) as commits_initial,
date(ps.latestCommit) as commits_latest,
ps.totalCommits as commits,
count(distinct pca.company) as organizations,
count(distinct pda.login) as committers,
min(pd.first) as downloads_first
from ProjectRollup as p
left join ProjectCompanyActivity as pca on p.subproject=pca.project
left join ProjectCommitterActivity as pda on p.subproject=pda.project
left join ProjectStats as ps on p.subproject=ps.project
left join ProjectInfoDownloads as pd on p.subproject=pd.project
where p.subproject in ($ids)
group by p.subproject";
query ( 'dashboard', $sql, array(), function ($row) use (&$projects) {
$id = $row ['id'];
if (isset ( $projects [$id] )) {
$projects [$id] += $row;
}
} );
}
/**
* This function answers a string containing the quoted ids for
* the projects.
*
* @param mixed $projects
* @return string
*/
private function getProjectIds(&$projects) {
$ids = array ();
foreach ( $projects as $project ) {
$ids [] = "'{$project['id']}'";
}
return implode ( ',', $ids );
}
/**
* Add information about the initial contribution to the
* project data.
* Note that data is only provided for projects
* that are already represented in the array.
*
* @param mixed $projects
*/
private function addInitialContributionStats(&$projects) {
$ids = $this->getProjectIds ( $projects );
$sql = "select
f.name as id,
b.bug_id as cq,
b.short_desc as title,
date(b.creation_ts) as created,
max(date(pmc.bug_when)) as pmc,
max(date(checkin.bug_when)) as checkin,
max(date(approved.bug_when)) as approved
from bugs as b
join (select
c.name,
min(bb.bug_id) as cq
from bugs as bb
join products as p on bb.product_id = p.id
join components as c on bb.component_id = c.id
join keywords as k on bb.bug_id=k.bug_id
join keyworddefs as kd on k.keywordid=kd.id and kd.name in ('projectcode')
where c.name in ($ids)
and c.name not in ('foundation-internal', 'IP_Discussion', 'IP', 'dsdp')
and bug_severity not in ('withdrawn')
and resolution not in ('INVALID')
group by c.id) as f on b.bug_id=f.cq
left join bugs_activity as pmc on b.bug_id=pmc.bug_id and pmc.added in ('PMC_Approved+')
left join bugs_activity as checkin on b.bug_id=checkin.bug_id and checkin.added in ('checkin', 'checkintocvs')
left join bugs_activity as approved on b.bug_id=approved.bug_id and approved.added in ('approved', 'approved_all_projects', 'FIXED', 'WORKSFORME')
group by f.name
order by f.name";
query ( 'ipzilla', $sql, array(), function ($row) use (&$projects) {
$id = $row ['id'];
if (isset ( $projects [$id] )) {
$cq = $row ['cq'];
$projects [$id] ['initial_cq'] = "https://dev.eclipse.org/ipzilla/show_bug.cgi?id=$cq";
$projects [$id] ['initial_cq_created'] = $row ['created'];
$projects [$id] ['initial_cq_pmc'] = $row ['pmc'];
$projects [$id] ['initial_cq_checkin'] = $row ['checkin'];
$projects [$id] ['initial_cq_approved'] = $row ['approved'];
}
} );
}
public static function valueOrDashes($value) {
if (! $value)
return '--';
return $value;
}
}
class ProjectStatusReporterColumn {
var $id, $config, $data;
public function __construct($id, $config, $data) {
$this->id = $id;
$this->config = $config;
$this->data = $data;
}
public function getTitle() {
return $this->config ['label'];
}
public function getValue() {
$value = $this->data [$this->id];
if (isset ( $this->config ['function'] )) {
$function = $this->config ['function'];
$value = call_user_func($function, $value, $this->id, $this->data );
}
return ProjectStatusReporter::valueOrDashes ( $value );
}
}