diff --git a/data-machine-code.php b/data-machine-code.php index ab2a08c..fbd26cb 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -317,6 +317,7 @@ function datamachine_code_load_chat_tools() { add_filter( 'datamachine_tasks', function ( array $tasks ): array { $tasks['github_create_issue'] = \DataMachineCode\Tasks\GitHubIssueTask::class; + $tasks['github_update_issue_labels'] = \DataMachineCode\Tasks\GitHubIssueLabelsTask::class; $tasks['worktree_cleanup_chunk'] = \DataMachineCode\Tasks\WorktreeCleanupChunkTask::class; $tasks['worktree_cleanup'] = \DataMachineCode\Tasks\WorktreeCleanupTask::class; $tasks['workspace_disk_emergency_cleanup'] = \DataMachineCode\Tasks\WorkspaceDiskEmergencyCleanupTask::class; diff --git a/inc/Abilities/GitHubAbilities.php b/inc/Abilities/GitHubAbilities.php index 62606d6..d4c2350 100644 --- a/inc/Abilities/GitHubAbilities.php +++ b/inc/Abilities/GitHubAbilities.php @@ -1943,7 +1943,7 @@ private static function getCurrentAgentSlug(): string { } } - $agent_slug = PermissionHelper::get_acting_agent_slug(); + $agent_slug = (string) PermissionHelper::get_acting_agent_slug(); if ( '' !== trim($agent_slug) ) { return sanitize_text_field($agent_slug); } diff --git a/inc/Tasks/GitHubIssueLabelsTask.php b/inc/Tasks/GitHubIssueLabelsTask.php new file mode 100644 index 0000000..3663cfb --- /dev/null +++ b/inc/Tasks/GitHubIssueLabelsTask.php @@ -0,0 +1,207 @@ + + */ + public static function getTaskMeta(): array { + return array( + 'label' => 'GitHub Issue Label Update', + 'description' => 'Deterministically add and remove GitHub issue labels without replacing the full label set.', + 'setting_key' => null, + 'default_enabled' => true, + 'supports_run' => false, + 'mutates' => true, + 'params_schema' => array( + 'type' => 'object', + 'required' => array( 'repo', 'issue_number' ), + 'properties' => array( + 'repo' => array( 'type' => 'string' ), + 'issue_number' => array( 'type' => 'integer' ), + 'add_labels' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'remove_labels' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + ); + } + + /** + * This task can infer the fetched GitHub issue when embedded after a fetch step. + * + * @return bool + */ + public function needsPipelineContext(): bool { + return true; + } + + /** + * Execute surgical GitHub issue label updates. + * + * @param int $jobId Job ID. + * @param array $params Task params. + * @return void + */ + public function executeTask( int $jobId, array $params ): void { + $params = $this->withFetchedGitHubIssueContext($params); + $repo = trim( (string) ( $params['repo'] ?? '' ) ); + $issue_number = (int) ( $params['issue_number'] ?? 0 ); + $add_labels = $this->normalizeLabels($params['add_labels'] ?? array()); + $remove_labels = $this->normalizeLabels($params['remove_labels'] ?? array()); + + if ( '' === $repo || $issue_number <= 0 ) { + $this->failJob($jobId, 'GitHub issue label update requires repo and issue_number.'); + return; + } + + if ( array() === $add_labels && array() === $remove_labels ) { + $this->failJob($jobId, 'GitHub issue label update requires at least one add_labels or remove_labels entry.'); + return; + } + + $removed = array(); + $results = array(); + + foreach ( $remove_labels as $label ) { + $result = GitHubAbilities::removeLabel( + array( + 'repo' => $repo, + 'issue_number' => $issue_number, + 'label' => $label, + ) + ); + if ( is_wp_error($result) ) { + $this->failJob($jobId, $result->get_error_message()); + return; + } + $removed[] = $label; + $results[] = array( + 'action' => 'remove', + 'label' => $label, + 'result' => $result, + ); + } + + $added = array(); + if ( array() !== $add_labels ) { + $result = GitHubAbilities::addLabels( + array( + 'repo' => $repo, + 'issue_number' => $issue_number, + 'labels' => $add_labels, + ) + ); + if ( is_wp_error($result) ) { + $this->failJob($jobId, $result->get_error_message()); + return; + } + $added = $add_labels; + $results[] = array( + 'action' => 'add', + 'labels' => $add_labels, + 'result' => $result, + ); + } + + $this->completeJob( + $jobId, + array( + 'success' => true, + 'repo' => $repo, + 'issue_number' => $issue_number, + 'added_labels' => $added, + 'removed_labels' => $removed, + 'results' => $results, + ) + ); + } + + /** + * Normalize a scalar or array of labels to a de-duplicated list. + * + * @param mixed $labels Raw labels. + * @return array + */ + private function normalizeLabels( mixed $labels ): array { + if ( is_string($labels) ) { + $labels = array( $labels ); + } + if ( ! is_array($labels) ) { + return array(); + } + + $normalized = array(); + foreach ( $labels as $label ) { + $label = trim( (string) $label ); + if ( '' !== $label ) { + $normalized[] = $label; + } + } + + return array_values(array_unique($normalized)); + } + + /** + * Fill repo and issue_number from the first GitHub issue packet when omitted. + * + * @param array $params Raw params. + * @return array + */ + private function withFetchedGitHubIssueContext( array $params ): array { + $repo_missing = '' === trim( (string) ( $params['repo'] ?? '' ) ); + $number_missing = (int) ( $params['issue_number'] ?? 0 ) <= 0; + if ( ! $repo_missing && ! $number_missing ) { + return $params; + } + + $data_packets = is_array($params['data_packets'] ?? null) ? $params['data_packets'] : array(); + foreach ( $data_packets as $packet ) { + if ( ! is_array($packet) ) { + continue; + } + $metadata = is_array($packet['metadata'] ?? null) ? $packet['metadata'] : array(); + if ( 'github' !== (string) ( $metadata['source_type'] ?? '' ) || 'issues' !== (string) ( $metadata['github_type'] ?? '' ) ) { + continue; + } + + if ( $repo_missing && ! empty($metadata['github_repo']) ) { + $params['repo'] = (string) $metadata['github_repo']; + } + if ( $number_missing && (int) ( $metadata['github_number'] ?? 0 ) > 0 ) { + $params['issue_number'] = (int) $metadata['github_number']; + } + return $params; + } + + return $params; + } +} diff --git a/tests/smoke-github-create-abilities.php b/tests/smoke-github-create-abilities.php index fe5418d..241029a 100644 --- a/tests/smoke-github-create-abilities.php +++ b/tests/smoke-github-create-abilities.php @@ -55,6 +55,42 @@ public static function get( string $key, string $default_value = '' ): string } namespace DataMachineCode\Support { + class PermissionHelper + { + public static function can_manage(): bool + { + return \DataMachine\Abilities\PermissionHelper::can_manage(); + } + + public static function get_acting_agent_slug(): ?string + { + return \DataMachine\Abilities\PermissionHelper::get_acting_agent_slug(); + } + + public static function get_acting_agent_id(): ?int + { + return \DataMachine\Abilities\PermissionHelper::get_acting_agent_id(); + } + + public static function get_runtime_context(): array + { + return \DataMachine\Abilities\PermissionHelper::get_runtime_context(); + } + + public static function get_execution_principal(): mixed + { + return null; + } + } + + class PluginSettings + { + public static function get( string $key, string $default_value = '' ): string + { + return \DataMachine\Core\PluginSettings::get($key, $default_value); + } + } + class GitHubCredentialResolver { public static string $mode = 'pat'; diff --git a/tests/smoke-github-issue-labels-task.php b/tests/smoke-github-issue-labels-task.php new file mode 100644 index 0000000..806a212 --- /dev/null +++ b/tests/smoke-github-issue-labels-task.php @@ -0,0 +1,178 @@ + true, + 'applied_labels' => $input['labels'] ?? array(), + ); + } + + public static function removeLabel( array $input ): array|\WP_Error + { + self::$removeLabelCalls[] = $input; + if (null !== self::$nextRemoveError ) { + $error = self::$nextRemoveError; + self::$nextRemoveError = null; + return $error; + } + return array( + 'success' => true, + 'removed_label' => $input['label'] ?? '', + ); + } + } +} + +namespace { + if (! defined('ABSPATH') ) { + define('ABSPATH', __DIR__ . '/'); + } + + class WP_Error + { + public function __construct( private string $code, private string $message ) + { + } + + public function get_error_code(): string + { + return $this->code; + } + + public function get_error_message(): string + { + return $this->message; + } + } + + function is_wp_error( $value ): bool + { + return $value instanceof WP_Error; + } + + require_once __DIR__ . '/../inc/Tasks/GitHubIssueLabelsTask.php'; + + $assert = static function ( string $message, bool $condition ): void { + if (! $condition ) { + fwrite(STDERR, "FAIL: {$message}\n"); + exit(1); + } + }; + + $plugin = file_get_contents(__DIR__ . '/../data-machine-code.php'); + $assert('plugin registers github_update_issue_labels task', is_string($plugin) && str_contains($plugin, "'github_update_issue_labels'") && str_contains($plugin, 'GitHubIssueLabelsTask::class')); + + $task = new \DataMachineCode\Tasks\GitHubIssueLabelsTask(); + $assert('task type is github_update_issue_labels', 'github_update_issue_labels' === $task->getTaskType()); + $assert('task requests pipeline context for fetched GitHub packet inference', true === $task->needsPipelineContext()); + + $meta = \DataMachineCode\Tasks\GitHubIssueLabelsTask::getTaskMeta(); + $assert('task meta marks mutation', true === ( $meta['mutates'] ?? null )); + $assert('task meta exposes required params', array( 'repo', 'issue_number' ) === ( $meta['params_schema']['required'] ?? array() )); + + $GLOBALS['dmc_completed_jobs'] = array(); + $GLOBALS['dmc_failed_jobs'] = array(); + + $task->executeTask( + 101, + array( + 'repo' => 'chubes4/wp-site-generator', + 'issue_number' => 505, + 'remove_labels' => array( ' status:idea-ready ', 'status:idea-ready', '' ), + 'add_labels' => array( 'status:design-ready', 'status:design-ready' ), + ) + ); + + $result = $GLOBALS['dmc_completed_jobs'][101] ?? array(); + $assert('task completes successfully', true === ( $result['success'] ?? false )); + $assert('task records removed label', array( 'status:idea-ready' ) === ( $result['removed_labels'] ?? array() )); + $assert('task records added label', array( 'status:design-ready' ) === ( $result['added_labels'] ?? array() )); + $assert('remove is called once before add', 1 === count(\DataMachineCode\Abilities\GitHubAbilities::$removeLabelCalls) && 1 === count(\DataMachineCode\Abilities\GitHubAbilities::$addLabelsCalls)); + $assert('remove forwards repo and issue number', 505 === (int) ( \DataMachineCode\Abilities\GitHubAbilities::$removeLabelCalls[0]['issue_number'] ?? 0 ) && 'chubes4/wp-site-generator' === ( \DataMachineCode\Abilities\GitHubAbilities::$removeLabelCalls[0]['repo'] ?? '' )); + $assert('add forwards de-duped labels array', array( 'status:design-ready' ) === ( \DataMachineCode\Abilities\GitHubAbilities::$addLabelsCalls[0]['labels'] ?? array() )); + + $task->executeTask( + 102, + array( + 'repo' => 'chubes4/wp-site-generator', + 'issue_number' => 506, + 'remove_labels' => 'status:idea-ready', + ) + ); + $assert('scalar remove label is accepted', array( 'status:idea-ready' ) === ( $GLOBALS['dmc_completed_jobs'][102]['removed_labels'] ?? array() )); + + $task->executeTask( + 105, + array( + 'remove_labels' => array( 'status:idea-ready' ), + 'add_labels' => array( 'status:design-ready' ), + 'data_packets' => array( + array( + 'metadata' => array( + 'source_type' => 'github', + 'github_type' => 'issues', + 'github_repo' => 'chubes4/wp-site-generator', + 'github_number' => 508, + ), + ), + ), + ) + ); + $assert('task infers repo from fetched GitHub issue packet', 'chubes4/wp-site-generator' === ( $GLOBALS['dmc_completed_jobs'][105]['repo'] ?? '' )); + $assert('task infers issue number from fetched GitHub issue packet', 508 === (int) ( $GLOBALS['dmc_completed_jobs'][105]['issue_number'] ?? 0 )); + + $task->executeTask(103, array( 'repo' => 'chubes4/wp-site-generator' )); + $assert('missing issue number fails', str_contains((string) ( $GLOBALS['dmc_failed_jobs'][103] ?? '' ), 'repo and issue_number')); + + \DataMachineCode\Abilities\GitHubAbilities::$nextRemoveError = new WP_Error('github_api_error', 'Remove failed'); + $task->executeTask( + 104, + array( + 'repo' => 'chubes4/wp-site-generator', + 'issue_number' => 507, + 'remove_labels' => array( 'status:idea-ready' ), + ) + ); + $assert('ability remove error fails job', 'Remove failed' === ( $GLOBALS['dmc_failed_jobs'][104] ?? '' )); + + fwrite(STDOUT, "PASS: GitHub issue labels task smoke test\n"); +}