diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml
new file mode 100644
index 00000000..08a94cf8
--- /dev/null
+++ b/.github/workflows/update-contributors.yml
@@ -0,0 +1,70 @@
+name: Update Contributors
+
+on:
+ # Manual trigger for release process
+ workflow_dispatch:
+ inputs:
+ update_output_files:
+ description: 'Update output files (readme.txt, CONTRIBUTORS.md, docs page)'
+ required: false
+ default: 'true'
+ type: choice
+ options:
+ - 'true'
+ - 'false'
+
+# Disable permissions for all available scopes by default
+# Any needed permissions should be configured at the job level
+permissions: {}
+
+jobs:
+ # This job runs manually during release process to update contributor data and output files
+ update-contributor-list:
+ name: Update contributor list
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ persist-credentials: true
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer install --no-progress --prefer-dist --no-interaction
+
+ - name: Run contributor backfill
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if [ "${{ inputs.update_output_files }}" = "true" ]; then
+ php bin/backfill-contributors.php --validate
+ else
+ php bin/backfill-contributors.php --validate --skip-output
+ fi
+
+ - name: Check for changes
+ id: check-changes
+ run: |
+ if ! git diff --quiet -- bin/contributors.json readme.txt CONTRIBUTORS.md docs/contributing/contributors.md 2>/dev/null; then
+ echo "changes=true" >> $GITHUB_OUTPUT
+ else
+ echo "changes=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Commit and push changes
+ if: steps.check-changes.outputs.changes == 'true'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add bin/contributors.json readme.txt CONTRIBUTORS.md docs/contributing/contributors.md
+ git commit -m "Update contributor list"
+ git push
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
index 9755c62a..f466fe60 100644
--- a/.phpcs.xml.dist
+++ b/.phpcs.xml.dist
@@ -28,6 +28,20 @@
+
+
+ /tests/
+
+
+ /tests/
+
+
+
+
+ /bin/backfill-contributors\.php$
+ /bin/contributors-functions\.php$
+
+
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 00000000..155a3c26
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,32 @@
+# Contributors
+
+Thank you to all the contributors who have helped make Secure Custom Fields better.
+
+This file is automatically generated from the contributor acknowledgement system.
+Contributors are recognized for their commits, code reviews, issue reports, and comments.
+
+| WordPress.org Username | GitHub Username | Display Name |
+| ---------------------- | --------------- | ------------ |
+| [@bernhard-reiter](https://profiles.wordpress.org/bernhard-reiter) | [@ockham](https://github.com/ockham) | |
+| [@bjorsch](https://profiles.wordpress.org/bjorsch) | [@anomiex](https://github.com/anomiex) | |
+| [@cbravobernal](https://profiles.wordpress.org/cbravobernal) | [@cbravobernal](https://github.com/cbravobernal) | |
+| [@gziolo](https://profiles.wordpress.org/gziolo) | [@gziolo](https://github.com/gziolo) | |
+| [@haseebnawaz298](https://profiles.wordpress.org/haseebnawaz298) | [@haseebnawaz298](https://github.com/haseebnawaz298) | |
+| [@jacobodonnell](https://profiles.wordpress.org/jacobodonnell) | [@jacobodonnell](https://github.com/jacobodonnell) | |
+| [@jamieburchell](https://profiles.wordpress.org/jamieburchell) | [@jamieburchell](https://github.com/jamieburchell) | |
+| [@kraftbj](https://profiles.wordpress.org/kraftbj) | [@kraftbj](https://github.com/kraftbj) | |
+| [@mcsf](https://profiles.wordpress.org/mcsf) | [@mcsf](https://github.com/mcsf) | |
+| [@mr2p](https://profiles.wordpress.org/mr2p) | [@Mr2P](https://github.com/Mr2P) | |
+| [@paulkevan](https://profiles.wordpress.org/paulkevan) | [@pkevan](https://github.com/pkevan) | |
+| [@priethor](https://profiles.wordpress.org/priethor) | [@priethor](https://github.com/priethor) | |
+| [@racmanuel](https://profiles.wordpress.org/racmanuel) | [@racmanuel](https://github.com/racmanuel) | |
+| [@trajche](https://profiles.wordpress.org/trajche) | [@trajche](https://github.com/trajche) | |
+| [@yanmetelitsa](https://profiles.wordpress.org/yanmetelitsa) | [@YanMetelitsa](https://github.com/YanMetelitsa) | |
+| [@youknowriad](https://profiles.wordpress.org/youknowriad) | [@youknowriad](https://github.com/youknowriad) | |
+| | [@cyberwani](https://github.com/cyberwani) | |
+| | [@DAnn2012](https://github.com/DAnn2012) | |
+| | [@duanestorey](https://github.com/duanestorey) | |
+| | [@gareins](https://github.com/gareins) | |
+| | [@j-hoffmann](https://github.com/j-hoffmann) | |
+| | [@justwhocares](https://github.com/justwhocares) | |
+| | [@robertdevore](https://github.com/robertdevore) | |
diff --git a/bin/backfill-contributors.php b/bin/backfill-contributors.php
new file mode 100644
index 00000000..f7ca322d
--- /dev/null
+++ b/bin/backfill-contributors.php
@@ -0,0 +1,1093 @@
+#!/usr/bin/env php
+github_token = $github_token;
+ $this->full_backfill = $full_backfill;
+ $this->dry_run = $dry_run;
+ $this->skip_wporg = $skip_wporg;
+ $this->skip_output = $skip_output;
+ $this->validate = $validate;
+ }
+
+ /**
+ * Run the backfill process
+ */
+ public function run() {
+ echo "Starting contributor backfill...\n";
+
+ if ( $this->dry_run ) {
+ echo "[DRY RUN] No changes will be saved.\n";
+ }
+
+ // Determine if we need a full backfill.
+ $is_incremental = $this->determine_backfill_mode();
+
+ if ( $is_incremental ) {
+ echo "[INCREMENTAL] Using cursor from last run.\n";
+ } else {
+ echo "[FULL] Fetching all historical data.\n";
+ }
+
+ // Collect contributors from REST API (commit authors) - always full for commits.
+ echo "\nFetching commit authors from REST API...\n";
+ $commit_contributors = $this->fetch_rest_api_contributors();
+ printf( "Found %d commit authors.\n", count( $commit_contributors ) );
+
+ // Collect contributors from GraphQL API (reviewers, commenters, issue reporters).
+ echo "\nFetching PR contributors from GraphQL API...\n";
+ $pr_contributors = $this->fetch_graphql_contributors( $is_incremental );
+ printf( "Found %d PR contributors (reviewers, commenters, issue reporters).\n", count( $pr_contributors ) );
+
+ // Merge all contributors.
+ echo "\nMerging contributors...\n";
+ $all_contributors = $this->merge_all_contributors( $commit_contributors, $pr_contributors );
+
+ // Filter bot accounts.
+ echo "Filtering bot accounts...\n";
+ $filtered_contributors = filter_bot_accounts( $all_contributors );
+ printf( "Filtered out %d bot accounts.\n", count( $all_contributors ) - count( $filtered_contributors ) );
+
+ // Load existing contributors and merge.
+ $existing_contributors = read_contributors();
+ printf( "Found %d existing contributors in contributors.json.\n", count( $existing_contributors ) );
+
+ $final_contributors = merge_contributors( $existing_contributors, $filtered_contributors );
+ printf( "Total unique contributors after merge: %d\n", count( $final_contributors ) );
+
+ // Perform WordPress.org profile lookup.
+ if ( ! $this->skip_wporg ) {
+ echo "\nLooking up WordPress.org profiles...\n";
+ $final_contributors = update_contributors_with_wporg_data(
+ $final_contributors,
+ function ( $message ) {
+ echo " $message\n";
+ }
+ );
+
+ $linked_count = count(
+ array_filter(
+ $final_contributors,
+ function ( $c ) {
+ return ! empty( $c['wporg_username'] );
+ }
+ )
+ );
+ printf( "Found %d contributors with linked WordPress.org accounts.\n", $linked_count );
+ } else {
+ echo "\nSkipping WordPress.org profile lookup.\n";
+ }
+
+ // Save or display results.
+ if ( $this->dry_run ) {
+ echo "\n[DRY RUN] Would save the following contributors:\n";
+ foreach ( $final_contributors as $contributor ) {
+ $wporg = $contributor['wporg_username'] ? " (wporg: {$contributor['wporg_username']})" : '';
+ printf(
+ " - %s%s: %s\n",
+ $contributor['github_username'],
+ $wporg,
+ implode( ', ', $contributor['contribution_types'] )
+ );
+ }
+ if ( $this->new_cursor ) {
+ echo "\n[DRY RUN] Would update cursor to: {$this->new_cursor}\n";
+ }
+ } else {
+ // Build metadata for the new format.
+ $metadata = $this->build_metadata( $is_incremental );
+
+ $result = write_contributors_with_metadata( $final_contributors, $metadata );
+ if ( $result ) {
+ echo "\nSuccessfully saved contributors to contributors.json\n";
+ if ( $this->new_cursor ) {
+ echo "Updated cursor for incremental processing.\n";
+ }
+ } else {
+ echo "\nError: Failed to save contributors.json\n";
+ exit( 1 );
+ }
+
+ // Generate output files.
+ if ( ! $this->skip_output ) {
+ echo "\nGenerating output files...\n";
+ $output_results = generate_all_output_files(
+ $final_contributors,
+ function ( $message ) {
+ echo " $message\n";
+ }
+ );
+
+ $success_count = count( array_filter( $output_results ) );
+ $total_count = count( $output_results );
+ printf( "Generated %d/%d output files successfully.\n", $success_count, $total_count );
+ } else {
+ echo "\nSkipping output file generation.\n";
+ }
+
+ // Validate against props-bot comments if requested.
+ if ( $this->validate ) {
+ echo "\nValidating against props-bot comments...\n";
+ $validation_result = $this->validate_against_props_bot( $final_contributors );
+
+ if ( ! empty( $validation_result['added'] ) ) {
+ // Re-save with newly added contributors.
+ $final_contributors = $validation_result['contributors'];
+ $result = write_contributors_with_metadata( $final_contributors, $metadata );
+ if ( $result ) {
+ echo "Updated contributors.json with validated contributors.\n";
+ }
+
+ // Regenerate output files if we added contributors.
+ if ( ! $this->skip_output ) {
+ echo "Regenerating output files with validated contributors...\n";
+ generate_all_output_files(
+ $final_contributors,
+ function ( $message ) {
+ echo " $message\n";
+ }
+ );
+ }
+ }
+ }
+ }
+
+ echo "\nBackfill complete!\n";
+ }
+
+ /**
+ * Determine if we should run in incremental mode
+ *
+ * Incremental mode is used when:
+ * - --full flag is NOT set
+ * - A valid cursor exists in the metadata
+ *
+ * @return bool True for incremental mode, false for full backfill.
+ */
+ private function determine_backfill_mode() {
+ // --full flag forces full backfill.
+ if ( $this->full_backfill ) {
+ return false;
+ }
+
+ // Check for existing cursor.
+ $metadata = read_contributors_metadata();
+ if ( ! empty( $metadata['last_processed_pr_cursor'] ) ) {
+ $this->last_cursor = $metadata['last_processed_pr_cursor'];
+ return true;
+ }
+
+ // No cursor means we need a full backfill.
+ return false;
+ }
+
+ /**
+ * Build metadata for the contributors file
+ *
+ * @param bool $is_incremental Whether this was an incremental run.
+ * @return array Metadata array.
+ */
+ private function build_metadata( bool $is_incremental ) {
+ $now = gmdate( 'c' ); // ISO 8601 format.
+
+ // Start with existing metadata or create new.
+ $existing_metadata = read_contributors_metadata();
+
+ $metadata = array(
+ 'last_processed_pr_cursor' => $this->new_cursor ?? $existing_metadata['last_processed_pr_cursor'] ?? null,
+ 'last_processed_date' => $now,
+ 'last_full_backfill' => $is_incremental
+ ? ( $existing_metadata['last_full_backfill'] ?? $now )
+ : $now,
+ 'last_validated_merge_date' => $this->new_validated_merge_date ?? $existing_metadata['last_validated_merge_date'] ?? null,
+ );
+
+ return $metadata;
+ }
+
+ /**
+ * Fetch contributors from GitHub REST API
+ *
+ * Uses the contributors endpoint to get commit authors, then fetches
+ * the first commit date for each contributor.
+ *
+ * @return array List of contributors from commits.
+ */
+ private function fetch_rest_api_contributors() {
+ $contributors = array();
+ $page = 1;
+ $per_page = 100;
+
+ // First, collect all contributor data (username and commit count).
+ $contributor_data = array();
+ do {
+ $url = sprintf(
+ 'https://api.github.com/repos/%s/%s/contributors?per_page=%d&page=%d',
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ $per_page,
+ $page
+ );
+ $response = $this->make_rest_request( $url );
+
+ if ( empty( $response ) || ! is_array( $response ) ) {
+ break;
+ }
+
+ foreach ( $response as $contributor ) {
+ if ( isset( $contributor['login'] ) ) {
+ $contributor_data[] = array(
+ 'login' => $contributor['login'],
+ 'commit_count' => $contributor['contributions'] ?? 1,
+ );
+ }
+ }
+
+ $response_count = count( $response );
+ ++$page;
+ } while ( $response_count === $per_page );
+
+ // Fetch first commit date for each contributor.
+ $total = count( $contributor_data );
+ foreach ( $contributor_data as $index => $data ) {
+ $first_commit_date = $this->fetch_first_commit_date( $data['login'] );
+
+ $contributors[] = array(
+ 'github_username' => $data['login'],
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => $first_commit_date,
+ );
+
+ // Progress indicator every 10 contributors.
+ if ( 0 === ( $index + 1 ) % 10 || ( $index + 1 ) === $total ) {
+ printf( " Fetched commit dates for %d/%d contributors...\n", $index + 1, $total );
+ }
+ }
+
+ return $contributors;
+ }
+
+ /**
+ * Fetch the first commit date for a contributor
+ *
+ * Queries the commits endpoint sorted by author-date ascending
+ * to find the earliest commit by this author.
+ *
+ * @param string $username GitHub username.
+ * @return string Date in YYYY-MM-DD format.
+ */
+ private function fetch_first_commit_date( string $username ) {
+ $url = sprintf(
+ 'https://api.github.com/repos/%s/%s/commits?author=%s&per_page=1&order=asc',
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ rawurlencode( $username )
+ );
+
+ $response = $this->make_rest_request( $url );
+
+ if ( ! empty( $response ) && is_array( $response ) && isset( $response[0]['commit']['author']['date'] ) ) {
+ $date = $response[0]['commit']['author']['date'];
+ // Extract YYYY-MM-DD from ISO 8601 format.
+ return substr( $date, 0, 10 );
+ }
+
+ // Fallback to today if we can't determine the date.
+ return gmdate( 'Y-m-d' );
+ }
+
+ /**
+ * Fetch contributors from GitHub GraphQL API
+ *
+ * Queries merged PRs to find reviewers, commenters, and linked issue reporters.
+ * In incremental mode, starts from the last processed cursor to only fetch new PRs.
+ *
+ * @param bool $is_incremental Whether to use incremental mode (start from last cursor).
+ * @return array List of contributors from PRs.
+ */
+ private function fetch_graphql_contributors( bool $is_incremental = false ) {
+ $contributors_map = array();
+ $page_count = 0;
+ $max_pages = 100; // Safety limit.
+ $first_cursor = null; // Track the first cursor for saving as the new cursor.
+
+ // In incremental mode, start from the last processed cursor.
+ $cursor = $is_incremental ? $this->last_cursor : null;
+
+ if ( $is_incremental && $cursor ) {
+ printf( " Starting from cursor: %s\n", substr( $cursor, 0, 30 ) . '...' );
+ }
+
+ do {
+ $query = $this->build_graphql_query( $cursor );
+ $response = $this->make_graphql_request( $query );
+
+ if ( ! $response || ! isset( $response['data']['repository']['pullRequests'] ) ) {
+ echo "Warning: GraphQL request failed or returned unexpected format.\n";
+ break;
+ }
+
+ $prs = $response['data']['repository']['pullRequests'];
+ $pr_nodes = $prs['nodes'] ?? array();
+
+ foreach ( $pr_nodes as $pr ) {
+ $this->process_pr_contributors( $pr, $contributors_map );
+ }
+
+ $has_next_page = $prs['pageInfo']['hasNextPage'] ?? false;
+ $end_cursor = $prs['pageInfo']['endCursor'] ?? null;
+
+ // Save the first end cursor we see as the new cursor for next incremental run.
+ if ( null === $first_cursor && $end_cursor ) {
+ $first_cursor = $end_cursor;
+ }
+
+ $cursor = $end_cursor;
+ ++$page_count;
+
+ if ( 0 === $page_count % 10 ) {
+ printf( " Processed %d pages of PRs...\n", $page_count );
+ }
+ } while ( $has_next_page && $cursor && $page_count < $max_pages );
+
+ // Store the new cursor for saving in metadata.
+ // For full backfill, use the last cursor (end of the list).
+ if ( ! $is_incremental && $cursor ) {
+ $this->new_cursor = $cursor;
+ } elseif ( ! $is_incremental && $first_cursor ) {
+ $this->new_cursor = $first_cursor;
+ }
+
+ return array_values( $contributors_map );
+ }
+
+ /**
+ * Build GraphQL query for merged PRs
+ *
+ * @param string|null $cursor Pagination cursor.
+ * @return string GraphQL query.
+ */
+ private function build_graphql_query( $cursor = null ) {
+ $after = $cursor ? sprintf( ', after: "%s"', $cursor ) : '';
+
+ return << array(
+ 'method' => 'GET',
+ 'header' => implode(
+ "\r\n",
+ array(
+ 'Accept: application/vnd.github+json',
+ 'Authorization: Bearer ' . $this->github_token,
+ 'User-Agent: WordPress-SCF-Contributor-Backfill',
+ 'X-GitHub-Api-Version: 2022-11-28',
+ )
+ ),
+ 'timeout' => 30,
+ 'ignore_errors' => true,
+ ),
+ )
+ );
+
+ $response = @file_get_contents( $url, false, $context );
+
+ // Extract status code and rate limit info from response headers.
+ $status_code = 0;
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => null,
+ );
+
+ // @phpstan-ignore isset.variable (http_response_header is a magic PHP variable set by file_get_contents)
+ if ( isset( $http_response_header ) && is_array( $http_response_header ) ) {
+ foreach ( $http_response_header as $header ) {
+ if ( preg_match( '/^HTTP\/\d+\.?\d*\s+(\d+)/', $header, $matches ) ) {
+ $status_code = (int) $matches[1];
+ }
+ }
+ $rate_limit_info = parse_rate_limit_headers( $http_response_header );
+ }
+
+ // Success case.
+ if ( false !== $response && $status_code >= 200 && $status_code < 300 ) {
+ return json_decode( $response, true );
+ }
+
+ // Check if we should retry.
+ $should_retry = ( 0 === $status_code ) ||
+ ( 429 === $status_code ) ||
+ ( $status_code >= 500 && $status_code < 600 );
+
+ if ( ! $should_retry || $attempt >= WPORG_MAX_RETRIES ) {
+ break;
+ }
+
+ // Calculate delay using smart backoff.
+ $delay_ms = calculate_smart_backoff( $rate_limit_info, $attempt );
+ usleep( $delay_ms * 1000 );
+ }
+
+ return null;
+ }
+
+ /**
+ * Make GraphQL request to GitHub with retry logic and rate limit handling.
+ *
+ * @param string $query GraphQL query.
+ * @return array|null Response data or null on failure.
+ */
+ private function make_graphql_request( string $query ) {
+ $url = 'https://api.github.com/graphql';
+ $data = json_encode( array( 'query' => $query ) );
+ $attempt = 0;
+
+ while ( $attempt < WPORG_MAX_RETRIES ) {
+ ++$attempt;
+
+ $context = stream_context_create(
+ array(
+ 'http' => array(
+ 'method' => 'POST',
+ 'header' => implode(
+ "\r\n",
+ array(
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $this->github_token,
+ 'User-Agent: WordPress-SCF-Contributor-Backfill',
+ )
+ ),
+ 'content' => $data,
+ 'timeout' => 30,
+ 'ignore_errors' => true,
+ ),
+ )
+ );
+
+ $response = @file_get_contents( $url, false, $context );
+
+ // Extract status code and rate limit info from response headers.
+ $status_code = 0;
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => null,
+ );
+
+ // @phpstan-ignore isset.variable (http_response_header is a magic PHP variable set by file_get_contents)
+ if ( isset( $http_response_header ) && is_array( $http_response_header ) ) {
+ foreach ( $http_response_header as $header ) {
+ if ( preg_match( '/^HTTP\/\d+\.?\d*\s+(\d+)/', $header, $matches ) ) {
+ $status_code = (int) $matches[1];
+ }
+ }
+ $rate_limit_info = parse_rate_limit_headers( $http_response_header );
+ }
+
+ // Success case.
+ if ( false !== $response && $status_code >= 200 && $status_code < 300 ) {
+ return json_decode( $response, true );
+ }
+
+ // Check if we should retry.
+ $should_retry = ( 0 === $status_code ) ||
+ ( 429 === $status_code ) ||
+ ( $status_code >= 500 && $status_code < 600 );
+
+ if ( ! $should_retry || $attempt >= WPORG_MAX_RETRIES ) {
+ break;
+ }
+
+ // Calculate delay using smart backoff.
+ $delay_ms = calculate_smart_backoff( $rate_limit_info, $attempt );
+ usleep( $delay_ms * 1000 );
+ }
+
+ return null;
+ }
+
+ /**
+ * Validate contributors against props-bot comments on merged PRs
+ *
+ * Fetches props-bot comments from merged PRs and ensures all mentioned
+ * WordPress.org usernames are in our contributor data.
+ *
+ * @param array $contributors Current contributor data.
+ * @return array Array with 'contributors' (updated list) and 'added' (newly added wporg usernames).
+ */
+ private function validate_against_props_bot( array $contributors ) {
+ // Load last validated merge date for incremental processing.
+ $existing_metadata = read_contributors_metadata();
+ $this->last_validated_merge_date = $existing_metadata['last_validated_merge_date'] ?? null;
+
+ if ( $this->full_backfill ) {
+ // Full backfill ignores the last validated date.
+ $this->last_validated_merge_date = null;
+ echo "Validating all merged PRs (full mode)...\n";
+ } elseif ( $this->last_validated_merge_date ) {
+ printf( "Validating PRs merged after %s (incremental)...\n", $this->last_validated_merge_date );
+ }
+
+ $props_usernames = $this->fetch_props_bot_usernames();
+ printf( "Found %d unique usernames in props-bot comments.\n", count( $props_usernames ) );
+
+ // Build a set of existing wporg usernames (case-insensitive).
+ $existing_wporg = array();
+ foreach ( $contributors as $contributor ) {
+ if ( ! empty( $contributor['wporg_username'] ) ) {
+ $existing_wporg[ strtolower( $contributor['wporg_username'] ) ] = true;
+ }
+ }
+
+ // Find missing usernames.
+ $missing = array();
+ $pr_map = array(); // Track which PRs each username came from.
+ foreach ( $props_usernames as $username => $prs ) {
+ if ( ! isset( $existing_wporg[ strtolower( $username ) ] ) ) {
+ $missing[] = $username;
+ $pr_map[ $username ] = $prs;
+ }
+ }
+
+ if ( empty( $missing ) ) {
+ echo "✓ All props-bot contributors are in our system.\n";
+ return array(
+ 'contributors' => $contributors,
+ 'added' => array(),
+ );
+ }
+
+ printf( "Adding %d missing contributors from props-bot:\n", count( $missing ) );
+ $added = array();
+ foreach ( $missing as $wporg_username ) {
+ $prs = $pr_map[ $wporg_username ];
+ printf( " + @%s (from PR %s)\n", $wporg_username, implode( ', ', array_slice( $prs, 0, 3 ) ) );
+
+ // Add as a new contributor with wporg_username.
+ // We use wporg_username as github_username placeholder since we don't have the mapping.
+ $contributors[] = array(
+ 'github_username' => $wporg_username,
+ 'wporg_username' => $wporg_username,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review' ), // Assume review since props-bot tracks PR activity.
+ 'first_contribution_date' => gmdate( 'Y-m-d' ),
+ );
+ $added[] = $wporg_username;
+ }
+
+ return array(
+ 'contributors' => $contributors,
+ 'added' => $added,
+ );
+ }
+
+ /**
+ * Fetch WordPress.org usernames from props-bot comments on merged PRs
+ *
+ * Uses incremental processing based on last_validated_merge_date.
+ * PRs are fetched sorted by update time (most recent first), so we can
+ * stop early when we reach PRs we've already processed.
+ *
+ * @return array Map of wporg_username => array of PR numbers where they were mentioned.
+ */
+ private function fetch_props_bot_usernames() {
+ $usernames = array();
+ $page = 1;
+ $per_page = 100;
+ $prs_processed = 0;
+ $reached_old = false;
+
+ echo "Fetching merged PRs with props-bot comments...\n";
+
+ do {
+ // Fetch merged PRs sorted by updated (most recent first).
+ $url = sprintf(
+ 'https://api.github.com/repos/%s/%s/pulls?state=closed&sort=updated&direction=desc&per_page=%d&page=%d',
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ $per_page,
+ $page
+ );
+ $response = $this->make_rest_request( $url );
+
+ if ( empty( $response ) || ! is_array( $response ) ) {
+ break;
+ }
+
+ foreach ( $response as $pr ) {
+ // Only process merged PRs.
+ if ( empty( $pr['merged_at'] ) ) {
+ continue;
+ }
+
+ $merged_at = $pr['merged_at'];
+ $pr_number = $pr['number'];
+
+ // Track the newest merge date we see (for saving to metadata).
+ if ( null === $this->new_validated_merge_date || $merged_at > $this->new_validated_merge_date ) {
+ $this->new_validated_merge_date = $merged_at;
+ }
+
+ // In incremental mode, skip PRs merged before our last validated date.
+ if ( $this->last_validated_merge_date && $merged_at <= $this->last_validated_merge_date ) {
+ $reached_old = true;
+ continue;
+ }
+
+ ++$prs_processed;
+
+ // Fetch comments for this PR.
+ $comments_url = sprintf(
+ 'https://api.github.com/repos/%s/%s/issues/%d/comments',
+ GITHUB_OWNER,
+ GITHUB_REPO,
+ $pr_number
+ );
+ $comments = $this->make_rest_request( $comments_url );
+
+ if ( empty( $comments ) || ! is_array( $comments ) ) {
+ continue;
+ }
+
+ // Look for props-bot comments.
+ foreach ( $comments as $comment ) {
+ if ( 'github-actions[bot]' !== ( $comment['user']['login'] ?? '' ) ) {
+ continue;
+ }
+
+ // Parse props line from comment body.
+ $parsed = $this->parse_props_from_comment( $comment['body'] ?? '' );
+ foreach ( $parsed as $username ) {
+ if ( ! isset( $usernames[ $username ] ) ) {
+ $usernames[ $username ] = array();
+ }
+ if ( ! in_array( $pr_number, $usernames[ $username ], true ) ) {
+ $usernames[ $username ][] = $pr_number;
+ }
+ }
+ }
+ }
+
+ $response_count = count( $response );
+ ++$page;
+
+ // In incremental mode, stop if we've reached old PRs.
+ // In full mode, limit to 10 pages (1000 PRs) for safety.
+ if ( $reached_old || ( ! $this->last_validated_merge_date && $page > 10 ) ) {
+ break;
+ }
+ } while ( $response_count === $per_page );
+
+ if ( $prs_processed > 0 ) {
+ printf( "Processed %d merged PRs.\n", $prs_processed );
+ }
+
+ return $usernames;
+ }
+
+ /**
+ * Parse WordPress.org usernames from a props-bot comment
+ *
+ * Props-bot comments contain a line like:
+ * Props username1, username2, username3.
+ *
+ * @param string $body Comment body.
+ * @return array List of usernames found.
+ */
+ private function parse_props_from_comment( string $body ) {
+ $usernames = array();
+
+ // Look for "Props username1, username2." pattern.
+ if ( preg_match( '/^Props\s+([^.]+)\./m', $body, $matches ) ) {
+ $props_line = $matches[1];
+ // Split by comma and clean up.
+ $parts = explode( ',', $props_line );
+ foreach ( $parts as $part ) {
+ $username = trim( $part );
+ // Remove any @ prefix if present.
+ $username = ltrim( $username, '@' );
+ if ( ! empty( $username ) ) {
+ $usernames[] = $username;
+ }
+ }
+ }
+
+ return $usernames;
+ }
+
+ /**
+ * Parse command-line arguments
+ *
+ * @param array $args Command-line arguments.
+ * @return array Parsed options.
+ */
+ public static function parse_arguments( array $args ) {
+ $options = array(
+ 'full' => false,
+ 'dry_run' => false,
+ 'skip_wporg' => false,
+ 'skip_output' => false,
+ 'validate' => false,
+ 'help' => false,
+ );
+
+ foreach ( $args as $arg ) {
+ if ( '--full' === $arg ) {
+ $options['full'] = true;
+ } elseif ( '--dry-run' === $arg ) {
+ $options['dry_run'] = true;
+ } elseif ( '--skip-wporg' === $arg ) {
+ $options['skip_wporg'] = true;
+ } elseif ( '--skip-output' === $arg ) {
+ $options['skip_output'] = true;
+ } elseif ( '--validate' === $arg ) {
+ $options['validate'] = true;
+ } elseif ( '--help' === $arg || '-h' === $arg ) {
+ $options['help'] = true;
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Display help message
+ */
+ public static function display_help() {
+ echo <<<'HELP'
+Backfill historical contributors from GitHub API
+
+Usage: php bin/backfill-contributors.php [options]
+
+Options:
+ --full Force full backfill (ignore cursor, fetch all historical data)
+ --validate Validate against props-bot comments and add missing contributors
+ --dry-run Preview changes without saving to contributors.json
+ --skip-wporg Skip WordPress.org profile lookup
+ --skip-output Skip output file generation (readme.txt, CONTRIBUTORS.md, docs page)
+ --help, -h Display this help message
+
+Environment variables:
+ GITHUB_TOKEN GitHub API token for authentication (required)
+
+Incremental Processing:
+ By default, the script runs in incremental mode. After the first run, it saves
+ a cursor that tracks the last processed PR. Subsequent runs only fetch PRs
+ merged after that point, significantly reducing API calls and processing time.
+
+ Use --full to force a complete backfill of all historical data.
+
+Props-bot Validation:
+ Use --validate to cross-check against props-bot comments on merged PRs.
+ This ensures any WordPress.org usernames mentioned in props-bot comments
+ are included in the contributor list. Missing contributors are automatically added.
+
+This script:
+ 1. Fetches commit authors from GitHub REST API
+ 2. Fetches reviewers, commenters, and issue reporters from GitHub GraphQL API
+ 3. Filters out bot accounts
+ 4. Merges with existing contributors.json data
+ 5. Looks up WordPress.org profiles for linked accounts
+ 6. Saves the updated contributor list with metadata for incremental processing
+ 7. Generates output files (readme.txt, CONTRIBUTORS.md, docs/contributing/contributors.md)
+ 8. (Optional) Validates against props-bot comments when --validate is used
+
+HELP;
+ }
+}
+
+// Main execution.
+$options = Contributor_Backfill::parse_arguments( array_slice( $argv, 1 ) );
+
+if ( $options['help'] ) {
+ Contributor_Backfill::display_help();
+ exit( 0 );
+}
+
+// Check for GITHUB_TOKEN.
+$github_token = getenv( 'GITHUB_TOKEN' );
+if ( ! $github_token ) {
+ echo "Error: GITHUB_TOKEN environment variable is required.\n";
+ echo "Set it with: export GITHUB_TOKEN=your_token_here\n";
+ exit( 1 );
+}
+
+// Run the backfill.
+$backfill = new Contributor_Backfill(
+ $github_token,
+ $options['full'],
+ $options['dry_run'],
+ $options['skip_wporg'],
+ $options['skip_output'],
+ $options['validate']
+);
+$backfill->run();
diff --git a/bin/contributors-functions.php b/bin/contributors-functions.php
new file mode 100644
index 00000000..d9cb959f
--- /dev/null
+++ b/bin/contributors-functions.php
@@ -0,0 +1,1275 @@
+ ! empty( $existing_metadata ) ? $existing_metadata : array(),
+ 'contributors' => $contributors,
+ );
+
+ $json = json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
+ if ( false === $json ) {
+ return false;
+ }
+
+ // Ensure trailing newline.
+ $json .= "\n";
+
+ $result = file_put_contents( $path, $json );
+ return false !== $result;
+}
+
+/**
+ * Write contributors to the contributors.json file with metadata.
+ *
+ * This function always uses the new format with metadata.
+ * Contributors are sorted alphabetically by GitHub username (case-insensitive).
+ *
+ * @param array $contributors Array of contributor data.
+ * @param array $metadata Metadata to include (cursor, dates, etc.).
+ * @param string|null $file_path Optional custom file path for testing.
+ * @return bool True on success, false on failure.
+ */
+function write_contributors_with_metadata( array $contributors, array $metadata, $file_path = null ) {
+ $path = $file_path ?? get_contributors_file_path();
+
+ // Sort contributors alphabetically by github_username (case-insensitive).
+ usort(
+ $contributors,
+ function ( $a, $b ) {
+ return strcasecmp( $a['github_username'] ?? '', $b['github_username'] ?? '' );
+ }
+ );
+
+ $data = array(
+ 'metadata' => $metadata,
+ 'contributors' => $contributors,
+ );
+
+ $json = json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
+ if ( false === $json ) {
+ return false;
+ }
+
+ // Ensure trailing newline.
+ $json .= "\n";
+
+ $result = file_put_contents( $path, $json );
+ return false !== $result;
+}
+
+/**
+ * Validate a contributor entry structure.
+ *
+ * Valid structure:
+ * - github_username: string (required)
+ * - wporg_username: string|null
+ * - wporg_display_name: string|null
+ * - contribution_types: array of valid contribution type strings
+ * - first_contribution_date: string in ISO 8601 format (YYYY-MM-DD)
+ *
+ * @param array $contributor The contributor data to validate.
+ * @return bool True if valid, false otherwise.
+ */
+function validate_contributor( array $contributor ) {
+ // Check required field: github_username.
+ if ( ! isset( $contributor['github_username'] ) || ! is_string( $contributor['github_username'] ) || '' === $contributor['github_username'] ) {
+ return false;
+ }
+
+ // Check optional string|null fields.
+ $nullable_strings = array( 'wporg_username', 'wporg_display_name' );
+ foreach ( $nullable_strings as $field ) {
+ if ( isset( $contributor[ $field ] ) && null !== $contributor[ $field ] && ! is_string( $contributor[ $field ] ) ) {
+ return false;
+ }
+ }
+
+ // Check contribution_types is an array with valid values.
+ if ( ! isset( $contributor['contribution_types'] ) || ! is_array( $contributor['contribution_types'] ) ) {
+ return false;
+ }
+
+ foreach ( $contributor['contribution_types'] as $type ) {
+ if ( ! in_array( $type, CONTRIBUTION_TYPES, true ) ) {
+ return false;
+ }
+ }
+
+ // Check first_contribution_date is a valid date string.
+ if ( ! isset( $contributor['first_contribution_date'] ) || ! is_string( $contributor['first_contribution_date'] ) ) {
+ return false;
+ }
+
+ // Validate date format (YYYY-MM-DD).
+ if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $contributor['first_contribution_date'] ) ) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Merge new contributors with existing data.
+ *
+ * Deduplication is by github_username (case-insensitive).
+ * When merging:
+ * - Preserves the earliest first_contribution_date
+ * - Merges contribution_types arrays (unique values)
+ * - Updates wporg_username and wporg_display_name if new data has values
+ *
+ * @param array $existing_contributors Existing contributor data.
+ * @param array $new_contributors New contributor data to merge.
+ * @return array Merged contributor data.
+ */
+function merge_contributors( array $existing_contributors, array $new_contributors ) {
+ // Index existing contributors by lowercase github_username for quick lookup.
+ $contributors_map = array();
+ foreach ( $existing_contributors as $contributor ) {
+ $key = strtolower( $contributor['github_username'] ?? '' );
+ $contributors_map[ $key ] = $contributor;
+ }
+
+ // Merge new contributors.
+ foreach ( $new_contributors as $new_contributor ) {
+ $key = strtolower( $new_contributor['github_username'] ?? '' );
+
+ if ( '' === $key ) {
+ continue;
+ }
+
+ if ( isset( $contributors_map[ $key ] ) ) {
+ // Merge with existing contributor.
+ $existing = $contributors_map[ $key ];
+
+ // Merge contribution types (unique values).
+ $merged_types = array_unique(
+ array_merge(
+ $existing['contribution_types'] ?? array(),
+ $new_contributor['contribution_types'] ?? array()
+ )
+ );
+ sort( $merged_types );
+
+ // Keep earliest first_contribution_date.
+ $existing_date = $existing['first_contribution_date'] ?? '9999-99-99';
+ $new_date = $new_contributor['first_contribution_date'] ?? '9999-99-99';
+ $merged_date = $existing_date < $new_date ? $existing_date : $new_date;
+
+ // Update wporg data if new data has values.
+ $wporg_username = $new_contributor['wporg_username'] ?? $existing['wporg_username'] ?? null;
+ $wporg_display_name = $new_contributor['wporg_display_name'] ?? $existing['wporg_display_name'] ?? null;
+
+ $contributors_map[ $key ] = array(
+ 'github_username' => $existing['github_username'],
+ 'wporg_username' => $wporg_username,
+ 'wporg_display_name' => $wporg_display_name,
+ 'contribution_types' => array_values( $merged_types ),
+ 'first_contribution_date' => $merged_date,
+ );
+ } else {
+ // Add new contributor.
+ $contributors_map[ $key ] = array(
+ 'github_username' => $new_contributor['github_username'],
+ 'wporg_username' => $new_contributor['wporg_username'] ?? null,
+ 'wporg_display_name' => $new_contributor['wporg_display_name'] ?? null,
+ 'contribution_types' => $new_contributor['contribution_types'] ?? array(),
+ 'first_contribution_date' => $new_contributor['first_contribution_date'] ?? '',
+ );
+ }
+ }
+
+ return array_values( $contributors_map );
+}
+
+/**
+ * Lookup WordPress.org profiles for GitHub usernames.
+ *
+ * Uses the WordPress.org API to find linked accounts.
+ * Implements batch processing (max 50 usernames per request) and
+ * exponential backoff retry logic for resilience.
+ *
+ * @param array $github_usernames Array of GitHub usernames to lookup.
+ * @param callable $logger Optional logging callback for failures.
+ * @return array Map of github_username => wporg data (slug, display_name).
+ */
+function lookup_wporg_profiles( array $github_usernames, ?callable $logger = null ) {
+ if ( empty( $github_usernames ) ) {
+ return array();
+ }
+
+ $all_results = array();
+ $batches = array_chunk( $github_usernames, WPORG_BATCH_SIZE );
+
+ foreach ( $batches as $batch_index => $batch ) {
+ $result = wporg_api_request_with_retry( $batch, $batch_index + 1, count( $batches ), $logger );
+
+ if ( is_array( $result ) ) {
+ $all_results = array_merge( $all_results, $result );
+ }
+ }
+
+ return $all_results;
+}
+
+/**
+ * Make WordPress.org API request with retry logic.
+ *
+ * Implements smart backoff using rate limit headers when available,
+ * falling back to exponential backoff for transient failures.
+ *
+ * @param array $usernames Array of GitHub usernames for this batch.
+ * @param int $batch_num Current batch number (for logging).
+ * @param int $total_batches Total number of batches (for logging).
+ * @param callable $logger Optional logging callback.
+ * @return array|null API response data or null on failure.
+ */
+function wporg_api_request_with_retry( array $usernames, int $batch_num, int $total_batches, ?callable $logger = null ) {
+ $attempt = 0;
+
+ while ( $attempt < WPORG_MAX_RETRIES ) {
+ ++$attempt;
+
+ $response = make_wporg_api_request( $usernames );
+
+ if ( null !== $response && isset( $response['data'] ) && is_array( $response['data'] ) ) {
+ return $response['data'];
+ }
+
+ $status_code = $response['status'] ?? 0;
+ $rate_limit_info = $response['rate_limit'] ?? array();
+
+ if ( ! should_retry_wporg_request( $attempt, $status_code ) ) {
+ if ( $logger ) {
+ $logger(
+ sprintf(
+ 'Batch %d/%d failed with status %d after %d attempt(s) - not retrying',
+ $batch_num,
+ $total_batches,
+ $status_code,
+ $attempt
+ )
+ );
+ }
+ break;
+ }
+
+ // Use smart backoff which considers rate limit headers.
+ $delay_ms = calculate_smart_backoff( $rate_limit_info, $attempt );
+
+ if ( $logger ) {
+ $using_smart_backoff = isset( $rate_limit_info['retry_after'] ) ||
+ ( isset( $rate_limit_info['remaining'] ) && 0 === $rate_limit_info['remaining'] );
+ $backoff_type = $using_smart_backoff ? 'smart' : 'exponential';
+
+ $logger(
+ sprintf(
+ 'Batch %d/%d: Attempt %d failed (status %d), retrying in %dms (%s backoff)...',
+ $batch_num,
+ $total_batches,
+ $attempt,
+ $status_code,
+ $delay_ms,
+ $backoff_type
+ )
+ );
+ }
+
+ usleep( $delay_ms * 1000 );
+ }
+
+ if ( $logger ) {
+ $logger(
+ sprintf(
+ 'Batch %d/%d failed after %d attempts',
+ $batch_num,
+ $total_batches,
+ $attempt
+ )
+ );
+ }
+
+ return null;
+}
+
+/**
+ * Make a single WordPress.org API request.
+ *
+ * @param array $usernames Array of GitHub usernames.
+ * @return array|null Response with 'status', 'data', and 'rate_limit' keys, or null on error.
+ */
+function make_wporg_api_request( array $usernames ) {
+ $request_body = json_encode( array( 'github_user' => $usernames ) );
+
+ if ( false === $request_body ) {
+ return null;
+ }
+
+ $context = stream_context_create(
+ array(
+ 'http' => array(
+ 'method' => 'POST',
+ 'header' => implode(
+ "\r\n",
+ array(
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ 'User-Agent: WordPress-SCF-Contributor-Lookup',
+ )
+ ),
+ 'content' => $request_body,
+ 'timeout' => 30,
+ 'ignore_errors' => true,
+ ),
+ )
+ );
+
+ $response = @file_get_contents( WPORG_API_ENDPOINT, false, $context );
+
+ // Extract status code and rate limit info from response headers.
+ $status_code = 0;
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => null,
+ );
+
+ // @phpstan-ignore isset.variable (http_response_header is a magic PHP variable set by file_get_contents)
+ if ( isset( $http_response_header ) && is_array( $http_response_header ) ) {
+ foreach ( $http_response_header as $header ) {
+ if ( preg_match( '/^HTTP\/\d+\.?\d*\s+(\d+)/', $header, $matches ) ) {
+ $status_code = (int) $matches[1];
+ }
+ }
+ // Parse rate limit headers.
+ $rate_limit_info = parse_rate_limit_headers( $http_response_header );
+ }
+
+ if ( false === $response ) {
+ return array(
+ 'status' => 0,
+ 'data' => null,
+ 'rate_limit' => $rate_limit_info,
+ );
+ }
+
+ $data = json_decode( $response, true );
+
+ return array(
+ 'status' => $status_code,
+ 'data' => ( 200 === $status_code && is_array( $data ) ) ? $data : null,
+ 'rate_limit' => $rate_limit_info,
+ );
+}
+
+/**
+ * Determine if a WordPress.org API request should be retried.
+ *
+ * @param int $attempt Current attempt number (1-based).
+ * @param int $status_code HTTP status code (0 for network failures).
+ * @return bool Whether to retry.
+ */
+function should_retry_wporg_request( int $attempt, int $status_code ) {
+ if ( $attempt >= WPORG_MAX_RETRIES ) {
+ return false;
+ }
+
+ // Retry on network failures.
+ if ( 0 === $status_code ) {
+ return true;
+ }
+
+ // Retry on rate limiting (429) and server errors (5xx).
+ if ( 429 === $status_code || ( $status_code >= 500 && $status_code < 600 ) ) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Calculate exponential backoff delay.
+ *
+ * @param int $attempt Current attempt number (1-based).
+ * @return int Delay in milliseconds.
+ */
+function calculate_backoff_delay( int $attempt ) {
+ $delay = WPORG_BASE_DELAY * pow( 2, $attempt - 1 );
+ return min( $delay, WPORG_MAX_DELAY );
+}
+
+/**
+ * Parse rate limit headers from HTTP response headers.
+ *
+ * Extracts rate limit information from the $http_response_header array
+ * that is automatically populated by file_get_contents().
+ *
+ * @param array $http_response_header The HTTP response headers array.
+ * @return array Associative array with keys:
+ * - 'remaining': int|null Requests remaining in current window.
+ * - 'reset': int|null Unix timestamp when limit resets.
+ * - 'retry_after': int|null Seconds to wait (from 429 responses).
+ */
+function parse_rate_limit_headers( array $http_response_header ) {
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => null,
+ );
+
+ foreach ( $http_response_header as $header ) {
+ // Parse X-RateLimit-Remaining header.
+ if ( preg_match( '/^X-RateLimit-Remaining:\s*(\d+)/i', $header, $matches ) ) {
+ $rate_limit_info['remaining'] = (int) $matches[1];
+ continue;
+ }
+
+ // Parse X-RateLimit-Reset header (Unix timestamp).
+ if ( preg_match( '/^X-RateLimit-Reset:\s*(\d+)/i', $header, $matches ) ) {
+ $rate_limit_info['reset'] = (int) $matches[1];
+ continue;
+ }
+
+ // Parse Retry-After header (seconds to wait).
+ if ( preg_match( '/^Retry-After:\s*(\d+)/i', $header, $matches ) ) {
+ $rate_limit_info['retry_after'] = (int) $matches[1];
+ continue;
+ }
+ }
+
+ return $rate_limit_info;
+}
+
+/**
+ * Calculate smart backoff delay based on rate limit headers.
+ *
+ * Uses rate limit headers when available for intelligent backoff:
+ * 1. If Retry-After header is present, use that value
+ * 2. If rate limit is exhausted (remaining = 0), wait until reset time
+ * 3. Otherwise, fall back to exponential backoff
+ *
+ * @param array $rate_limit_info Rate limit info from parse_rate_limit_headers().
+ * @param int $attempt Current attempt number (1-based) for fallback.
+ * @return int Delay in milliseconds.
+ */
+function calculate_smart_backoff( array $rate_limit_info, int $attempt ) {
+ // Priority 1: Use Retry-After header if present (commonly sent with 429 responses).
+ if ( isset( $rate_limit_info['retry_after'] ) && $rate_limit_info['retry_after'] > 0 ) {
+ // Retry-After is in seconds, convert to milliseconds.
+ // Add a small buffer (100ms) to account for timing variations.
+ $delay_ms = ( $rate_limit_info['retry_after'] * 1000 ) + 100;
+ // Allow up to 2x max delay for rate limits.
+ return min( $delay_ms, WPORG_MAX_DELAY * 2 );
+ }
+
+ // Priority 2: If rate limit is exhausted, wait until reset time.
+ if ( isset( $rate_limit_info['remaining'] ) && 0 === $rate_limit_info['remaining'] && isset( $rate_limit_info['reset'] ) ) {
+ $current_time = time();
+ $reset_time = $rate_limit_info['reset'];
+ $wait_seconds = max( 0, $reset_time - $current_time );
+ $wait_milliseconds = ( $wait_seconds * 1000 ) + 100; // Add small buffer.
+
+ // Cap at a reasonable maximum (5 minutes) to avoid extremely long waits.
+ return min( $wait_milliseconds, 300000 );
+ }
+
+ // Priority 3: Fall back to exponential backoff.
+ return calculate_backoff_delay( $attempt );
+}
+
+/**
+ * Update contributors with WordPress.org profile data.
+ *
+ * Performs batch lookups for all contributors and updates their
+ * wporg_username and wporg_display_name fields.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param callable $logger Optional logging callback.
+ * @return array Updated contributor data.
+ */
+function update_contributors_with_wporg_data( array $contributors, ?callable $logger = null ) {
+ if ( empty( $contributors ) ) {
+ return $contributors;
+ }
+
+ // Extract GitHub usernames.
+ $github_usernames = array_column( $contributors, 'github_username' );
+
+ // Lookup WordPress.org profiles.
+ $wporg_data = lookup_wporg_profiles( $github_usernames, $logger );
+
+ // Apply WordPress.org data to contributors.
+ return apply_wporg_data_to_contributors( $contributors, $wporg_data );
+}
+
+/**
+ * Apply WordPress.org API response data to contributors.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param array $wporg_data WordPress.org API response (github_username => profile data).
+ * @return array Updated contributor data.
+ */
+function apply_wporg_data_to_contributors( array $contributors, array $wporg_data ) {
+ // Create case-insensitive lookup map.
+ $wporg_map = array();
+ foreach ( $wporg_data as $github_username => $profile ) {
+ $wporg_map[ strtolower( $github_username ) ] = $profile;
+ }
+
+ return array_map(
+ function ( $contributor ) use ( $wporg_map ) {
+ $key = strtolower( $contributor['github_username'] ?? '' );
+
+ if ( isset( $wporg_map[ $key ] ) ) {
+ $contributor['wporg_username'] = $wporg_map[ $key ]['slug'] ?? null;
+ $contributor['wporg_display_name'] = $wporg_map[ $key ]['display_name'] ?? null;
+ }
+
+ return $contributor;
+ },
+ $contributors
+ );
+}
+
+/**
+ * Get contributors with linked WordPress.org accounts.
+ *
+ * Filters the contributor list to only include those who have
+ * a valid (non-null, non-empty) wporg_username.
+ *
+ * @param array $contributors Array of contributor data.
+ * @return array Filtered array containing only linked contributors.
+ */
+function get_linked_contributors( array $contributors ) {
+ return array_values(
+ array_filter(
+ $contributors,
+ function ( $contributor ) {
+ $wporg_username = $contributor['wporg_username'] ?? null;
+ return ! empty( $wporg_username );
+ }
+ )
+ );
+}
+
+/**
+ * Generate the Contributors field value for readme.txt.
+ *
+ * Returns a comma-separated list of WordPress.org usernames.
+ * Always includes 'wordpressdotorg' first, then linked contributors
+ * sorted by first contribution date (earliest first).
+ *
+ * @param array $contributors Array of contributor data.
+ * @return string Comma-separated WordPress.org usernames.
+ */
+function generate_readme_contributors_field( array $contributors ) {
+ $linked = get_linked_contributors( $contributors );
+
+ // Sort by first_contribution_date (earliest first).
+ usort(
+ $linked,
+ function ( $a, $b ) {
+ $date_a = $a['first_contribution_date'] ?? '9999-99-99';
+ $date_b = $b['first_contribution_date'] ?? '9999-99-99';
+ return strcmp( $date_a, $date_b );
+ }
+ );
+
+ $wporg_usernames = array_column( $linked, 'wporg_username' );
+
+ // Always include wordpressdotorg first.
+ array_unshift( $wporg_usernames, 'wordpressdotorg' );
+
+ // Remove any duplicates (in case wordpressdotorg is already in the list).
+ $wporg_usernames = array_unique( $wporg_usernames );
+
+ return implode( ', ', $wporg_usernames );
+}
+
+/**
+ * Generate CONTRIBUTORS.md content.
+ *
+ * Creates a markdown file with introductory text and a table
+ * of all contributors (linked and unlinked).
+ *
+ * Table columns: WordPress.org Username | GitHub Username | Display Name
+ * Sorted by: wporg_username (blanks at end), then github_username
+ *
+ * @param array $contributors Array of contributor data.
+ * @return string Markdown content for CONTRIBUTORS.md.
+ */
+function generate_contributors_md( array $contributors ) {
+ $lines = array();
+
+ // Header and introduction.
+ $lines[] = '# Contributors';
+ $lines[] = '';
+ $lines[] = 'Thank you to all the contributors who have helped make Secure Custom Fields better.';
+ $lines[] = '';
+ $lines[] = 'This file is automatically generated from the contributor acknowledgement system.';
+ $lines[] = 'Contributors are recognized for their commits, code reviews, issue reports, and comments.';
+ $lines[] = '';
+
+ // Table header.
+ $lines[] = '| WordPress.org Username | GitHub Username | Display Name |';
+ $lines[] = '| ---------------------- | --------------- | ------------ |';
+
+ // Sort contributors: wporg_username first (blanks at end), then github_username.
+ $sorted = $contributors;
+ usort(
+ $sorted,
+ function ( $a, $b ) {
+ $wporg_a = $a['wporg_username'] ?? '';
+ $wporg_b = $b['wporg_username'] ?? '';
+ $github_a = $a['github_username'] ?? '';
+ $github_b = $b['github_username'] ?? '';
+
+ // Blanks go to the end.
+ $a_has_wporg = ! empty( $wporg_a );
+ $b_has_wporg = ! empty( $wporg_b );
+
+ if ( $a_has_wporg && ! $b_has_wporg ) {
+ return -1;
+ }
+ if ( ! $a_has_wporg && $b_has_wporg ) {
+ return 1;
+ }
+
+ // Both have wporg or both don't - sort by wporg first, then github.
+ if ( $a_has_wporg && $b_has_wporg ) {
+ $wporg_cmp = strcasecmp( $wporg_a, $wporg_b );
+ if ( 0 !== $wporg_cmp ) {
+ return $wporg_cmp;
+ }
+ }
+
+ return strcasecmp( $github_a, $github_b );
+ }
+ );
+
+ // Table rows.
+ foreach ( $sorted as $contributor ) {
+ $github_username = $contributor['github_username'] ?? '';
+ $wporg_username = $contributor['wporg_username'] ?? null;
+ $display_name = $contributor['wporg_display_name'] ?? null;
+
+ // WordPress.org username with link (or empty).
+ $wporg_cell = '';
+ if ( ! empty( $wporg_username ) ) {
+ $wporg_cell = sprintf( '[@%s](https://profiles.wordpress.org/%s)', $wporg_username, $wporg_username );
+ }
+
+ // GitHub username with link.
+ $github_cell = sprintf( '[@%s](https://github.com/%s)', $github_username, $github_username );
+
+ // Display name (or empty).
+ $display_cell = $display_name ?? '';
+
+ $lines[] = sprintf( '| %s | %s | %s |', $wporg_cell, $github_cell, $display_cell );
+ }
+
+ $lines[] = '';
+
+ return implode( "\n", $lines );
+}
+
+/**
+ * Generate docs/contributing/contributors.md content.
+ *
+ * Creates a markdown file formatted for the developer.wordpress.org
+ * docs site style.
+ *
+ * Table columns: WordPress.org Username | GitHub Username | Display Name
+ * Sorted by: wporg_username (blanks at end), then github_username
+ *
+ * @param array $contributors Array of contributor data.
+ * @return string Markdown content for docs/contributing/contributors.md.
+ */
+function generate_docs_contributors_md( array $contributors ) {
+ $lines = array();
+
+ // Header for docs site.
+ $lines[] = '# Contributors';
+ $lines[] = '';
+ $lines[] = 'This page acknowledges all contributors to Secure Custom Fields.';
+ $lines[] = '';
+ $lines[] = 'Contributors are recognized for their commits, code reviews, issue reports, and comments on the project.';
+ $lines[] = '';
+ $lines[] = '## Contributor List';
+ $lines[] = '';
+
+ // Table header.
+ $lines[] = '| WordPress.org Username | GitHub Username | Display Name |';
+ $lines[] = '| ---------------------- | --------------- | ------------ |';
+
+ // Sort contributors: wporg_username first (blanks at end), then github_username.
+ $sorted = $contributors;
+ usort(
+ $sorted,
+ function ( $a, $b ) {
+ $wporg_a = $a['wporg_username'] ?? '';
+ $wporg_b = $b['wporg_username'] ?? '';
+ $github_a = $a['github_username'] ?? '';
+ $github_b = $b['github_username'] ?? '';
+
+ // Blanks go to the end.
+ $a_has_wporg = ! empty( $wporg_a );
+ $b_has_wporg = ! empty( $wporg_b );
+
+ if ( $a_has_wporg && ! $b_has_wporg ) {
+ return -1;
+ }
+ if ( ! $a_has_wporg && $b_has_wporg ) {
+ return 1;
+ }
+
+ // Both have wporg or both don't - sort by wporg first, then github.
+ if ( $a_has_wporg && $b_has_wporg ) {
+ $wporg_cmp = strcasecmp( $wporg_a, $wporg_b );
+ if ( 0 !== $wporg_cmp ) {
+ return $wporg_cmp;
+ }
+ }
+
+ return strcasecmp( $github_a, $github_b );
+ }
+ );
+
+ // Table rows.
+ foreach ( $sorted as $contributor ) {
+ $github_username = $contributor['github_username'] ?? '';
+ $wporg_username = $contributor['wporg_username'] ?? null;
+ $display_name = $contributor['wporg_display_name'] ?? null;
+
+ // WordPress.org username with link (or empty).
+ $wporg_cell = '';
+ if ( ! empty( $wporg_username ) ) {
+ $wporg_cell = sprintf( '[@%s](https://profiles.wordpress.org/%s)', $wporg_username, $wporg_username );
+ }
+
+ // GitHub username with link.
+ $github_cell = sprintf( '[@%s](https://github.com/%s)', $github_username, $github_username );
+
+ // Display name (or empty).
+ $display_cell = $display_name ?? '';
+
+ $lines[] = sprintf( '| %s | %s | %s |', $wporg_cell, $github_cell, $display_cell );
+ }
+
+ $lines[] = '';
+ $lines[] = '## How to Get Listed';
+ $lines[] = '';
+ $lines[] = 'Contributors are automatically added when they:';
+ $lines[] = '';
+ $lines[] = '- Commit code to the repository';
+ $lines[] = '- Review pull requests';
+ $lines[] = '- Report issues that are resolved';
+ $lines[] = '- Provide helpful comments on pull requests';
+ $lines[] = '';
+ $lines[] = 'To link your GitHub account to your WordPress.org profile, visit your [WordPress.org profile settings](https://profiles.wordpress.org/me/profile/edit/).';
+ $lines[] = '';
+
+ return implode( "\n", $lines );
+}
+
+/**
+ * Update readme.txt with new contributors field.
+ *
+ * Reads the existing readme.txt, updates the Contributors field on line 2,
+ * and writes the file back.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param string|null $file_path Optional custom file path for testing.
+ * @return bool True on success, false on failure.
+ */
+function update_readme_contributors( array $contributors, $file_path = null ) {
+ $path = $file_path ?? dirname( __DIR__ ) . '/readme.txt';
+
+ if ( ! file_exists( $path ) ) {
+ return false;
+ }
+
+ $contents = file_get_contents( $path );
+ if ( false === $contents ) {
+ return false;
+ }
+
+ $contributors_field = generate_readme_contributors_field( $contributors );
+
+ // If no linked contributors, keep the existing field or use placeholder.
+ if ( empty( $contributors_field ) ) {
+ $contributors_field = 'wordpressdotorg';
+ }
+
+ // Replace the Contributors line (line 2).
+ $pattern = '/^Contributors:.*$/m';
+ $replacement = 'Contributors: ' . $contributors_field;
+ $new_contents = preg_replace( $pattern, $replacement, $contents, 1 );
+
+ if ( null === $new_contents ) {
+ return false;
+ }
+
+ $result = file_put_contents( $path, $new_contents );
+ return false !== $result;
+}
+
+/**
+ * Write CONTRIBUTORS.md file.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param string|null $file_path Optional custom file path for testing.
+ * @return bool True on success, false on failure.
+ */
+function write_contributors_md( array $contributors, $file_path = null ) {
+ $path = $file_path ?? dirname( __DIR__ ) . '/CONTRIBUTORS.md';
+
+ $content = generate_contributors_md( $contributors );
+
+ $result = file_put_contents( $path, $content );
+ return false !== $result;
+}
+
+/**
+ * Write docs/contributing/contributors.md file.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param string|null $file_path Optional custom file path for testing.
+ * @return bool True on success, false on failure.
+ */
+function write_docs_contributors_md( array $contributors, $file_path = null ) {
+ $path = $file_path ?? dirname( __DIR__ ) . '/docs/contributing/contributors.md';
+
+ // Ensure directory exists.
+ $dir = dirname( $path );
+ if ( ! is_dir( $dir ) ) {
+ mkdir( $dir, 0755, true );
+ }
+
+ $content = generate_docs_contributors_md( $contributors );
+
+ $result = file_put_contents( $path, $content );
+ return false !== $result;
+}
+
+/**
+ * Default bot account exclusion list.
+ *
+ * These usernames are filtered out in addition to accounts ending in [bot].
+ */
+const BOT_EXCLUSION_LIST = array(
+ 'web-flow',
+ 'github-actions',
+ 'codecov',
+ 'copilot-pull-request-reviewer',
+ 'dependabot',
+ 'renovate',
+);
+
+/**
+ * Filter bot accounts from contributor list.
+ *
+ * Removes accounts that:
+ * - End with [bot] (case-insensitive)
+ * - Are in the exclusion list (case-insensitive)
+ *
+ * @param array $contributors List of contributors with 'github_username' key.
+ * @param array $exclusion_list Additional usernames to exclude (optional, uses BOT_EXCLUSION_LIST if empty).
+ * @return array Filtered list without bot accounts.
+ */
+function filter_bot_accounts( array $contributors, array $exclusion_list = array() ) {
+ // Use default exclusion list if none provided.
+ if ( empty( $exclusion_list ) ) {
+ $exclusion_list = BOT_EXCLUSION_LIST;
+ }
+
+ $exclusion_list_lower = array_map( 'strtolower', $exclusion_list );
+
+ return array_values(
+ array_filter(
+ $contributors,
+ function ( $contributor ) use ( $exclusion_list_lower ) {
+ $username = $contributor['github_username'] ?? '';
+
+ // Filter accounts ending in [bot].
+ if ( preg_match( '/\[bot\]$/i', $username ) ) {
+ return false;
+ }
+
+ // Filter accounts in exclusion list.
+ if ( in_array( strtolower( $username ), $exclusion_list_lower, true ) ) {
+ return false;
+ }
+
+ return true;
+ }
+ )
+ );
+}
+
+/**
+ * Parse REST API contributors response.
+ *
+ * Transforms the GitHub REST API /repos/{owner}/{repo}/contributors response
+ * into the internal contributor format.
+ *
+ * @param array $api_response The API response array from GitHub.
+ * @param string $default_date Default contribution date (YYYY-MM-DD format).
+ * @return array Parsed contributors in internal format.
+ */
+function parse_rest_api_contributors( array $api_response, string $default_date = '' ) {
+ if ( empty( $default_date ) ) {
+ $default_date = gmdate( 'Y-m-d' );
+ }
+
+ $contributors = array();
+
+ foreach ( $api_response as $contributor ) {
+ if ( ! isset( $contributor['login'] ) ) {
+ continue;
+ }
+
+ $contributors[] = array(
+ 'github_username' => $contributor['login'],
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => $default_date,
+ );
+ }
+
+ return $contributors;
+}
+
+/**
+ * Add contributor to map with contribution type.
+ *
+ * Helper function for building contributor maps from various sources.
+ * Creates new entries or updates existing ones, merging contribution types
+ * and tracking the earliest contribution date.
+ *
+ * @param array $map Reference to contributors map (keyed by lowercase username).
+ * @param string $login GitHub username.
+ * @param string $type Contribution type (commit, review, comment, issue).
+ * @param string $date Contribution date (YYYY-MM-DD format).
+ */
+function add_to_contributors_map( array &$map, string $login, string $type, string $date ) {
+ $key = strtolower( $login );
+
+ if ( ! isset( $map[ $key ] ) ) {
+ $map[ $key ] = array(
+ 'github_username' => $login,
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array(),
+ 'first_contribution_date' => $date,
+ );
+ }
+
+ if ( ! in_array( $type, $map[ $key ]['contribution_types'], true ) ) {
+ $map[ $key ]['contribution_types'][] = $type;
+ }
+
+ if ( $date < $map[ $key ]['first_contribution_date'] ) {
+ $map[ $key ]['first_contribution_date'] = $date;
+ }
+}
+
+/**
+ * Parse GraphQL PR data response.
+ *
+ * Transforms the GitHub GraphQL API response for merged PRs into
+ * the internal contributor format. Extracts reviewers, commenters,
+ * and linked issue reporters.
+ *
+ * @param array $response The GraphQL response with repository.pullRequests structure.
+ * @return array Parsed contributors in internal format.
+ */
+function parse_graphql_pr_data( array $response ) {
+ $contributors_map = array();
+
+ $prs = $response['data']['repository']['pullRequests']['nodes'] ?? array();
+
+ foreach ( $prs as $pr ) {
+ $merged_at = $pr['mergedAt'] ?? null;
+ $date = $merged_at ? substr( $merged_at, 0, 10 ) : gmdate( 'Y-m-d' );
+
+ // Process reviews.
+ $reviews = $pr['reviews']['nodes'] ?? array();
+ foreach ( $reviews as $review ) {
+ $login = $review['author']['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'review', $date );
+ }
+ }
+
+ // Process comments.
+ $comments = $pr['comments']['nodes'] ?? array();
+ foreach ( $comments as $comment ) {
+ $login = $comment['author']['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'comment', $date );
+ }
+ }
+
+ // Process linked issues.
+ $issues = $pr['closingIssuesReferences']['nodes'] ?? array();
+ foreach ( $issues as $issue ) {
+ $login = $issue['author']['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'issue', $date );
+ }
+ }
+ }
+
+ return array_values( $contributors_map );
+}
+
+/**
+ * Merge contributors from multiple source types.
+ *
+ * Combines contributors from commits, reviews, comments, and issues
+ * into a single contributor list with merged contribution types.
+ *
+ * @param array $commit_contributors Contributors from commits (with 'login' key).
+ * @param array $review_contributors Contributors from reviews (with 'login' key).
+ * @param array $comment_contributors Contributors from comments (with 'login' key).
+ * @param array $issue_contributors Contributors from issues (with 'login' key).
+ * @param string $default_date Default contribution date (YYYY-MM-DD format).
+ * @return array Merged contributors in internal format.
+ */
+function merge_contribution_sources(
+ array $commit_contributors,
+ array $review_contributors,
+ array $comment_contributors,
+ array $issue_contributors,
+ string $default_date
+) {
+ $contributors_map = array();
+
+ // Process commits.
+ foreach ( $commit_contributors as $contributor ) {
+ $login = $contributor['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'commit', $default_date );
+ }
+ }
+
+ // Process reviews.
+ foreach ( $review_contributors as $contributor ) {
+ $login = $contributor['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'review', $default_date );
+ }
+ }
+
+ // Process comments.
+ foreach ( $comment_contributors as $contributor ) {
+ $login = $contributor['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'comment', $default_date );
+ }
+ }
+
+ // Process issues.
+ foreach ( $issue_contributors as $contributor ) {
+ $login = $contributor['login'] ?? null;
+ if ( $login ) {
+ add_to_contributors_map( $contributors_map, $login, 'issue', $default_date );
+ }
+ }
+
+ return array_values( $contributors_map );
+}
+
+/**
+ * Find contributor by username in array.
+ *
+ * Searches a contributor array for a specific GitHub username (case-insensitive).
+ *
+ * @param array $contributors List of contributors.
+ * @param string $username GitHub username to find.
+ * @return array|null Contributor data or null if not found.
+ */
+function find_contributor_by_username( array $contributors, string $username ) {
+ foreach ( $contributors as $contributor ) {
+ if ( strtolower( $contributor['github_username'] ?? '' ) === strtolower( $username ) ) {
+ return $contributor;
+ }
+ }
+ return null;
+}
+
+/**
+ * Generate all output files from contributor data.
+ *
+ * Updates readme.txt, creates CONTRIBUTORS.md, and creates
+ * docs/contributing/contributors.md.
+ *
+ * @param array $contributors Array of contributor data.
+ * @param callable $logger Optional logging callback.
+ * @return array Results with 'readme', 'contributors_md', 'docs_md' keys.
+ */
+function generate_all_output_files( array $contributors, ?callable $logger = null ) {
+ $results = array(
+ 'readme' => false,
+ 'contributors_md' => false,
+ 'docs_md' => false,
+ );
+
+ // Update readme.txt.
+ if ( $logger ) {
+ $logger( 'Updating readme.txt...' );
+ }
+ $results['readme'] = update_readme_contributors( $contributors );
+ if ( $logger ) {
+ $logger( $results['readme'] ? 'readme.txt updated successfully.' : 'Failed to update readme.txt.' );
+ }
+
+ // Create CONTRIBUTORS.md.
+ if ( $logger ) {
+ $logger( 'Creating CONTRIBUTORS.md...' );
+ }
+ $results['contributors_md'] = write_contributors_md( $contributors );
+ if ( $logger ) {
+ $logger( $results['contributors_md'] ? 'CONTRIBUTORS.md created successfully.' : 'Failed to create CONTRIBUTORS.md.' );
+ }
+
+ // Create docs/contributing/contributors.md.
+ if ( $logger ) {
+ $logger( 'Creating docs/contributing/contributors.md...' );
+ }
+ $results['docs_md'] = write_docs_contributors_md( $contributors );
+ if ( $logger ) {
+ $logger( $results['docs_md'] ? 'docs/contributing/contributors.md created successfully.' : 'Failed to create docs/contributing/contributors.md.' );
+ }
+
+ return $results;
+}
diff --git a/bin/contributors.json b/bin/contributors.json
new file mode 100644
index 00000000..12cc7197
--- /dev/null
+++ b/bin/contributors.json
@@ -0,0 +1,243 @@
+{
+ "metadata": {
+ "last_processed_pr_cursor": "Y3Vyc29yOnYyOpHOu6ENBw==",
+ "last_processed_date": "2026-01-14T16:01:05+00:00",
+ "last_full_backfill": "2026-01-14T16:01:05+00:00",
+ "last_validated_merge_date": null
+ },
+ "contributors": [
+ {
+ "github_username": "anomiex",
+ "wporg_username": "bjorsch",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "review"
+ ],
+ "first_contribution_date": "2024-12-18"
+ },
+ {
+ "github_username": "cbravobernal",
+ "wporg_username": "cbravobernal",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "review"
+ ],
+ "first_contribution_date": "2025-03-15"
+ },
+ {
+ "github_username": "cyberwani",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "commit"
+ ],
+ "first_contribution_date": "2025-02-20"
+ },
+ {
+ "github_username": "DAnn2012",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "commit"
+ ],
+ "first_contribution_date": "2025-09-29"
+ },
+ {
+ "github_username": "duanestorey",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "issue"
+ ],
+ "first_contribution_date": "2024-11-26"
+ },
+ {
+ "github_username": "gareins",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "issue"
+ ],
+ "first_contribution_date": "2025-10-15"
+ },
+ {
+ "github_username": "gziolo",
+ "wporg_username": "gziolo",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "review"
+ ],
+ "first_contribution_date": "2025-03-26"
+ },
+ {
+ "github_username": "haseebnawaz298",
+ "wporg_username": "haseebnawaz298",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit"
+ ],
+ "first_contribution_date": "2025-03-25"
+ },
+ {
+ "github_username": "j-hoffmann",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "issue"
+ ],
+ "first_contribution_date": "2024-12-13"
+ },
+ {
+ "github_username": "jacobodonnell",
+ "wporg_username": "jacobodonnell",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment"
+ ],
+ "first_contribution_date": "2025-12-29"
+ },
+ {
+ "github_username": "jamieburchell",
+ "wporg_username": "jamieburchell",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "commit",
+ "issue"
+ ],
+ "first_contribution_date": "2025-12-16"
+ },
+ {
+ "github_username": "justwhocares",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "issue"
+ ],
+ "first_contribution_date": "2025-01-12"
+ },
+ {
+ "github_username": "kraftbj",
+ "wporg_username": "kraftbj",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "issue",
+ "review"
+ ],
+ "first_contribution_date": "2024-12-13"
+ },
+ {
+ "github_username": "mcsf",
+ "wporg_username": "mcsf",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "review"
+ ],
+ "first_contribution_date": "2025-05-14"
+ },
+ {
+ "github_username": "Mr2P",
+ "wporg_username": "mr2p",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "commit",
+ "review"
+ ],
+ "first_contribution_date": "2025-09-23"
+ },
+ {
+ "github_username": "ockham",
+ "wporg_username": "bernhard-reiter",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "review"
+ ],
+ "first_contribution_date": "2025-06-12"
+ },
+ {
+ "github_username": "pkevan",
+ "wporg_username": "paulkevan",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "issue",
+ "review"
+ ],
+ "first_contribution_date": "2025-03-14"
+ },
+ {
+ "github_username": "priethor",
+ "wporg_username": "priethor",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "issue",
+ "review"
+ ],
+ "first_contribution_date": "2025-03-24"
+ },
+ {
+ "github_username": "racmanuel",
+ "wporg_username": "racmanuel",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit",
+ "issue",
+ "review"
+ ],
+ "first_contribution_date": "2025-05-15"
+ },
+ {
+ "github_username": "robertdevore",
+ "wporg_username": null,
+ "wporg_display_name": null,
+ "contribution_types": [
+ "issue"
+ ],
+ "first_contribution_date": "2024-12-13"
+ },
+ {
+ "github_username": "trajche",
+ "wporg_username": "trajche",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "commit"
+ ],
+ "first_contribution_date": "2025-12-29"
+ },
+ {
+ "github_username": "YanMetelitsa",
+ "wporg_username": "yanmetelitsa",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "commit",
+ "issue"
+ ],
+ "first_contribution_date": "2025-05-05"
+ },
+ {
+ "github_username": "youknowriad",
+ "wporg_username": "youknowriad",
+ "wporg_display_name": null,
+ "contribution_types": [
+ "comment",
+ "review"
+ ],
+ "first_contribution_date": "2025-05-14"
+ }
+ ]
+}
diff --git a/composer.json b/composer.json
index 198be53f..a2d81ad7 100644
--- a/composer.json
+++ b/composer.json
@@ -54,6 +54,7 @@
"post-autoload-dump": [
"php bin/remove-installed-versions.php"
],
+ "contributors:update": "php bin/backfill-contributors.php",
"docs": [
"@docs:parse",
"@docs:links",
diff --git a/docs/bin/manifest.json b/docs/bin/manifest.json
index 3149e8bd..f7870fa8 100644
--- a/docs/bin/manifest.json
+++ b/docs/bin/manifest.json
@@ -224,6 +224,11 @@
"parent": "concepts",
"markdown_source": "https://github.com/wordpress/secure-custom-fields/blob/trunk/docs/concepts/security.md"
},
+ "contributing/contributors": {
+ "slug": "contributors",
+ "parent": "contributing",
+ "markdown_source": "https://github.com/wordpress/secure-custom-fields/blob/trunk/docs/contributing/contributors.md"
+ },
"contributing/documentation": {
"slug": "documentation",
"parent": "contributing",
diff --git a/docs/contributing/contributors.md b/docs/contributing/contributors.md
new file mode 100644
index 00000000..678f56c2
--- /dev/null
+++ b/docs/contributing/contributors.md
@@ -0,0 +1,44 @@
+# Contributors
+
+This page acknowledges all contributors to Secure Custom Fields.
+
+Contributors are recognized for their commits, code reviews, issue reports, and comments on the project.
+
+## Contributor List
+
+| WordPress.org Username | GitHub Username | Display Name |
+| ---------------------- | --------------- | ------------ |
+| [@bernhard-reiter](https://profiles.wordpress.org/bernhard-reiter) | [@ockham](https://github.com/ockham) | |
+| [@bjorsch](https://profiles.wordpress.org/bjorsch) | [@anomiex](https://github.com/anomiex) | |
+| [@cbravobernal](https://profiles.wordpress.org/cbravobernal) | [@cbravobernal](https://github.com/cbravobernal) | |
+| [@gziolo](https://profiles.wordpress.org/gziolo) | [@gziolo](https://github.com/gziolo) | |
+| [@haseebnawaz298](https://profiles.wordpress.org/haseebnawaz298) | [@haseebnawaz298](https://github.com/haseebnawaz298) | |
+| [@jacobodonnell](https://profiles.wordpress.org/jacobodonnell) | [@jacobodonnell](https://github.com/jacobodonnell) | |
+| [@jamieburchell](https://profiles.wordpress.org/jamieburchell) | [@jamieburchell](https://github.com/jamieburchell) | |
+| [@kraftbj](https://profiles.wordpress.org/kraftbj) | [@kraftbj](https://github.com/kraftbj) | |
+| [@mcsf](https://profiles.wordpress.org/mcsf) | [@mcsf](https://github.com/mcsf) | |
+| [@mr2p](https://profiles.wordpress.org/mr2p) | [@Mr2P](https://github.com/Mr2P) | |
+| [@paulkevan](https://profiles.wordpress.org/paulkevan) | [@pkevan](https://github.com/pkevan) | |
+| [@priethor](https://profiles.wordpress.org/priethor) | [@priethor](https://github.com/priethor) | |
+| [@racmanuel](https://profiles.wordpress.org/racmanuel) | [@racmanuel](https://github.com/racmanuel) | |
+| [@trajche](https://profiles.wordpress.org/trajche) | [@trajche](https://github.com/trajche) | |
+| [@yanmetelitsa](https://profiles.wordpress.org/yanmetelitsa) | [@YanMetelitsa](https://github.com/YanMetelitsa) | |
+| [@youknowriad](https://profiles.wordpress.org/youknowriad) | [@youknowriad](https://github.com/youknowriad) | |
+| | [@cyberwani](https://github.com/cyberwani) | |
+| | [@DAnn2012](https://github.com/DAnn2012) | |
+| | [@duanestorey](https://github.com/duanestorey) | |
+| | [@gareins](https://github.com/gareins) | |
+| | [@j-hoffmann](https://github.com/j-hoffmann) | |
+| | [@justwhocares](https://github.com/justwhocares) | |
+| | [@robertdevore](https://github.com/robertdevore) | |
+
+## How to Get Listed
+
+Contributors are automatically added when they:
+
+- Commit code to the repository
+- Review pull requests
+- Report issues that are resolved
+- Provide helpful comments on pull requests
+
+To link your GitHub account to your WordPress.org profile, visit your [WordPress.org profile settings](https://profiles.wordpress.org/me/profile/edit/).
diff --git a/docs/contributing/index.md b/docs/contributing/index.md
index f250bc31..459cdf69 100644
--- a/docs/contributing/index.md
+++ b/docs/contributing/index.md
@@ -39,3 +39,25 @@ Guide for contributing to Secure Custom Fields development.
- Write unit tests for new features
- Document all changes
- Keep pull requests focused
+
+## Contributor Acknowledgement
+
+All contributors are automatically tracked and acknowledged in [`CONTRIBUTORS.md`](/CONTRIBUTORS.md). The system recognizes:
+
+- **Commits**: Authors of merged commits
+- **Reviews**: Pull request reviewers
+- **Comments**: Pull request commenters
+- **Issues**: Authors of issues that are closed by merged PRs
+
+### How It Works
+
+1. **Props Bot**: Posts a comment on each PR listing contributors (runs while PRs are open)
+2. **Release Process**: During releases, the Update Contributors workflow is manually triggered to refresh `contributors.json` from the GitHub API and generate `CONTRIBUTORS.md` and `readme.txt`
+
+### Linking Your WordPress.org Account
+
+To have your WordPress.org username appear alongside your GitHub username, link your accounts at [WordPress.org profile settings](https://profiles.wordpress.org/me/profile/edit/).
+
+### Known Limitations
+
+- **PR History Limit**: The contributor tracking system processes up to 10,000 merged pull requests (100 pages of 100 PRs). For repositories exceeding this threshold, older PR contributors (reviewers, commenters, issue reporters) may not be captured. Commit authors are not affected by this limit.
diff --git a/readme.txt b/readme.txt
index e3590f21..65d4d34b 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,5 +1,5 @@
=== Secure Custom Fields ===
-Contributors: wordpressdotorg
+Contributors: wordpressdotorg, kraftbj, bjorsch, paulkevan, cbravobernal, priethor, haseebnawaz298, gziolo, yanmetelitsa, mcsf, youknowriad, racmanuel, bernhard-reiter, mr2p, jamieburchell, jacobodonnell, trajche
Tags: fields, custom fields, meta, scf
Requires at least: 6.0
Tested up to: 6.9
diff --git a/tests/php/test-contributors-automation.php b/tests/php/test-contributors-automation.php
new file mode 100644
index 00000000..b9873f95
--- /dev/null
+++ b/tests/php/test-contributors-automation.php
@@ -0,0 +1,126 @@
+assertFileExists( $composer_path, 'composer.json should exist' );
+
+ $composer_content = file_get_contents( $composer_path );
+ $composer_data = json_decode( $composer_content, true );
+
+ $this->assertNotNull( $composer_data, 'composer.json should be valid JSON' );
+ $this->assertArrayHasKey( 'scripts', $composer_data, 'composer.json should have scripts section' );
+ $this->assertArrayHasKey( 'contributors:update', $composer_data['scripts'], 'composer.json should have contributors:update script' );
+
+ // Verify the script executes the backfill script.
+ $script = $composer_data['scripts']['contributors:update'];
+ $this->assertStringContainsString( 'backfill-contributors.php', $script, 'Script should reference backfill-contributors.php' );
+
+ // Verify the backfill script exists.
+ $backfill_script_path = dirname( dirname( __DIR__ ) ) . '/bin/backfill-contributors.php';
+ $this->assertFileExists( $backfill_script_path, 'Backfill script should exist at bin/backfill-contributors.php' );
+ }
+
+ /**
+ * Test update-contributors workflow file syntax validation
+ *
+ * Verifies that the update-contributors.yml workflow file exists
+ * and contains valid YAML syntax with required workflow elements.
+ */
+ public function test_update_contributors_workflow_file_syntax_validation() {
+ $workflow_path = dirname( dirname( __DIR__ ) ) . '/.github/workflows/update-contributors.yml';
+
+ $this->assertFileExists( $workflow_path, 'Workflow file should exist' );
+
+ $workflow_content = file_get_contents( $workflow_path );
+
+ // Verify required YAML structure elements.
+ $this->assertStringContainsString( 'name:', $workflow_content, 'Workflow should have a name' );
+ $this->assertStringContainsString( 'on:', $workflow_content, 'Workflow should have an on trigger' );
+ $this->assertStringContainsString( 'workflow_dispatch', $workflow_content, 'Workflow should have workflow_dispatch trigger for manual execution' );
+ $this->assertStringContainsString( 'jobs:', $workflow_content, 'Workflow should have jobs section' );
+
+ // Verify the update-contributor-list job exists.
+ $this->assertStringContainsString( 'update-contributor-list:', $workflow_content, 'Workflow should have update-contributor-list job' );
+
+ // Verify key steps are present.
+ $this->assertStringContainsString( 'actions/checkout', $workflow_content, 'Workflow should checkout repository' );
+ $this->assertStringContainsString( 'backfill-contributors.php', $workflow_content, 'Workflow should run the backfill script' );
+ $this->assertStringContainsString( 'GITHUB_TOKEN', $workflow_content, 'Workflow should use GITHUB_TOKEN for authentication' );
+
+ // Verify commit and push functionality.
+ $this->assertStringContainsString( 'git', $workflow_content, 'Workflow should have git commands for committing changes' );
+
+ // Verify permissions are set for write access.
+ $this->assertStringContainsString( 'permissions:', $workflow_content, 'Workflow should have permissions section' );
+ $this->assertStringContainsString( 'contents:', $workflow_content, 'Workflow should specify contents permission' );
+
+ // Verify valid YAML by checking for proper indentation patterns.
+ $lines = explode( "\n", $workflow_content );
+ foreach ( $lines as $line ) {
+ // Skip empty lines and comments.
+ if ( empty( trim( $line ) ) || strpos( trim( $line ), '#' ) === 0 ) {
+ continue;
+ }
+ // Check that lines don't have tab characters (YAML uses spaces).
+ $this->assertStringNotContainsString( "\t", $line, 'YAML should not contain tabs, only spaces for indentation' );
+ }
+ }
+
+ /**
+ * Test props-bot workflow file syntax validation
+ *
+ * Verifies that the props-bot.yml workflow file exists
+ * and contains valid YAML syntax with required workflow elements.
+ */
+ public function test_props_bot_workflow_file_syntax_validation() {
+ $workflow_path = dirname( dirname( __DIR__ ) ) . '/.github/workflows/props-bot.yml';
+
+ $this->assertFileExists( $workflow_path, 'Props bot workflow file should exist' );
+
+ $workflow_content = file_get_contents( $workflow_path );
+
+ // Verify required YAML structure elements.
+ $this->assertStringContainsString( 'name:', $workflow_content, 'Workflow should have a name' );
+ $this->assertStringContainsString( 'on:', $workflow_content, 'Workflow should have an on trigger' );
+ $this->assertStringContainsString( 'pull_request_target:', $workflow_content, 'Workflow should have pull_request_target trigger' );
+ $this->assertStringContainsString( 'issue_comment:', $workflow_content, 'Workflow should have issue_comment trigger' );
+ $this->assertStringContainsString( 'pull_request_review:', $workflow_content, 'Workflow should have pull_request_review trigger' );
+ $this->assertStringContainsString( 'jobs:', $workflow_content, 'Workflow should have jobs section' );
+
+ // Verify it uses the WordPress props-bot-action.
+ $this->assertStringContainsString( 'WordPress/props-bot-action', $workflow_content, 'Workflow should use WordPress/props-bot-action' );
+
+ // Verify permissions are set correctly.
+ $this->assertStringContainsString( 'pull-requests: write', $workflow_content, 'Workflow should have pull-requests write permission' );
+
+ // Verify valid YAML by checking for proper indentation patterns.
+ $lines = explode( "\n", $workflow_content );
+ foreach ( $lines as $line ) {
+ // Skip empty lines and comments.
+ if ( empty( trim( $line ) ) || strpos( trim( $line ), '#' ) === 0 ) {
+ continue;
+ }
+ // Check that lines don't have tab characters (YAML uses spaces).
+ $this->assertStringNotContainsString( "\t", $line, 'YAML should not contain tabs, only spaces for indentation' );
+ }
+ }
+}
diff --git a/tests/php/test-contributors-data.php b/tests/php/test-contributors-data.php
new file mode 100644
index 00000000..91ba3a54
--- /dev/null
+++ b/tests/php/test-contributors-data.php
@@ -0,0 +1,280 @@
+temp_file = tempnam( sys_get_temp_dir(), 'contributors_test_' ) . '.json';
+ }
+
+ /**
+ * Teardown after tests
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ if ( file_exists( $this->temp_file ) ) {
+ unlink( $this->temp_file );
+ }
+ }
+
+ /**
+ * Test valid contributor entry structure validation
+ *
+ * Validates that a complete contributor entry with all required fields
+ * (github_username, wporg_username, wporg_display_name, contribution_types[],
+ * first_contribution_date) passes validation.
+ */
+ public function test_valid_contributor_entry_structure() {
+ $valid_contributor = array(
+ 'github_username' => 'testuser',
+ 'wporg_username' => 'wporguser',
+ 'wporg_display_name' => 'Test User',
+ 'contribution_types' => array( 'commit', 'review' ),
+ 'first_contribution_date' => '2024-01-15',
+ );
+
+ $this->assertTrue(
+ validate_contributor( $valid_contributor ),
+ 'Valid contributor entry should pass validation'
+ );
+
+ // Test with null optional fields.
+ $contributor_with_nulls = array(
+ 'github_username' => 'anotheruser',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-06-01',
+ );
+
+ $this->assertTrue(
+ validate_contributor( $contributor_with_nulls ),
+ 'Contributor with null optional fields should pass validation'
+ );
+
+ // Test invalid contributor - missing github_username.
+ $invalid_no_github = array(
+ 'wporg_username' => 'wporguser',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-15',
+ );
+
+ $this->assertFalse(
+ validate_contributor( $invalid_no_github ),
+ 'Contributor without github_username should fail validation'
+ );
+
+ // Test invalid contributor - invalid contribution type.
+ $invalid_contribution_type = array(
+ 'github_username' => 'testuser',
+ 'contribution_types' => array( 'invalid_type' ),
+ 'first_contribution_date' => '2024-01-15',
+ );
+
+ $this->assertFalse(
+ validate_contributor( $invalid_contribution_type ),
+ 'Contributor with invalid contribution type should fail validation'
+ );
+
+ // Test invalid contributor - bad date format.
+ $invalid_date = array(
+ 'github_username' => 'testuser',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '01-15-2024',
+ );
+
+ $this->assertFalse(
+ validate_contributor( $invalid_date ),
+ 'Contributor with invalid date format should fail validation'
+ );
+
+ // Test all valid contribution types.
+ $all_types = array(
+ 'github_username' => 'fullcontributor',
+ 'contribution_types' => array( 'commit', 'review', 'comment', 'issue' ),
+ 'first_contribution_date' => '2024-01-01',
+ );
+
+ $this->assertTrue(
+ validate_contributor( $all_types ),
+ 'All valid contribution types should be accepted'
+ );
+ }
+
+ /**
+ * Test contributor sorting is alphabetical by GitHub username
+ *
+ * Verifies that when writing contributors, they are sorted
+ * alphabetically by github_username (case-insensitive).
+ */
+ public function test_contributor_sorting_alphabetical_by_github_username() {
+ $unsorted_contributors = array(
+ array(
+ 'github_username' => 'zebra',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'Alpha',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ array(
+ 'github_username' => 'beta',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'comment' ),
+ 'first_contribution_date' => '2024-01-03',
+ ),
+ );
+
+ // Write the contributors.
+ $write_result = write_contributors( $unsorted_contributors, $this->temp_file );
+ $this->assertTrue( $write_result, 'Write operation should succeed' );
+
+ // Read them back.
+ $sorted_contributors = read_contributors( $this->temp_file );
+
+ // Verify order is alphabetical (case-insensitive).
+ $this->assertCount( 3, $sorted_contributors, 'Should have 3 contributors' );
+ $this->assertEquals( 'Alpha', $sorted_contributors[0]['github_username'], 'First should be Alpha' );
+ $this->assertEquals( 'beta', $sorted_contributors[1]['github_username'], 'Second should be beta' );
+ $this->assertEquals( 'zebra', $sorted_contributors[2]['github_username'], 'Third should be zebra' );
+ }
+
+ /**
+ * Test JSON file read/write operations
+ *
+ * Tests that:
+ * - Writing to a new file creates valid JSON
+ * - Reading from a file returns the correct data
+ * - Merging contributors handles deduplication correctly
+ */
+ public function test_json_file_read_write_operations() {
+ // Test writing to a new file.
+ $contributors = array(
+ array(
+ 'github_username' => 'developer1',
+ 'wporg_username' => 'wpdev1',
+ 'wporg_display_name' => 'Developer One',
+ 'contribution_types' => array( 'commit', 'review' ),
+ 'first_contribution_date' => '2024-01-15',
+ ),
+ array(
+ 'github_username' => 'developer2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-02-20',
+ ),
+ );
+
+ // Write.
+ $write_result = write_contributors( $contributors, $this->temp_file );
+ $this->assertTrue( $write_result, 'Write operation should succeed' );
+ $this->assertFileExists( $this->temp_file, 'File should be created' );
+
+ // Read back and verify.
+ $read_data = read_contributors( $this->temp_file );
+ $this->assertIsArray( $read_data, 'Read data should be an array' );
+ $this->assertCount( 2, $read_data, 'Should have 2 contributors' );
+
+ // Verify content is valid JSON.
+ $file_contents = file_get_contents( $this->temp_file );
+ $json_data = json_decode( $file_contents, true );
+ $this->assertNotNull( $json_data, 'File should contain valid JSON' );
+
+ // Test merge functionality - add a new contributor and update an existing one.
+ $new_contributors = array(
+ array(
+ 'github_username' => 'developer1', // Existing - should merge.
+ 'wporg_username' => 'wpdev1_updated',
+ 'wporg_display_name' => 'Developer One Updated',
+ 'contribution_types' => array( 'comment' ), // New type.
+ 'first_contribution_date' => '2024-03-01', // Later date - should keep original.
+ ),
+ array(
+ 'github_username' => 'newdeveloper', // New contributor.
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-03-15',
+ ),
+ );
+
+ $merged = merge_contributors( $read_data, $new_contributors );
+
+ // Write merged data.
+ $merge_write_result = write_contributors( $merged, $this->temp_file );
+ $this->assertTrue( $merge_write_result, 'Merge write operation should succeed' );
+
+ // Read merged data.
+ $merged_data = read_contributors( $this->temp_file );
+ $this->assertCount( 3, $merged_data, 'Should have 3 contributors after merge' );
+
+ // Find developer1 and verify merge logic.
+ $dev1 = null;
+ foreach ( $merged_data as $contributor ) {
+ if ( 'developer1' === $contributor['github_username'] ) {
+ $dev1 = $contributor;
+ break;
+ }
+ }
+
+ $this->assertNotNull( $dev1, 'developer1 should exist in merged data' );
+ $this->assertEquals( '2024-01-15', $dev1['first_contribution_date'], 'Should keep earlier date' );
+ $this->assertContains( 'commit', $dev1['contribution_types'], 'Should have commit type' );
+ $this->assertContains( 'review', $dev1['contribution_types'], 'Should have review type' );
+ $this->assertContains( 'comment', $dev1['contribution_types'], 'Should have new comment type' );
+ $this->assertEquals( 'wpdev1_updated', $dev1['wporg_username'], 'Should update wporg_username' );
+
+ // Test reading from non-existent file.
+ $nonexistent = read_contributors( '/nonexistent/path/contributors.json' );
+ $this->assertIsArray( $nonexistent, 'Reading non-existent file should return array' );
+ $this->assertEmpty( $nonexistent, 'Reading non-existent file should return empty array' );
+
+ // Test reading from empty file.
+ file_put_contents( $this->temp_file, '' );
+ $empty_read = read_contributors( $this->temp_file );
+ $this->assertIsArray( $empty_read, 'Reading empty file should return array' );
+ $this->assertEmpty( $empty_read, 'Reading empty file should return empty array' );
+
+ // Test reading from file with empty array.
+ file_put_contents( $this->temp_file, '[]' );
+ $empty_array_read = read_contributors( $this->temp_file );
+ $this->assertIsArray( $empty_array_read, 'Reading file with empty array should return array' );
+ $this->assertEmpty( $empty_array_read, 'Reading file with empty array should return empty array' );
+ }
+}
diff --git a/tests/php/test-contributors-integration.php b/tests/php/test-contributors-integration.php
new file mode 100644
index 00000000..7550a20f
--- /dev/null
+++ b/tests/php/test-contributors-integration.php
@@ -0,0 +1,361 @@
+temp_dir = sys_get_temp_dir() . '/contributors_integration_' . uniqid();
+ mkdir( $this->temp_dir, 0755, true );
+ mkdir( $this->temp_dir . '/docs/contributing', 0755, true );
+ }
+
+ /**
+ * Teardown after tests
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ // Clean up temporary directory.
+ $this->remove_directory( $this->temp_dir );
+ }
+
+ /**
+ * Recursively remove a directory
+ *
+ * @param string $dir Directory path.
+ */
+ private function remove_directory( string $dir ) {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+
+ $items = scandir( $dir );
+ foreach ( $items as $item ) {
+ if ( '.' === $item || '..' === $item ) {
+ continue;
+ }
+
+ $path = $dir . '/' . $item;
+ if ( is_dir( $path ) ) {
+ $this->remove_directory( $path );
+ } else {
+ unlink( $path );
+ }
+ }
+
+ rmdir( $dir );
+ }
+
+ /**
+ * Test end-to-end workflow: data storage -> merge -> output generation
+ *
+ * This tests the complete critical path from storing contributor data
+ * through generating all output files.
+ */
+ public function test_end_to_end_data_to_output_workflow() {
+ // Initial contributors data.
+ $initial_contributors = array(
+ array(
+ 'github_username' => 'developer1',
+ 'wporg_username' => 'wpdev1',
+ 'wporg_display_name' => 'Developer One',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-15',
+ ),
+ array(
+ 'github_username' => 'developer2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-02-20',
+ ),
+ );
+
+ $contributors_file = $this->temp_dir . '/contributors.json';
+ $readme_file = $this->temp_dir . '/readme.txt';
+ $contributors_md = $this->temp_dir . '/CONTRIBUTORS.md';
+ $docs_md = $this->temp_dir . '/docs/contributing/contributors.md';
+
+ // Create initial readme.txt with placeholder.
+ file_put_contents( $readme_file, "=== Secure Custom Fields ===\nContributors: wordpressdotorg\nTags: acf, custom fields\n" );
+
+ // Step 1: Write initial contributors.
+ $write_result = write_contributors( $initial_contributors, $contributors_file );
+ $this->assertTrue( $write_result, 'Should write contributors.json successfully' );
+
+ // Step 2: Read back and verify.
+ $read_contributors = read_contributors( $contributors_file );
+ $this->assertCount( 2, $read_contributors, 'Should have 2 contributors' );
+
+ // Step 3: Merge with new contributors.
+ $new_contributors = array(
+ array(
+ 'github_username' => 'developer3',
+ 'wporg_username' => 'wpdev3',
+ 'wporg_display_name' => 'Developer Three',
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-03-10',
+ ),
+ );
+
+ $merged = merge_contributors( $read_contributors, $new_contributors );
+ $this->assertCount( 3, $merged, 'Should have 3 contributors after merge' );
+
+ // Save merged data.
+ write_contributors( $merged, $contributors_file );
+
+ // Step 4: Apply simulated WordPress.org data.
+ $wporg_api_response = array(
+ 'developer2' => array(
+ 'slug' => 'wpdev2',
+ 'display_name' => 'Developer Two',
+ ),
+ );
+ $updated = apply_wporg_data_to_contributors( $merged, $wporg_api_response );
+
+ // Verify developer2 is now linked.
+ $dev2 = null;
+ foreach ( $updated as $c ) {
+ if ( 'developer2' === $c['github_username'] ) {
+ $dev2 = $c;
+ break;
+ }
+ }
+ $this->assertEquals( 'wpdev2', $dev2['wporg_username'], 'developer2 should now be linked' );
+
+ // Step 5: Generate readme.txt contributors field.
+ $readme_field = generate_readme_contributors_field( $updated );
+ $this->assertStringContainsString( 'wpdev1', $readme_field, 'Should include wpdev1' );
+ $this->assertStringContainsString( 'wpdev2', $readme_field, 'Should include wpdev2' );
+ $this->assertStringContainsString( 'wpdev3', $readme_field, 'Should include wpdev3' );
+
+ // Step 6: Generate CONTRIBUTORS.md.
+ $contributors_md_content = generate_contributors_md( $updated );
+ $this->assertStringContainsString( '# Contributors', $contributors_md_content, 'Should have heading' );
+ $this->assertStringContainsString( 'developer1', $contributors_md_content, 'Should include all GitHub usernames' );
+ $this->assertStringContainsString( 'developer2', $contributors_md_content, 'Should include all GitHub usernames' );
+ $this->assertStringContainsString( 'developer3', $contributors_md_content, 'Should include all GitHub usernames' );
+
+ // Step 7: Generate docs page.
+ $docs_content = generate_docs_contributors_md( $updated );
+ $this->assertStringContainsString( '# Contributors', $docs_content, 'Should have heading' );
+ $this->assertStringContainsString( 'Secure Custom Fields', $docs_content, 'Should mention SCF' );
+
+ // Step 8: Write output files using actual functions.
+ $readme_update_result = update_readme_contributors( $updated, $readme_file );
+ $this->assertTrue( $readme_update_result, 'Should update readme.txt' );
+
+ $md_result = write_contributors_md( $updated, $contributors_md );
+ $this->assertTrue( $md_result, 'Should write CONTRIBUTORS.md' );
+
+ $docs_result = write_docs_contributors_md( $updated, $docs_md );
+ $this->assertTrue( $docs_result, 'Should write docs contributors.md' );
+
+ // Verify files were written correctly.
+ $this->assertFileExists( $contributors_md, 'CONTRIBUTORS.md should exist' );
+ $this->assertFileExists( $docs_md, 'docs/contributing/contributors.md should exist' );
+
+ $readme_contents = file_get_contents( $readme_file );
+ $this->assertStringContainsString( 'Contributors:', $readme_contents, 'readme.txt should have Contributors field' );
+ $this->assertStringContainsString( 'wpdev1', $readme_contents, 'readme.txt should list linked contributors' );
+ }
+
+ /**
+ * Test handling of empty contributor list
+ *
+ * Verifies that the system handles an empty contributor list gracefully
+ * across all output generators.
+ */
+ public function test_empty_contributor_list_handling() {
+ $empty_contributors = array();
+
+ // Test readme field generation - always includes wordpressdotorg first.
+ $readme_field = generate_readme_contributors_field( $empty_contributors );
+ $this->assertEquals( 'wordpressdotorg', $readme_field, 'Empty list should produce wordpressdotorg' );
+
+ // Test get_linked_contributors.
+ $linked = get_linked_contributors( $empty_contributors );
+ $this->assertEmpty( $linked, 'Empty list should produce empty linked list' );
+
+ // Test CONTRIBUTORS.md generation.
+ $contributors_md = generate_contributors_md( $empty_contributors );
+ $this->assertStringContainsString( '# Contributors', $contributors_md, 'Should still have heading' );
+ $this->assertStringContainsString( '| GitHub Username', $contributors_md, 'Should have table header' );
+
+ // Test docs page generation.
+ $docs_md = generate_docs_contributors_md( $empty_contributors );
+ $this->assertStringContainsString( '# Contributors', $docs_md, 'Should still have heading' );
+
+ // Test file write operations with empty list.
+ $readme_file = $this->temp_dir . '/readme.txt';
+ file_put_contents( $readme_file, "=== Test ===\nContributors: oldcontributor\nTags: test\n" );
+
+ $update_result = update_readme_contributors( $empty_contributors, $readme_file );
+ $this->assertTrue( $update_result, 'Update should succeed even with empty list' );
+
+ $readme_contents = file_get_contents( $readme_file );
+ $this->assertStringContainsString( 'Contributors: wordpressdotorg', $readme_contents, 'Should default to wordpressdotorg when empty' );
+ }
+
+ /**
+ * Test all unlinked accounts scenario
+ *
+ * Verifies correct handling when all contributors lack WordPress.org links.
+ */
+ public function test_all_unlinked_accounts_scenario() {
+ $unlinked_contributors = array(
+ array(
+ 'github_username' => 'unlinked1',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'unlinked2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review', 'comment' ),
+ 'first_contribution_date' => '2024-02-01',
+ ),
+ array(
+ 'github_username' => 'unlinked3',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-03-01',
+ ),
+ );
+
+ // Test linked filtering returns empty.
+ $linked = get_linked_contributors( $unlinked_contributors );
+ $this->assertEmpty( $linked, 'Should return empty array when no linked accounts' );
+
+ // Test readme field always includes wordpressdotorg first.
+ $readme_field = generate_readme_contributors_field( $unlinked_contributors );
+ $this->assertEquals( 'wordpressdotorg', $readme_field, 'Should produce wordpressdotorg when all unlinked' );
+
+ // Test CONTRIBUTORS.md still includes all contributors.
+ $contributors_md = generate_contributors_md( $unlinked_contributors );
+ $this->assertStringContainsString( 'unlinked1', $contributors_md, 'Should include unlinked1' );
+ $this->assertStringContainsString( 'unlinked2', $contributors_md, 'Should include unlinked2' );
+ $this->assertStringContainsString( 'unlinked3', $contributors_md, 'Should include unlinked3' );
+
+ // Test actual readme update defaults to wordpressdotorg.
+ $readme_file = $this->temp_dir . '/readme.txt';
+ file_put_contents( $readme_file, "=== Test ===\nContributors: previousvalue\nDescription: Test\n" );
+
+ update_readme_contributors( $unlinked_contributors, $readme_file );
+
+ $readme_contents = file_get_contents( $readme_file );
+ $this->assertStringContainsString( 'Contributors: wordpressdotorg', $readme_contents, 'Should use wordpressdotorg placeholder when all unlinked' );
+ }
+
+ /**
+ * Test generate_all_output_files orchestration function
+ *
+ * Verifies that the orchestration function correctly calls all
+ * individual generators and returns proper status.
+ */
+ public function test_generate_all_output_files_orchestration() {
+ $contributors = array(
+ array(
+ 'github_username' => 'testuser1',
+ 'wporg_username' => 'wptestuser1',
+ 'wporg_display_name' => 'Test User One',
+ 'contribution_types' => array( 'commit', 'review' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'testuser2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-02-01',
+ ),
+ );
+
+ // Create necessary files in temp directory.
+ $readme_file = $this->temp_dir . '/readme.txt';
+ file_put_contents( $readme_file, "=== Test Plugin ===\nContributors: placeholder\nTags: test\n" );
+
+ // Capture log messages.
+ $log_messages = array();
+ $logger = function ( $message ) use ( &$log_messages ) {
+ $log_messages[] = $message;
+ };
+
+ // Note: generate_all_output_files uses default paths, so we can't easily
+ // test it with temp directory without modifying the function.
+ // Instead, we verify the individual output generators work correctly.
+
+ // Write CONTRIBUTORS.md.
+ $md_file = $this->temp_dir . '/CONTRIBUTORS.md';
+ $md_result = write_contributors_md( $contributors, $md_file );
+ $this->assertTrue( $md_result, 'CONTRIBUTORS.md write should succeed' );
+ $this->assertFileExists( $md_file, 'CONTRIBUTORS.md should exist' );
+
+ $md_content = file_get_contents( $md_file );
+ $this->assertStringContainsString( '# Contributors', $md_content, 'Should have proper heading' );
+ $this->assertStringContainsString( 'testuser1', $md_content, 'Should include testuser1' );
+ $this->assertStringContainsString( 'testuser2', $md_content, 'Should include testuser2' );
+ $this->assertStringContainsString( 'wptestuser1', $md_content, 'Should include wporg username' );
+
+ // Write docs/contributing/contributors.md.
+ $docs_file = $this->temp_dir . '/docs/contributing/contributors.md';
+ $docs_result = write_docs_contributors_md( $contributors, $docs_file );
+ $this->assertTrue( $docs_result, 'docs contributors.md write should succeed' );
+ $this->assertFileExists( $docs_file, 'docs/contributing/contributors.md should exist' );
+
+ $docs_content = file_get_contents( $docs_file );
+ $this->assertStringContainsString( '# Contributors', $docs_content, 'Should have proper heading' );
+ $this->assertStringContainsString( 'Contributor List', $docs_content, 'Should have contributor list section' );
+ $this->assertStringContainsString( 'How to Get Listed', $docs_content, 'Should have how to get listed section' );
+
+ // Update readme.txt.
+ $readme_result = update_readme_contributors( $contributors, $readme_file );
+ $this->assertTrue( $readme_result, 'readme.txt update should succeed' );
+
+ $readme_content = file_get_contents( $readme_file );
+ $this->assertStringContainsString( 'Contributors: wordpressdotorg, wptestuser1', $readme_content, 'Should contain wordpressdotorg first and linked wporg username' );
+ $this->assertStringNotContainsString( 'placeholder', $readme_content, 'Should not contain placeholder' );
+ }
+}
diff --git a/tests/php/test-contributors-rate-limit.php b/tests/php/test-contributors-rate-limit.php
new file mode 100644
index 00000000..ae21dc30
--- /dev/null
+++ b/tests/php/test-contributors-rate-limit.php
@@ -0,0 +1,313 @@
+assertIsArray( $result, 'Result should be an array' );
+ $this->assertEquals( 42, $result['remaining'], 'Should extract remaining count' );
+ $this->assertEquals( 1704067200, $result['reset'], 'Should extract reset timestamp' );
+ $this->assertEquals( 60, $result['retry_after'], 'Should extract retry-after seconds' );
+ }
+
+ /**
+ * Test parsing headers when only some rate limit headers are present
+ *
+ * Verifies that missing headers result in null values.
+ */
+ public function test_parse_rate_limit_headers_handles_partial_headers() {
+ $http_response_header = array(
+ 'HTTP/1.1 429 Too Many Requests',
+ 'Content-Type: application/json',
+ 'Retry-After: 30',
+ );
+
+ $result = parse_rate_limit_headers( $http_response_header );
+
+ $this->assertNull( $result['remaining'], 'Missing remaining should be null' );
+ $this->assertNull( $result['reset'], 'Missing reset should be null' );
+ $this->assertEquals( 30, $result['retry_after'], 'Should extract retry-after' );
+ }
+
+ /**
+ * Test parsing empty header array
+ *
+ * Verifies that empty headers return all null values.
+ */
+ public function test_parse_rate_limit_headers_handles_empty_array() {
+ $result = parse_rate_limit_headers( array() );
+
+ $this->assertNull( $result['remaining'], 'remaining should be null' );
+ $this->assertNull( $result['reset'], 'reset should be null' );
+ $this->assertNull( $result['retry_after'], 'retry_after should be null' );
+ }
+
+ /**
+ * Test case-insensitive header parsing
+ *
+ * HTTP headers are case-insensitive, verify our parsing handles this.
+ */
+ public function test_parse_rate_limit_headers_is_case_insensitive() {
+ $http_response_header = array(
+ 'HTTP/1.1 200 OK',
+ 'x-ratelimit-remaining: 100',
+ 'X-RATELIMIT-RESET: 1704067200',
+ 'retry-after: 45',
+ );
+
+ $result = parse_rate_limit_headers( $http_response_header );
+
+ $this->assertEquals( 100, $result['remaining'], 'Should extract lowercase header' );
+ $this->assertEquals( 1704067200, $result['reset'], 'Should extract uppercase header' );
+ $this->assertEquals( 45, $result['retry_after'], 'Should extract lowercase retry-after' );
+ }
+
+ /**
+ * Test that smart backoff uses Retry-After header when present
+ *
+ * The Retry-After header should take priority over other methods.
+ */
+ public function test_calculate_smart_backoff_uses_retry_after_first() {
+ $rate_limit_info = array(
+ 'remaining' => 0,
+ 'reset' => time() + 3600, // 1 hour from now.
+ 'retry_after' => 10, // 10 seconds.
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should use retry-after (10 seconds = 10000ms + 100ms buffer).
+ $this->assertEquals( 10100, $result, 'Should use Retry-After header value with buffer' );
+ }
+
+ /**
+ * Test that smart backoff waits until reset when rate limit exhausted
+ *
+ * When remaining is 0 and no Retry-After, should wait until reset time.
+ */
+ public function test_calculate_smart_backoff_waits_for_reset_when_exhausted() {
+ $current_time = time();
+ $reset_time = $current_time + 30; // 30 seconds from now.
+
+ $rate_limit_info = array(
+ 'remaining' => 0,
+ 'reset' => $reset_time,
+ 'retry_after' => null,
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should wait until reset time (30 seconds = ~30000ms + buffer).
+ $expected_min = 29900; // Allow for timing variations.
+ $expected_max = 31000;
+
+ $this->assertGreaterThanOrEqual( $expected_min, $result, 'Should wait at least 30 seconds' );
+ $this->assertLessThanOrEqual( $expected_max, $result, 'Should not wait much more than 30 seconds' );
+ }
+
+ /**
+ * Test that smart backoff falls back to exponential backoff
+ *
+ * When no rate limit headers are available, should use exponential backoff.
+ */
+ public function test_calculate_smart_backoff_falls_back_to_exponential() {
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => null,
+ );
+
+ $attempt_1 = calculate_smart_backoff( $rate_limit_info, 1 );
+ $attempt_2 = calculate_smart_backoff( $rate_limit_info, 2 );
+ $attempt_3 = calculate_smart_backoff( $rate_limit_info, 3 );
+
+ $this->assertEquals( WPORG_BASE_DELAY, $attempt_1, 'First attempt should use base delay' );
+ $this->assertEquals( WPORG_BASE_DELAY * 2, $attempt_2, 'Second attempt should double' );
+ $this->assertEquals( WPORG_BASE_DELAY * 4, $attempt_3, 'Third attempt should quadruple' );
+ }
+
+ /**
+ * Test that smart backoff caps Retry-After at reasonable maximum
+ *
+ * Very large Retry-After values should be capped.
+ */
+ public function test_calculate_smart_backoff_caps_retry_after() {
+ $rate_limit_info = array(
+ 'remaining' => null,
+ 'reset' => null,
+ 'retry_after' => 3600, // 1 hour in seconds.
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should be capped at 2x WPORG_MAX_DELAY.
+ $max_allowed = WPORG_MAX_DELAY * 2;
+ $this->assertLessThanOrEqual( $max_allowed, $result, 'Retry-After should be capped' );
+ }
+
+ /**
+ * Test that smart backoff caps reset wait time
+ *
+ * Very long reset wait times should be capped at 5 minutes.
+ */
+ public function test_calculate_smart_backoff_caps_reset_wait_time() {
+ $rate_limit_info = array(
+ 'remaining' => 0,
+ 'reset' => time() + 7200, // 2 hours from now.
+ 'retry_after' => null,
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should be capped at 5 minutes (300000ms).
+ $this->assertEquals( 300000, $result, 'Reset wait time should be capped at 5 minutes' );
+ }
+
+ /**
+ * Test that smart backoff handles already-passed reset time
+ *
+ * If reset time is in the past, should use exponential backoff.
+ */
+ public function test_calculate_smart_backoff_handles_past_reset_time() {
+ $rate_limit_info = array(
+ 'remaining' => 0,
+ 'reset' => time() - 10, // 10 seconds in the past.
+ 'retry_after' => null,
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should return minimal wait (just the 100ms buffer since wait is 0).
+ $this->assertLessThanOrEqual( 200, $result, 'Past reset should result in minimal wait' );
+ }
+
+ /**
+ * Test that remaining > 0 doesn't trigger reset wait
+ *
+ * If we still have remaining requests, should fall back to exponential.
+ */
+ public function test_calculate_smart_backoff_ignores_reset_when_remaining_positive() {
+ $rate_limit_info = array(
+ 'remaining' => 50, // Still have requests.
+ 'reset' => time() + 3600, // Reset in 1 hour.
+ 'retry_after' => null,
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 1 );
+
+ // Should use exponential backoff, not wait for reset.
+ $this->assertEquals( WPORG_BASE_DELAY, $result, 'Should use exponential backoff when remaining > 0' );
+ }
+
+ /**
+ * Test exponential backoff delay calculation
+ *
+ * Verifies the base calculate_backoff_delay function works correctly.
+ */
+ public function test_calculate_backoff_delay_exponential_growth() {
+ $delay_1 = calculate_backoff_delay( 1 );
+ $delay_2 = calculate_backoff_delay( 2 );
+ $delay_3 = calculate_backoff_delay( 3 );
+ $delay_4 = calculate_backoff_delay( 4 );
+ $delay_5 = calculate_backoff_delay( 5 );
+
+ $this->assertEquals( WPORG_BASE_DELAY, $delay_1, 'Attempt 1: base delay' );
+ $this->assertEquals( WPORG_BASE_DELAY * 2, $delay_2, 'Attempt 2: 2x base' );
+ $this->assertEquals( WPORG_BASE_DELAY * 4, $delay_3, 'Attempt 3: 4x base' );
+ $this->assertEquals( WPORG_BASE_DELAY * 8, $delay_4, 'Attempt 4: 8x base' );
+ $this->assertEquals( WPORG_BASE_DELAY * 16, $delay_5, 'Attempt 5: 16x base' );
+ }
+
+ /**
+ * Test that exponential backoff is capped at maximum
+ *
+ * Verifies that delay doesn't exceed WPORG_MAX_DELAY.
+ */
+ public function test_calculate_backoff_delay_capped_at_max() {
+ // High attempt number that would exceed max.
+ $delay = calculate_backoff_delay( 10 );
+
+ $this->assertLessThanOrEqual(
+ WPORG_MAX_DELAY,
+ $delay,
+ 'Delay should be capped at WPORG_MAX_DELAY'
+ );
+ $this->assertEquals( WPORG_MAX_DELAY, $delay, 'High attempts should return max delay' );
+ }
+
+ /**
+ * Test parsing headers with extra whitespace
+ *
+ * HTTP headers may have varying whitespace after colons.
+ */
+ public function test_parse_rate_limit_headers_handles_whitespace() {
+ $http_response_header = array(
+ 'HTTP/1.1 200 OK',
+ 'X-RateLimit-Remaining: 50', // Extra spaces.
+ 'X-RateLimit-Reset:1704067200', // No space.
+ 'Retry-After: 25', // Normal space.
+ );
+
+ $result = parse_rate_limit_headers( $http_response_header );
+
+ $this->assertEquals( 50, $result['remaining'], 'Should handle extra whitespace' );
+ $this->assertEquals( 1704067200, $result['reset'], 'Should handle no whitespace' );
+ $this->assertEquals( 25, $result['retry_after'], 'Should handle normal whitespace' );
+ }
+
+ /**
+ * Test that zero Retry-After falls back to other methods
+ *
+ * A Retry-After of 0 should not be used.
+ */
+ public function test_calculate_smart_backoff_ignores_zero_retry_after() {
+ $rate_limit_info = array(
+ 'remaining' => 100,
+ 'reset' => null,
+ 'retry_after' => 0,
+ );
+
+ $result = calculate_smart_backoff( $rate_limit_info, 2 );
+
+ // Should fall back to exponential backoff.
+ $this->assertEquals( WPORG_BASE_DELAY * 2, $result, 'Zero retry-after should use exponential' );
+ }
+}
diff --git a/tests/php/test-github-api-integration.php b/tests/php/test-github-api-integration.php
new file mode 100644
index 00000000..9879564c
--- /dev/null
+++ b/tests/php/test-github-api-integration.php
@@ -0,0 +1,386 @@
+ 'developer1',
+ 'id' => 12345,
+ 'type' => 'User',
+ 'contributions' => 50,
+ ),
+ array(
+ 'login' => 'developer2',
+ 'id' => 67890,
+ 'type' => 'User',
+ 'contributions' => 25,
+ ),
+ );
+
+ $contributors = parse_rest_api_contributors( $api_response );
+
+ $this->assertCount( 2, $contributors, 'Should parse 2 contributors' );
+ $this->assertEquals( 'developer1', $contributors[0]['github_username'], 'First contributor username should match' );
+ $this->assertEquals( 'developer2', $contributors[1]['github_username'], 'Second contributor username should match' );
+ $this->assertContains( 'commit', $contributors[0]['contribution_types'], 'Should have commit contribution type' );
+ $this->assertContains( 'commit', $contributors[1]['contribution_types'], 'Should have commit contribution type' );
+ }
+
+ /**
+ * Test GitHub GraphQL query for PR data
+ *
+ * Validates that GraphQL response for merged PRs is parsed correctly,
+ * extracting reviewers, commenters, and issue reporters.
+ */
+ public function test_github_graphql_query_for_pr_data() {
+ // Sample GraphQL response structure for merged PRs.
+ $graphql_response = array(
+ 'data' => array(
+ 'repository' => array(
+ 'pullRequests' => array(
+ 'pageInfo' => array(
+ 'hasNextPage' => false,
+ 'endCursor' => null,
+ ),
+ 'nodes' => array(
+ array(
+ 'number' => 123,
+ 'mergedAt' => '2024-06-15T10:30:00Z',
+ 'author' => array( 'login' => 'pr_author' ),
+ 'reviews' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'reviewer1' ) ),
+ array( 'author' => array( 'login' => 'reviewer2' ) ),
+ ),
+ ),
+ 'comments' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'commenter1' ) ),
+ array( 'author' => array( 'login' => 'pr_author' ) ),
+ ),
+ ),
+ 'closingIssuesReferences' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'issue_reporter' ) ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $contributors = parse_graphql_pr_data( $graphql_response );
+
+ // Should find unique contributors with correct contribution types.
+ $usernames = array_column( $contributors, 'github_username' );
+
+ $this->assertContains( 'reviewer1', $usernames, 'Should include reviewer1' );
+ $this->assertContains( 'reviewer2', $usernames, 'Should include reviewer2' );
+ $this->assertContains( 'commenter1', $usernames, 'Should include commenter1' );
+ $this->assertContains( 'issue_reporter', $usernames, 'Should include issue_reporter' );
+
+ // Verify contribution types.
+ $reviewer1 = find_contributor_by_username( $contributors, 'reviewer1' );
+ $this->assertContains( 'review', $reviewer1['contribution_types'], 'Reviewer should have review type' );
+
+ $commenter1 = find_contributor_by_username( $contributors, 'commenter1' );
+ $this->assertContains( 'comment', $commenter1['contribution_types'], 'Commenter should have comment type' );
+
+ $issue_reporter = find_contributor_by_username( $contributors, 'issue_reporter' );
+ $this->assertContains( 'issue', $issue_reporter['contribution_types'], 'Issue reporter should have issue type' );
+ }
+
+ /**
+ * Test bot account filtering
+ *
+ * Validates that bot accounts (ending in [bot]) and those in the exclusion list
+ * are filtered out from contributor lists.
+ */
+ public function test_bot_account_filtering() {
+ $contributors = array(
+ array(
+ 'github_username' => 'developer1',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'dependabot[bot]',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ array(
+ 'github_username' => 'github-actions[bot]',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-03',
+ ),
+ array(
+ 'github_username' => 'renovate[bot]',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-04',
+ ),
+ array(
+ 'github_username' => 'developer2',
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-01-05',
+ ),
+ );
+
+ $exclusion_list = array( 'some-service-account' );
+ $filtered = filter_bot_accounts( $contributors, $exclusion_list );
+
+ $this->assertCount( 2, $filtered, 'Should only have 2 human contributors after filtering' );
+ $usernames = array_column( $filtered, 'github_username' );
+ $this->assertContains( 'developer1', $usernames, 'developer1 should remain' );
+ $this->assertContains( 'developer2', $usernames, 'developer2 should remain' );
+ $this->assertNotContains( 'dependabot[bot]', $usernames, 'dependabot[bot] should be filtered' );
+ $this->assertNotContains( 'github-actions[bot]', $usernames, 'github-actions[bot] should be filtered' );
+ $this->assertNotContains( 'renovate[bot]', $usernames, 'renovate[bot] should be filtered' );
+
+ // Test with exclusion list.
+ $contributors_with_service = array(
+ array(
+ 'github_username' => 'some-service-account',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'real-developer',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ );
+
+ $filtered_with_exclusion = filter_bot_accounts( $contributors_with_service, $exclusion_list );
+ $this->assertCount( 1, $filtered_with_exclusion, 'Exclusion list account should be filtered' );
+ $this->assertEquals( 'real-developer', $filtered_with_exclusion[0]['github_username'], 'Only real developer should remain' );
+ }
+
+ /**
+ * Test contribution type assignment from API response
+ *
+ * Validates that contribution types are correctly assigned based on
+ * where the contributor was found (commits, reviews, comments, issues).
+ */
+ public function test_contribution_type_assignment_from_api_response() {
+ // Simulate a contributor found in multiple contexts.
+ $commit_contributors = array(
+ array(
+ 'login' => 'multi-contributor',
+ 'contributions' => 10,
+ ),
+ );
+
+ $review_contributors = array(
+ array( 'login' => 'multi-contributor' ),
+ array( 'login' => 'review-only' ),
+ );
+
+ $comment_contributors = array(
+ array( 'login' => 'multi-contributor' ),
+ array( 'login' => 'comment-only' ),
+ );
+
+ $issue_contributors = array(
+ array( 'login' => 'issue-only' ),
+ );
+
+ $contributors = merge_contribution_sources(
+ $commit_contributors,
+ $review_contributors,
+ $comment_contributors,
+ $issue_contributors,
+ '2024-01-01'
+ );
+
+ // Find multi-contributor - should have all applicable types.
+ $multi = find_contributor_by_username( $contributors, 'multi-contributor' );
+ $this->assertNotNull( $multi, 'multi-contributor should exist' );
+ $this->assertContains( 'commit', $multi['contribution_types'], 'Should have commit type' );
+ $this->assertContains( 'review', $multi['contribution_types'], 'Should have review type' );
+ $this->assertContains( 'comment', $multi['contribution_types'], 'Should have comment type' );
+
+ // Find single-type contributors.
+ $review_only = find_contributor_by_username( $contributors, 'review-only' );
+ $this->assertNotNull( $review_only, 'review-only should exist' );
+ $this->assertContains( 'review', $review_only['contribution_types'], 'Should have review type' );
+ $this->assertNotContains( 'commit', $review_only['contribution_types'], 'Should not have commit type' );
+
+ $comment_only = find_contributor_by_username( $contributors, 'comment-only' );
+ $this->assertNotNull( $comment_only, 'comment-only should exist' );
+ $this->assertContains( 'comment', $comment_only['contribution_types'], 'Should have comment type' );
+
+ $issue_only = find_contributor_by_username( $contributors, 'issue-only' );
+ $this->assertNotNull( $issue_only, 'issue-only should exist' );
+ $this->assertContains( 'issue', $issue_only['contribution_types'], 'Should have issue type' );
+ }
+
+ /**
+ * Test default bot exclusion list filtering
+ *
+ * Validates that the default BOT_EXCLUSION_LIST constant is used
+ * when no exclusion list is provided.
+ */
+ public function test_default_bot_exclusion_list() {
+ $contributors = array(
+ array(
+ 'github_username' => 'web-flow',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'github-actions',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ array(
+ 'github_username' => 'real-developer',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-03',
+ ),
+ );
+
+ // Use default exclusion list (empty array triggers default).
+ $filtered = filter_bot_accounts( $contributors );
+
+ $usernames = array_column( $filtered, 'github_username' );
+ $this->assertNotContains( 'web-flow', $usernames, 'web-flow should be filtered by default' );
+ $this->assertNotContains( 'github-actions', $usernames, 'github-actions should be filtered by default' );
+ $this->assertContains( 'real-developer', $usernames, 'real-developer should remain' );
+ }
+
+ /**
+ * Test find_contributor_by_username function
+ *
+ * Validates that the helper function correctly finds contributors
+ * by username (case-insensitive).
+ */
+ public function test_find_contributor_by_username() {
+ $contributors = array(
+ array(
+ 'github_username' => 'UserOne',
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'USERTWO',
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ );
+
+ // Test exact match.
+ $result = find_contributor_by_username( $contributors, 'UserOne' );
+ $this->assertNotNull( $result, 'Should find UserOne' );
+ $this->assertEquals( 'UserOne', $result['github_username'] );
+
+ // Test case-insensitive match.
+ $result = find_contributor_by_username( $contributors, 'userone' );
+ $this->assertNotNull( $result, 'Should find userone (case-insensitive)' );
+ $this->assertEquals( 'UserOne', $result['github_username'] );
+
+ $result = find_contributor_by_username( $contributors, 'usertwo' );
+ $this->assertNotNull( $result, 'Should find usertwo (case-insensitive)' );
+ $this->assertEquals( 'USERTWO', $result['github_username'] );
+
+ // Test non-existent user.
+ $result = find_contributor_by_username( $contributors, 'nonexistent' );
+ $this->assertNull( $result, 'Should return null for nonexistent user' );
+ }
+
+ /**
+ * Test GraphQL parsing with contribution counts
+ *
+ * Validates that contribution counts are tracked when parsing GraphQL data.
+ */
+ public function test_graphql_parsing_tracks_contribution_counts() {
+ // Create a response with the same reviewer reviewing multiple PRs.
+ $graphql_response = array(
+ 'data' => array(
+ 'repository' => array(
+ 'pullRequests' => array(
+ 'pageInfo' => array(
+ 'hasNextPage' => false,
+ 'endCursor' => null,
+ ),
+ 'nodes' => array(
+ array(
+ 'number' => 1,
+ 'mergedAt' => '2024-06-15T10:30:00Z',
+ 'author' => array( 'login' => 'author' ),
+ 'reviews' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'frequent_reviewer' ) ),
+ ),
+ ),
+ 'comments' => array( 'nodes' => array() ),
+ 'closingIssuesReferences' => array( 'nodes' => array() ),
+ ),
+ array(
+ 'number' => 2,
+ 'mergedAt' => '2024-06-16T10:30:00Z',
+ 'author' => array( 'login' => 'author' ),
+ 'reviews' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'frequent_reviewer' ) ),
+ ),
+ ),
+ 'comments' => array( 'nodes' => array() ),
+ 'closingIssuesReferences' => array( 'nodes' => array() ),
+ ),
+ array(
+ 'number' => 3,
+ 'mergedAt' => '2024-06-17T10:30:00Z',
+ 'author' => array( 'login' => 'author' ),
+ 'reviews' => array(
+ 'nodes' => array(
+ array( 'author' => array( 'login' => 'frequent_reviewer' ) ),
+ ),
+ ),
+ 'comments' => array( 'nodes' => array() ),
+ 'closingIssuesReferences' => array( 'nodes' => array() ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $contributors = parse_graphql_pr_data( $graphql_response );
+ $reviewer = find_contributor_by_username( $contributors, 'frequent_reviewer' );
+
+ $this->assertNotNull( $reviewer, 'frequent_reviewer should exist' );
+ $this->assertContains( 'review', $reviewer['contribution_types'], 'Should have review contribution type' );
+ }
+}
diff --git a/tests/php/test-output-generators.php b/tests/php/test-output-generators.php
new file mode 100644
index 00000000..aa771741
--- /dev/null
+++ b/tests/php/test-output-generators.php
@@ -0,0 +1,258 @@
+sample_contributors = array(
+ array(
+ 'github_username' => 'alice',
+ 'wporg_username' => 'alicewp',
+ 'wporg_display_name' => 'Alice Developer',
+ 'contribution_types' => array( 'commit', 'review' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'bob',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'issue' ),
+ 'first_contribution_date' => '2024-02-15',
+ ),
+ array(
+ 'github_username' => 'charlie',
+ 'wporg_username' => 'charliewp',
+ 'wporg_display_name' => 'Charlie Smith',
+ 'contribution_types' => array( 'comment', 'review' ),
+ 'first_contribution_date' => '2024-03-20',
+ ),
+ array(
+ 'github_username' => 'diana',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-04-10',
+ ),
+ );
+ }
+
+ /**
+ * Test readme.txt Contributors field formatting
+ *
+ * Validates that the Contributors field is generated as a
+ * comma-separated list of WordPress.org usernames.
+ */
+ public function test_readme_contributors_field_formatting() {
+ $contributors_field = generate_readme_contributors_field( $this->sample_contributors );
+
+ // Should be comma-separated WordPress.org usernames.
+ $this->assertIsString( $contributors_field, 'Contributors field should be a string' );
+
+ // Should only contain linked accounts (alicewp, charliewp).
+ $this->assertStringContainsString( 'alicewp', $contributors_field, 'Should contain alicewp' );
+ $this->assertStringContainsString( 'charliewp', $contributors_field, 'Should contain charliewp' );
+
+ // Should NOT contain unlinked GitHub usernames.
+ $this->assertStringNotContainsString( 'bob', $contributors_field, 'Should not contain unlinked bob' );
+ $this->assertStringNotContainsString( 'diana', $contributors_field, 'Should not contain unlinked diana' );
+
+ // Should be comma-separated with space.
+ $this->assertMatchesRegularExpression( '/^[\w]+(?:, [\w]+)*$/', $contributors_field, 'Should be comma-separated' );
+
+ // Test with empty contributor list - always includes wordpressdotorg first.
+ $empty_result = generate_readme_contributors_field( array() );
+ $this->assertEquals( 'wordpressdotorg', $empty_result, 'Empty contributors should produce wordpressdotorg' );
+
+ // Test with all unlinked contributors - still includes wordpressdotorg.
+ $unlinked_only = array(
+ array(
+ 'github_username' => 'unlinked1',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ );
+ $unlinked_result = generate_readme_contributors_field( $unlinked_only );
+ $this->assertEquals( 'wordpressdotorg', $unlinked_result, 'All unlinked should produce wordpressdotorg' );
+ }
+
+ /**
+ * Test CONTRIBUTORS.md markdown table generation
+ *
+ * Validates that CONTRIBUTORS.md is generated with proper markdown
+ * table format including all contributors (linked and unlinked).
+ */
+ public function test_contributors_md_markdown_table_generation() {
+ $contributors_md = generate_contributors_md( $this->sample_contributors );
+
+ // Should contain header/introductory text.
+ $this->assertStringContainsString( '# Contributors', $contributors_md, 'Should have main heading' );
+ $this->assertStringContainsString( 'acknowledgement', strtolower( $contributors_md ), 'Should mention acknowledgement system' );
+
+ // Should contain markdown table with proper headers.
+ $this->assertStringContainsString( '| GitHub Username', $contributors_md, 'Should have GitHub Username column' );
+ $this->assertStringContainsString( 'WordPress.org Username', $contributors_md, 'Should have WordPress.org Username column' );
+ $this->assertStringContainsString( 'Display Name', $contributors_md, 'Should have Display Name column' );
+
+ // Should contain table separator row.
+ $this->assertMatchesRegularExpression( '/\|[\s-]+\|/', $contributors_md, 'Should have table separator row' );
+
+ // Should include ALL contributors (linked and unlinked).
+ $this->assertStringContainsString( 'alice', $contributors_md, 'Should contain alice' );
+ $this->assertStringContainsString( 'bob', $contributors_md, 'Should contain bob (unlinked)' );
+ $this->assertStringContainsString( 'charlie', $contributors_md, 'Should contain charlie' );
+ $this->assertStringContainsString( 'diana', $contributors_md, 'Should contain diana (unlinked)' );
+
+ // GitHub usernames should be linked.
+ $this->assertStringContainsString( '[@alice](https://github.com/alice)', $contributors_md, 'GitHub username should be linked' );
+
+ // WordPress.org usernames should be linked when present.
+ $this->assertStringContainsString( '[@alicewp](https://profiles.wordpress.org/alicewp)', $contributors_md, 'WPorg username should be linked' );
+
+ // Display name should be shown when available.
+ $this->assertStringContainsString( 'Alice Developer', $contributors_md, 'Display name should be shown' );
+
+ // Should be sorted by wporg username first (blanks at end), then by github username.
+ // Linked users (alicewp, charliewp) come first, then unlinked (bob, diana).
+ $alice_pos = strpos( $contributors_md, '[@alice]' );
+ $bob_pos = strpos( $contributors_md, '[@bob]' );
+ $charlie_pos = strpos( $contributors_md, '[@charlie]' );
+ $diana_pos = strpos( $contributors_md, '[@diana]' );
+
+ // Linked users come before unlinked users.
+ $this->assertLessThan( $bob_pos, $alice_pos, 'alice (linked) should come before bob (unlinked)' );
+ $this->assertLessThan( $bob_pos, $charlie_pos, 'charlie (linked) should come before bob (unlinked)' );
+ $this->assertLessThan( $diana_pos, $alice_pos, 'alice (linked) should come before diana (unlinked)' );
+ $this->assertLessThan( $diana_pos, $charlie_pos, 'charlie (linked) should come before diana (unlinked)' );
+ }
+
+ /**
+ * Test docs/contributing/contributors.md generation
+ *
+ * Validates that the docs page is generated with proper format
+ * for developer.wordpress.org docs site style.
+ */
+ public function test_docs_contributors_md_generation() {
+ $docs_md = generate_docs_contributors_md( $this->sample_contributors );
+
+ // Should have main heading for docs site.
+ $this->assertStringContainsString( '# Contributors', $docs_md, 'Should have main heading' );
+
+ // Should have introductory text.
+ $this->assertStringContainsString( 'Secure Custom Fields', $docs_md, 'Should mention Secure Custom Fields' );
+
+ // Should contain contributor table.
+ $this->assertStringContainsString( '| GitHub Username', $docs_md, 'Should have GitHub Username column' );
+
+ // Should include all contributors.
+ $this->assertStringContainsString( 'alice', $docs_md, 'Should contain alice' );
+ $this->assertStringContainsString( 'bob', $docs_md, 'Should contain bob' );
+
+ // Should be formatted as proper docs page.
+ $lines = explode( "\n", $docs_md );
+ $first_line = trim( $lines[0] );
+ $this->assertEquals( '# Contributors', $first_line, 'First line should be the title' );
+
+ // Should not have duplicate headers or malformed structure.
+ $header_count = substr_count( $docs_md, '# Contributors' );
+ $this->assertEquals( 1, $header_count, 'Should have exactly one main heading' );
+ }
+
+ /**
+ * Test filtering logic - readme.txt only includes linked accounts
+ *
+ * Validates that the filtering function correctly identifies
+ * contributors with linked WordPress.org accounts.
+ */
+ public function test_filtering_logic_readme_only_linked_accounts() {
+ $linked = get_linked_contributors( $this->sample_contributors );
+
+ // Should return only contributors with wporg_username set.
+ $this->assertCount( 2, $linked, 'Should have 2 linked contributors' );
+
+ // Get GitHub usernames of linked contributors.
+ $linked_usernames = array_column( $linked, 'github_username' );
+
+ $this->assertContains( 'alice', $linked_usernames, 'alice should be in linked list' );
+ $this->assertContains( 'charlie', $linked_usernames, 'charlie should be in linked list' );
+ $this->assertNotContains( 'bob', $linked_usernames, 'bob should not be in linked list' );
+ $this->assertNotContains( 'diana', $linked_usernames, 'diana should not be in linked list' );
+
+ // All returned contributors should have wporg_username.
+ foreach ( $linked as $contributor ) {
+ $this->assertNotNull( $contributor['wporg_username'], 'Linked contributor should have wporg_username' );
+ $this->assertNotEmpty( $contributor['wporg_username'], 'Linked contributor wporg_username should not be empty' );
+ }
+
+ // Test with empty input.
+ $empty_result = get_linked_contributors( array() );
+ $this->assertIsArray( $empty_result, 'Should return array for empty input' );
+ $this->assertEmpty( $empty_result, 'Should return empty array for empty input' );
+
+ // Test with all unlinked.
+ $all_unlinked = array(
+ array(
+ 'github_username' => 'unlinked1',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'unlinked2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-02-01',
+ ),
+ );
+ $no_linked = get_linked_contributors( $all_unlinked );
+ $this->assertEmpty( $no_linked, 'Should return empty array when no contributors are linked' );
+
+ // Test with empty string wporg_username (should be treated as unlinked).
+ $empty_string_wporg = array(
+ array(
+ 'github_username' => 'emptystring',
+ 'wporg_username' => '',
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ );
+ $empty_string_result = get_linked_contributors( $empty_string_wporg );
+ $this->assertEmpty( $empty_string_result, 'Empty string wporg_username should be treated as unlinked' );
+ }
+}
diff --git a/tests/php/test-wporg-api-integration.php b/tests/php/test-wporg-api-integration.php
new file mode 100644
index 00000000..7d2755bf
--- /dev/null
+++ b/tests/php/test-wporg-api-integration.php
@@ -0,0 +1,312 @@
+create_username_batches( $small_batch, 50 );
+ $this->assertCount( 1, $batches, 'Small batch should produce 1 batch' );
+ $this->assertCount( 25, $batches[0], 'First batch should have 25 usernames' );
+
+ // Test with exactly 50 usernames.
+ $exact_batch = array_map(
+ function ( $i ) {
+ return "user{$i}";
+ },
+ range( 1, 50 )
+ );
+
+ $batches = $this->create_username_batches( $exact_batch, 50 );
+ $this->assertCount( 1, $batches, 'Exact 50 should produce 1 batch' );
+ $this->assertCount( 50, $batches[0], 'First batch should have 50 usernames' );
+
+ // Test with more than 50 usernames.
+ $large_batch = array_map(
+ function ( $i ) {
+ return "user{$i}";
+ },
+ range( 1, 125 )
+ );
+
+ $batches = $this->create_username_batches( $large_batch, 50 );
+ $this->assertCount( 3, $batches, '125 usernames should produce 3 batches' );
+ $this->assertCount( 50, $batches[0], 'First batch should have 50 usernames' );
+ $this->assertCount( 50, $batches[1], 'Second batch should have 50 usernames' );
+ $this->assertCount( 25, $batches[2], 'Third batch should have 25 usernames' );
+
+ // Verify request format is correct.
+ $request_body = $this->format_lookup_request( array( 'user1', 'user2', 'user3' ) );
+ $this->assertArrayHasKey( 'github_user', $request_body, 'Request should have github_user key' );
+ $this->assertIsArray( $request_body['github_user'], 'github_user should be an array' );
+ $this->assertEquals( array( 'user1', 'user2', 'user3' ), $request_body['github_user'], 'Usernames should match' );
+ }
+
+ /**
+ * Test response parsing for linked accounts
+ *
+ * Validates that the WordPress.org API response is correctly parsed
+ * to extract wporg_username and wporg_display_name for linked accounts.
+ */
+ public function test_response_parsing_for_linked_accounts() {
+ // Sample response from WordPress.org API for linked accounts.
+ $api_response = array(
+ 'linkeduser1' => array(
+ 'slug' => 'wporguser1',
+ 'display_name' => 'WordPress User One',
+ ),
+ 'linkeduser2' => array(
+ 'slug' => 'wporguser2',
+ 'display_name' => 'WordPress User Two',
+ ),
+ );
+
+ $contributors = array(
+ array(
+ 'github_username' => 'linkeduser1',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'commit' ),
+ 'first_contribution_date' => '2024-01-01',
+ ),
+ array(
+ 'github_username' => 'linkeduser2',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'review' ),
+ 'first_contribution_date' => '2024-01-02',
+ ),
+ array(
+ 'github_username' => 'unlinkeduser',
+ 'wporg_username' => null,
+ 'wporg_display_name' => null,
+ 'contribution_types' => array( 'comment' ),
+ 'first_contribution_date' => '2024-01-03',
+ ),
+ );
+
+ $updated_contributors = $this->apply_wporg_data( $contributors, $api_response );
+
+ // Verify linked user 1.
+ $linked1 = $this->find_contributor_by_username( $updated_contributors, 'linkeduser1' );
+ $this->assertNotNull( $linked1, 'linkeduser1 should exist' );
+ $this->assertEquals( 'wporguser1', $linked1['wporg_username'], 'wporg_username should be set' );
+ $this->assertEquals( 'WordPress User One', $linked1['wporg_display_name'], 'wporg_display_name should be set' );
+
+ // Verify linked user 2.
+ $linked2 = $this->find_contributor_by_username( $updated_contributors, 'linkeduser2' );
+ $this->assertNotNull( $linked2, 'linkeduser2 should exist' );
+ $this->assertEquals( 'wporguser2', $linked2['wporg_username'], 'wporg_username should be set' );
+ $this->assertEquals( 'WordPress User Two', $linked2['wporg_display_name'], 'wporg_display_name should be set' );
+
+ // Verify unlinked user remains unchanged.
+ $unlinked = $this->find_contributor_by_username( $updated_contributors, 'unlinkeduser' );
+ $this->assertNotNull( $unlinked, 'unlinkeduser should exist' );
+ $this->assertNull( $unlinked['wporg_username'], 'wporg_username should remain null for unlinked' );
+ $this->assertNull( $unlinked['wporg_display_name'], 'wporg_display_name should remain null for unlinked' );
+
+ // Verify other fields are preserved.
+ $this->assertEquals( '2024-01-01', $linked1['first_contribution_date'], 'first_contribution_date should be preserved' );
+ $this->assertContains( 'commit', $linked1['contribution_types'], 'contribution_types should be preserved' );
+
+ // Test empty API response.
+ $empty_result = $this->apply_wporg_data( $contributors, array() );
+ $this->assertCount( 3, $empty_result, 'All contributors should remain' );
+ foreach ( $empty_result as $contributor ) {
+ $this->assertNull( $contributor['wporg_username'], 'wporg_username should remain null with empty response' );
+ }
+
+ // Test response with different case GitHub usernames (API may normalize case).
+ $case_response = array(
+ 'LinkedUser1' => array(
+ 'slug' => 'wporguser1',
+ 'display_name' => 'WordPress User One',
+ ),
+ );
+
+ $case_result = $this->apply_wporg_data( $contributors, $case_response );
+ $case_linked = $this->find_contributor_by_username( $case_result, 'linkeduser1' );
+ $this->assertEquals( 'wporguser1', $case_linked['wporg_username'], 'Should handle case-insensitive matching' );
+ }
+
+ /**
+ * Test exponential backoff retry logic
+ *
+ * Validates that the retry logic implements exponential backoff correctly
+ * with appropriate delays and max retries.
+ */
+ public function test_exponential_backoff_retry_logic() {
+ // Test delay calculation for exponential backoff.
+ $base_delay_ms = 1000; // 1 second.
+ $max_delay_ms = 32000; // 32 seconds.
+
+ // First retry: 1000ms * 2^0 = 1000ms.
+ $delay_1 = $this->calculate_backoff_delay( 1, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 1000, $delay_1, 'First retry should wait 1 second' );
+
+ // Second retry: 1000ms * 2^1 = 2000ms.
+ $delay_2 = $this->calculate_backoff_delay( 2, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 2000, $delay_2, 'Second retry should wait 2 seconds' );
+
+ // Third retry: 1000ms * 2^2 = 4000ms.
+ $delay_3 = $this->calculate_backoff_delay( 3, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 4000, $delay_3, 'Third retry should wait 4 seconds' );
+
+ // Fourth retry: 1000ms * 2^3 = 8000ms.
+ $delay_4 = $this->calculate_backoff_delay( 4, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 8000, $delay_4, 'Fourth retry should wait 8 seconds' );
+
+ // Fifth retry: 1000ms * 2^4 = 16000ms.
+ $delay_5 = $this->calculate_backoff_delay( 5, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 16000, $delay_5, 'Fifth retry should wait 16 seconds' );
+
+ // Sixth retry: 1000ms * 2^5 = 32000ms (max).
+ $delay_6 = $this->calculate_backoff_delay( 6, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 32000, $delay_6, 'Sixth retry should be capped at max' );
+
+ // Seventh retry should still be capped at max.
+ $delay_7 = $this->calculate_backoff_delay( 7, $base_delay_ms, $max_delay_ms );
+ $this->assertEquals( 32000, $delay_7, 'Seventh retry should still be capped at max' );
+
+ // Test retry decision logic.
+ $max_retries = 5;
+
+ $this->assertTrue( $this->should_retry( 1, $max_retries, 429 ), 'Should retry on rate limit (attempt 1)' );
+ $this->assertTrue( $this->should_retry( 3, $max_retries, 500 ), 'Should retry on server error (attempt 3)' );
+ $this->assertTrue( $this->should_retry( 5, $max_retries, 503 ), 'Should retry on service unavailable (attempt 5)' );
+ $this->assertFalse( $this->should_retry( 6, $max_retries, 429 ), 'Should not retry after max attempts' );
+ $this->assertFalse( $this->should_retry( 1, $max_retries, 400 ), 'Should not retry on client error 400' );
+ $this->assertFalse( $this->should_retry( 1, $max_retries, 404 ), 'Should not retry on client error 404' );
+ $this->assertTrue( $this->should_retry( 1, $max_retries, 0 ), 'Should retry on network failure (status 0)' );
+ }
+
+ /**
+ * Create username batches of specified size
+ *
+ * @param array $usernames List of usernames.
+ * @param int $batch_size Maximum batch size.
+ * @return array Array of username batches.
+ */
+ private function create_username_batches( array $usernames, int $batch_size ) {
+ return array_chunk( $usernames, $batch_size );
+ }
+
+ /**
+ * Format lookup request body
+ *
+ * @param array $usernames List of GitHub usernames.
+ * @return array Request body.
+ */
+ private function format_lookup_request( array $usernames ) {
+ return array( 'github_user' => $usernames );
+ }
+
+ /**
+ * Apply WordPress.org data to contributors
+ *
+ * @param array $contributors List of contributors.
+ * @param array $api_response WordPress.org API response.
+ * @return array Updated contributors.
+ */
+ private function apply_wporg_data( array $contributors, array $api_response ) {
+ // Create case-insensitive lookup map from API response.
+ $wporg_map = array();
+ foreach ( $api_response as $github_username => $wporg_data ) {
+ $wporg_map[ strtolower( $github_username ) ] = $wporg_data;
+ }
+
+ return array_map(
+ function ( $contributor ) use ( $wporg_map ) {
+ $key = strtolower( $contributor['github_username'] ?? '' );
+
+ if ( isset( $wporg_map[ $key ] ) ) {
+ $contributor['wporg_username'] = $wporg_map[ $key ]['slug'] ?? null;
+ $contributor['wporg_display_name'] = $wporg_map[ $key ]['display_name'] ?? null;
+ }
+
+ return $contributor;
+ },
+ $contributors
+ );
+ }
+
+ /**
+ * Calculate exponential backoff delay
+ *
+ * @param int $attempt Current attempt number (1-based).
+ * @param int $base_delay Base delay in milliseconds.
+ * @param int $max_delay Maximum delay in milliseconds.
+ * @return int Delay in milliseconds.
+ */
+ private function calculate_backoff_delay( int $attempt, int $base_delay, int $max_delay ) {
+ $delay = $base_delay * pow( 2, $attempt - 1 );
+ return min( $delay, $max_delay );
+ }
+
+ /**
+ * Determine if a retry should be attempted
+ *
+ * @param int $attempt Current attempt number (1-based).
+ * @param int $max_retries Maximum number of retries.
+ * @param int $status_code HTTP status code (0 for network failures).
+ * @return bool Whether to retry.
+ */
+ private function should_retry( int $attempt, int $max_retries, int $status_code ) {
+ if ( $attempt > $max_retries ) {
+ return false;
+ }
+
+ // Retry on network failures.
+ if ( 0 === $status_code ) {
+ return true;
+ }
+
+ // Retry on rate limiting (429) and server errors (5xx).
+ if ( 429 === $status_code || ( $status_code >= 500 && $status_code < 600 ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Find contributor by username in array
+ *
+ * @param array $contributors List of contributors.
+ * @param string $username Username to find.
+ * @return array|null Contributor data or null if not found.
+ */
+ private function find_contributor_by_username( array $contributors, string $username ) {
+ foreach ( $contributors as $contributor ) {
+ if ( strtolower( $contributor['github_username'] ?? '' ) === strtolower( $username ) ) {
+ return $contributor;
+ }
+ }
+ return null;
+ }
+}