diff --git a/project.test b/project.test index d0027c8..1596279 100644 --- a/project.test +++ b/project.test @@ -7,10 +7,8 @@ class ProjectWebTestCase extends DrupalWebTestCase { function setUp() { // Setup the required modules for all tests. $modules = func_get_args(); - $modules = array_merge(array('project'), $modules); - // We can't call parent::setUp() with a single array argument, so we need - // this ugly call_user_func_array(). - call_user_func_array(array('ProjectWebTestCase', 'parent::setUp'), $modules); + $modules = array_merge(array('views', 'project'), $modules); + parent::setUp($modules); $perms = array('create full projects', 'access user profiles', 'access projects'); @@ -87,8 +85,11 @@ class ProjectWebTestCase extends DrupalWebTestCase { * * @param $edit array * An array of form values to be passed to DrupalWebTestCase::drupalPost(). + * @return + * A node object. + * @see DrupalWebTestCase::drupalGetNodeByTitle() */ - function createProject($edit = array()) { + protected function createProject($edit = array()) { $edit += array( 'title' => $this->randomName(), 'project[uri]' => $this->randomName(8), @@ -106,6 +107,43 @@ class ProjectWebTestCase extends DrupalWebTestCase { return $this->drupalGetNodeByTitle($edit['title']); } + + /** + * Helper function for creating a new project release. + * + * @param $project + * Project node object to create release for. + * @param $edit array + * An array of form values to be passed to DrupalWebTestCase::drupalPost(). + * @return + * A node object. + * @see DrupalWebTestCase::drupalGetNodeByTitle() + */ + protected function createRelease($project, $edit = array()) { + $edit += array( + 'project_release[version_major]' => 1, + 'project_release[version_minor]' => '', + 'project_release[version_patch]' => 'x', + 'project_release[version_extra]' => 'dev', + 'body' => $this->randomString(128), + ); + $this->drupalGet('node/add/project-release/' . $project->nid); + if (!$this->xpath('//input[@name="project_release[version_minor]"]')) { + unset($edit['project_release[version_minor]']); + } + $this->drupalPost('node/add/project-release/' . $project->nid, $edit, t('Save')); + + $version = new stdClass(); + foreach (array('major', 'minor', 'patch', 'extra') as $field) { + $field = 'version_' . $field; + $version->$field = isset($edit['project_release[' . $field . ']']) ? $edit['project_release[' . $field . ']'] : NULL; + } + $version = project_release_get_version($version, $project); + $title = $project->project['uri'] . ' ' . $version; + $this->assertRaw(t('!post %title has been created.', array('!post' => 'Project release', '%title' => $title)), t('Project release created.')); + + return $this->drupalGetNodeByTitle($title); + } } @@ -357,12 +395,11 @@ class ProjectDrupalOrgWebTestCase extends ProjectWebTestCase { 'project_type' => $core ? 1 : 2, 'title' => ucfirst($project), 'project[uri]' => $project, - 'body' => $this->randomString(64), ); $this->projects[$project] = $this->createProject($edit); } - foreach ($this->projects as $project) { + foreach ($this->projects as $name => $project) { $core = ($project->title == 'Drupal') ? TRUE : FALSE; // Drupal core requires a special version format string. if ($core) { @@ -376,13 +413,8 @@ class ProjectDrupalOrgWebTestCase extends ProjectWebTestCase { $edit = array( 'taxonomy[3][]' => 8, // 7.x. 'project_release[version_major]' => $core ? '7' : '1', - 'project_release[version_patch]' => 'x', - 'project_release[version_extra]' => 'dev', - 'body' => $this->randomString(64), ); - $this->drupalPost('node/add/project-release/' . $project->nid, $edit, t('Save')); - - $this->releases[$project->title] = $this->drupalGetNodeByTitle($project->title . ' 7.x' . ($core ? '' : ' -1.x') . '-dev'); + $this->releases[$name] = $this->createRelease($project, $edit); } } } diff --git a/release/package-release-nodes.php b/release/package-release-nodes.php index 875366d..ceb2b51 100755 --- a/release/package-release-nodes.php +++ b/release/package-release-nodes.php @@ -151,6 +151,8 @@ function package_releases($type, $project_id = 0) { global $drupal_root, $dest_root, $dest_rel, $tmp_dir, $wd_err_msg; global $php, $project_release_create_history; + module_load_include('drupal.inc', 'project_release'); + if (!empty($project_id)) { if (is_numeric($project_id)) { $project_nid = $project_id; @@ -226,6 +228,10 @@ function package_releases($type, $project_id = 0) { continue; } + // Process the module .info files. + // @todo: Fix me. + //package_release_info_process_all($release_node->nid, $info_files, $project_short_name, $version); + $packager = project_release_get_packager_plugin($release_node, $dest_root, $dest_rel, $tmp_dir); if (empty($packager)) { wd_err("ERROR: Can't find packager plugin to use for %release", array('%release' => $release_node->title)); diff --git a/release/project_release.batch.inc b/release/project_release.batch.inc new file mode 100644 index 0000000..354357a --- /dev/null +++ b/release/project_release.batch.inc @@ -0,0 +1,202 @@ + 'fieldset', + '#title' => t('Filter'), + ); + $form['filter']['api'] = array( + '#type' => 'checkboxes', + '#title' => t('API'), + '#description' => t('Select the API versions that releases must be compatible with.'), + '#options' => array(), + '#default_value' => array(), + ); + $terms = project_release_get_api_taxonomy(); + foreach ($terms as $term) { + $form['filter']['api']['#options'][$term->tid] = $term->name; + // Defaultly select + if (($major = array_shift(explode('.', $term->name, 2))) && $major >= PROJECT_RELEASE_INFO_MINIMUM_API) { + $form['filter']['api']['#default_value'][] = $term->tid; + } + } + $form['submit'] = array('#type' => 'submit', '#value' => t('Submit')); + + return $form; +} + +/** + * Start the batch to process batch releases. + */ +function project_release_info_batch_form_submit($form, &$form_state) { + $tids = array_keys(array_filter($form_state['values']['api'])); + + $batch = array( + 'title' => t('Processing releases'), + 'operations' => array( + array('project_release_info_batch_batch_operation_parse', array($tids)), + array('project_release_info_batch_batch_operation_dependencies', array()), + ), + 'finished' => 'project_release_info_batch_batch_finished', + 'init_message' => t('Determining releases to parse...'), + 'file' => drupal_get_path('module', 'project_release_info') . '/project_release_info.batch.inc', + ); + batch_set($batch); +} + +/** + * Parse the modules contained within a project and their dependencies. + */ +function project_release_info_batch_batch_operation_parse(array $tids, &$context) { + if (!isset($context['sandbox']['max'])) { + // First iteration, initialize working values. + $context['sandbox']['max'] = project_release_info_batch_releases($tids, 0, TRUE); + $context['sandbox']['release_id'] = 0; + $context['sandbox']['progress'] = 0; + $context['results']['dependencies'] = array(); + $context['results']['count'] = 0; + } + + // Load the package include that contains functions to save and parse data. + module_load_include('package.inc', 'project_release'); + + $releases = project_release_info_batch_releases($tids, $context['sandbox']['release_id']); + foreach ($releases as $release) { + if (($dependencies = project_release_info_batch_process_release($release)) === FALSE) { + $context['results']['error'] = $release; + $context['finished'] = 1; + return; + } + $context['results']['dependencies'][$release['nid']] = $dependencies; + $context['sandbox']['release_id'] = $release['nid']; + $context['sandbox']['progress']++; + $context['results']['count']++; + } + + $context['message'] = t('Last parsed %module release %tag (@progress of @max).', array( + '%module' => $release['uri'], + '%tag' => $release['tag'], + '@progress' => number_format($context['sandbox']['progress']), + '@max' => number_format($context['sandbox']['max']), + )); + + $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max']; +} + +/** + * Determine dependencies of releases. + */ +function project_release_info_batch_batch_operation_dependencies(&$context) { + if (!isset($context['sandbox']['max'])) { + $context['sandbox']['max'] = count($context['results']['dependencies']); + $context['sandbox']['progress'] = 0; + } + + // Load the package include that contains functions to save and parse data. + module_load_include('package.inc', 'project_release'); + + $i = 0; + foreach ($context['results']['dependencies'] as $rid => $modules_dependencies) { + $modules = project_release_info_module_list_get($rid); + foreach ($modules_dependencies as $module => $dependencies) { + package_release_info_process_dependencies($rid, $modules[$module], $dependencies); + } + + // Remove already processed releases. + unset($context['results']['dependencies'][$rid]); + + $context['sandbox']['progress']++; + + // Only process 20 releases in a single batch operation. + if (++$i < 20) { + break; + } + } + + $context['message'] = t('Processed @progress of @max release dependencies.', array( + '@progress' => number_format($context['sandbox']['progress']), + '@max' => number_format($context['sandbox']['max']), + )); + + $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max']; +} + +/** + * Display relevant finished message after batch run. + */ +function project_release_info_batch_batch_finished($success, $results, $operations) { + if ($success && !isset($results['error'])) { + drupal_set_message(t('Processed @count release(s).', array( + '@count' => number_format($results['count']), + ))); + } + else { + drupal_set_message(t('Failed to process @module release #@rid.', array( + '@module' => $results['error']['uri'], + '@rid' => $results['error']['nid'], + )), 'error'); + } +} + +/** + * Select all releases that are compatible with the API tids. + * + * @return + * List of release information: rid, project uri, and cvs directory. + */ +function project_release_info_batch_releases(array $tids, $release_id, $count = FALSE) { + $fields = ($count ? 'COUNT(r.nid)' : 'r.nid, r.tag, p.uri, cp.directory'); + $placeholders = db_placeholders($tids, 'int'); + $sql = "SELECT $fields + FROM {project_release_nodes} r + INNER JOIN {node} n ON r.nid = n.nid + INNER JOIN {term_node} t ON n.vid = t.vid + INNER JOIN {project_projects} p ON r.pid = p.nid + INNER JOIN {cvs_projects} cp ON p.nid = cp.nid + WHERE t.tid IN ($placeholders) AND r.nid > %d"; + $args = array_merge($tids, array($release_id)); + + if ($count) { + return db_result(db_query($sql, $args)); + } + + $sql .= ' ORDER BY r.nid'; + $result = db_query_range($sql, $args, 0, 10); + $releases = array(); + while ($release = db_fetch_array($result)) { + $releases[] = $release; + } + return $releases; +} + diff --git a/release/project_release.drupal.inc b/release/project_release.drupal.inc new file mode 100644 index 0000000..ab693f8 --- /dev/null +++ b/release/project_release.drupal.inc @@ -0,0 +1,386 @@ += PROJECT_RELEASE_INFO_MINIMUM_API) { + // Clear previous records for the release. + project_release_info_package_clear($release_nid); + + $info = package_release_info_parse($info_files); + + // Store list of components. + $records = project_release_info_package_list_store($release_nid, $info); + + // Process list of dependencies to determine the release related to each + // dependency. + package_release_info_process($release_nid, $records, $info); + } +} + +/** + * Parse all the .info files. + * + * @param $info_files + * List of component .info files. + * + * @return + * Associative array of component information keyed by component name and + * merged with an array of defaults. + */ +function package_release_info_parse(array $info_files) { + $defaults = array( + 'name' => 'Unknown', + 'description' => '', + 'dependencies' => array(), + 'test_dependencies' => array(), + ); + + $info = array(); + foreach ($info_files as $file) { + $component = basename($file, '.info'); + $info[$component] = drupal_parse_info_file($file) + $defaults; + + // Change info keys to suite project_release_info. + $info[$component]['title'] = $info[$component]['name']; + $info[$component]['name'] = $component; + } + return $info; +} + +/** + * Determine and store the list of dependencies for a release. + * + * @param $release_nid + * The project release NID. + * @param $records + * List of saved component information from + * project_release_info_package_list_store(). + * @param $info + * Associative array of information from component .info files keyed by component + * name and merged with a set of detaults from package_release_info_parse(). + */ +function package_release_info_process($release_nid, array $records, array $info) { + foreach ($info as $component => $component_info) { + if ($component_info['dependencies']) { + package_release_info_process_dependencies($release_nid, $records[$component], PROJECT_RELEASE_DEPENDENCY_REQUIRED, $component_info['dependencies']); + } + if ($component_info['test_dependencies']) { + package_release_info_process_dependencies($release_nid, $records[$component], PROJECT_RELEASE_DEPENDENCY_RECOMMENDED, $component_info['test_dependencies']); + } + } +} + +/** + * Store the list of dependencies for a component of a release. + * + * @param $release_nid + * The project release NID. + * @param $component + * Component information, returned from project_release_info_package_list_store(). + * @param $dependency_type + * Dependency type, PROJECT_RELEASE_DEPENDENCY_*. + * @param $dependencies + * List of dependency component names. + */ +function package_release_info_process_dependencies($release_nid, array $component, $dependency_type, array $dependencies) { + // Load the release and determine is core API version. + $release = node_load($release_nid); + if (!($api_term = package_release_info_core_api($release))) { + wd_err('ERROR: No core release API term found.'); + return; + } + + // Preprocess dependencies looking for duplications, or overrides. Since test + // dependencies were appended to regular dependencies they should override. + $dependency_info = array(); + foreach ($dependencies as $dependency) { + $info = drupal_parse_dependency($dependency, $api_term->name); + if (empty($info['name'])) { + wd_err('ERROR: Invalid dependency found [' . $dependency . '] of [' . $component['name'] . '].'); + return; + } + $dependency_info[$info['name']] = $info; + } + + $dependency_components = array(); + foreach ($dependency_info as $dependency => $info) { + $releases = package_release_info_releases_get($dependency, $api_term->tid); + + // Check for dependency version restriction information. + if (empty($info['versions'])) { + // Cycle through the releases made by the project until a dev branch is + // found of the latest stable series that matches the core API. The + // releases are in descending order from the largest release version to + // the smallest release version. + $best_release = NULL; + foreach ($releases as $release) { + $release_node = node_load($release['release_nid']); + if ($release_node->project_release['rebuild']) { + // Release represents a dev branch, store it. + $best_release = $release; + } + elseif ($best_release) { + // Release represents a stable branch, since a dev branch has already + // been found then stop and use the dev branch as the best branch. + $dependency_components[$dependency] = $best_release['component_id']; + break; + } + } + + // If no releases found, then generate error. + if (empty($dependency_components[$dependency])) { + wd_err('ERROR: No development release with a corresponding stable release was found that ' . + 'matches the requirements for dependency [' . $dependency . '] of [' . $component['name'] . '].'); + return; + } + } + else { + // Cycle through each release from highest version number to lowest and + // select the first dev release that matches the criteria. + foreach ($releases as $release) { + $release_node = node_load($release['release_nid']); + + // Get the release version string without the Drupal API part. + $parts = explode('-', $release_node->project_release['version']); + unset($parts[0]); + $current = implode('-', $parts); + + // Compare the release version string against the requirements. + $valid = TRUE; + foreach ($info['versions'] as $required) { + if ((isset($required_version['op']) && !version_compare($required, $required['version'], $required['op']))) { + $valid = FALSE; + break; + } + } + + // If all the requirements were met then use the release. + if ($valid) { + $dependency_components[$dependency] = $release['component_id']; + } + } + + // If no releases found, then generate error. + if (empty($dependency_components[$dependency])) { + wd_err('ERROR: No release found that matches requirements [' . trim($info['original_version']) . + '] for dependency [' . $dependency . '] of [' . $component['name'] . '].'); + return; + } + } + } + + // Store dependencies for component. + project_release_info_package_dependencies_store($component['component_id'], $dependency_type, $dependency_components); +} + +/** + * Get the releases that contain a component and are compatible with an API. + * + * @param $component + * Component name. + * @param $api_tid + * Core API compatibility tid. + * + * @return + * List of releases with keys: 'component_id', 'release_nid'. + */ +function package_release_info_releases_get($component, $api_tid) { + $result = db_query("SELECT DISTINCT p.component_id, p.release_nid + FROM {project_release_component} p + INNER JOIN {project_release_nodes} r ON p.release_nid = r.nid + INNER JOIN {term_node} t ON p.release_nid = t.nid + WHERE t.tid = %d AND p.name = '%s' + ORDER BY r.version DESC, t.vid DESC + ", $api_tid, $component); + + $releases = array(); + while ($release = db_fetch_array($result)) { + $releases[] = $release; + } + return $releases; +} + +/** + * Attempt to determine the Drupal core API term. + * + * @param $node + * Node object. + * + * @return + * Core API term to which the node belongs, otherwise FALSE. + */ +function package_release_info_core_api($node) { + static $api_terms = array(); + + if (!isset($api_terms[$node->nid])) { + $api_terms[$node->nid] = FALSE; + + if (!empty($node->taxonomy)) { + // Relase API version vocabulary. + $api_vid = _project_release_get_api_vid(); + + foreach ($node->taxonomy as $tid => $term) { + if ($term->vid == $api_vid) { + $api_terms[$node->nid] = $term; + } + } + } + } + return $api_terms[$node->nid]; +} + +/** + * Parse a dependency for comparison by drupal_check_incompatibility(). + * + * @param $dependency + * A dependency string, for example 'foo (>=7.x-4.5-beta5, 3.x)'. + * + * @return + * An associative array with three keys: + * - 'name' includes the name of the thing to depend on (e.g. 'foo'). + * - 'original_version' contains the original version string (which can be + * used in the UI for reporting incompatibilities). + * - 'versions' is a list of associative arrays, each containing the keys + * 'op' and 'version'. 'op' can be one of: '=', '==', '!=', '<>', '<', + * '<=', '>', or '>='. 'version' is one piece like '4.5-beta3'. + * Callers should pass this structure to drupal_check_incompatibility(). + * + * @see drupal_check_incompatibility() + */ +function drupal_parse_dependency($dependency, $core_api) { + // We use named subpatterns and support every op that version_compare + // supports. Also, op is optional and defaults to equals. + $p_op = '(?P!=|==|=|<|<=|>|>=|<>)?'; + // Core version is always optional: 7.x-2.x and 2.x is treated the same. + $p_core = '(?:' . preg_quote($core_api . '.x') . '-)?'; + $p_major = '(?P\d+)'; + // By setting the minor version to x, branches can be matched. + $p_minor = '(?P(?:\d+|x)(?:-[A-Za-z]+\d+)?)'; + $value = array(); + $parts = explode('(', $dependency, 2); + $value['name'] = trim($parts[0]); + if (isset($parts[1])) { + $value['original_version'] = ' (' . $parts[1]; + foreach (explode(',', $parts[1]) as $version) { + if (preg_match("/^\s*$p_op\s*$p_core$p_major\.$p_minor/", $version, $matches)) { + $op = !empty($matches['operation']) ? $matches['operation'] : '='; + if ($matches['minor'] == 'x') { + // Drupal considers "2.x" to mean any version that begins with + // "2" (e.g. 2.0, 2.9 are all "2.x"). PHP's version_compare(), + // on the other hand, treats "x" as a string; so to + // version_compare(), "2.x" is considered less than 2.0. This + // means that >=2.x and <2.x are handled by version_compare() + // as we need, but > and <= are not. + if ($op == '>' || $op == '<=') { + $matches['major']++; + } + // Equivalence can be checked by adding two restrictions. + if ($op == '=' || $op == '==') { + $value['versions'][] = array('op' => '<', 'version' => ($matches['major'] + 1) . '.x'); + $op = '>='; + } + } + $value['versions'][] = array('op' => $op, 'version' => $matches['major'] . '.' . $matches['minor']); + } + } + } + return $value; +} + +/** + * Checkout a release and determine relevant information. + * + * Locate all .info files contained within the project, determine the names of + * the module(s) contained within the project and store the dependencies for + * later processing. + */ +function project_release_info_batch_process_release(array $release) { + $directory_original = getcwd(); + + // Use batch directory. + chdir(PROJECT_RELEASE_INFO_BATCH_DIRECTORY); + + // Clean directory. + exec('rm -r *'); + + // Checkout release. + $release['tag'] = escapeshellcmd($release['tag']); + $release['uri'] = escapeshellcmd($release['uri']); + + if ($release['uri'] == 'drupal') { + $url = ':pserver:anonymous:anonymous@cvs.drupal.org:/cvs/drupal'; + $branch = $release['tag']; + $module = $release['uri']; + $directory = $release['uri']; + } + else { + $url = ':pserver:anonymous:anonymous@cvs.drupal.org:/cvs/drupal-contrib'; + $branch = $release['tag']; + $module = 'contributions/modules/' . $release['uri']; + $directory = $release['uri']; + } + exec("cvs -z6 -Q -d$url checkout -d$directory -r $branch $module", $output, $status); + + // Return to original directory. + chdir($directory_original); + + // Check for CVS checkout failure. + if ($status != 0) { + return FALSE; + } + + // Scan checkout for .info files and create a list in the same format that + // project release uses, so that standard API functions can be used. + $files = file_scan_directory(PROJECT_RELEASE_INFO_BATCH_DIRECTORY . '/' . $directory, '/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.info$/'); + $info_files = array(); + foreach ($files as $file) { + $info_files[] = $file->filename; + } + $info = project_release_info_package_parse($info_files); + + $dependencies = array(); + foreach ($info as $module => $module_info) { + $dependencies[$module] = $module_info['dependencies']; + } + + // Clear previous records for the release. + project_release_info_package_clear($release['nid']); + + // Store the list of modules contained by the project. + project_release_info_package_list_store($release['nid'], $info); + + return $dependencies; +} diff --git a/release/project_release.info b/release/project_release.info index 0ea1e2d..bf8718a 100644 --- a/release/project_release.info +++ b/release/project_release.info @@ -6,3 +6,5 @@ dependencies[] = taxonomy dependencies[] = upload dependencies[] = views core = 6.x + +test_dependencies[] = pift diff --git a/release/project_release.install b/release/project_release.install index a8331d8..af5fe18 100644 --- a/release/project_release.install +++ b/release/project_release.install @@ -392,6 +392,72 @@ function project_release_schema() { 'primary key' => array('nid', 'uid'), ); + $schema['project_release_component'] = array( + 'description' => 'The component(s) contained by a project release.', + 'fields' => array( + 'component_id' => array( + 'description' => 'Unique component ID.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'release_nid' => array( + 'description' => 'The {node}.nid of the project_release node that includes a component.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'name' => array( + 'description' => 'The machine readable name of a component.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title' => array( + 'description' => 'The human readable name of a component', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ), + 'description' => array( + 'description' => 'A description of a component.', + 'type' => 'text', + 'size' => 'medium' + ), + ), + 'primary key' => array('component_id'), + 'indexes' => array( + 'release_nid' => array('release_nid'), + 'name' => array('name'), + ), + ); + + $schema['project_release_dependency'] = array( + 'description' => 'The dependencies of a component.', + 'fields' => array( + 'source_id' => array( + 'description' => 'ID of a component.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'target_id' => array( + 'description' => 'ID of a component that the component is dependent upon.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'dependency_type' => array( + 'description' => 'Dependency type, PROJECT_RELEASE_DEPENDENCY_*.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + ), + 'primary key' => array('source_id', 'target_id'), + ); + return $schema; } @@ -827,3 +893,79 @@ function project_release_update_6012() { db_add_field($ret, 'project_release_file', 'downloads', $spec); return $ret; } + +/** + * Create project dependency info tables. + */ +function project_release_update_6013() { + $ret = array(); + if (!db_table_exists('project_release_component')) { + db_create_table($ret, 'project_release_component', array( + 'description' => 'The component(s) contained by a project release.', + 'fields' => array( + 'component_id' => array( + 'description' => 'Unique component ID.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'release_nid' => array( + 'description' => 'The {node}.nid of the project_release node that includes a component.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'name' => array( + 'description' => 'The machine readable name of a component.', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ), + 'title' => array( + 'description' => 'The human readable name of a component', + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ), + 'description' => array( + 'description' => 'A description of a component.', + 'type' => 'text', + 'size' => 'medium' + ), + ), + 'primary key' => array('component_id'), + 'indexes' => array( + 'release_nid' => array('release_nid'), + 'name' => array('name'), + ), + )); + } + if (!db_table_exists('project_release_dependency')) { + db_create_table($ret, 'project_release_dependency', array( + 'description' => 'The dependencies of a component.', + 'fields' => array( + 'source_id' => array( + 'description' => 'ID of a component.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'target_id' => array( + 'description' => 'ID of a component that the component is dependent upon.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + 'dependency_type' => array( + 'description' => 'Dependency type, PROJECT_RELEASE_DEPENDENCY_*.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ), + ), + 'primary key' => array('source_id', 'target_id'), + )); + } + return $ret; +} diff --git a/release/project_release.module b/release/project_release.module index f94d8af..040fbfb 100644 --- a/release/project_release.module +++ b/release/project_release.module @@ -10,6 +10,12 @@ define('PROJECT_RELEASE_UPDATE_STATUS_CURRENT', 0); define('PROJECT_RELEASE_UPDATE_STATUS_NOT_CURRENT', 1); define('PROJECT_RELEASE_UPDATE_STATUS_NOT_SECURE', 2); +/* + * Constants for the possible values of {project_release_dependency}.dependency_type. + */ +define('PROJECT_RELEASE_DEPENDENCY_REQUIRED', 0); +define('PROJECT_RELEASE_DEPENDENCY_RECOMMENDED', 1); + /** * @defgroup project_release_core Core Drupal hooks */ @@ -29,6 +35,15 @@ function project_release_init() { } /** + * Implementation of hook_perm(). + */ +function project_release_perm() { + return array( + 'parse project_release_info batch', + ); +} + +/** * Implementation of hook_menu() * @ingroup project_release_core */ @@ -65,6 +80,14 @@ function project_release_menu() { 'type' => MENU_NORMAL_ITEM, 'file' => 'includes/admin.settings.inc', ); + $items['admin/project/project_release_info/batch'] = array( + 'title' => 'Parse batch releases', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('project_release_info_batch_form'), + 'access arguments' => array('parse project_release_info batch'), + 'file' => 'project_release.batch.inc', + 'type' => MENU_CALLBACK, + ); // Redirect node/add/project_release/* to node/add/project-release. $items['node/add/project_release'] = array( @@ -2297,3 +2320,125 @@ function project_release_ctools_plugin_directory($module, $plugin) { } } } + +/** + * @defgroup project_release_info Project release information + * @{ + */ + +/** + * Loads a component. + * + * @param $component_id + * Component ID to load. + * + * @return + * Array of component information. + */ +function project_release_component_load($component_id) { + return db_fetch_array(db_query('SELECT * FROM {project_release_component} WHERE component_id = %d', $component_id)); +} + +/** + * Loads the list of components contained by a project release. + * + * @param $release_nid + * Project release ID. + * + * @return + * Associative array of components keyed by component name and containing + * array keys: 'name', 'title', 'description'. + */ +function project_release_get_components($release_nid) { + $result = db_query('SELECT * FROM {project_release_component} WHERE release_nid = %d', $release_nid); + $components = array(); + while ($component = db_fetch_array($result)) { + $components[$component['name']] = $component; + } + return $components; +} + +/** + * Gets a list of dependency components for a component. + * + * @param $component_id + * Component ID to get dependencies for. + * @param $dependency_type + * (Optional) Minimum dependency type, PROJECT_RELEASE_DEPENDENCY_*. All + * dependencies of equal or lesser stringency will be included. + * + * @return + * Associative array of dependencies keyed by component name. + */ +function project_release_component_dependencies_load_all($component_id, $dependency_type = PROJECT_RELEASE_DEPENDENCY_REQUIRED) { + $result = db_query('SELECT c.* + FROM {project_release_dependency} d + JOIN {project_release_component} c + ON c.component_id = d.target_id + WHERE source_id = %d + AND dependency_type <= %d', $component_id, $dependency_type); + $dependencies = array(); + while ($dependency = db_fetch_array($result)) { + $dependencies[$dependency['name']] = $dependency; + } + return $dependencies; +} + +/** + * Recursively determines the list of dependencies of a release. + * + * @param $release_nid + * Project release ID. + * @param $dependency_type + * (Optional) Minimum dependency type, PROJECT_RELEASE_DEPENDENCY_*. All + * dependencies of equal or lesser stringency will be included. + * + * @return + * List of dependencies of the release. + */ +function project_release_get_dependencies($release_nid, $dependency_type = PROJECT_RELEASE_DEPENDENCY_REQUIRED) { + $components = project_release_get_components($release_nid); + return _project_release_get_component_dependencies($components, $dependency_type); +} + +/** + * Recursively determines the list of dependencies for a list of components. + * + * @param array $components + * List of components to determine the dependencies for. + * @param $dependency_type + * (Optional) Minimum dependency type, PROJECT_RELEASE_DEPENDENCY_*. All + * dependencies of equal or lesser stringency will be included. + * @param $dependencies + * (Internal) Array of already determined dependencies. + * + * @return + * Associative array of dependencies keyed by component name and containing + * all component info keys: 'component_id', 'rid', 'name', 'title', and + * 'description'. + */ +function _project_release_get_component_dependencies(array $components, $dependency_type, array $dependencies = array()) { + foreach ($components as $component) { + // Get all dependencies of the component. + $dependencies_new = project_release_component_dependencies_load_all($component['component_id'], $dependency_type); + + // Add the new dependencies to the list of dependencies and remove the + // dependencies that have already been added. + foreach ($dependencies_new as $name => $dependency_new) { + if (!isset($dependencies[$name])) { + $dependencies[$name] = $dependency_new; + } + else { + unset($dependencies_new[$name]); + } + } + + // Process the dependencies of all the new dependency components. + $dependencies = _project_release_get_component_dependencies($dependencies_new, $dependency_type, $dependencies); + } + return $dependencies; +} + +/** + * @} End of "defgroup project_release_info". + */ diff --git a/release/project_release.package.inc b/release/project_release.package.inc new file mode 100644 index 0000000..cdae51a --- /dev/null +++ b/release/project_release.package.inc @@ -0,0 +1,71 @@ + $component_info) { + $info = array( + 'release_nid' => $release_nid, + 'name' => $component_info['name'], + 'title' => $component_info['title'], + 'description' => $component_info['description'], + ); + drupal_write_record('project_release_component', $info); + $records[$component] = $info; + } + return $records; +} + +/** + * Store a list of dependencies for a component. + * + * @param $component_id + * The component ID to which the dependencies relate. + * @param $dependency_type + * Dependency type, PROJECT_RELEASE_DEPENDENCY_*. + * @param array $dependencies + * List of dependencies component IDs. + */ +function project_release_info_package_dependencies_store($component_id, $dependency_type, array $dependencies) { + foreach ($dependencies as $dependency_id) { + // Store the dependency if a best release was found. + $info = array( + 'source_id' => $component_id, + 'target_id' => $dependency_id, + 'dependency_type' => $dependency_type, + ); + drupal_write_record('project_release_dependency', $info); + } +} diff --git a/release/project_release.test b/release/project_release.test new file mode 100644 index 0000000..0c6a79c --- /dev/null +++ b/release/project_release.test @@ -0,0 +1,251 @@ + 'Component API', + 'description' => 'Ensure that the component storage and retrieval API works correctly.', + 'group' => 'Project release', + ); + } + + protected function testComponentAPI() { + module_load_include('package.inc', 'project_release'); + + // Create a project and release. + $project = $this->createProject(); + $release = $this->createRelease($project); + + // Store two components for the release. + $components = array( + 'foo' => array( + 'name' => 'foo', + 'title' => 'Foo', + 'description' => 'Foo is not bar.', + ), + 'bar' => array( + 'name' => 'bar', + 'title' => 'Bar', + 'description' => 'Bar is not foo.', + ), + ); + $components = project_release_info_package_list_store($release->nid, $components); + project_release_info_package_dependencies_store($components['foo']['component_id'], PROJECT_RELEASE_DEPENDENCY_REQUIRED, array($components['bar']['component_id'])); + + // Retrieve stored components and compared with components array. + $stored = project_release_get_components($release->nid); + $this->assertEqual($stored, $components, 'Components stored and retrieved successfully'); + + // Retrieve stored component dependency and compare with components array. + $dependencies = project_release_component_dependencies_load_all($components['foo']['component_id']); + $this->assertEqual(count($dependencies), 1, 'One dependency retrieved for foo'); + $this->assertEqual(current($dependencies), $components['bar'], 'Component dependency stored and retrieved successfully'); + + // Retrieve stored release dependency and compare with components array. + $release_dependencies = project_release_get_dependencies($release->nid); + $this->assertEqual($release_dependencies, $dependencies, 'Release dependency stored and retrieved successfully'); + + // Load an individual component and ensure that it works properly. + $component = project_release_component_load($components['foo']['component_id']); + $this->assertEqual($component, $components['foo'], 'Component stored and retrieved successfully'); + + // Create a dependency tree and ensure that it is resolved properly. + // Foo -> Bar -> Baz. + $project2 = $this->createProject(); + $release2 = $this->createRelease($project2); + $component = array( + 'name' => 'baz', + 'title' => 'Baz', + 'description' => 'Baz is not bar.', + ); + $component = current(project_release_info_package_list_store($release2->nid, array($component))); + project_release_info_package_dependencies_store($components['bar']['component_id'], PROJECT_RELEASE_DEPENDENCY_RECOMMENDED, array($component['component_id'])); + + // Ensure that the dependency is handled properly. + $dependencies = project_release_component_dependencies_load_all($components['bar']['component_id']); + $this->assertFalse($dependencies, 'Bar has no required dependencies'); + + // Retrieve stored component dependency and compare with component array. + $dependencies = project_release_component_dependencies_load_all($components['bar']['component_id'], PROJECT_RELEASE_DEPENDENCY_RECOMMENDED); + $this->assertEqual(count($dependencies), 1, 'One dependency retrieved for bar'); + $this->assertEqual(current($dependencies), $component, 'Component dependency stored and retrieved successfully'); + + // Ensure that retrieval of the dependency tree respects type. + $dependencies = project_release_get_dependencies($release->nid); + $this->assertEqual(array_keys($dependencies), array('bar'), 'Foo requires bar'); + + // Ensure that retreval of the dependency tree is correct. + $dependencies = project_release_get_dependencies($release->nid, PROJECT_RELEASE_DEPENDENCY_RECOMMENDED); + $this->assertEqual(array_keys($dependencies), array('bar', 'baz'), 'Foo recommends bar and baz'); + } +} + +/** + * Ensure that the Drupal component parsing functions work properly. + */ +class ProjectReleaseComponentDrupalTestCase extends ProjectDrupalOrgWebTestCase { + + public static function getInfo() { + return array( + 'name' => 'Drupal component parsing', + 'description' => 'Ensure that the Drupal component parsing functions work properly.', + 'group' => 'Project release', + ); + } + + protected function testDrupalComponentParsing() { + $this->process(); + + // Ensure that the component information is correct. + $components = project_release_get_components($this->releases['foo']->nid); + $this->assertEqual(array_keys($components), array('project_release_foo'), 'Project foo contains foo'); + $this->assertEqual($components, array( + 'project_release_foo' => array( + 'component_id' => 1, + 'release_nid' => $this->releases['foo']->nid, + 'name' => 'project_release_foo', + 'title' => 'Project release foo', + 'description' => 'Test module for project_release. (do not enable)', + ), + ), 'Project foo components contain proper information'); + + $components = project_release_get_components($this->releases['bar']->nid); + $this->assertEqual(array_keys($components), array('project_release_bar', 'project_release_baz'), 'Project bar contains bar and baz'); + $this->assertEqual($components, array( + 'project_release_bar' => array( + 'component_id' => 2, + 'release_nid' => $this->releases['bar']->nid, + 'name' => 'project_release_bar', + 'title' => 'Project release bar', + 'description' => 'Test module for project_release. (do not enable)', + ), + 'project_release_baz' => array( + 'component_id' => 3, + 'release_nid' => $this->releases['bar']->nid, + 'name' => 'project_release_baz', + 'title' => 'Project release baz', + 'description' => 'Test module for project_release. (do not enable)', + ), + ), 'Project bar components contain proper information'); + + // Ensure that dependencies were parsed properly. + $dependencies = project_release_get_dependencies($this->releases['foo']->nid); + $this->assertFalse($dependencies, 'No dependencies found for project foo'); + + $dependencies = project_release_get_dependencies($this->releases['bar']->nid); // @TODO Should pick up soft dependency. + $this->assertEqual(array_keys($dependencies), array('project_release_foo'), 'Project bar depends on component foo'); + + // Ensure that parsing creates an error due to the lack of a stable release. + $this->assertPackageError('ERROR: No development release with a corresponding stable release was found that matches the requirements for dependency [project_release_foo] of [project_release_bar].'); + + // @TODO Figure out what CVS/Version control API stuff needs to be in setup. +// $this->createTag('bar', 'DRUPAL-7--1-0'); +// $this->drupalPost('node/add/project-release/' . $this->projects['bar']->nid, array(), t('Next')); +// $edit = array( +// 'body' => $this->randomString(32), +// ); +// $this->drupalPost(NULL, $edit, t('Save')); +// +// $this->process(); +// +// $this->assertNoPackageErrors(); + } + + /** + * Process the test modules. + */ + protected function process() { + // Remove all previously existing project_release_info data. + db_query('DELETE FROM {project_release_dependency}'); + db_query('DELETE FROM {project_release_component}'); + + // Reset packaging errors. + wd_err(FALSE, TRUE); + + // Load Drupal parsing implementation. + module_load_include('drupal.inc', 'project_release'); + + // Parse test modules related to projects foo and bar. + $info_files = array( + drupal_get_path('module', 'project_release_foo') . '/project_release_foo.info', + ); + package_release_info_process_all($this->releases['foo']->nid, $info_files, 'foo', '7.x-1.0'); + + $info_files = array( + drupal_get_path('module', 'project_release_bar') . '/project_release_bar.info', + drupal_get_path('module', 'project_release_baz') . '/project_release_baz.info', + ); + package_release_info_process_all($this->releases['bar']->nid, $info_files, 'bar', '7.x-1.2'); + } + + protected function createTag($project, $tag, $branch = FALSE) { + db_query("INSERT INTO {cvs_tags} VALUES (%d, '%s', %d, %d)", $this->projects[$project]->nid, $tag, $branch ? 1 : 0, time()); + } + + /** + * Assert that the package error were created. + * + * @param $message + * The packaging error message. + * @return + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertPackageError($message) { + $found = FALSE; + foreach (wd_err() as $error) { + if ($error == $message) { + $found = TRUE; + break; + } + } + return $this->assertTrue($found, 'Found package error [' . $message . ']'); + } + + /** + * Assert that no package errors were created. + * + * @return + * TRUE if the assertion succeeded, FALSE otherwise. + */ + protected function assertNoPackageErrors() { + if (!$this->assertFalse(wd_err(), 'No packaging errors')) { + foreach (wd_err() as $error) { + $this->fail($error); + } + } + } +} + +function wd_err($message = FALSE, $reset = FALSE) { + static $messages = array(); + + if ($reset) { + $messages = array(); + } + + if ($message) { + $messages[] = $message; + } + return $messages; +} diff --git a/release/tests/bar/baz/project_release_baz.info b/release/tests/bar/baz/project_release_baz.info new file mode 100644 index 0000000..dcd4fbd --- /dev/null +++ b/release/tests/bar/baz/project_release_baz.info @@ -0,0 +1,8 @@ +name = Project release baz +description = Test module for project_release. (do not enable) +package = Project +version = 1.2 +core = 7.x +dependencies[] = project_release_bar + +test_dependencies[] = project_release_foo (1.x) diff --git a/release/tests/bar/baz/project_release_baz.module b/release/tests/bar/baz/project_release_baz.module new file mode 100644 index 0000000..ec803ae --- /dev/null +++ b/release/tests/bar/baz/project_release_baz.module @@ -0,0 +1,8 @@ +