Skip to content

Implement Comment/Timeline Management System #22

@jordanpartridge

Description

@jordanpartridge

Summary

Implement a comprehensive comment management system with support for creating, updating, deleting comments, managing reactions, and querying issue timelines. Should follow the fluent API patterns established in conduit-ui/repo.

Requirements

Comment DTO

File: src/Data/Comment.php

namespace ConduitUI\Issue\Data;

use Carbon\Carbon;
use Illuminate\Support\Collection;

readonly class Comment
{
    public function __construct(
        public int $id,
        public int $issueNumber,
        public string $body,
        public User $author,
        public Carbon $createdAt,
        public Carbon $updatedAt,
        public string $htmlUrl,
        public string $apiUrl,
        public Collection $reactions, // Collection<Reaction>
    ) {}
    
    public static function fromArray(array $data): self
    {
        return new self(
            id: $data['id'],
            issueNumber: $data['issue_url'] 
                ? (int) basename($data['issue_url']) 
                : 0,
            body: $data['body'] ?? '',
            author: User::fromArray($data['user']),
            createdAt: Carbon::parse($data['created_at']),
            updatedAt: Carbon::parse($data['updated_at']),
            htmlUrl: $data['html_url'],
            apiUrl: $data['url'],
            reactions: isset($data['reactions']) 
                ? Reaction::collectionFromArray($data['reactions'])
                : collect(),
        );
    }
}

Reaction DTO

File: src/Data/Reaction.php

namespace ConduitUI\Issue\Data;

use Carbon\Carbon;
use Illuminate\Support\Collection;

readonly class Reaction
{
    public const PLUS_ONE = '+1';
    public const MINUS_ONE = '-1';
    public const LAUGH = 'laugh';
    public const CONFUSED = 'confused';
    public const HEART = 'heart';
    public const HOORAY = 'hooray';
    public const ROCKET = 'rocket';
    public const EYES = 'eyes';
    
    public function __construct(
        public int $id,
        public string $content, // +1, -1, laugh, confused, heart, hooray, rocket, eyes
        public User $user,
        public Carbon $createdAt,
    ) {}
    
    public static function fromArray(array $data): self
    {
        return new self(
            id: $data['id'],
            content: $data['content'],
            user: User::fromArray($data['user']),
            createdAt: Carbon::parse($data['created_at']),
        );
    }
    
    public static function collectionFromArray(array $data): Collection
    {
        // GitHub returns reactions as counts, not individual objects
        // We'll need to handle both formats
        if (isset($data['url'])) {
            // Individual reaction format
            return collect([self::fromArray($data)]);
        }
        
        // Summary format - convert to collection
        return collect();
    }
    
    public function isPlusOne(): bool
    {
        return $this->content === self::PLUS_ONE;
    }
    
    public function isMinusOne(): bool
    {
        return $this->content === self::MINUS_ONE;
    }
}

CommentManager Service

File: src/Services/CommentManager.php

namespace ConduitUI\Issue\Services;

use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Comment;
use ConduitUI\Issue\Data\Reaction;
use Illuminate\Support\Collection;

final class CommentManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $issueNumber,
    ) {}
    
    /**
     * Get all comments
     */
    public function all(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/comments"
        );
        
        return collect($response->json())
            ->map(fn($comment) => Comment::fromArray($comment));
    }
    
    /**
     * Get a specific comment
     */
    public function find(int $commentId): Comment
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/comments/{$commentId}"
        );
        
        return Comment::fromArray($response->json());
    }
    
    /**
     * Create a new comment
     */
    public function create(string $body): CommentInstance
    {
        $response = $this->github->post(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/comments",
            ['body' => $body]
        );
        
        $comment = Comment::fromArray($response->json());
        
        return new CommentInstance(
            github: $this->github,
            fullName: $this->fullName,
            commentId: $comment->id,
        );
    }
    
    /**
     * Update a 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();
    }
    
    /**
     * Get comment instance for chaining
     */
    public function comment(int $commentId): CommentInstance
    {
        return new CommentInstance(
            github: $this->github,
            fullName: $this->fullName,
            commentId: $commentId,
        );
    }
}

CommentInstance Service

File: src/Services/CommentInstance.php

namespace ConduitUI\Issue\Services;

use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Comment;
use ConduitUI\Issue\Data\Reaction;
use Illuminate\Support\Collection;

final class CommentInstance
{
    protected ?Comment $comment = null;
    
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $commentId,
    ) {}
    
    /**
     * Get the comment data
     */
    public function get(): Comment
    {
        if ($this->comment === null) {
            $this->comment = $this->fetch();
        }
        
        return $this->comment;
    }
    
    /**
     * Update comment body
     */
    public function update(string $body): self
    {
        $response = $this->github->patch(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}",
            ['body' => $body]
        );
        
        $this->comment = Comment::fromArray($response->json());
        return $this;
    }
    
    /**
     * Delete the comment
     */
    public function delete(): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}"
        );
        
        return $response->successful();
    }
    
    /**
     * Add a reaction to the comment
     */
    public function react(string $content): Reaction
    {
        $response = $this->github->post(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}/reactions",
            ['content' => $content],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return Reaction::fromArray($response->json());
    }
    
    /**
     * Get all reactions on the comment
     */
    public function reactions(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}/reactions",
            [],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return collect($response->json())
            ->map(fn($reaction) => Reaction::fromArray($reaction));
    }
    
    /**
     * Delete a reaction
     */
    public function deleteReaction(int $reactionId): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}/reactions/{$reactionId}",
            [],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return $response->successful();
    }
    
    /**
     * Fetch comment from API
     */
    protected function fetch(): Comment
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}"
        );
        
        return Comment::fromArray($response->json());
    }
}

Timeline Events

File: src/Services/TimelineQuery.php

namespace ConduitUI\Issue\Services;

use ConduitUI\Connector\GitHub;
use Illuminate\Support\Collection;

final class TimelineQuery
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $issueNumber,
    ) {}
    
    /**
     * Get all timeline events for an issue
     */
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/timeline",
            [],
            ['Accept' => 'application/vnd.github.mockingbird-preview+json']
        );
        
        return collect($response->json());
    }
    
    /**
     * Get only specific event types
     */
    public function ofType(string|array $types): Collection
    {
        $types = is_array($types) ? $types : [$types];
        
        return $this->get()
            ->filter(fn($event) => in_array($event['event'] ?? null, $types));
    }
    
    /**
     * Get only comment events
     */
    public function comments(): Collection
    {
        return $this->ofType('commented');
    }
    
    /**
     * Get only label events
     */
    public function labels(): Collection
    {
        return $this->ofType(['labeled', 'unlabeled']);
    }
}

Usage Examples

use ConduitUI\Issue\Facades\Issues;
use ConduitUI\Issue\Data\Reaction;

// Create and manage comments
$comment = Issues::find('owner/repo', 123)
    ->comments()
    ->create('This is a great idea!');

// Update comment
Issues::find('owner/repo', 123)
    ->comments()
    ->update($commentId, 'Updated comment text');

// Delete comment
Issues::find('owner/repo', 123)
    ->comments()
    ->delete($commentId);

// Chained comment actions
Issues::find('owner/repo', 123)
    ->comments()
    ->create('Great work!')
    ->react(Reaction::PLUS_ONE);

// Get all comments
$comments = Issues::find('owner/repo', 123)
    ->comments()
    ->all();

// Add reactions
Issues::find('owner/repo', 123)
    ->comments()
    ->comment($commentId)
    ->react(Reaction::HEART);

// Get reactions
$reactions = Issues::find('owner/repo', 123)
    ->comments()
    ->comment($commentId)
    ->reactions();

// Timeline events
$timeline = Issues::find('owner/repo', 123)
    ->timeline()
    ->get();

$comments = Issues::find('owner/repo', 123)
    ->timeline()
    ->comments();

Acceptance Criteria

  • Comment DTO with all properties
  • Reaction DTO with type constants
  • CommentManager for CRUD operations
  • CommentInstance for chainable actions
  • Reaction management (create, list, delete)
  • Timeline query support
  • Event type filtering
  • Full test coverage
  • Follows established patterns

Dependencies

Technical Notes

Reaction Types:

  • +1 - Thumbs up
  • -1 - Thumbs down
  • laugh - Laugh
  • confused - Confused
  • heart - Heart
  • hooray - Hooray
  • rocket - Rocket
  • eyes - Eyes

Timeline Event Types:

  • commented - Comment added
  • labeled - Label added
  • unlabeled - Label removed
  • assigned - User assigned
  • unassigned - User unassigned
  • closed - Issue closed
  • reopened - Issue reopened
  • referenced - Referenced from commit/PR
  • renamed - Title changed
  • Many more...

GitHub API References:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions