| <?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__ ) . '/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' |
| ), |
| '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 " "; |
| 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." ) |
| ->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(date_sub(now(), interval :ago month),'%Y%m'), |
| date_format(now(),'%Y%m'), |
| greatest(min(period), date_format(date_sub(now(), interval :ago month),'%Y%m'))) |
| from ProjectCommitActivity |
| where project=':id') |
| and date_format(now(),'%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 ( ':ago', 48 ) |
| ->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' |
| ") |
| ->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 () ) |
| ->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; |
| } |
| |
| public function getLivelinessIcon($liveliness) { |
| global $images; |
| switch ($liveliness) { |
| case PROJECT_LIVELINESS_NEVER_ACTIVE : |
| return $images->tools_small; |
| case PROJECT_LIVELINESS_ACTIVE : |
| return $images->active_small; |
| case PROJECT_LIVELINESS_STALE : |
| return $images->stale_small; |
| case PROJECT_LIVELINESS_INACTIVE : |
| return $images->inactive_small; |
| case PROJECT_LIVELINESS_DEAD : |
| return $images->dead_small; |
| } |
| } |
| } |
| 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 ); |
| } else { |
| $value = ProjectStatusReporter::valueOrDashes ( $value ); |
| } |
| return $value; |
| } |
| } |