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 ) );