diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index be228a1885..0d3e99a041 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,6 +29,9 @@ jobs: - name: Slack Trac Bot working-directory: common/includes/tests/slack/trac phpunit-args: "--no-configuration bot.php" + - name: Trac Notifications + working-directory: wordpress.org/public_html/wp-content/plugins/trac-notifications + phpunit-args: "" - name: Slack Props Library working-directory: common/includes/slack/props/tests phpunit-args: "--no-configuration --bootstrap /tmp/phpunit-bootstrap.php ." diff --git a/.gitignore b/.gitignore index 86b79d4d5f..afbb47f386 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ package-lock.json vendor/ +.phpunit.cache/ .phpunit.result.cache .svn/ diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/class-trac-api.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/class-trac-api.php new file mode 100644 index 0000000000..219e9e1417 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/class-trac-api.php @@ -0,0 +1,272 @@ +client_factory = $client_factory; + } + + /** + * Fetch a ticket with comments, changelog, attachments, and custom fields. + * + * The mixed return is intentional — callers should treat `null` and `false` + * the same when deciding whether to serve data and only differentiate + * when generating an HTTP status (404 vs 503): + * + * $result = $api->get_ticket( 'core', 42 ); + * if ( ! is_array( $result ) ) { + * // No data. $result === null → ticket missing (404). + * // $result === false → trac unreachable or bad input (503/400). + * } + * + * @param string $trac Trac slug ('core' or 'meta'). + * @param int $ticket_id Ticket id. + * @param array $opts Forwarded to Trac_Notifications_DB::get_trac_ticket_full(). + * @return array|null|false Array on success; null when the ticket does not exist + * (negatively cached); false when the input is invalid or + * Trac is unreachable with no stale cache. + */ + public function get_ticket( $trac, $ticket_id, $opts = array() ) { + $ticket_id = (int) $ticket_id; + if ( $ticket_id <= 0 ) { + $this->last_stale = false; + return false; + } + + $key = $this->ticket_cache_key( $ticket_id, $opts ); + $client_factory = $this->client_factory; + + $result = $this->cached_call( + $trac, + $key, + static function () use ( $client_factory, $trac, $ticket_id, $opts ) { + $client = call_user_func( $client_factory, $trac ); + if ( ! $client ) { + return false; + } + return $client->get_trac_ticket_full( $ticket_id, $opts ); + } + ); + + if ( is_array( $result ) ) { + return $this->normalize_ticket( $result, $trac ); + } + + return $result; + } + + /** + * Whether the most recent get_*() call returned data from the stale cache + * because Trac was unreachable. Callers wrapping responses in HTTP may + * want to surface this as `_stale: true` for client-side messaging. + * + * @return bool + */ + public function is_last_stale() { + return $this->last_stale; + } + + /** + * Shared caching + circuit-breaker path. + * + * Lookup precedence: + * 1. Fresh cache (includes NULL_SENTINEL → returns null short-circuit). + * 2. Live callable, when the breaker is closed. + * - Array result is written to fresh+stale, returned. + * - Null result writes NULL_SENTINEL to both layers (preventing a + * deleted ticket from resurfacing via stale) and returns null. + * - Any other return trips the breaker and falls through. + * 3. Stale cache → returned (marks last_stale). + * 4. false. + * + * @param string $trac Trac slug. + * @param string $key Method+args cache-key suffix. + * @param callable $callback Live data fetcher returning array, null, or false on failure. + * @return array|null|false + */ + protected function cached_call( $trac, $key, callable $callback ) { + $this->last_stale = false; + + $fresh_key = "{$trac}/{$key}:fresh"; + $stale_key = "{$trac}/{$key}:stale"; + $breaker_key = "{$trac}:breaker"; + + $cached = wp_cache_get( $fresh_key, self::CACHE_GROUP ); + if ( false !== $cached ) { + return ( self::NULL_SENTINEL === $cached ) ? null : $cached; + } + + if ( ! wp_cache_get( $breaker_key, self::CACHE_GROUP ) ) { + $live = $callback(); + + if ( is_array( $live ) ) { + wp_cache_set( $fresh_key, $live, self::CACHE_GROUP, self::FRESH_TTL ); + wp_cache_set( $stale_key, $live, self::CACHE_GROUP, self::STALE_TTL ); + return $live; + } + + if ( null === $live ) { + wp_cache_set( $fresh_key, self::NULL_SENTINEL, self::CACHE_GROUP, self::NEGATIVE_TTL ); + wp_cache_set( $stale_key, self::NULL_SENTINEL, self::CACHE_GROUP, self::STALE_TTL ); + return null; + } + + wp_cache_set( $breaker_key, 1, self::CACHE_GROUP, self::BREAKER_TTL ); + } + + $stale = wp_cache_get( $stale_key, self::CACHE_GROUP ); + if ( false !== $stale ) { + $this->last_stale = true; + return ( self::NULL_SENTINEL === $stale ) ? null : $stale; + } + + return false; + } + + /** + * Stable cache-key suffix for ticket reads. `ksort` ensures opts in + * different insertion order resolve to the same key. + * + * @param int $ticket_id Ticket id. + * @param array $opts Get-ticket options. + * @return string + */ + protected function ticket_cache_key( $ticket_id, $opts ) { + ksort( $opts ); + return 'ticket:' . $ticket_id . ':' . md5( wp_json_encode( $opts ) ); + } + + /** + * Default factory used when none is injected. Memoised per-trac. Logs + * (loudly) when TRAC_NOTIFICATIONS_API_KEY is undefined so a permanent + * misconfiguration is distinguishable from a transient Trac outage. + * + * @param string $trac Trac slug. + * @return Trac_Notifications_HTTP_Client|null Null when credentials are missing. + */ + protected function default_client_factory( $trac ) { + static $clients = array(); + if ( isset( $clients[ $trac ] ) ) { + return $clients[ $trac ]; + } + + if ( ! defined( 'TRAC_NOTIFICATIONS_API_KEY' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional operational log: a missing API key is a misconfiguration, not a transient error. + error_log( 'Trac_API: TRAC_NOTIFICATIONS_API_KEY is not defined; Trac requests will fail.' ); + return null; + } + + require_once __DIR__ . '/autoload.php'; + $clients[ $trac ] = new Trac_Notifications_HTTP_Client( + "https://{$trac}.trac.wordpress.org/wpapi", + TRAC_NOTIFICATIONS_API_KEY + ); + return $clients[ $trac ]; + } + + /** + * Convert microsecond timestamps to ISO-8601, cast integer-shaped fields, + * and inject the canonical Trac URL. + * + * @param array $ticket Ticket payload from the DAO. + * @param string $trac Trac slug, for URL generation. + * @return array + */ + protected function normalize_ticket( array $ticket, $trac ) { + $ticket = $this->normalize_times( $ticket, array( 'time', 'changetime' ) ); + + foreach ( array( 'comments', 'changelog', 'attachments' ) as $list ) { + if ( ! isset( $ticket[ $list ] ) || ! is_array( $ticket[ $list ] ) ) { + continue; + } + foreach ( $ticket[ $list ] as &$row ) { + if ( is_array( $row ) ) { + $row = $this->normalize_times( $row, array( 'time' ) ); + } + } + unset( $row ); + } + + if ( isset( $ticket['id'] ) ) { + $ticket['id'] = (int) $ticket['id']; + $ticket['url'] = sprintf( 'https://%s.trac.wordpress.org/ticket/%d', $trac, $ticket['id'] ); + } + + return $ticket; + } + + /** + * Replace microsecond integer timestamps with ISO-8601 strings. Values + * that are not numeric are left untouched (already-normalised or absent). + * + * @param array $row Associative row. + * @param array $fields Field names to normalise. + * @return array + */ + protected function normalize_times( array $row, array $fields ) { + foreach ( $fields as $f ) { + if ( isset( $row[ $f ] ) && is_numeric( $row[ $f ] ) ) { + $row[ $f ] = gmdate( 'c', (int) ( $row[ $f ] / 1000000 ) ); + } + } + return $row; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/trac-notifications/phpunit.xml new file mode 100644 index 0000000000..ce795d7f09 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests/ + + + diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_API_Tests.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_API_Tests.php new file mode 100644 index 0000000000..a35a60a7fe --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_API_Tests.php @@ -0,0 +1,380 @@ + + */ + protected $clients; + + /** + * System under test. + * + * @var Trac_API + */ + protected $api; + + /** + * Reset the cache polyfill, build per-trac clients, and an api instance. + */ + public function setUp(): void { + Test_Cache::reset(); + + $this->clients = array( + 'core' => new Fake_Client(), + 'meta' => new Fake_Client(), + ); + + $clients = $this->clients; + $factory = static function ( $trac ) use ( $clients ) { + return $clients[ $trac ] ?? null; + }; + + $this->api = new Trac_API( $factory ); + } + + /** + * Build a representative DAO-shaped ticket payload, with overrides merged on top. + * + * @param array $overrides Fields to override. + * @return array + */ + protected function ticket_row( $overrides = array() ) { + return array_merge( + array( + 'id' => '42', + 'summary' => 'Test ticket', + 'status' => 'new', + 'time' => '1640000000000000', + 'changetime' => '1641000000000000', + 'custom_fields' => array(), + 'participants' => array(), + 'comments' => array(), + 'comments_total' => 0, + 'changelog' => array(), + 'attachments' => array(), + ), + $overrides + ); + } + + /** + * First request hits the client and writes both fresh and stale entries. + */ + public function test_cold_cache_calls_client_and_writes_both_layers() { + $this->clients['core']->next_response = $this->ticket_row(); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $this->clients['core']->calls ); + + $keys = array_column( Test_Cache::$set_calls, 'key' ); + $this->assertCount( 2, Test_Cache::$set_calls, 'fresh + stale written' ); + $this->assertStringContainsString( ':fresh', $keys[0] ); + $this->assertStringContainsString( ':stale', $keys[1] ); + $this->assertFalse( $this->api->is_last_stale() ); + } + + /** + * A warm fresh cache returns the previously-cached value without touching the client. + */ + public function test_warm_cache_skips_client_and_returns_same_value() { + $this->clients['core']->next_response = $this->ticket_row(); + $first = $this->api->get_ticket( 'core', 42 ); + + $this->clients['core']->calls = array(); + + $second = $this->api->get_ticket( 'core', 42 ); + + $this->assertSame( $first, $second ); + $this->assertCount( 0, $this->clients['core']->calls, 'client not called when fresh cache hits' ); + $this->assertFalse( $this->api->is_last_stale() ); + } + + /** + * A client failure trips the breaker and falls back to stale data. + */ + public function test_client_failure_trips_breaker_and_serves_stale() { + $this->clients['core']->next_response = $this->ticket_row(); + $this->api->get_ticket( 'core', 42 ); + + foreach ( array_keys( Test_Cache::$store ) as $key ) { + if ( str_contains( $key, ':fresh' ) ) { + unset( Test_Cache::$store[ $key ] ); + } + } + + $this->clients['core']->next_response = false; + $this->clients['core']->calls = array(); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertIsArray( $result, 'stale served when live call fails' ); + $this->assertTrue( $this->api->is_last_stale() ); + $this->assertCount( 1, $this->clients['core']->calls, 'one live attempt was made' ); + + $breaker_set = false; + foreach ( Test_Cache::$set_calls as $set ) { + if ( str_ends_with( $set['key'], ':breaker' ) ) { + $breaker_set = true; + } + } + $this->assertTrue( $breaker_set, 'breaker tripped after live failure' ); + } + + /** + * When the breaker is already open, the client is not contacted and stale wins. + */ + public function test_breaker_open_skips_client_and_serves_stale() { + $this->clients['core']->next_response = $this->ticket_row(); + $this->api->get_ticket( 'core', 42 ); + + foreach ( array_keys( Test_Cache::$store ) as $key ) { + if ( str_contains( $key, ':fresh' ) ) { + unset( Test_Cache::$store[ $key ] ); + } + } + Test_Cache::$store[ Test_Cache::key( 'core:breaker', Trac_API::CACHE_GROUP ) ] = 1; + + $this->clients['core']->calls = array(); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertIsArray( $result ); + $this->assertTrue( $this->api->is_last_stale() ); + $this->assertCount( 0, $this->clients['core']->calls, 'client never called when breaker is open' ); + } + + /** + * Breaker open and no stale data: returns false without calling the client. + */ + public function test_breaker_open_and_no_stale_returns_false() { + Test_Cache::$store[ Test_Cache::key( 'core:breaker', Trac_API::CACHE_GROUP ) ] = 1; + + $result = $this->api->get_ticket( 'core', 999 ); + + $this->assertFalse( $result ); + $this->assertFalse( $this->api->is_last_stale() ); + $this->assertCount( 0, $this->clients['core']->calls ); + } + + /** + * Live failure with no stale entry returns false, last_stale stays false. + */ + public function test_failure_with_no_stale_returns_false() { + $this->clients['core']->next_response = false; + + $result = $this->api->get_ticket( 'core', 999 ); + + $this->assertFalse( $result ); + $this->assertFalse( $this->api->is_last_stale(), 'flag stays false because no stale data was actually served' ); + } + + /** + * Null response is negatively cached so subsequent calls do not re-hit Trac. + */ + public function test_null_response_is_cached_and_returned_as_null() { + $this->clients['core']->next_response = null; + + $first = $this->api->get_ticket( 'core', 999 ); + $this->assertNull( $first ); + + $this->clients['core']->calls = array(); + $this->clients['core']->next_response = null; + + $second = $this->api->get_ticket( 'core', 999 ); + $this->assertNull( $second ); + $this->assertCount( 0, $this->clients['core']->calls, 'negative cache prevents re-call' ); + } + + /** + * A NULL_SENTINEL value seeded in fresh cache is read back as null. + */ + public function test_null_sentinel_in_fresh_cache_is_returned_as_null() { + $key = 'ticket:42:' . md5( wp_json_encode( array() ) ); + $fresh_key = "core/{$key}:fresh"; + Test_Cache::$store[ Test_Cache::key( $fresh_key, Trac_API::CACHE_GROUP ) ] = Trac_API::NULL_SENTINEL; + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertNull( $result ); + $this->assertCount( 0, $this->clients['core']->calls ); + } + + /** + * Non-positive ticket ids are rejected up-front. + */ + public function test_invalid_ticket_id_returns_false() { + $this->assertFalse( $this->api->get_ticket( 'core', 0 ) ); + $this->assertFalse( $this->api->get_ticket( 'core', -1 ) ); + $this->assertCount( 0, $this->clients['core']->calls ); + } + + /** + * A core.trac breaker does not block meta.trac calls. + */ + public function test_core_breaker_does_not_block_meta() { + Test_Cache::$store[ Test_Cache::key( 'core:breaker', Trac_API::CACHE_GROUP ) ] = 1; + $this->clients['meta']->next_response = $this->ticket_row(); + + $result = $this->api->get_ticket( 'meta', 42 ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $this->clients['meta']->calls ); + $this->assertCount( 0, $this->clients['core']->calls ); + } + + /** + * Microsecond timestamps on the ticket root are converted to ISO-8601. + */ + public function test_normalisation_converts_microseconds_to_iso8601() { + $this->clients['core']->next_response = $this->ticket_row( + array( + 'time' => '1640995200000000', + 'changetime' => '1672531200000000', + ) + ); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertSame( '2022-01-01T00:00:00+00:00', $result['time'] ); + $this->assertSame( '2023-01-01T00:00:00+00:00', $result['changetime'] ); + } + + /** + * Microsecond timestamps inside comments, changelog, and attachments are also converted. + */ + public function test_normalisation_converts_nested_times() { + $this->clients['core']->next_response = $this->ticket_row( + array( + 'comments' => array( + array( + 'id' => '1', + 'time' => '1640995200000000', + 'author' => 'alice', + 'body' => 'hi', + ), + ), + 'changelog' => array( + array( + 'time' => '1641081600000000', + 'author' => 'bob', + 'field' => 'status', + 'oldvalue' => 'new', + 'newvalue' => 'assigned', + ), + ), + 'attachments' => array( + array( + 'filename' => 'patch.diff', + 'time' => '1641168000000000', + 'size' => '100', + 'author' => 'carol', + 'description' => '', + ), + ), + ) + ); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertSame( '2022-01-01T00:00:00+00:00', $result['comments'][0]['time'] ); + $this->assertSame( '2022-01-02T00:00:00+00:00', $result['changelog'][0]['time'] ); + $this->assertSame( '2022-01-03T00:00:00+00:00', $result['attachments'][0]['time'] ); + } + + /** + * Non-numeric time fields are left untouched (already-formatted strings survive). + */ + public function test_normalisation_leaves_non_numeric_time_alone() { + $this->clients['core']->next_response = $this->ticket_row( + array( 'time' => 'already-iso' ) + ); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertSame( 'already-iso', $result['time'] ); + } + + /** + * Ticket id is cast to int and a canonical Trac URL is injected. + */ + public function test_normalisation_casts_id_and_injects_url() { + $this->clients['core']->next_response = $this->ticket_row( array( 'id' => '42' ) ); + + $result = $this->api->get_ticket( 'core', 42 ); + + $this->assertSame( 42, $result['id'] ); + $this->assertSame( 'https://core.trac.wordpress.org/ticket/42', $result['url'] ); + } + + /** + * URL injection uses the trac slug provided to get_ticket. + */ + public function test_meta_trac_url_normalisation() { + $this->clients['meta']->next_response = $this->ticket_row( array( 'id' => '99' ) ); + + $result = $this->api->get_ticket( 'meta', 99 ); + + $this->assertSame( 'https://meta.trac.wordpress.org/ticket/99', $result['url'] ); + } + + /** + * Different opts payloads resolve to different cache keys, so each is fetched independently. + */ + public function test_different_opts_get_separate_cache_keys() { + $this->clients['core']->next_response = $this->ticket_row(); + $this->api->get_ticket( 'core', 42, array( 'comments' => 25 ) ); + + $this->clients['core']->next_response = $this->ticket_row( array( 'summary' => 'Different opts' ) ); + $this->api->get_ticket( 'core', 42, array( 'comments' => false ) ); + + $this->assertCount( 2, $this->clients['core']->calls, 'distinct opts produce distinct cache keys' ); + } + + /** + * Opt insertion order does not affect the cache key, thanks to ksort(). + */ + public function test_opts_in_different_order_resolve_to_same_cache_key() { + $this->clients['core']->next_response = $this->ticket_row(); + $this->api->get_ticket( + 'core', + 42, + array( + 'comments' => 25, + 'changelog' => true, + ) + ); + + $this->clients['core']->calls = array(); + + $this->api->get_ticket( + 'core', + 42, + array( + 'changelog' => true, + 'comments' => 25, + ) + ); + + $this->assertCount( 0, $this->clients['core']->calls, 'ksort makes opt ordering irrelevant' ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_Notifications_DB_Tests.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_Notifications_DB_Tests.php new file mode 100644 index 0000000000..51fa833faa --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/Trac_Notifications_DB_Tests.php @@ -0,0 +1,597 @@ +fake = new Fake_DB(); + $this->dao = new Trac_Notifications_DB( $this->fake ); + } + + /** + * Comments are returned in chronological order with the expected SQL shape. + */ + public function test_comments_returns_rows_in_chronological_order() { + $this->fake->get_results_returns[] = array( + array( + 'id' => '1', + 'time' => '100', + 'author' => 'alice', + 'body' => 'first', + ), + array( + 'id' => '2', + 'time' => '200', + 'author' => 'bob', + 'body' => 'second', + ), + ); + + $result = $this->dao->get_trac_ticket_comments( 42, 10 ); + + $this->assertCount( 2, $result ); + $this->assertSame( 'alice', $result[0]['author'] ); + $this->assertSame( 'second', $result[1]['body'] ); + $this->assertStringContainsString( 'ORDER BY time ASC', $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( 'LIMIT 10', $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( "field = 'comment'", $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( "newvalue <> ''", $this->fake->get_results_calls[0] ); + } + + /** + * No rows yields an empty array, not null. + */ + public function test_comments_empty_result_returns_empty_array() { + $this->fake->get_results_returns[] = array(); + $this->assertSame( array(), $this->dao->get_trac_ticket_comments( 42 ) ); + } + + /** + * A zero limit omits the LIMIT clause (return all rows). + */ + public function test_comments_unlimited_when_limit_zero() { + $this->fake->get_results_returns[] = array(); + $this->dao->get_trac_ticket_comments( 42, 0 ); + $this->assertStringNotContainsString( 'LIMIT', $this->fake->get_results_calls[0] ); + } + + /** + * Offset is passed through to the SQL. + */ + public function test_comments_honours_offset() { + $this->fake->get_results_returns[] = array(); + $this->dao->get_trac_ticket_comments( 42, 5, 10 ); + $this->assertStringContainsString( 'LIMIT 5 OFFSET 10', $this->fake->get_results_calls[0] ); + } + + /** + * Non-integer ticket ids are coerced to int before being prepared. + */ + public function test_comments_passes_cast_ticket_id_to_prepare() { + $this->fake->get_results_returns[] = array(); + $this->dao->get_trac_ticket_comments( '42abc', 5 ); + $this->assertSame( 42, $this->fake->prepare_calls[0]['args'][0] ); + } + + /** + * Comment count is coerced to an int. + */ + public function test_comment_count_returns_int() { + $this->fake->get_var_returns[] = '7'; + $this->assertSame( 7, $this->dao->get_trac_ticket_comment_count( 42 ) ); + } + + /** + * Null from the DB collapses to zero (not null/false). + */ + public function test_comment_count_zero_when_null() { + $this->fake->get_var_returns[] = null; + $this->assertSame( 0, $this->dao->get_trac_ticket_comment_count( 42 ) ); + } + + /** + * Changelog returns every non-comment, non-cc field transition. + */ + public function test_changelog_returns_non_comment_changes() { + $this->fake->get_results_returns[] = array( + array( + 'time' => '100', + 'author' => 'alice', + 'field' => 'status', + 'oldvalue' => 'new', + 'newvalue' => 'assigned', + ), + array( + 'time' => '200', + 'author' => 'bob', + 'field' => 'keywords', + 'oldvalue' => '', + 'newvalue' => 'has-patch', + ), + ); + + $result = $this->dao->get_trac_ticket_changelog( 42 ); + + $this->assertCount( 2, $result ); + $this->assertSame( 'status', $result[0]['field'] ); + $this->assertStringContainsString( "field <> 'comment'", $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( "field <> 'cc'", $this->fake->get_results_calls[0] ); + } + + /** + * Changelog with no rows yields an empty array. + */ + public function test_changelog_empty() { + $this->fake->get_results_returns[] = array(); + $this->assertSame( array(), $this->dao->get_trac_ticket_changelog( 42 ) ); + } + + /** + * Attachments query reads the attachment table filtered by type=ticket. + */ + public function test_attachments_returns_rows() { + $this->fake->get_results_returns[] = array( + array( + 'filename' => 'patch.diff', + 'size' => '1024', + 'time' => '100', + 'description' => '', + 'author' => 'alice', + ), + ); + + $result = $this->dao->get_trac_ticket_attachments( 42 ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'patch.diff', $result[0]['filename'] ); + $this->assertStringContainsString( "type = 'ticket'", $this->fake->get_results_calls[0] ); + } + + /** + * Custom fields are returned as a name => value map. + */ + public function test_custom_fields_returns_name_value_map() { + $this->fake->get_results_returns[] = array( + array( + 'name' => 'focuses', + 'value' => 'rest-api, performance', + ), + array( + 'name' => 'keywords', + 'value' => 'has-patch', + ), + ); + + $result = $this->dao->get_trac_ticket_custom_fields( 42 ); + + $this->assertSame( + array( + 'focuses' => 'rest-api, performance', + 'keywords' => 'has-patch', + ), + $result + ); + } + + /** + * No custom fields yields an empty map (not null/false). + */ + public function test_custom_fields_empty() { + $this->fake->get_results_returns[] = array(); + $this->assertSame( array(), $this->dao->get_trac_ticket_custom_fields( 42 ) ); + } + + /** + * Missing ticket short-circuits to null with no follow-up queries. + */ + public function test_full_returns_null_for_missing_ticket() { + $this->fake->get_row_returns[] = null; + + $this->assertNull( $this->dao->get_trac_ticket_full( 999 ) ); + $this->assertCount( 1, $this->fake->get_row_calls, 'No follow-up queries when ticket is missing' ); + } + + /** + * Composite payload includes ticket fields, custom fields, participants, comments + count, changelog, attachments. + */ + public function test_full_assembles_complete_payload() { + $this->fake->get_row_returns[] = array( + 'id' => '42', + 'summary' => 'Test ticket', + 'status' => 'new', + ); + $this->fake->get_results_returns[] = array( // custom_fields. + array( + 'name' => 'focuses', + 'value' => 'rest-api', + ), + ); + $this->fake->get_col_returns[] = array( 'alice', 'bob' ); // participants. + $this->fake->get_var_returns[] = '1'; // comment count. + $this->fake->get_results_returns[] = array( // comments. + array( + 'id' => '1', + 'time' => '100', + 'author' => 'alice', + 'body' => 'a comment', + ), + ); + $this->fake->get_results_returns[] = array(); // changelog. + $this->fake->get_results_returns[] = array(); // attachments. + + $result = $this->dao->get_trac_ticket_full( 42 ); + + $this->assertSame( '42', $result['id'] ); + $this->assertSame( 'Test ticket', $result['summary'] ); + $this->assertSame( array( 'focuses' => 'rest-api' ), $result['custom_fields'] ); + $this->assertSame( array( 'alice', 'bob' ), $result['participants'] ); + $this->assertCount( 1, $result['comments'] ); + $this->assertSame( 1, $result['comments_total'] ); + $this->assertSame( array(), $result['changelog'] ); + $this->assertSame( array(), $result['attachments'] ); + } + + /** + * Passing comments=false drops the comments and comments_total keys. + */ + public function test_full_can_skip_comments() { + $this->fake->get_row_returns[] = array( 'id' => '42' ); + $this->fake->get_results_returns[] = array(); // custom_fields. + $this->fake->get_col_returns[] = array(); // participants. + $this->fake->get_results_returns[] = array(); // changelog. + $this->fake->get_results_returns[] = array(); // attachments. + + $result = $this->dao->get_trac_ticket_full( 42, array( 'comments' => false ) ); + + $this->assertArrayNotHasKey( 'comments', $result ); + $this->assertArrayNotHasKey( 'comments_total', $result ); + $this->assertArrayHasKey( 'changelog', $result ); + } + + /** + * Passing changelog=false and attachments=false drops their respective keys. + */ + public function test_full_can_skip_changelog_and_attachments() { + $this->fake->get_row_returns[] = array( 'id' => '42' ); + $this->fake->get_results_returns[] = array(); // custom_fields. + $this->fake->get_col_returns[] = array(); // participants. + $this->fake->get_var_returns[] = '0'; // comment count. + $this->fake->get_results_returns[] = array(); // comments. + + $result = $this->dao->get_trac_ticket_full( + 42, + array( + 'changelog' => false, + 'attachments' => false, + ) + ); + + $this->assertArrayHasKey( 'comments', $result ); + $this->assertArrayNotHasKey( 'changelog', $result ); + $this->assertArrayNotHasKey( 'attachments', $result ); + } + + /** + * The composite computes offset = total - limit so the trailing window of comments + * is returned in ASC display order. + */ + public function test_full_returns_most_recent_n_comments_in_chronological_order() { + $this->fake->get_row_returns[] = array( 'id' => '42' ); + $this->fake->get_results_returns[] = array(); // custom_fields. + $this->fake->get_col_returns[] = array(); // participants. + $this->fake->get_var_returns[] = '100'; // total comments. + $this->fake->get_results_returns[] = array(); // comments. + $this->fake->get_results_returns[] = array(); // changelog. + $this->fake->get_results_returns[] = array(); // attachments. + + $this->dao->get_trac_ticket_full( 42, array( 'comments' => 5 ) ); + + $comments_query = null; + foreach ( $this->fake->get_results_calls as $query ) { + if ( str_contains( $query, "field = 'comment'" ) ) { + $comments_query = $query; + break; + } + } + $this->assertNotNull( $comments_query ); + $this->assertStringContainsString( 'LIMIT 5 OFFSET 95', $comments_query ); + } + + /** + * Passing comments=0 (unlimited) uses offset 0, so no LIMIT clause is appended. + */ + public function test_full_unlimited_comments_uses_zero_offset() { + $this->fake->get_row_returns[] = array( 'id' => '42' ); + $this->fake->get_results_returns[] = array(); // custom_fields. + $this->fake->get_col_returns[] = array(); // participants. + $this->fake->get_var_returns[] = '100'; // total comments. + $this->fake->get_results_returns[] = array(); // comments. + $this->fake->get_results_returns[] = array(); // changelog. + $this->fake->get_results_returns[] = array(); // attachments. + + $this->dao->get_trac_ticket_full( 42, array( 'comments' => 0 ) ); + + $comments_query = null; + foreach ( $this->fake->get_results_calls as $query ) { + if ( str_contains( $query, "field = 'comment'" ) ) { + $comments_query = $query; + break; + } + } + $this->assertNotNull( $comments_query ); + $this->assertStringNotContainsString( 'LIMIT', $comments_query ); + } + + /** + * Focuses accessor returns the focuses value when present in the custom fields. + */ + public function test_focuses_returns_value_via_custom_fields() { + $this->fake->get_results_returns[] = array( + array( + 'name' => 'focuses', + 'value' => 'rest-api, performance', + ), + ); + $this->assertSame( 'rest-api, performance', $this->dao->get_trac_ticket_focuses( 42 ) ); + } + + /** + * Focuses accessor returns null when the ticket has no focuses custom field. + */ + public function test_focuses_returns_null_when_absent() { + $this->fake->get_results_returns[] = array(); + $this->assertNull( $this->dao->get_trac_ticket_focuses( 42 ) ); + } + + /** + * Search returns the canonical column set ordered by changetime DESC. + */ + public function test_search_returns_rows_with_default_order() { + $this->fake->get_results_returns[] = array( + array( + 'id' => '42', + 'summary' => 'Recent', + 'status' => 'new', + 'type' => 'defect', + 'component' => 'REST API', + 'priority' => 'normal', + 'changetime' => '200', + ), + ); + + $result = $this->dao->search_trac_tickets(); + + $this->assertCount( 1, $result ); + $this->assertSame( '42', $result[0]['id'] ); + $this->assertStringContainsString( 'ORDER BY t.changetime DESC', $this->fake->get_results_calls[0] ); + } + + /** + * Search excludes closed tickets unless asked otherwise. + */ + public function test_search_excludes_closed_by_default() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets(); + $this->assertStringContainsString( "t.status <> 'closed'", $this->fake->get_results_calls[0] ); + } + + /** + * Supplying an explicit status disables the default closed-exclusion clause. + */ + public function test_search_includes_closed_when_status_supplied() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array( 'status' => 'closed' ) ); + $this->assertStringNotContainsString( "t.status <> 'closed'", $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( 't.status = %s', $this->fake->get_results_calls[0] ); + } + + /** + * The include_closed flag also disables the default closed-exclusion clause. + */ + public function test_search_includes_closed_with_flag() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array( 'include_closed' => true ) ); + $this->assertStringNotContainsString( "t.status <> 'closed'", $this->fake->get_results_calls[0] ); + } + + /** + * Unknown filter keys are silently dropped (allowlist enforcement). + */ + public function test_search_unknown_filter_keys_ignored() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'type' => 'defect', + 'malicious_column' => 'value', + 'DROP TABLE x;' => '1', + ) + ); + + $prepared_args = $this->fake->prepare_calls[0]['args'][0]; + $this->assertSame( array( 'defect' ), $prepared_args, 'only the allowlisted "type" value is bound' ); + $this->assertStringNotContainsString( 'malicious_column', $this->fake->get_results_calls[0] ); + } + + /** + * Type + component filter combines into the WHERE clause and binds both values. + */ + public function test_search_combines_filters() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'type' => 'defect', + 'component' => 'REST API', + ) + ); + + $this->assertStringContainsString( 't.type = %s', $this->fake->get_results_calls[0] ); + $this->assertStringContainsString( 't.component = %s', $this->fake->get_results_calls[0] ); + $this->assertSame( + array( 'defect', 'REST API' ), + $this->fake->prepare_calls[0]['args'][0] + ); + } + + /** + * The focuses filter adds a ticket_custom join and a LIKE comparison. + */ + public function test_search_focuses_filter_adds_join() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'component' => 'Media', + 'focuses' => 'rest-api', + ) + ); + + $query = $this->fake->get_results_calls[0]; + $this->assertStringContainsString( "LEFT JOIN ticket_custom cf ON cf.ticket = t.id AND cf.name = 'focuses'", $query ); + $this->assertStringContainsString( 'cf.value LIKE %s', $query ); + $this->assertSame( + array( 'Media', '%rest-api%' ), + $this->fake->prepare_calls[0]['args'][0] + ); + } + + /** + * The keywords filter adds a ticket_custom join with its own alias. + */ + public function test_search_keywords_filter_adds_join() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'component' => 'Media', + 'keywords' => 'has-patch', + ) + ); + + $query = $this->fake->get_results_calls[0]; + $this->assertStringContainsString( "LEFT JOIN ticket_custom ck ON ck.ticket = t.id AND ck.name = 'keywords'", $query ); + $this->assertStringContainsString( 'ck.value LIKE %s', $query ); + } + + /** + * changed_since accepts a strtotime string and converts to Trac's microsecond unit. + */ + public function test_search_changed_since_converts_to_microseconds() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array( 'changed_since' => '2022-01-01' ) ); + + $bound = $this->fake->prepare_calls[0]['args'][0][0]; + $this->assertSame( strtotime( '2022-01-01' ) * 1000000, $bound ); + } + + /** + * Limit is clamped to [1, 50]. + */ + public function test_search_limit_capped_at_50() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array(), 999 ); + $this->assertStringContainsString( 'LIMIT 50', $this->fake->get_results_calls[0] ); + + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array(), 0 ); + $this->assertStringContainsString( 'LIMIT 1', $this->fake->get_results_calls[1] ); + } + + /** + * Offset is passed through and clamped at zero. + */ + public function test_search_offset_passed_through() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( array(), 25, 75 ); + $this->assertStringContainsString( 'OFFSET 75', $this->fake->get_results_calls[0] ); + } + + /** + * With no filters, prepare() is not called because the SQL has no placeholders to bind. + */ + public function test_search_no_filters_skips_prepare() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets(); + $this->assertCount( 0, $this->fake->prepare_calls ); + } + + /** + * Unscoped focuses LIKE is refused (returns empty) without touching the DB. + */ + public function test_search_refuses_unscoped_focuses() { + $result = $this->dao->search_trac_tickets( array( 'focuses' => 'performance' ) ); + + $this->assertSame( array(), $result ); + $this->assertCount( 0, $this->fake->get_results_calls, 'no SQL executed' ); + $this->assertCount( 0, $this->fake->prepare_calls ); + } + + /** + * Unscoped keywords LIKE is also refused. + */ + public function test_search_refuses_unscoped_keywords() { + $result = $this->dao->search_trac_tickets( array( 'keywords' => 'has-patch' ) ); + + $this->assertSame( array(), $result ); + $this->assertCount( 0, $this->fake->get_results_calls ); + } + + /** + * A LIKE filter scoped by an equality filter is allowed through. + */ + public function test_search_allows_focuses_when_scoped_by_equality_filter() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'component' => 'Media', + 'focuses' => 'performance', + ) + ); + + $this->assertCount( 1, $this->fake->get_results_calls ); + $this->assertStringContainsString( 'cf.value LIKE %s', $this->fake->get_results_calls[0] ); + } + + /** + * A LIKE filter scoped by changed_since is allowed through. + */ + public function test_search_allows_focuses_when_scoped_by_changed_since() { + $this->fake->get_results_returns[] = array(); + $this->dao->search_trac_tickets( + array( + 'changed_since' => '2024-01-01', + 'focuses' => 'performance', + ) + ); + + $this->assertCount( 1, $this->fake->get_results_calls ); + $this->assertStringContainsString( 'cf.value LIKE %s', $this->fake->get_results_calls[0] ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/bootstrap.php new file mode 100644 index 0000000000..99a97b76f7 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/bootstrap.php @@ -0,0 +1,84 @@ +calls[] = array( + 'method' => $method, + 'args' => $args, + ); + return $this->next_response; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-fake-db.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-fake-db.php new file mode 100644 index 0000000000..7bebbe9694 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-fake-db.php @@ -0,0 +1,139 @@ + + */ + public $prepare_calls = array(); + + /** + * Log of queries passed to get_row(). + * + * @var array + */ + public $get_row_calls = array(); + + /** + * Log of queries passed to get_results(). + * + * @var array + */ + public $get_results_calls = array(); + + /** + * Log of queries passed to get_col(). + * + * @var array + */ + public $get_col_calls = array(); + + /** + * Log of queries passed to get_var(). + * + * @var array + */ + public $get_var_calls = array(); + + /** + * Queued return values for get_row(). + * + * @var array + */ + public $get_row_returns = array(); + + /** + * Queued return values for get_results(). + * + * @var array + */ + public $get_results_returns = array(); + + /** + * Queued return values for get_col(). + * + * @var array + */ + public $get_col_returns = array(); + + /** + * Queued return values for get_var(). + * + * @var array + */ + public $get_var_returns = array(); + + /** + * Mimic wpdb::prepare(). Returns the raw query for assertion convenience; + * arg substitution is not performed since the production DAO casts numeric + * args before calling prepare(). + * + * @param string $query SQL with placeholders. + * @param mixed ...$args Placeholder values. + * @return string + */ + public function prepare( $query, ...$args ) { + $this->prepare_calls[] = array( + 'query' => $query, + 'args' => $args, + ); + return $query; + } + + /** + * Mimic wpdb::get_row(). + * + * @param string $query Prepared query. + * @param string $output Output type (ignored). + * @return mixed + */ + public function get_row( $query, $output = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- mirrors wpdb signature. + $this->get_row_calls[] = $query; + return array_shift( $this->get_row_returns ); + } + + /** + * Mimic wpdb::get_results(). + * + * @param string $query Prepared query. + * @param string $output Output type (ignored). + * @return mixed + */ + public function get_results( $query, $output = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- mirrors wpdb signature. + $this->get_results_calls[] = $query; + return array_shift( $this->get_results_returns ); + } + + /** + * Mimic wpdb::get_col(). + * + * @param string $query Prepared query. + * @return mixed + */ + public function get_col( $query ) { + $this->get_col_calls[] = $query; + return array_shift( $this->get_col_returns ); + } + + /** + * Mimic wpdb::get_var(). + * + * @param string $query Prepared query. + * @return mixed + */ + public function get_var( $query ) { + $this->get_var_calls[] = $query; + return array_shift( $this->get_var_returns ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-test-cache.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-test-cache.php new file mode 100644 index 0000000000..7788fdc2b2 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/tests/includes/class-test-cache.php @@ -0,0 +1,97 @@ + + */ + public static $store = array(); + + /** + * Log of get() requests, keyed by "group:key". + * + * @var array + */ + public static $get_calls = array(); + + /** + * Log of set() requests as { key, ttl }. + * + * @var array + */ + public static $set_calls = array(); + + /** + * Clear all state. Call from test setUp() so each test starts clean. + */ + public static function reset() { + self::$store = array(); + self::$get_calls = array(); + self::$set_calls = array(); + } + + /** + * Build the composite key the polyfill stores under. + * + * @param string $key Cache key. + * @param string $group Cache group. + * @return string + */ + public static function key( $key, $group ) { + return $group . ':' . $key; + } + + /** + * Polyfill implementation of wp_cache_get(): miss returns false. + * + * @param string $key Cache key. + * @param string $group Cache group. + * @return mixed + */ + public static function get( $key, $group ) { + $k = self::key( $key, $group ); + self::$get_calls[] = $k; + return array_key_exists( $k, self::$store ) ? self::$store[ $k ] : false; + } + + /** + * Polyfill implementation of wp_cache_set(): TTL is recorded but not enforced. + * + * @param string $key Cache key. + * @param mixed $value Value. + * @param string $group Cache group. + * @param int $ttl TTL in seconds. + * @return bool + */ + public static function set( $key, $value, $group, $ttl ) { + $k = self::key( $key, $group ); + self::$set_calls[] = array( + 'key' => $k, + 'ttl' => $ttl, + ); + self::$store[ $k ] = $value; + return true; + } + + /** + * Force-expire a key (test helper). + * + * @param string $key Cache key. + * @param string $group Cache group. + */ + public static function expire( $key, $group ) { + unset( self::$store[ self::key( $key, $group ) ] ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/trac-notifications/trac-notifications-db.php b/wordpress.org/public_html/wp-content/plugins/trac-notifications/trac-notifications-db.php index 09baeaff56..66a4598407 100644 --- a/wordpress.org/public_html/wp-content/plugins/trac-notifications/trac-notifications-db.php +++ b/wordpress.org/public_html/wp-content/plugins/trac-notifications/trac-notifications-db.php @@ -5,6 +5,12 @@ * It must work without any other dependencies, such as WordPress. */ class Trac_Notifications_DB implements Trac_Notifications_API { + /** + * Database driver: wpdb on the wp.org side, Trac_Notifications_SQLite_Driver + * (or wpdb against the Trac MySQL) on the Trac boxes. + * + * @var object + */ public $db; function __construct( $db ) { @@ -117,7 +123,8 @@ function get_trac_ticket( $ticket_id ) { } function get_trac_ticket_focuses( $ticket_id ) { - return $this->db->get_var( $this->db->prepare( "SELECT value FROM ticket_custom WHERE ticket = %d AND name = 'focuses'", $ticket_id ) ); + $fields = $this->get_trac_ticket_custom_fields( $ticket_id ); + return $fields['focuses'] ?? null; } function get_trac_ticket_participants( $ticket_id ) { @@ -128,6 +135,284 @@ function get_trac_ticket_participants( $ticket_id ) { return $this->db->get_col( $this->db->prepare( "SELECT DISTINCT author FROM ticket_change WHERE $ignore_cc ticket = %d", $ticket_id ) ); } + /** + * Composite ticket payload — one Trac round-trip from the wp.org side. + * + * Combines the ticket row, participants, custom fields, the most recent + * comments (chronologically ordered), changelog (non-comment field + * changes), and attachments. Time columns are returned raw; Trac stores + * them as microseconds since epoch and the caller converts at the output + * boundary. + * + * @param int $ticket_id Ticket id. + * @param array $opts { + * Optional. Payload-shape flags. + * + * @type int|false $comments Most-recent-N cap. Default 25. false omits comments. 0 returns all. + * @type bool $changelog Include non-comment field changes. Default true. + * @type bool $attachments Include attachments. Default true. + * } + * @return array|null Ticket payload, or null when the ticket does not exist. + */ + public function get_trac_ticket_full( $ticket_id, $opts = array() ) { + $ticket = $this->get_trac_ticket( $ticket_id ); + if ( ! $ticket ) { + return null; + } + + $opts = array_merge( + array( + 'comments' => 25, + 'changelog' => true, + 'attachments' => true, + ), + $opts + ); + + $ticket['custom_fields'] = $this->get_trac_ticket_custom_fields( $ticket_id ); + $ticket['participants'] = $this->get_trac_ticket_participants( $ticket_id ); + + if ( false !== $opts['comments'] ) { + $limit = (int) $opts['comments']; + $total = $this->get_trac_ticket_comment_count( $ticket_id ); + $ticket['comments_total'] = $total; + + /* + * Most-recent-N in chronological order: skip the leading rows so + * the trailing window remains in ASC display order. + */ + $offset = ( $limit > 0 ) ? max( 0, $total - $limit ) : 0; + $ticket['comments'] = $this->get_trac_ticket_comments( $ticket_id, $limit, $offset ); + } + + if ( $opts['changelog'] ) { + $ticket['changelog'] = $this->get_trac_ticket_changelog( $ticket_id ); + } + + if ( $opts['attachments'] ) { + $ticket['attachments'] = $this->get_trac_ticket_attachments( $ticket_id ); + } + + return $ticket; + } + + /** + * ASC-ordered window of comments. Use $offset to page or to take the tail + * of the conversation. Trac stores the comment number in `oldvalue` for + * comment rows; we surface it as `id`. Empty-body rows (used internally + * by Trac for property-change numbering) are excluded. + * + * @param int $ticket_id Ticket id. + * @param int $limit Row cap. 0 returns all rows. + * @param int $offset Row offset. + * @return array + */ + public function get_trac_ticket_comments( $ticket_id, $limit = 25, $offset = 0 ) { + $limit = (int) $limit; + + $sql = "SELECT oldvalue AS id, time, author, newvalue AS body + FROM ticket_change + WHERE ticket = %d AND field = 'comment' AND newvalue <> '' + ORDER BY time ASC"; + + if ( $limit > 0 ) { + $sql .= sprintf( ' LIMIT %d OFFSET %d', $limit, (int) $offset ); + } + + return $this->db->get_results( $this->db->prepare( $sql, (int) $ticket_id ), ARRAY_A ); + } + + /** + * Total comment count for a ticket. + * + * @param int $ticket_id Ticket id. + * @return int + */ + public function get_trac_ticket_comment_count( $ticket_id ) { + return (int) $this->db->get_var( + $this->db->prepare( + "SELECT COUNT(*) FROM ticket_change WHERE ticket = %d AND field = 'comment' AND newvalue <> ''", + (int) $ticket_id + ) + ); + } + + /** + * Non-comment ticket changelog — every field transition (status, owner, + * keywords, milestone, etc.) except comment bodies and cc-only changes. + * Consumers filter for the fields they care about. Ordered oldest to + * newest so a forward read reconstructs ticket history. + * + * @param int $ticket_id Ticket id. + * @return array + */ + public function get_trac_ticket_changelog( $ticket_id ) { + return $this->db->get_results( + $this->db->prepare( + "SELECT time, author, field, oldvalue, newvalue + FROM ticket_change + WHERE ticket = %d AND field <> 'comment' AND field <> 'cc' + ORDER BY time ASC", + (int) $ticket_id + ), + ARRAY_A + ); + } + + /** + * Attachments uploaded to a ticket. + * + * @param int $ticket_id Ticket id. + * @return array + */ + public function get_trac_ticket_attachments( $ticket_id ) { + return $this->db->get_results( + $this->db->prepare( + "SELECT filename, size, time, description, author + FROM attachment + WHERE type = 'ticket' AND id = %d + ORDER BY time ASC", + (int) $ticket_id + ), + ARRAY_A + ); + } + + /** + * All custom fields for a ticket as a name => value map. + * + * @param int $ticket_id Ticket id. + * @return array + */ + public function get_trac_ticket_custom_fields( $ticket_id ) { + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT name, value FROM ticket_custom WHERE ticket = %d', + (int) $ticket_id + ), + ARRAY_A + ); + + $out = array(); + foreach ( $rows as $row ) { + $out[ $row['name'] ] = $row['value']; + } + return $out; + } + + /** + * Filter-and-paginate ticket search for external callers (HTTP API, MCP). + * + * Filter keys are allowlisted to keep the WHERE clause safe to build from + * untrusted input. Equality filters cover ticket-table columns; `focuses` + * and `keywords` join the ticket_custom table; `changed_since` accepts any + * strtotime-parseable string and is compared against ticket.changetime + * (which Trac stores as microseconds since epoch). + * + * Default behaviour excludes closed tickets unless `status` is set + * explicitly or `include_closed` is truthy. + * + * @param array $filters { + * Optional. Allowlisted filters. + * + * @type string $type Ticket type (e.g. 'defect', 'enhancement'). + * @type string $status Ticket status; supplying this disables the default closed-exclusion. + * @type string $resolution Resolution value. + * @type string $milestone Milestone name. + * @type string $component Component name. + * @type string $priority Priority value. + * @type string $severity Severity value. + * @type string $owner Owner username. + * @type string $reporter Reporter username. + * @type string $focuses Substring match against the focuses custom field. + * @type string $keywords Substring match against the keywords custom field. + * @type string $changed_since strtotime-parseable date; matches ticket.changetime >= value. + * @type bool $include_closed Allow closed tickets in the result set without naming a specific status. + * } + * @param int $limit 1-50, default 25. + * @param int $offset Row offset for pagination. + * @return array + */ + public function search_trac_tickets( $filters = array(), $limit = 25, $offset = 0 ) { + $allowed_eq = array( + 'type', + 'status', + 'resolution', + 'milestone', + 'component', + 'priority', + 'severity', + 'owner', + 'reporter', + ); + + /* + * Refuse unscoped LIKE queries on ticket_custom. A bare focuses=X or + * keywords=X with no other narrowing filter forces a full table scan + * plus a join across ~250k custom-field rows, which is the most + * expensive query this method can produce. + */ + $has_scope = ! empty( $filters['changed_since'] ); + foreach ( $allowed_eq as $key ) { + if ( ! empty( $filters[ $key ] ) ) { + $has_scope = true; + break; + } + } + $uses_custom_like = ! empty( $filters['focuses'] ) || ! empty( $filters['keywords'] ); + if ( $uses_custom_like && ! $has_scope ) { + return array(); + } + + $where = array( '1=1' ); + $vals = array(); + $join = ''; + + foreach ( $allowed_eq as $key ) { + if ( isset( $filters[ $key ] ) && '' !== $filters[ $key ] ) { + $where[] = "t.$key = %s"; + $vals[] = $filters[ $key ]; + } + } + + if ( empty( $filters['status'] ) && empty( $filters['include_closed'] ) ) { + $where[] = "t.status <> 'closed'"; + } + + if ( ! empty( $filters['focuses'] ) ) { + $join .= " LEFT JOIN ticket_custom cf ON cf.ticket = t.id AND cf.name = 'focuses' "; + $where[] = 'cf.value LIKE %s'; + $vals[] = '%' . $filters['focuses'] . '%'; + } + + if ( ! empty( $filters['keywords'] ) ) { + $join .= " LEFT JOIN ticket_custom ck ON ck.ticket = t.id AND ck.name = 'keywords' "; + $where[] = 'ck.value LIKE %s'; + $vals[] = '%' . $filters['keywords'] . '%'; + } + + if ( ! empty( $filters['changed_since'] ) ) { + $where[] = 't.changetime >= %s'; + $vals[] = (int) ( strtotime( $filters['changed_since'] ) * 1000000 ); + } + + $limit = max( 1, min( 50, (int) $limit ) ); + $offset = max( 0, (int) $offset ); + + $sql = "SELECT t.id, t.summary, t.status, t.resolution, t.type, t.component, + t.priority, t.milestone, t.owner, t.reporter, t.changetime + FROM ticket t $join + WHERE " . implode( ' AND ', $where ) . " + ORDER BY t.changetime DESC + LIMIT $limit OFFSET $offset"; + + if ( ! empty( $vals ) ) { + $sql = $this->db->prepare( $sql, $vals ); + } + + return $this->db->get_results( $sql, ARRAY_A ); + } + function get_trac_ticket_subscriptions( $ticket_id ) { $by_status = array( 'blocked' => array(), 'starred' => array() ); $subscriptions = $this->db->get_results( $this->db->prepare( "SELECT username, status FROM _ticket_subs WHERE ticket = %d", $ticket_id ) );