-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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
- Requires:
ConduitUI\Connector\GitHub - Requires:
ConduitUI\Issue\Data\User - Related to: Add Active Record entity layer (matching conduit-ui/pr pattern) #2 (IssueInstance)
- Related to: feat: Add explicit Saloon requests and comprehensive error handling #3 (CommentInstance)
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:
- Issue Reactions: https://docs.github.com/en/rest/reactions#list-reactions-for-an-issue
- Comment Reactions: https://docs.github.com/en/rest/reactions#list-reactions-for-an-issue-comment
Error Handling:
Should throw InvalidArgumentException for invalid reaction types with helpful error message listing all valid types.