Skip to content

Files: Diff and changed files API #25

@jordanpartridge

Description

@jordanpartridge

Summary

Implement API for querying PR file changes, diffs, and patches with filtering and analysis capabilities.

Acceptance Criteria

File Query Interface

namespace ConduitUI\Pr\Contracts;

interface FileQueryInterface
{
    public function get(): Collection;
    public function whereAdded(): Collection;
    public function whereModified(): Collection;
    public function whereRemoved(): Collection;
    public function whereRenamed(): Collection;
    public function wherePath(string $pattern): Collection;
    public function whereExtension(string $extension): Collection;
    public function stats(): FileStats;
}

File Query Implementation

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Contracts\FileQueryInterface;
use ConduitUI\Pr\Data\FileChange;
use ConduitUI\Pr\Data\FileStats;
use Illuminate\Support\Str;

final class FileQuery implements FileQueryInterface
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $prNumber,
    ) {}
    
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/pulls/{$this->prNumber}/files"
        );
        
        return collect($response->json())
            ->map(fn($file) => FileChange::fromArray($file));
    }
    
    public function whereAdded(): Collection
    {
        return $this->get()->where('status', 'added');
    }
    
    public function whereModified(): Collection
    {
        return $this->get()->where('status', 'modified');
    }
    
    public function whereRemoved(): Collection
    {
        return $this->get()->where('status', 'removed');
    }
    
    public function whereRenamed(): Collection
    {
        return $this->get()->where('status', 'renamed');
    }
    
    public function wherePath(string $pattern): Collection
    {
        return $this->get()->filter(
            fn($file) => Str::is($pattern, $file->filename)
        );
    }
    
    public function whereExtension(string $extension): Collection
    {
        return $this->get()->filter(
            fn($file) => Str::endsWith($file->filename, ".{$extension}")
        );
    }
    
    public function stats(): FileStats
    {
        $files = $this->get();
        
        return new FileStats(
            totalFiles: $files->count(),
            additions: $files->sum('additions'),
            deletions: $files->sum('deletions'),
            changes: $files->sum('changes'),
            added: $files->where('status', 'added')->count(),
            modified: $files->where('status', 'modified')->count(),
            removed: $files->where('status', 'removed')->count(),
            renamed: $files->where('status', 'renamed')->count(),
        );
    }
}

File Change DTO

namespace ConduitUI\Pr\Data;

final readonly class FileChange
{
    public function __construct(
        public string $sha,
        public string $filename,
        public string $status, // added | modified | removed | renamed
        public int $additions,
        public int $deletions,
        public int $changes,
        public ?string $patch,
        public ?string $previousFilename,
        public string $blobUrl,
        public string $rawUrl,
        public string $contentsUrl,
    ) {}
    
    public static function fromArray(array $data): self;
    
    public function isAdded(): bool
    {
        return $this->status === 'added';
    }
    
    public function isModified(): bool
    {
        return $this->status === 'modified';
    }
    
    public function isRemoved(): bool
    {
        return $this->status === 'removed';
    }
    
    public function isRenamed(): bool
    {
        return $this->status === 'renamed';
    }
    
    public function extension(): string
    {
        return pathinfo($this->filename, PATHINFO_EXTENSION);
    }
    
    public function basename(): string
    {
        return basename($this->filename);
    }
    
    public function directory(): string
    {
        return dirname($this->filename);
    }
}

File Stats DTO

namespace ConduitUI\Pr\Data;

final readonly class FileStats
{
    public function __construct(
        public int $totalFiles,
        public int $additions,
        public int $deletions,
        public int $changes,
        public int $added,
        public int $modified,
        public int $removed,
        public int $renamed,
    ) {}
    
    public function netChange(): int
    {
        return $this->additions - $this->deletions;
    }
    
    public function summary(): string
    {
        return "{$this->totalFiles} files: +{$this->additions}, -{$this->deletions}";
    }
}

Integration with PullRequest DTO

// Add to PullRequest DTO
public function files(): FileQuery
{
    return new FileQuery(
        $this->github,
        $this->fullName,
        $this->number
    );
}

public function diff(): string
{
    $response = $this->github->get(
        "/repos/{$this->fullName}/pulls/{$this->number}",
        [],
        ['Accept' => 'application/vnd.github.v3.diff']
    );
    
    return $response->body();
}

public function patch(): string
{
    $response = $this->github->get(
        "/repos/{$this->fullName}/pulls/{$this->number}",
        [],
        ['Accept' => 'application/vnd.github.v3.patch']
    );
    
    return $response->body();
}

Usage Examples

Query All Files

$pr = PullRequests::find('owner/repo', 123);

// Get all changed files
$files = $pr->files()->get();

foreach ($files as $file) {
    echo "{$file->filename}: +{$file->additions} -{$file->deletions}\n";
}

Filter Files by Status

// New files
$newFiles = $pr->files()->whereAdded();

// Modified files
$modified = $pr->files()->whereModified();

// Deleted files
$deleted = $pr->files()->whereRemoved();

Filter by Path Pattern

// All PHP files
$phpFiles = $pr->files()->whereExtension('php');

// Files in src directory
$srcFiles = $pr->files()->wherePath('src/*');

// Test files
$tests = $pr->files()->wherePath('tests/**/*.php');

// Configuration files
$configs = $pr->files()->wherePath('config/*.php');

File Statistics

$stats = $pr->files()->stats();

echo $stats->summary();
// Output: "12 files: +234, -45"

echo "Net change: {$stats->netChange()} lines";
// Output: "Net change: 189 lines"

echo "Files modified: {$stats->modified}";

Get Raw Diff

$pr = PullRequests::find('owner/repo', 123);

// Get unified diff
$diff = $pr->diff();
file_put_contents('pr-123.diff', $diff);

// Get patch format
$patch = $pr->patch();
file_put_contents('pr-123.patch', $patch);

Automated Checks Based on Files

$pr = PullRequests::find('owner/repo', 123);

// Check if migrations were added without tests
$migrations = $pr->files()->wherePath('database/migrations/*.php');
$tests = $pr->files()->wherePath('tests/**/*Test.php');

if ($migrations->isNotEmpty() && $tests->isEmpty()) {
    $pr->comment('⚠️ Migration detected but no tests added.');
}

// Check for large PRs
$stats = $pr->files()->stats();
if ($stats->totalFiles > 20 || $stats->changes > 500) {
    $pr->comment('⚠️ Large PR detected. Consider breaking into smaller PRs.');
}

Require Tests for Source Changes

$pr = PullRequests::find('owner/repo', 123);

$sourceChanged = $pr->files()
    ->wherePath('src/**/*.php')
    ->isNotEmpty();

$testsChanged = $pr->files()
    ->wherePath('tests/**/*.php')
    ->isNotEmpty();

if ($sourceChanged && !$testsChanged) {
    $pr->requestChanges('Please add tests for source code changes')
       ->submit();
}

Documentation Check

$pr = PullRequests::find('owner/repo', 123);

$docsChanged = $pr->files()
    ->wherePath('docs/**/*.md')
    ->isNotEmpty();

$readmeChanged = $pr->files()
    ->wherePath('README.md')
    ->isNotEmpty();

if (!$docsChanged && !$readmeChanged) {
    $pr->comment('💡 Consider updating documentation for these changes.');
}

Analyze File Types

$pr = PullRequests::find('owner/repo', 123);

$files = $pr->files()->get();

$byExtension = $files->groupBy(fn($file) => $file->extension());

foreach ($byExtension as $ext => $files) {
    echo "{$ext}: {$files->count()} files\n";
}

Testing Requirements

it('gets all changed files')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->files()->get()
    )->toBeInstanceOf(Collection::class);

it('filters added files')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->files()->whereAdded()
    )->toBeInstanceOf(Collection::class);

it('filters by extension')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->files()->whereExtension('php')
    )->toBeInstanceOf(Collection::class);

it('generates file statistics')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->files()->stats()
    )->toBeInstanceOf(FileStats::class);

it('retrieves diff')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->diff()
    )->toBeString();

Dependencies

References

Labels

  • enhancement
  • files
  • diff

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions