ai_upgrade_assistant-0.2.0-alpha2/src/Service/PatchSearcher.php
src/Service/PatchSearcher.php
<?php
namespace Drupal\ai_upgrade_assistant\Service;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\update\UpdateManagerInterface;
use GuzzleHttp\ClientInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
/**
* Service for finding and managing patches for module upgrades.
*/
class PatchSearcher {
use StringTranslationTrait;
use DependencySerializationTrait;
/**
* The update manager.
*
* @var \Drupal\update\UpdateManagerInterface
*/
protected $updateManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* The logger factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected $loggerFactory;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The cache backend.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* Constructs a new PatchSearcher.
*
* @param \Drupal\update\UpdateManagerInterface $update_manager
* The update manager service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend.
*/
public function __construct(
UpdateManagerInterface $update_manager,
ModuleHandlerInterface $module_handler,
ClientInterface $http_client,
FileSystemInterface $file_system,
LoggerChannelFactoryInterface $logger_factory,
StateInterface $state,
CacheBackendInterface $cache
) {
$this->updateManager = $update_manager;
$this->moduleHandler = $module_handler;
$this->httpClient = $http_client;
$this->fileSystem = $file_system;
$this->loggerFactory = $logger_factory;
$this->state = $state;
$this->cache = $cache;
}
/**
* Check module compatibility with a target Drupal version.
*
* @param string $module_name
* The module name.
* @param string $current_version
* Current version of the module.
* @param string $target_version
* Target Drupal core version.
*
* @return array
* Compatibility information array containing:
* - compatible_version: The compatible version if found
* - update_type: Type of update (major, minor, none)
* - message: Status message
* - core_compatibility: Core version compatibility
*/
public function checkModuleCompatibility($module_name, $current_version, $target_version) {
// Refresh available updates data
if (!$this->state->get('update.last_check') ||
$this->state->get('update.last_check') < (REQUEST_TIME - (24 * 60 * 60))) {
$this->updateManager->refreshUpdateData();
}
$project_data = $this->updateManager->getProjects();
if (!isset($project_data[$module_name])) {
$this->loggerFactory->get('ai_upgrade_assistant')
->notice('No update information available for @module', ['@module' => $module_name]);
return [
'compatible_version' => NULL,
'update_type' => 'none',
'message' => $this->t('No update information available'),
'core_compatibility' => NULL,
];
}
$project = $project_data[$module_name];
$compatible_release = NULL;
$update_type = 'none';
// Check each available release for compatibility
if (!empty($project['releases'])) {
foreach ($project['releases'] as $version => $release) {
// Skip if no core compatibility info
if (empty($release['core_compatibility'])) {
continue;
}
// Check if release is compatible with target version
if (version_compare($release['core_compatibility'], $target_version, '>=')) {
// Determine update type
$current_major = explode('.', $current_version)[0];
$release_major = explode('.', $version)[0];
if ($current_major != $release_major) {
$update_type = 'major';
} elseif (version_compare($version, $current_version, '>')) {
$update_type = 'minor';
}
$compatible_release = $release;
break;
}
}
}
if ($compatible_release) {
return [
'compatible_version' => $compatible_release['version'],
'update_type' => $update_type,
'message' => $this->t('Compatible version found'),
'core_compatibility' => $compatible_release['core_compatibility'],
];
}
return [
'compatible_version' => NULL,
'update_type' => 'none',
'message' => $this->t('No compatible version found for Drupal @version', ['@version' => $target_version]),
'core_compatibility' => NULL,
];
}
/**
* Find existing patches for a module.
*
* @param string $module_name
* The module name.
* @param string $issue_description
* Description to search for.
* @param array $options
* Additional options for the search.
*
* @return array
* Array of found patches.
*/
public function findExistingPatches($module_name, $issue_description, array $options = []) {
$this->updateTerminalOutput("Searching for patches for $module_name...");
$patches = [];
// Get project data from update manager
$project_data = $this->updateManager->getProjects();
if (!isset($project_data[$module_name])) {
$this->updateTerminalOutput("No update information available for $module_name");
return [];
}
$project = $project_data[$module_name];
// First check release notes for D11 compatibility mentions
if (!empty($project['releases'])) {
foreach ($project['releases'] as $version => $release) {
if (!empty($release['release_notes']) &&
stripos($release['release_notes'], 'drupal 11') !== FALSE) {
$patches[] = [
'title' => $this->t('Version @version release notes', ['@version' => $version]),
'url' => $release['release_link'],
'status' => 'released',
'created' => $release['date'],
'changed' => $release['date'],
'type' => 'release',
];
}
}
}
// Check if we have cached issue data
$cid = 'ai_upgrade_assistant:issues:' . $module_name;
$cache = $this->cache->get($cid);
if ($cache && $cache->data) {
$this->updateTerminalOutput("Using cached issue data for $module_name");
return array_merge($patches, $cache->data);
}
// Then try to search issue queue if available
try {
// Use the REST API v2 endpoint
$api_url = sprintf(
'https://www.drupal.org/api/project/%s/issues?filter[category][value][]=bug&filter[category][value][]=task&filter[status][value][]=1&filter[status][value][]=8&filter[status][value][]=13&filter[status][value][]=14&sort=-changed',
urlencode($module_name)
);
$request_options = [
'headers' => [
'Accept' => 'application/vnd.api+json',
],
'timeout' => 5,
'connect_timeout' => 3,
];
$response = $this->httpClient->get($api_url, $request_options);
$status_code = $response->getStatusCode();
if ($status_code === 200) {
$data = json_decode($response->getBody(), TRUE);
$issue_patches = [];
if (!empty($data['data'])) {
foreach ($data['data'] as $issue) {
$attributes = $issue['attributes'];
// Check if issue is related to D11 compatibility
if (stripos($attributes['title'], 'drupal 11') !== FALSE ||
stripos($attributes['body'], 'drupal 11') !== FALSE) {
$issue_patches[] = [
'title' => $attributes['title'],
'url' => sprintf('https://www.drupal.org/node/%s', $issue['id']),
'status' => $attributes['status'],
'created' => $attributes['created'],
'changed' => $attributes['changed'],
'type' => 'issue',
];
}
}
// Cache the issue data for 1 hour
$this->cache->set($cid, $issue_patches, time() + 3600);
$this->updateTerminalOutput("Found " . count($issue_patches) . " patches for $module_name");
$patches = array_merge($patches, $issue_patches);
}
} else {
// Try to use cached data if available
$cache = $this->cache->get($cid);
if ($cache && $cache->data) {
$this->updateTerminalOutput("Using cached issue data for $module_name");
$patches = array_merge($patches, $cache->data);
}
$this->loggerFactory->get('ai_upgrade_assistant')
->warning('Drupal.org API returned status code @code for @module. Using cached data if available.', [
'@code' => $status_code,
'@module' => $module_name,
]);
}
}
catch (\Exception $e) {
// Try to use cached data if available
$cache = $this->cache->get($cid);
if ($cache && $cache->data) {
$this->updateTerminalOutput("Using cached issue data for $module_name");
$patches = array_merge($patches, $cache->data);
}
$this->loggerFactory->get('ai_upgrade_assistant')
->warning('Error searching Drupal.org issues for @module: @error. Using cached data if available.', [
'@module' => $module_name,
'@error' => $e->getMessage(),
]);
}
return $patches;
}
/**
* Find patches for a module.
*
* @param string $module_name
* The module name.
* @param array $context
* Additional context for patch search.
*
* @return array
* Array of found patches with:
* - url: URL to the patch
* - description: Description of what the patch does
* - status: Status of the patch (e.g., 'needs review', 'reviewed')
* - created: Timestamp when patch was created
* - changed: Timestamp when patch was last updated
*/
public function findPatches($module_name, array $context = []) {
$cid = 'ai_upgrade_assistant:patches:' . $module_name;
if ($cached = $this->cache->get($cid)) {
return $cached->data;
}
$patches = [];
try {
// Search for existing patches
$existing_patches = $this->findExistingPatches(
$module_name,
$context['issue_description'] ?? 'Drupal 11 compatibility'
);
$patches = array_merge($patches, $existing_patches);
// Check module compatibility
$module = $this->moduleHandler->getModule($module_name);
if ($module && !empty($module->info['version'])) {
$compatibility = $this->checkModuleCompatibility(
$module_name,
$module->info['version'],
$context['target_version'] ?? '11.0'
);
if ($compatibility['update_type'] !== 'none') {
$patches[] = [
'type' => 'version_update',
'description' => $this->t('Update to version @version for Drupal @core compatibility', [
'@version' => $compatibility['compatible_version'],
'@core' => $compatibility['core_compatibility'],
]),
'version' => $compatibility['compatible_version'],
'core_compatibility' => $compatibility['core_compatibility'],
'update_type' => $compatibility['update_type'],
];
}
}
// Cache for 1 hour
$this->cache->set($cid, $patches, time() + 3600);
return $patches;
}
catch (\Exception $e) {
$this->loggerFactory->get('ai_upgrade_assistant')->error(
'Error finding patches for @module: @error',
[
'@module' => $module_name,
'@error' => $e->getMessage(),
]
);
return [];
}
}
/**
* Get the composer command to update a module.
*
* @param array $compatibility
* Compatibility information from checkModuleCompatibility().
*
* @return string|null
* Composer command or NULL if not applicable.
*/
public function getComposerUpdateCommand($compatibility) {
if (empty($compatibility['compatible_version'])) {
return NULL;
}
$constraint = $compatibility['compatible_version'];
if ($compatibility['update_type'] === 'minor') {
// For minor updates, stay within the same major version
$major_version = explode('.', $compatibility['compatible_version'])[0];
$constraint = "^{$major_version}.0";
}
return sprintf(
'composer require drupal/%s:%s --update-with-dependencies',
$compatibility['module'],
$constraint
);
}
/**
* Updates the terminal output in the state system.
*
* @param string $message
* The message to append to the terminal output.
*/
protected function updateTerminalOutput($message) {
$output = $this->state->get('ai_upgrade_assistant.terminal_output', []);
$output[] = [
'timestamp' => time(),
'message' => $message,
];
// Keep only the last 100 messages
if (count($output) > 100) {
$output = array_slice($output, -100);
}
$this->state->set('ai_upgrade_assistant.terminal_output', $output);
}
}
