Skip to content

Comments: PR comment and inline comment management #29

@jordanpartridge

Description

@jordanpartridge

Summary

Implement comprehensive comment management API for PR comments, inline code comments, and comment threads.

Acceptance Criteria

Comment Manager Interface

namespace ConduitUI\Pr\Contracts;

use ConduitUI\Pr\Data\Comment;
use Illuminate\Support\Collection;

interface CommentManagerInterface
{
    public function get(): Collection;
    public function create(string $body): Comment;
    public function update(int $commentId, string $body): Comment;
    public function delete(int $commentId): bool;
    public function reply(int $commentId, string $body): Comment;
}

Comment Manager Implementation

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Contracts\CommentManagerInterface;
use ConduitUI\Pr\Data\Comment;
use ConduitUI\Connector\GitHub;
use Illuminate\Support\Collection;

final class CommentManager implements CommentManagerInterface
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $prNumber,
    ) {}
    
    /**
     * Get all issue comments for the PR
     */
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->prNumber}/comments"
        );
        
        return collect($response->json())
            ->map(fn($comment) => Comment::fromArray($comment));
    }
    
    /**
     * Create a new issue comment
     */
    public function create(string $body): Comment
    {
        $response = $this->github->post(
            "/repos/{$this->fullName}/issues/{$this->prNumber}/comments",
            ['body' => $body]
        );
        
        return Comment::fromArray($response->json());
    }
    
    /**
     * Update an existing comment
     */
    public function update(int $commentId, string $body): Comment
    {
        $response = $this->github->patch(
            "/repos/{$this->fullName}/issues/comments/{$commentId}",
            ['body' => $body]
        );
        
        return Comment::fromArray($response->json());
    }
    
    /**
     * Delete a comment
     */
    public function delete(int $commentId): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/issues/comments/{$commentId}"
        );
        
        return $response->successful();
    }
    
    /**
     * Reply to a comment (creates new comment with reference)
     */
    public function reply(int $commentId, string $body): Comment
    {
        $replyBody = "> Replying to comment #{$commentId}\n\n{$body}";
        return $this->create($replyBody);
    }
}

Inline Comment Manager

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Data\ReviewComment;
use ConduitUI\Connector\GitHub;
use Illuminate\Support\Collection;

final class InlineCommentManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $prNumber,
    ) {}
    
    /**
     * Get all review comments (inline comments)
     */
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/pulls/{$this->prNumber}/comments"
        );
        
        return collect($response->json())
            ->map(fn($comment) => ReviewComment::fromArray($comment));
    }
    
    /**
     * Create inline comment on specific line
     */
    public function create(
        string $path,
        int $line,
        string $body,
        ?string $side = 'RIGHT'
    ): ReviewComment {
        $data = [
            'body' => $body,
            'path' => $path,
            'line' => $line,
            'side' => $side,
        ];
        
        $response = $this->github->post(
            "/repos/{$this->fullName}/pulls/{$this->prNumber}/comments",
            $data
        );
        
        return ReviewComment::fromArray($response->json());
    }
    
    /**
     * Create suggestion comment
     */
    public function suggest(
        string $path,
        int $startLine,
        int $endLine,
        string $suggestion
    ): ReviewComment {
        $body = "```suggestion\n{$suggestion}\n```";
        
        return $this->create($path, $endLine, $body);
    }
    
    /**
     * Reply to inline comment
     */
    public function reply(int $commentId, string $body): ReviewComment
    {
        $response = $this->github->post(
            "/repos/{$this->fullName}/pulls/{$this->prNumber}/comments/{$commentId}/replies",
            ['body' => $body]
        );
        
        return ReviewComment::fromArray($response->json());
    }
    
    /**
     * Update inline comment
     */
    public function update(int $commentId, string $body): ReviewComment
    {
        $response = $this->github->patch(
            "/repos/{$this->fullName}/pulls/comments/{$commentId}",
            ['body' => $body]
        );
        
        return ReviewComment::fromArray($response->json());
    }
    
    /**
     * Delete inline comment
     */
    public function delete(int $commentId): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/pulls/comments/{$commentId}"
        );
        
        return $response->successful();
    }
}

Comment DTOs

namespace ConduitUI\Pr\Data;

use Carbon\Carbon;

/**
 * Issue comment (general PR comment)
 */
final readonly class Comment
{
    public function __construct(
        public int $id,
        public User $user,
        public string $body,
        public string $htmlUrl,
        public Carbon $createdAt,
        public Carbon $updatedAt,
    ) {}
    
    public static function fromArray(array $data): self;
}

/**
 * Review comment (inline code comment)
 */
final readonly class ReviewComment
{
    public function __construct(
        public int $id,
        public User $user,
        public string $body,
        public string $path,
        public int $line,
        public ?int $startLine,
        public string $side, // LEFT | RIGHT
        public ?int $inReplyToId,
        public string $htmlUrl,
        public Carbon $createdAt,
        public Carbon $updatedAt,
    ) {}
    
    public static function fromArray(array $data): self;
    
    public function isReply(): bool
    {
        return $this->inReplyToId !== null;
    }
    
    public function isSuggestion(): bool
    {
        return str_contains($this->body, '```suggestion');
    }
}

Integration with PullRequestInstance

// Add to PullRequestInstance
public function comments(): CommentManager
{
    return new CommentManager($this->github, $this->fullName, $this->number);
}

public function inlineComments(): InlineCommentManager
{
    return new InlineCommentManager($this->github, $this->fullName, $this->number);
}

public function comment(string $body): PullRequest
{
    $this->comments()->create($body);
    return $this->fresh();
}

Usage Examples

General PR Comments

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

// Add comment
$pr->comment('Please address the failing tests');

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

// Update comment
$pr->comments()->update(456, 'Updated comment text');

// Delete comment
$pr->comments()->delete(456);

Inline Code Comments

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

// Add inline comment
$pr->inlineComments()->create(
    path: 'src/Service.php',
    line: 42,
    body: 'This could cause a race condition'
);

// Add suggestion
$pr->inlineComments()->suggest(
    path: 'src/Controller.php',
    startLine: 10,
    endLine: 15,
    suggestion: 'return $this->repository->findOrFail($id);'
);

// Reply to inline comment
$pr->inlineComments()->reply(789, 'Good catch! Will fix.');

// Get all inline comments
$inlineComments = $pr->inlineComments()->get();

Automated Comment Bot

PullRequests::forRepo('owner/repo')
    ->whereOpen()
    ->get()
    ->each(function($pr) {
        $stats = $pr->files()->stats();
        
        if ($stats->totalFiles > 20) {
            $pr->comment('⚠️ Large PR detected. Consider splitting into smaller PRs.');
        }
        
        if ($pr->checksFail()) {
            $failing = $pr->checks()->whereFailing();
            $message = "❌ Failing checks:\n";
            foreach ($failing as $check) {
                $message .= "- {$check->name}\n";
            }
            $pr->comment($message);
        }
    });

Conditional Comments

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

// Only comment if no tests changed
$testsChanged = $pr->files()
    ->wherePath('tests/**/*.php')
    ->isNotEmpty();

if (!$testsChanged) {
    $pr->comment('💡 Consider adding tests for these changes.');
}

Multi-line Suggestions

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

$pr->inlineComments()->suggest(
    path: 'src/UserController.php',
    startLine: 20,
    endLine: 25,
    suggestion: <<<'PHP'
        return $this->validate($request, [
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8',
        ]);
        PHP
);

Comment Threading

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

// Get a specific comment and reply
$comment = $pr->comments()->get()->first();
$pr->comments()->reply($comment->id, 'Thanks for the feedback!');

Bulk Comment Operations

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

// Delete all bot comments
$pr->comments()
    ->get()
    ->filter(fn($c) => $c->user->login === 'github-actions[bot]')
    ->each(fn($c) => $pr->comments()->delete($c->id));

Testing Requirements

it('creates PR comment')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->comment('Test comment')
    )->toBeInstanceOf(PullRequest::class);

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

it('creates inline comment')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)
            ->inlineComments()
            ->create('src/File.php', 10, 'Comment')
    )->toBeInstanceOf(ReviewComment::class);

it('creates suggestion')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)
            ->inlineComments()
            ->suggest('src/File.php', 10, 15, 'new code')
    )->toBeInstanceOf(ReviewComment::class);

it('deletes comment')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->comments()->delete(1)
    )->toBeBool();

Dependencies

References

Labels

  • enhancement
  • comments
  • reviews

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