Skip to content

Implement Issue Model with Chainable Action Methods #21

@jordanpartridge

Description

@jordanpartridge

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

Technical Notes

Lock Reasons:

  • off-topic - Discussion is off-topic
  • too heated - Discussion is too heated
  • resolved - Issue is resolved
  • spam - Issue is spam

State Reasons (for close):

  • completed - Issue was completed
  • not_planned - Issue will not be worked on

Pattern Consistency:
Follow RepositoryInstance pattern:

  1. Lazy-load issue data with caching
  2. Chainable action methods
  3. Automatic refresh after mutations
  4. Manager delegation for complex operations
  5. Magic __get for property access

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