Skip to content

Implement Comprehensive Reactions API for Issues and Comments #25

@jordanpartridge

Description

@jordanpartridge

Summary

Implement a full-featured reactions API that supports adding, listing, and deleting reactions on both issues and comments. Should provide a fluent interface with type-safe reaction constants and collection utilities.

Requirements

Enhanced Reaction DTO

File: src/Data/Reaction.php

namespace ConduitUI\Issue\Data;

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

readonly class Reaction
{
    // Reaction type constants
    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,
        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']),
        );
    }
    
    /**
     * Get all valid reaction types
     */
    public static function validTypes(): array
    {
        return [
            self::PLUS_ONE,
            self::MINUS_ONE,
            self::LAUGH,
            self::CONFUSED,
            self::HEART,
            self::HOORAY,
            self::ROCKET,
            self::EYES,
        ];
    }
    
    /**
     * Validate reaction type
     */
    public static function isValidType(string $type): bool
    {
        return in_array($type, self::validTypes(), true);
    }
    
    // Type checking methods
    public function isPlusOne(): bool
    {
        return $this->content === self::PLUS_ONE;
    }
    
    public function isMinusOne(): bool
    {
        return $this->content === self::MINUS_ONE;
    }
    
    public function isLaugh(): bool
    {
        return $this->content === self::LAUGH;
    }
    
    public function isConfused(): bool
    {
        return $this->content === self::CONFUSED;
    }
    
    public function isHeart(): bool
    {
        return $this->content === self::HEART;
    }
    
    public function isHooray(): bool
    {
        return $this->content === self::HOORAY;
    }
    
    public function isRocket(): bool
    {
        return $this->content === self::ROCKET;
    }
    
    public function isEyes(): bool
    {
        return $this->content === self::EYES;
    }
}

Issue Reaction Manager

File: src/Services/IssueReactionManager.php

namespace ConduitUI\Issue\Services;

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

final class IssueReactionManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $issueNumber,
    ) {}
    
    /**
     * Get all reactions for the issue
     */
    public function all(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/reactions",
            [],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return collect($response->json())
            ->map(fn($reaction) => Reaction::fromArray($reaction));
    }
    
    /**
     * Add a reaction to the issue
     */
    public function add(string $content): Reaction
    {
        if (!Reaction::isValidType($content)) {
            throw new \InvalidArgumentException(
                "Invalid reaction type: {$content}. Valid types: " . 
                implode(', ', Reaction::validTypes())
            );
        }
        
        $response = $this->github->post(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/reactions",
            ['content' => $content],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return Reaction::fromArray($response->json());
    }
    
    /**
     * Delete a reaction
     */
    public function delete(int $reactionId): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/issues/{$this->issueNumber}/reactions/{$reactionId}",
            [],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return $response->successful();
    }
    
    /**
     * Get reactions by type
     */
    public function ofType(string $type): Collection
    {
        return $this->all()->filter(fn($r) => $r->content === $type);
    }
    
    /**
     * Get reaction counts
     */
    public function counts(): array
    {
        $reactions = $this->all();
        
        return [
            Reaction::PLUS_ONE => $reactions->filter(fn($r) => $r->isPlusOne())->count(),
            Reaction::MINUS_ONE => $reactions->filter(fn($r) => $r->isMinusOne())->count(),
            Reaction::LAUGH => $reactions->filter(fn($r) => $r->isLaugh())->count(),
            Reaction::CONFUSED => $reactions->filter(fn($r) => $r->isConfused())->count(),
            Reaction::HEART => $reactions->filter(fn($r) => $r->isHeart())->count(),
            Reaction::HOORAY => $reactions->filter(fn($r) => $r->isHooray())->count(),
            Reaction::ROCKET => $reactions->filter(fn($r) => $r->isRocket())->count(),
            Reaction::EYES => $reactions->filter(fn($r) => $r->isEyes())->count(),
        ];
    }
    
    // Convenience methods for common reactions
    public function thumbsUp(): Reaction
    {
        return $this->add(Reaction::PLUS_ONE);
    }
    
    public function thumbsDown(): Reaction
    {
        return $this->add(Reaction::MINUS_ONE);
    }
    
    public function heart(): Reaction
    {
        return $this->add(Reaction::HEART);
    }
    
    public function rocket(): Reaction
    {
        return $this->add(Reaction::ROCKET);
    }
    
    public function eyes(): Reaction
    {
        return $this->add(Reaction::EYES);
    }
}

Comment Reaction Manager

File: src/Services/CommentReactionManager.php

namespace ConduitUI\Issue\Services;

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

final class CommentReactionManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $commentId,
    ) {}
    
    /**
     * Get all reactions for the comment
     */
    public function all(): 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));
    }
    
    /**
     * Add a reaction to the comment
     */
    public function add(string $content): Reaction
    {
        if (!Reaction::isValidType($content)) {
            throw new \InvalidArgumentException(
                "Invalid reaction type: {$content}. Valid types: " . 
                implode(', ', Reaction::validTypes())
            );
        }
        
        $response = $this->github->post(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}/reactions",
            ['content' => $content],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return Reaction::fromArray($response->json());
    }
    
    /**
     * Delete a reaction
     */
    public function delete(int $reactionId): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/issues/comments/{$this->commentId}/reactions/{$reactionId}",
            [],
            ['Accept' => 'application/vnd.github+json']
        );
        
        return $response->successful();
    }
    
    /**
     * Get reactions by type
     */
    public function ofType(string $type): Collection
    {
        return $this->all()->filter(fn($r) => $r->content === $type);
    }
    
    /**
     * Get reaction counts
     */
    public function counts(): array
    {
        $reactions = $this->all();
        
        return [
            Reaction::PLUS_ONE => $reactions->filter(fn($r) => $r->isPlusOne())->count(),
            Reaction::MINUS_ONE => $reactions->filter(fn($r) => $r->isMinusOne())->count(),
            Reaction::LAUGH => $reactions->filter(fn($r) => $r->isLaugh())->count(),
            Reaction::CONFUSED => $reactions->filter(fn($r) => $r->isConfused())->count(),
            Reaction::HEART => $reactions->filter(fn($r) => $r->isHeart())->count(),
            Reaction::HOORAY => $reactions->filter(fn($r) => $r->isHooray())->count(),
            Reaction::ROCKET => $reactions->filter(fn($r) => $r->isRocket())->count(),
            Reaction::EYES => $reactions->filter(fn($r) => $r->isEyes())->count(),
        ];
    }
    
    // Convenience methods
    public function thumbsUp(): Reaction
    {
        return $this->add(Reaction::PLUS_ONE);
    }
    
    public function thumbsDown(): Reaction
    {
        return $this->add(Reaction::MINUS_ONE);
    }
    
    public function heart(): Reaction
    {
        return $this->add(Reaction::HEART);
    }
    
    public function rocket(): Reaction
    {
        return $this->add(Reaction::ROCKET);
    }
    
    public function eyes(): Reaction
    {
        return $this->add(Reaction::EYES);
    }
}

Integration with IssueInstance

Add to: src/Services/IssueInstance.php

/**
 * Get reactions manager for this issue
 */
public function reactions(): IssueReactionManager
{
    return new IssueReactionManager(
        github: $this->github,
        fullName: $this->fullName,
        issueNumber: $this->number,
    );
}

/**
 * Add a reaction to the issue
 */
public function react(string $content): Reaction
{
    return $this->reactions()->add($content);
}

Integration with CommentInstance

Add to: src/Services/CommentInstance.php

/**
 * Get reactions manager for this comment
 */
public function reactions(): CommentReactionManager
{
    return new CommentReactionManager(
        github: $this->github,
        fullName: $this->fullName,
        commentId: $this->commentId,
    );
}

Usage Examples

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

// Add reactions to issue
Issues::find('owner/repo', 123)
    ->reactions()
    ->thumbsUp();

Issues::find('owner/repo', 123)
    ->react(Reaction::HEART);

// Add reactions to comment
Issues::find('owner/repo', 123)
    ->comments()
    ->comment(456)
    ->reactions()
    ->thumbsUp();

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

// Filter by type
$hearts = Issues::find('owner/repo', 123)
    ->reactions()
    ->ofType(Reaction::HEART);

// Get reaction counts
$counts = Issues::find('owner/repo', 123)
    ->reactions()
    ->counts();
// Returns: ['+1' => 5, '-1' => 1, 'heart' => 3, ...]

// Delete a reaction
Issues::find('owner/repo', 123)
    ->reactions()
    ->delete($reactionId);

// Chained creation
$comment = Issues::find('owner/repo', 123)
    ->comment('Great work!');

$comment->reactions()->thumbsUp();
$comment->reactions()->heart();

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

Collection Extension Methods

Create a custom collection class for enhanced reaction handling:

File: src/Support/ReactionCollection.php

namespace ConduitUI\Issue\Support;

use ConduitUI\Issue\Data\Reaction;
use Illuminate\Support\Collection;

class ReactionCollection extends Collection
{
    /**
     * Get counts by type
     */
    public function countsByType(): array
    {
        return [
            Reaction::PLUS_ONE => $this->filter(fn($r) => $r->isPlusOne())->count(),
            Reaction::MINUS_ONE => $this->filter(fn($r) => $r->isMinusOne())->count(),
            Reaction::LAUGH => $this->filter(fn($r) => $r->isLaugh())->count(),
            Reaction::CONFUSED => $this->filter(fn($r) => $r->isConfused())->count(),
            Reaction::HEART => $this->filter(fn($r) => $r->isHeart())->count(),
            Reaction::HOORAY => $this->filter(fn($r) => $r->isHooray())->count(),
            Reaction::ROCKET => $this->filter(fn($r) => $r->isRocket())->count(),
            Reaction::EYES => $this->filter(fn($r) => $r->isEyes())->count(),
        ];
    }
    
    /**
     * Get most popular reaction
     */
    public function mostPopular(): ?Reaction
    {
        $counts = $this->countsByType();
        arsort($counts);
        $topType = array_key_first($counts);
        
        return $this->first(fn($r) => $r->content === $topType);
    }
}

Acceptance Criteria

  • Enhanced Reaction DTO with type constants
  • Type checking methods on Reaction DTO
  • IssueReactionManager for issue reactions
  • CommentReactionManager for comment reactions
  • Validation of reaction types
  • Convenience methods for common reactions
  • Reaction count utilities
  • Integration with IssueInstance
  • Integration with CommentInstance
  • Custom ReactionCollection class
  • Full test coverage
  • Error handling for invalid types

Dependencies

Technical Notes

Valid Reaction Types:
GitHub supports exactly 8 reaction types:

  • +1 - Thumbs up (👍)
  • -1 - Thumbs down (👎)
  • laugh - Laugh (😄)
  • confused - Confused (😕)
  • heart - Heart (❤️)
  • hooray - Hooray (🎉)
  • rocket - Rocket (🚀)
  • eyes - Eyes (👀)

API Header Requirement:
Reactions API requires the header:

Accept: application/vnd.github+json

API References:

Error Handling:
Should throw InvalidArgumentException for invalid reaction types with helpful error message listing all valid types.

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