Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 215 additions & 2 deletions inc/Abilities/GitHubAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -726,8 +726,9 @@ public static function getPullReviewContext( array $input ): array|\WP_Error {
$pull['pull'],
$files,
array(
'head_sha' => sanitize_text_field( $input['head_sha'] ?? '' ),
'max_patch_chars' => (int) ( $input['max_patch_chars'] ?? 200000 ),
'head_sha' => sanitize_text_field( $input['head_sha'] ?? '' ),
'max_patch_chars' => (int) ( $input['max_patch_chars'] ?? 200000 ),
'expanded_context' => self::buildPullReviewExpandedContext( $repo, $pull['pull'], $files, $input ),
)
);

Expand All @@ -741,6 +742,214 @@ public static function getPullReviewContext( array $input ): array|\WP_Error {
);
}

/**
* Build optional full-file context for PR review packets.
*
* @param string $repo Repository in owner/repo format.
* @param array $pull Normalized pull request payload.
* @param array $files Normalized changed file payloads.
* @param array $options Expansion options.
* @param callable|null $fetcher Optional test seam: fn(string $path, string $ref, string $side): array|WP_Error.
* @return array|null Expanded context object, or null when no expansion was requested.
*/
public static function buildPullReviewExpandedContext( string $repo, array $pull, array $files, array $options = array(), ?callable $fetcher = null ): ?array {
$include_file_contents = ! empty( $options['include_file_contents'] );
$include_base_contents = ! empty( $options['include_base_contents'] );
$context_paths = self::normalizeContextPaths( $options['context_paths'] ?? array() );

if ( ! $include_file_contents && empty( $context_paths ) ) {
return null;
}

$limits = array(
'max_file_content_chars' => max( 1, (int) ( $options['max_file_content_chars'] ?? 20000 ) ),
'max_context_files' => max( 1, (int) ( $options['max_context_files'] ?? 10 ) ),
'max_total_context_chars' => max( 1, (int) ( $options['max_total_context_chars'] ?? 100000 ) ),
);

$head_ref = (string) ( $pull['head_sha'] ?? $pull['head_ref'] ?? $pull['head'] ?? '' );
$base_ref = (string) ( $pull['base_sha'] ?? $pull['base_ref'] ?? $pull['base'] ?? '' );
$fetcher = $fetcher ?? static function ( string $path, string $ref, string $_side ) use ( $repo ): array|\WP_Error {
return self::getFileContents(
array(
'repo' => $repo,
'path' => $path,
'branch' => $ref,
)
);
};

$expanded = array(
'changed_files' => array(),
'extra_files' => array(),
'skipped' => array(),
'limits' => $limits,
'summary' => array(
'included_files' => 0,
'included_chars' => 0,
'skipped_files' => 0,
'truncated' => false,
),
);

$remaining_files = $limits['max_context_files'];
$remaining_chars = $limits['max_total_context_chars'];

if ( $include_file_contents ) {
foreach ( $files as $file ) {
$path = (string) ( $file['filename'] ?? '' );
if ( '' === $path ) {
continue;
}

if ( $remaining_files <= 0 || $remaining_chars <= 0 ) {
self::recordExpandedContextSkip( $expanded, $path, 'changed_file', 'limit_exceeded' );
continue;
}

$entry = array(
'path' => $path,
);

if ( '' !== $head_ref ) {
$entry['head'] = self::fetchBoundedContextFile( $fetcher, $path, $head_ref, 'head', $limits['max_file_content_chars'], $remaining_chars );
self::applyContextFileAccounting( $expanded, $entry['head'], $remaining_chars );
}

if ( $include_base_contents && '' !== $base_ref && $remaining_chars > 0 ) {
$entry['base'] = self::fetchBoundedContextFile( $fetcher, $path, $base_ref, 'base', $limits['max_file_content_chars'], $remaining_chars );
self::applyContextFileAccounting( $expanded, $entry['base'], $remaining_chars );
}

$expanded['changed_files'][] = $entry;
$remaining_files--;
}
}

foreach ( $context_paths as $path ) {
if ( $remaining_files <= 0 || $remaining_chars <= 0 ) {
self::recordExpandedContextSkip( $expanded, $path, 'context_path', 'limit_exceeded' );
continue;
}

$file_context = self::fetchBoundedContextFile( $fetcher, $path, $head_ref, 'head', $limits['max_file_content_chars'], $remaining_chars );
self::applyContextFileAccounting( $expanded, $file_context, $remaining_chars );
$expanded['extra_files'][] = array(
'path' => $path,
'head' => $file_context,
);
$remaining_files--;
}

$expanded['summary']['included_files'] = count( $expanded['changed_files'] ) + count( $expanded['extra_files'] );
return $expanded;
}

/**
* Normalize context paths from array or comma/newline separated string input.
*/
private static function normalizeContextPaths( mixed $paths ): array {
if ( is_string( $paths ) ) {
$paths = preg_split( '/[\r\n,]+/', $paths ) ?: array();
}

if ( ! is_array( $paths ) ) {
return array();
}

$normalized = array();
foreach ( $paths as $path ) {
$path = trim( (string) $path );
$path = ltrim( $path, '/' );
if ( '' === $path || str_contains( $path, '..' ) ) {
continue;
}
$normalized[ $path ] = true;
}

return array_keys( $normalized );
}

/**
* Fetch and bound one expanded-context file response.
*/
private static function fetchBoundedContextFile( callable $fetcher, string $path, string $ref, string $side, int $max_file_chars, int $remaining_chars ): array {
if ( '' === $ref ) {
return array(
'ref' => $ref,
'included' => false,
'reason' => 'missing_ref',
);
}

if ( $remaining_chars <= 0 ) {
return array(
'ref' => $ref,
'included' => false,
'reason' => 'total_limit_exceeded',
);
}

$result = $fetcher( $path, $ref, $side );
if ( is_wp_error( $result ) ) {
return array(
'ref' => $ref,
'included' => false,
'reason' => $result->get_error_code(),
'error' => $result->get_error_message(),
);
}

$file = $result['file'] ?? $result;
$content = (string) ( $file['content'] ?? '' );
$original = strlen( $content );
$limit = min( $max_file_chars, $remaining_chars );
$included = substr( $content, 0, $limit );
$chars = strlen( $included );

return array(
'ref' => $ref,
'sha' => $file['sha'] ?? '',
'size' => (int) ( $file['size'] ?? $original ),
'html_url' => $file['html_url'] ?? '',
'content' => $included,
'included' => true,
'included_chars' => $chars,
'original_chars' => $original,
'truncated' => $chars < $original,
);
}

/**
* Apply char accounting for one expanded-context file side.
*/
private static function applyContextFileAccounting( array &$expanded, array $file_context, int &$remaining_chars ): void {
if ( empty( $file_context['included'] ) ) {
$expanded['summary']['skipped_files']++;
return;
}

$included_chars = (int) ( $file_context['included_chars'] ?? 0 );
$expanded['summary']['included_chars'] += $included_chars;
$remaining_chars = max( 0, $remaining_chars - $included_chars );

if ( ! empty( $file_context['truncated'] ) ) {
$expanded['summary']['truncated'] = true;
}
}

/**
* Record a file skipped before any API fetch was attempted.
*/
private static function recordExpandedContextSkip( array &$expanded, string $path, string $source, string $reason ): void {
$expanded['skipped'][] = array(
'path' => $path,
'source' => $source,
'reason' => $reason,
);
$expanded['summary']['skipped_files']++;
}

/**
* Get a single pull request.
*
Expand Down Expand Up @@ -1246,6 +1455,10 @@ public static function normalizePullReviewContext( string $repo, array $pull, ar
),
);

if ( isset( $options['expanded_context'] ) && is_array( $options['expanded_context'] ) ) {
$context['expanded_context'] = $options['expanded_context'];
}

return array(
'title' => $title,
'content' => wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
Expand Down
14 changes: 10 additions & 4 deletions inc/Handlers/GitHub/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,16 @@ private function fetchPullReviewContext( array $config, ExecutionContext $contex
}

$result = GitHubAbilities::getPullReviewContext( array(
'repo' => $repo,
'pull_number' => $pull_number,
'head_sha' => $config['head_sha'] ?? '',
'max_patch_chars' => $config['max_patch_chars'] ?? 200000,
'repo' => $repo,
'pull_number' => $pull_number,
'head_sha' => $config['head_sha'] ?? '',
'max_patch_chars' => $config['max_patch_chars'] ?? 200000,
'include_file_contents' => ! empty( $config['include_file_contents'] ),
'include_base_contents' => ! empty( $config['include_base_contents'] ),
'context_paths' => $config['context_paths'] ?? array(),
'max_file_content_chars' => $config['max_file_content_chars'] ?? 20000,
'max_context_files' => $config['max_context_files'] ?? 10,
'max_total_context_chars' => $config['max_total_context_chars'] ?? 100000,
) );

if ( is_wp_error( $result ) ) {
Expand Down
41 changes: 41 additions & 0 deletions inc/Handlers/GitHub/GitHubSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,47 @@ public static function get_fields(): array {
'required' => false,
'default' => 200000,
),
'include_file_contents' => array(
'type' => 'checkbox',
'label' => __( 'Include Changed File Contents', 'data-machine-code' ),
'description' => __( 'Opt in to bounded full-file contents for changed files in PR review context.', 'data-machine-code' ),
'required' => false,
'default' => false,
),
'include_base_contents' => array(
'type' => 'checkbox',
'label' => __( 'Include Base File Contents', 'data-machine-code' ),
'description' => __( 'When changed file contents are enabled, also include bounded base-branch contents for comparison.', 'data-machine-code' ),
'required' => false,
'default' => false,
),
'context_paths' => array(
'type' => 'textarea',
'label' => __( 'Additional Context Paths', 'data-machine-code' ),
'description' => __( 'Optional comma- or newline-separated repository paths to include from the PR head ref.', 'data-machine-code' ),
'required' => false,
),
'max_file_content_chars' => array(
'type' => 'number',
'label' => __( 'Max File Content Characters', 'data-machine-code' ),
'description' => __( 'Maximum characters included per expanded file content block.', 'data-machine-code' ),
'required' => false,
'default' => 20000,
),
'max_context_files' => array(
'type' => 'number',
'label' => __( 'Max Context Files', 'data-machine-code' ),
'description' => __( 'Maximum number of files included in expanded PR review context.', 'data-machine-code' ),
'required' => false,
'default' => 10,
),
'max_total_context_chars' => array(
'type' => 'number',
'label' => __( 'Max Total Context Characters', 'data-machine-code' ),
'description' => __( 'Maximum cumulative characters included across all expanded PR review context files.', 'data-machine-code' ),
'required' => false,
'default' => 100000,
),
'state' => array(
'type' => 'select',
'label' => __( 'State Filter', 'data-machine-code' ),
Expand Down
Loading