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
Summary
Implement fluent API for managing PR assignees and milestones, enabling team-based automation and project tracking.
Acceptance Criteria
Assignee Manager Interface
Assignee Manager Implementation
Milestone Manager
Repository Milestone Manager
Milestone DTO
Integration with PullRequestInstance
Usage Examples
Basic Assignee Operations
Milestone Operations
Repository Milestone Management
Automated Assignee Management
Team-Based Assignment
Milestone-Based Automation
Progress Tracking
Chaining with Other Operations
Bulk Assignment
Smart Assignment Based on Author
Testing Requirements
Dependencies
References
Labels