-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
Summary
Implement an Issue model (DTO) and IssueInstance service that provides chainable methods for performing actions on individual issues. This should follow the RepositoryInstance pattern from conduit-ui/repo.
Requirements
Issue Model (DTO)
File: src/Data/Issue.php
namespace ConduitUI\Issue\Data;
use Carbon\Carbon;
use Illuminate\Support\Collection;
readonly class Issue
{
public function __construct(
public int $id,
public int $number,
public string $title,
public string $state, // 'open' | 'closed'
public ?string $body,
public User $author,
public Collection $assignees, // Collection<User>
public Collection $labels, // Collection<Label>
public ?Milestone $milestone,
public int $comments,
public bool $locked,
public ?string $activeLockReason,
public Carbon $createdAt,
public Carbon $updatedAt,
public ?Carbon $closedAt,
public string $htmlUrl,
public string $apiUrl,
) {}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
number: $data['number'],
title: $data['title'],
state: $data['state'],
body: $data['body'] ?? null,
author: User::fromArray($data['user']),
assignees: collect($data['assignees'] ?? [])
->map(fn($user) => User::fromArray($user)),
labels: collect($data['labels'] ?? [])
->map(fn($label) => Label::fromArray($label)),
milestone: isset($data['milestone'])
? Milestone::fromArray($data['milestone'])
: null,
comments: $data['comments'] ?? 0,
locked: $data['locked'] ?? false,
activeLockReason: $data['active_lock_reason'] ?? null,
createdAt: Carbon::parse($data['created_at']),
updatedAt: Carbon::parse($data['updated_at']),
closedAt: isset($data['closed_at'])
? Carbon::parse($data['closed_at'])
: null,
htmlUrl: $data['html_url'],
apiUrl: $data['url'],
);
}
public function isOpen(): bool
{
return $this->state === 'open';
}
public function isClosed(): bool
{
return $this->state === 'closed';
}
public function isLocked(): bool
{
return $this->locked;
}
public function hasLabel(string $label): bool
{
return $this->labels
->pluck('name')
->contains($label);
}
public function isAssignedTo(string $username): bool
{
return $this->assignees
->pluck('login')
->contains($username);
}
}IssueInstance Service
File: src/Services/IssueInstance.php
namespace ConduitUI\Issue\Services;
use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Issue;
use ConduitUI\Issue\Data\Comment;
use Illuminate\Support\Collection;
final class IssueInstance
{
protected ?Issue $issue = null;
public function __construct(
protected GitHub $github,
protected string $fullName, // owner/repo
protected int $number,
) {}
/**
* Get the issue data (cached)
*/
public function get(): Issue
{
if ($this->issue === null) {
$this->issue = $this->fetch();
}
return $this->issue;
}
/**
* Fetch fresh issue data
*/
public function fresh(): Issue
{
$this->issue = $this->fetch();
return $this->issue;
}
/**
* Update issue attributes
*/
public function update(array $attributes): self
{
$response = $this->github->patch(
"/repos/{$this->fullName}/issues/{$this->number}",
$attributes
);
$this->issue = Issue::fromArray($response->json());
return $this;
}
/**
* Set the title
*/
public function title(string $title): self
{
return $this->update(['title' => $title]);
}
/**
* Set the body
*/
public function body(string $body): self
{
return $this->update(['body' => $body]);
}
/**
* Add labels (merges with existing)
*/
public function addLabel(string $label): self
{
return $this->addLabels([$label]);
}
/**
* Add multiple labels
*/
public function addLabels(array $labels): self
{
$response = $this->github->post(
"/repos/{$this->fullName}/issues/{$this->number}/labels",
['labels' => $labels]
);
$this->issue = $this->fresh();
return $this;
}
/**
* Remove a label
*/
public function removeLabel(string $label): self
{
$this->github->delete(
"/repos/{$this->fullName}/issues/{$this->number}/labels/{$label}"
);
$this->issue = $this->fresh();
return $this;
}
/**
* Remove multiple labels
*/
public function removeLabels(array $labels): self
{
foreach ($labels as $label) {
$this->removeLabel($label);
}
return $this;
}
/**
* Replace all labels
*/
public function setLabels(array $labels): self
{
$response = $this->github->put(
"/repos/{$this->fullName}/issues/{$this->number}/labels",
['labels' => $labels]
);
$this->issue = $this->fresh();
return $this;
}
/**
* Assign to user(s)
*/
public function assign(string|array $assignees): self
{
$assignees = is_array($assignees) ? $assignees : [$assignees];
return $this->update(['assignees' => $assignees]);
}
/**
* Convenience method - assign to single user
*/
public function assignTo(string $username): self
{
return $this->assign($username);
}
/**
* Remove assignees
*/
public function unassign(string|array $assignees): self
{
$assignees = is_array($assignees) ? $assignees : [$assignees];
$current = $this->get()->assignees->pluck('login')->toArray();
$remaining = array_diff($current, $assignees);
return $this->update(['assignees' => array_values($remaining)]);
}
/**
* Set milestone
*/
public function milestone(int|null $milestoneNumber): self
{
return $this->update(['milestone' => $milestoneNumber]);
}
/**
* Close the issue
*/
public function close(?string $reason = null): self
{
$params = ['state' => 'closed'];
if ($reason !== null) {
$params['state_reason'] = $reason; // completed | not_planned
}
return $this->update($params);
}
/**
* Reopen the issue
*/
public function reopen(): self
{
return $this->update(['state' => 'open']);
}
/**
* Lock the issue
*/
public function lock(?string $reason = null): self
{
$params = $reason ? ['lock_reason' => $reason] : [];
$this->github->put(
"/repos/{$this->fullName}/issues/{$this->number}/lock",
$params
);
$this->issue = $this->fresh();
return $this;
}
/**
* Unlock the issue
*/
public function unlock(): self
{
$this->github->delete(
"/repos/{$this->fullName}/issues/{$this->number}/lock"
);
$this->issue = $this->fresh();
return $this;
}
/**
* Add a comment
*/
public function comment(string $body): Comment
{
$response = $this->github->post(
"/repos/{$this->fullName}/issues/{$this->number}/comments",
['body' => $body]
);
return Comment::fromArray($response->json());
}
/**
* Get comments manager
*/
public function comments(): CommentManager
{
return new CommentManager(
github: $this->github,
fullName: $this->fullName,
issueNumber: $this->number,
);
}
/**
* Get labels manager
*/
public function labels(): LabelManager
{
return new LabelManager(
github: $this->github,
fullName: $this->fullName,
issueNumber: $this->number,
);
}
/**
* Fetch issue from API
*/
protected function fetch(): Issue
{
$response = $this->github->get(
"/repos/{$this->fullName}/issues/{$this->number}"
);
return Issue::fromArray($response->json());
}
/**
* Magic method to access issue properties
*/
public function __get(string $name): mixed
{
return $this->get()->$name;
}
}Facade Method
Add to: src/Facades/Issues.php
/**
* @method static IssueInstance find(string $fullName, int $number)
*/
class Issues extends Facade
{
// ...
}Add to: src/Services/Issues.php
public function find(string $fullName, int $number): IssueInstance
{
return new IssueInstance($this->github, $fullName, $number);
}Usage Examples
use ConduitUI\Issue\Facades\Issues;
// Find and update single issue
Issues::find('owner/repo', 123)
->addLabel('bug')
->assignTo('developer')
->comment('Looking into this')
->close();
// Update title and body
Issues::find('owner/repo', 456)
->title('New Title')
->body('Updated description')
->addLabels(['enhancement', 'priority-high']);
// Manage labels
Issues::find('owner/repo', 789)
->addLabels(['bug', 'critical'])
->removeLabel('triage')
->setLabels(['bug', 'critical', 'verified']);
// Lock/unlock
Issues::find('owner/repo', 101)
->lock('too heated')
->comment('Locking this discussion');
Issues::find('owner/repo', 101)
->unlock();
// Close with reason
Issues::find('owner/repo', 202)
->close('completed'); // or 'not_planned'Acceptance Criteria
- Issue DTO with all required properties
- Issue DTO helper methods (isOpen, isClosed, hasLabel, etc.)
- IssueInstance service with chainable methods
- All action methods return self for chaining
- Methods that modify issue state refresh cached data
- Label management (add, remove, set)
- Assignment management
- State management (close, reopen)
- Lock/unlock functionality
- Comment creation
- Access to comment and label managers
- Full test coverage
- Follows conduit-ui/repo patterns
Dependencies
- Requires:
ConduitUI\Connector\GitHub - Requires:
ConduitUI\Issue\Data\User(supporting DTO) - Requires:
ConduitUI\Issue\Data\Label(supporting DTO) - Requires:
ConduitUI\Issue\Data\Milestone(supporting DTO) - Related to: feat: Add explicit Saloon requests and comprehensive error handling #3 (CommentManager)
- Related to: 🔒 Security audit for production release #4 (LabelManager)
Technical Notes
Lock Reasons:
off-topic- Discussion is off-topictoo heated- Discussion is too heatedresolved- Issue is resolvedspam- Issue is spam
State Reasons (for close):
completed- Issue was completednot_planned- Issue will not be worked on
Pattern Consistency:
Follow RepositoryInstance pattern:
- Lazy-load issue data with caching
- Chainable action methods
- Automatic refresh after mutations
- Manager delegation for complex operations
- Magic __get for property access
coderabbitai
Metadata
Metadata
Assignees
Labels
No labels