Skip to content

Assignees: PR assignee and milestone management #32

@jordanpartridge

Description

@jordanpartridge

Summary

Implement fluent API for managing PR assignees and milestones, enabling team-based automation and project tracking.

Acceptance Criteria

Assignee Manager Interface

namespace ConduitUI\Pr\Contracts;

use Illuminate\Support\Collection;

interface AssigneeManagerInterface
{
    public function get(): Collection;
    public function add(string $username): self;
    public function addMany(array $usernames): self;
    public function remove(string $username): self;
    public function removeMany(array $usernames): self;
    public function replace(array $usernames): self;
    public function clear(): self;
    public function has(string $username): bool;
}

Assignee Manager Implementation

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Contracts\AssigneeManagerInterface;
use ConduitUI\Pr\Data\User;
use ConduitUI\Connector\GitHub;
use Illuminate\Support\Collection;

final class AssigneeManager implements AssigneeManagerInterface
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $prNumber,
    ) {}
    
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->prNumber}"
        );
        
        return collect($response->json('assignees', []))
            ->map(fn($assignee) => User::fromArray($assignee));
    }
    
    public function add(string $username): self
    {
        return $this->addMany([$username]);
    }
    
    public function addMany(array $usernames): self
    {
        $this->github->post(
            "/repos/{$this->fullName}/issues/{$this->prNumber}/assignees",
            ['assignees' => $usernames]
        );
        
        return $this;
    }
    
    public function remove(string $username): self
    {
        return $this->removeMany([$username]);
    }
    
    public function removeMany(array $usernames): self
    {
        $this->github->delete(
            "/repos/{$this->fullName}/issues/{$this->prNumber}/assignees",
            ['assignees' => $usernames]
        );
        
        return $this;
    }
    
    public function replace(array $usernames): self
    {
        $current = $this->get()->pluck('login')->toArray();
        
        if (!empty($current)) {
            $this->removeMany($current);
        }
        
        if (!empty($usernames)) {
            $this->addMany($usernames);
        }
        
        return $this;
    }
    
    public function clear(): self
    {
        $current = $this->get()->pluck('login')->toArray();
        
        if (!empty($current)) {
            $this->removeMany($current);
        }
        
        return $this;
    }
    
    public function has(string $username): bool
    {
        return $this->get()
            ->contains('login', $username);
    }
}

Milestone Manager

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Data\Milestone;
use ConduitUI\Connector\GitHub;

final class MilestoneManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
        protected int $prNumber,
    ) {}
    
    public function get(): ?Milestone
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/issues/{$this->prNumber}"
        );
        
        $milestone = $response->json('milestone');
        
        return $milestone ? Milestone::fromArray($milestone) : null;
    }
    
    public function set(int $milestoneNumber): Milestone
    {
        $response = $this->github->patch(
            "/repos/{$this->fullName}/issues/{$this->prNumber}",
            ['milestone' => $milestoneNumber]
        );
        
        return Milestone::fromArray($response->json('milestone'));
    }
    
    public function remove(): bool
    {
        $response = $this->github->patch(
            "/repos/{$this->fullName}/issues/{$this->prNumber}",
            ['milestone' => null]
        );
        
        return $response->successful();
    }
}

Repository Milestone Manager

namespace ConduitUI\Pr\Services;

use ConduitUI\Pr\Data\Milestone;
use ConduitUI\Connector\GitHub;
use Illuminate\Support\Collection;
use Carbon\Carbon;

final class RepositoryMilestoneManager
{
    public function __construct(
        protected GitHub $github,
        protected string $fullName,
    ) {}
    
    public function get(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/milestones",
            ['state' => 'all']
        );
        
        return collect($response->json())
            ->map(fn($milestone) => Milestone::fromArray($milestone));
    }
    
    public function whereOpen(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/milestones",
            ['state' => 'open']
        );
        
        return collect($response->json())
            ->map(fn($milestone) => Milestone::fromArray($milestone));
    }
    
    public function whereClosed(): Collection
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/milestones",
            ['state' => 'closed']
        );
        
        return collect($response->json())
            ->map(fn($milestone) => Milestone::fromArray($milestone));
    }
    
    public function find(int $number): Milestone
    {
        $response = $this->github->get(
            "/repos/{$this->fullName}/milestones/{$number}"
        );
        
        return Milestone::fromArray($response->json());
    }
    
    public function create(
        string $title,
        ?string $description = null,
        ?Carbon $dueOn = null,
        string $state = 'open'
    ): Milestone {
        $data = [
            'title' => $title,
            'state' => $state,
        ];
        
        if ($description !== null) {
            $data['description'] = $description;
        }
        
        if ($dueOn !== null) {
            $data['due_on'] = $dueOn->toIso8601String();
        }
        
        $response = $this->github->post(
            "/repos/{$this->fullName}/milestones",
            $data
        );
        
        return Milestone::fromArray($response->json());
    }
    
    public function update(
        int $number,
        ?string $title = null,
        ?string $description = null,
        ?Carbon $dueOn = null,
        ?string $state = null
    ): Milestone {
        $data = array_filter([
            'title' => $title,
            'description' => $description,
            'due_on' => $dueOn?->toIso8601String(),
            'state' => $state,
        ]);
        
        $response = $this->github->patch(
            "/repos/{$this->fullName}/milestones/{$number}",
            $data
        );
        
        return Milestone::fromArray($response->json());
    }
    
    public function delete(int $number): bool
    {
        $response = $this->github->delete(
            "/repos/{$this->fullName}/milestones/{$number}"
        );
        
        return $response->successful();
    }
}

Milestone DTO

namespace ConduitUI\Pr\Data;

use Carbon\Carbon;

final readonly class Milestone
{
    public function __construct(
        public int $number,
        public string $title,
        public ?string $description,
        public string $state, // open | closed
        public int $openIssues,
        public int $closedIssues,
        public ?Carbon $dueOn,
        public Carbon $createdAt,
        public Carbon $updatedAt,
        public ?Carbon $closedAt,
        public string $htmlUrl,
    ) {}
    
    public static function fromArray(array $data): self
    {
        return new self(
            number: $data['number'],
            title: $data['title'],
            description: $data['description'] ?? null,
            state: $data['state'],
            openIssues: $data['open_issues'],
            closedIssues: $data['closed_issues'],
            dueOn: isset($data['due_on']) ? Carbon::parse($data['due_on']) : 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'],
        );
    }
    
    public function isOpen(): bool
    {
        return $this->state === 'open';
    }
    
    public function isClosed(): bool
    {
        return $this->state === 'closed';
    }
    
    public function isOverdue(): bool
    {
        return $this->dueOn !== null 
            && $this->dueOn->isPast() 
            && $this->isOpen();
    }
    
    public function progress(): float
    {
        $total = $this->openIssues + $this->closedIssues;
        
        if ($total === 0) {
            return 0;
        }
        
        return round(($this->closedIssues / $total) * 100, 2);
    }
}

Integration with PullRequestInstance

// Add to PullRequestInstance
public function assignees(): AssigneeManager
{
    return new AssigneeManager($this->github, $this->fullName, $this->number);
}

public function assign(string $username): self
{
    $this->assignees()->add($username);
    return $this;
}

public function unassign(string $username): self
{
    $this->assignees()->remove($username);
    return $this;
}

public function milestone(): MilestoneManager
{
    return new MilestoneManager($this->github, $this->fullName, $this->number);
}

public function setMilestone(int $milestoneNumber): self
{
    $this->milestone()->set($milestoneNumber);
    return $this;
}

Usage Examples

Basic Assignee Operations

$pr = PullRequests::find('owner/repo', 123);

// Assign single user
$pr->assign('jordan');

// Assign multiple users
$pr->assignees()->addMany(['jordan', 'senior-dev', 'team-lead']);

// Remove assignee
$pr->unassign('jordan');

// Replace all assignees
$pr->assignees()->replace(['new-dev']);

// Clear all assignees
$pr->assignees()->clear();

// Check if assigned
if ($pr->assignees()->has('jordan')) {
    // User is assigned
}

Milestone Operations

$pr = PullRequests::find('owner/repo', 123);

// Set milestone
$pr->setMilestone(5);

// Get milestone
$milestone = $pr->milestone()->get();
echo $milestone?->title;

// Remove milestone
$pr->milestone()->remove();

Repository Milestone Management

use ConduitUI\Pr\Services\RepositoryMilestoneManager;

$milestones = new RepositoryMilestoneManager($github, 'owner/repo');

// Get all milestones
$all = $milestones->get();

// Get open milestones
$open = $milestones->whereOpen();

// Create milestone
$milestone = $milestones->create(
    title: 'v2.0 Release',
    description: 'Major version 2.0',
    dueOn: Carbon::parse('2025-12-31')
);

// Update milestone
$milestones->update(
    number: 5,
    state: 'closed'
);

Automated Assignee Management

// Auto-assign based on file changes
$pr = PullRequests::find('owner/repo', 123);

$files = $pr->files()->get();

if ($files->wherePath('database/**/*')->isNotEmpty()) {
    $pr->assign('database-expert');
}

if ($files->wherePath('frontend/**/*')->isNotEmpty()) {
    $pr->assign('frontend-lead');
}

if ($files->wherePath('tests/**/*')->isNotEmpty()) {
    $pr->assign('qa-lead');
}

Team-Based Assignment

// Assign to team member rotation
$pr = PullRequests::find('owner/repo', 123);

$teamMembers = ['alice', 'bob', 'charlie'];
$assignee = $teamMembers[array_rand($teamMembers)];

$pr->assign($assignee);

Milestone-Based Automation

use ConduitUI\Pr\Services\RepositoryMilestoneManager;

// Auto-assign PRs to current milestone
$milestones = new RepositoryMilestoneManager($github, 'owner/repo');
$currentMilestone = $milestones->whereOpen()->first();

if ($currentMilestone) {
    PullRequests::forRepo('owner/repo')
        ->whereOpen()
        ->get()
        ->each(fn($pr) => $pr->setMilestone($currentMilestone->number));
}

Progress Tracking

$milestones = new RepositoryMilestoneManager($github, 'owner/repo');

foreach ($milestones->whereOpen() as $milestone) {
    echo "{$milestone->title}: {$milestone->progress()}%\n";
    
    if ($milestone->isOverdue()) {
        echo "⚠️ Overdue!\n";
    }
}

Chaining with Other Operations

PullRequests::find('owner/repo', 123)
    ->assign('jordan')
    ->setMilestone(5)
    ->addLabels(['feature', 'high-priority'])
    ->requestReview('senior-dev')
    ->comment('Assigned to milestone v2.0');

Bulk Assignment

// Assign all open PRs to specific user
PullRequests::forRepo('owner/repo')
    ->whereOpen()
    ->whereLabel('needs-review')
    ->get()
    ->each(fn($pr) => $pr->assign('reviewer'));

// Remove assignees from closed PRs
PullRequests::forRepo('owner/repo')
    ->whereClosed()
    ->get()
    ->each(fn($pr) => $pr->assignees()->clear());

Smart Assignment Based on Author

$pr = PullRequests::find('owner/repo', 123);

// Don't self-assign
if (!$pr->assignees()->has($pr->author->login)) {
    $pr->assign('default-reviewer');
}

Testing Requirements

it('assigns user to PR')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->assign('user')
    )->toBeInstanceOf(PullRequestInstance::class);

it('assigns multiple users')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)
            ->assignees()
            ->addMany(['user1', 'user2'])
    )->toBeInstanceOf(AssigneeManager::class);

it('checks if user is assigned')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->assignees()->has('user')
    )->toBeBool();

it('sets milestone')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->setMilestone(5)
    )->toBeInstanceOf(PullRequestInstance::class);

it('gets milestone')
    ->expect(fn() => 
        PullRequests::find('test/repo', 1)->milestone()->get()
    )->toBeInstanceOf(Milestone::class);

Dependencies

References

Labels

  • enhancement
  • assignees
  • milestones
  • automation

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions