-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
Summary
Implement comprehensive milestone support including querying, creating, updating, and managing milestones at the repository level, plus assigning issues to milestones. Should follow the fluent patterns established in the package.
Requirements
Milestone DTO
File: src/Data/Milestone.php
namespace ConduitUI\Issue\Data;
use Carbon\Carbon;
readonly class Milestone
{
public function __construct(
public int $id,
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 string $apiUrl,
) {}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
number: $data['number'],
title: $data['title'],
description: $data['description'] ?? null,
state: $data['state'],
openIssues: $data['open_issues'] ?? 0,
closedIssues: $data['closed_issues'] ?? 0,
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'],
apiUrl: $data['url'],
);
}
/**
* Check if milestone is open
*/
public function isOpen(): bool
{
return $this->state === 'open';
}
/**
* Check if milestone is closed
*/
public function isClosed(): bool
{
return $this->state === 'closed';
}
/**
* Calculate completion percentage
*/
public function completionPercentage(): float
{
$total = $this->openIssues + $this->closedIssues;
if ($total === 0) {
return 0.0;
}
return round(($this->closedIssues / $total) * 100, 2);
}
/**
* Check if milestone is overdue
*/
public function isOverdue(): bool
{
return $this->dueOn !== null
&& $this->dueOn->isPast()
&& $this->isOpen();
}
/**
* Get total issue count
*/
public function totalIssues(): int
{
return $this->openIssues + $this->closedIssues;
}
}Milestone Query
File: src/Services/MilestoneQuery.php
namespace ConduitUI\Issue\Services;
use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Milestone;
use Illuminate\Support\Collection;
final class MilestoneQuery
{
protected string $state = 'open';
protected ?string $sort = null;
protected string $direction = 'asc';
protected int $perPage = 30;
protected int $page = 1;
public function __construct(
protected GitHub $github,
protected string $fullName,
) {}
/**
* Filter to open milestones
*/
public function open(): self
{
$this->state = 'open';
return $this;
}
/**
* Filter to closed milestones
*/
public function closed(): self
{
$this->state = 'closed';
return $this;
}
/**
* Get all milestones
*/
public function all(): self
{
$this->state = 'all';
return $this;
}
/**
* Set state filter
*/
public function state(string $state): self
{
$this->state = $state;
return $this;
}
/**
* Sort results
*/
public function sort(string $sort, string $direction = 'asc'): self
{
$this->sort = $sort;
$this->direction = $direction;
return $this;
}
/**
* Sort by due date
*/
public function sortByDueDate(string $direction = 'asc'): self
{
return $this->sort('due_on', $direction);
}
/**
* Sort by completion
*/
public function sortByCompleteness(string $direction = 'asc'): self
{
return $this->sort('completeness', $direction);
}
/**
* Set pagination
*/
public function perPage(int $perPage): self
{
$this->perPage = $perPage;
return $this;
}
/**
* Set page
*/
public function page(int $page): self
{
$this->page = $page;
return $this;
}
/**
* Execute query
*/
public function get(): Collection
{
$response = $this->github->get(
"/repos/{$this->fullName}/milestones",
$this->buildParams()
);
return collect($response->json())
->map(fn($milestone) => Milestone::fromArray($milestone));
}
/**
* Get first milestone
*/
public function first(): ?Milestone
{
return $this->get()->first();
}
/**
* Get count
*/
public function count(): int
{
return $this->get()->count();
}
/**
* Build query parameters
*/
protected function buildParams(): array
{
$params = [
'state' => $this->state,
'per_page' => $this->perPage,
'page' => $this->page,
];
if ($this->sort !== null) {
$params['sort'] = $this->sort;
$params['direction'] = $this->direction;
}
return $params;
}
}Milestone Manager
File: src/Services/MilestoneManager.php
namespace ConduitUI\Issue\Services;
use Carbon\Carbon;
use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Milestone;
use Illuminate\Support\Collection;
final class MilestoneManager
{
public function __construct(
protected GitHub $github,
protected string $fullName,
) {}
/**
* Start a query
*/
public function query(): MilestoneQuery
{
return new MilestoneQuery($this->github, $this->fullName);
}
/**
* Get all milestones
*/
public function all(): Collection
{
return $this->query()->get();
}
/**
* Find milestone by number
*/
public function find(int $number): Milestone
{
$response = $this->github->get(
"/repos/{$this->fullName}/milestones/{$number}"
);
return Milestone::fromArray($response->json());
}
/**
* Create a milestone
*/
public function create(string $title, array $options = []): Milestone
{
$params = ['title' => $title];
if (isset($options['description'])) {
$params['description'] = $options['description'];
}
if (isset($options['due_on'])) {
$params['due_on'] = $options['due_on'] instanceof Carbon
? $options['due_on']->toIso8601String()
: $options['due_on'];
}
if (isset($options['state'])) {
$params['state'] = $options['state'];
}
$response = $this->github->post(
"/repos/{$this->fullName}/milestones",
$params
);
return Milestone::fromArray($response->json());
}
/**
* Update a milestone
*/
public function update(int $number, array $attributes): Milestone
{
if (isset($attributes['due_on']) && $attributes['due_on'] instanceof Carbon) {
$attributes['due_on'] = $attributes['due_on']->toIso8601String();
}
$response = $this->github->patch(
"/repos/{$this->fullName}/milestones/{$number}",
$attributes
);
return Milestone::fromArray($response->json());
}
/**
* Delete a milestone
*/
public function delete(int $number): bool
{
$response = $this->github->delete(
"/repos/{$this->fullName}/milestones/{$number}"
);
return $response->successful();
}
/**
* Get milestone instance for chaining
*/
public function milestone(int $number): MilestoneInstance
{
return new MilestoneInstance(
github: $this->github,
fullName: $this->fullName,
number: $number,
);
}
/**
* Get builder for fluent creation
*/
public function builder(): MilestoneBuilder
{
return new MilestoneBuilder($this->github, $this->fullName);
}
}Milestone Instance
File: src/Services/MilestoneInstance.php
namespace ConduitUI\Issue\Services;
use Carbon\Carbon;
use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Milestone;
final class MilestoneInstance
{
protected ?Milestone $milestone = null;
public function __construct(
protected GitHub $github,
protected string $fullName,
protected int $number,
) {}
/**
* Get milestone data
*/
public function get(): Milestone
{
if ($this->milestone === null) {
$this->milestone = $this->fetch();
}
return $this->milestone;
}
/**
* Update milestone
*/
public function update(array $attributes): self
{
if (isset($attributes['due_on']) && $attributes['due_on'] instanceof Carbon) {
$attributes['due_on'] = $attributes['due_on']->toIso8601String();
}
$response = $this->github->patch(
"/repos/{$this->fullName}/milestones/{$this->number}",
$attributes
);
$this->milestone = Milestone::fromArray($response->json());
return $this;
}
/**
* Set title
*/
public function title(string $title): self
{
return $this->update(['title' => $title]);
}
/**
* Set description
*/
public function description(string $description): self
{
return $this->update(['description' => $description]);
}
/**
* Set due date
*/
public function dueOn(Carbon|string $date): self
{
return $this->update(['due_on' => $date]);
}
/**
* Close milestone
*/
public function close(): self
{
return $this->update(['state' => 'closed']);
}
/**
* Reopen milestone
*/
public function reopen(): self
{
return $this->update(['state' => 'open']);
}
/**
* Delete milestone
*/
public function delete(): bool
{
$response = $this->github->delete(
"/repos/{$this->fullName}/milestones/{$this->number}"
);
return $response->successful();
}
/**
* Get issues for this milestone
*/
public function issues(): IssueQuery
{
return (new IssueQuery($this->github))
->forRepo($this->fullName)
->milestone($this->number);
}
/**
* Fetch from API
*/
protected function fetch(): Milestone
{
$response = $this->github->get(
"/repos/{$this->fullName}/milestones/{$this->number}"
);
return Milestone::fromArray($response->json());
}
}Milestone Builder
File: src/Services/MilestoneBuilder.php
namespace ConduitUI\Issue\Services;
use Carbon\Carbon;
use ConduitUI\Connector\GitHub;
use ConduitUI\Issue\Data\Milestone;
final class MilestoneBuilder
{
protected ?string $title = null;
protected ?string $description = null;
protected ?Carbon $dueOn = null;
protected string $state = 'open';
public function __construct(
protected GitHub $github,
protected string $fullName,
) {}
public function title(string $title): self
{
$this->title = $title;
return $this;
}
public function description(string $description): self
{
$this->description = $description;
return $this;
}
public function dueOn(Carbon|string $date): self
{
$this->dueOn = $date instanceof Carbon
? $date
: Carbon::parse($date);
return $this;
}
public function state(string $state): self
{
$this->state = $state;
return $this;
}
public function create(): Milestone
{
$params = [
'title' => $this->title,
'state' => $this->state,
];
if ($this->description !== null) {
$params['description'] = $this->description;
}
if ($this->dueOn !== null) {
$params['due_on'] = $this->dueOn->toIso8601String();
}
$response = $this->github->post(
"/repos/{$this->fullName}/milestones",
$params
);
return Milestone::fromArray($response->json());
}
}Usage Examples
use ConduitUI\Issue\Facades\Issues;
// Query milestones
$milestones = Issues::milestones('owner/repo')
->open()
->sortByDueDate()
->get();
$overdue = Issues::milestones('owner/repo')
->open()
->get()
->filter(fn($m) => $m->isOverdue());
// Create milestone
$milestone = Issues::milestones('owner/repo')
->create('v2.0', [
'description' => 'Second major release',
'due_on' => now()->addMonth(),
]);
// Fluent builder
$milestone = Issues::milestones('owner/repo')
->builder()
->title('v2.1')
->description('Bug fixes and improvements')
->dueOn(now()->addWeeks(2))
->create();
// Update milestone
Issues::milestones('owner/repo')
->milestone(1)
->title('v2.0 - Updated')
->dueOn(now()->addMonth())
->description('Updated description');
// Assign issue to milestone
Issues::find('owner/repo', 123)
->milestone(1);
// Get issues in milestone
$issues = Issues::milestones('owner/repo')
->milestone(1)
->issues()
->get();
// Close milestone
Issues::milestones('owner/repo')
->milestone(1)
->close();Acceptance Criteria
- Milestone DTO with utility methods
- MilestoneQuery with filtering and sorting
- MilestoneManager for CRUD operations
- MilestoneInstance for chainable actions
- MilestoneBuilder for fluent creation
- Integration with IssueQuery
- Integration with IssueInstance
- Completion percentage calculation
- Overdue detection
- Full test coverage
Dependencies
- Requires:
ConduitUI\Connector\GitHub - Related to: GitHub integration setup #1 (IssueQuery integration)
- Related to: Add Active Record entity layer (matching conduit-ui/pr pattern) #2 (IssueInstance integration)
Technical Notes
Sort Options:
due_on- Sort by due datecompleteness- Sort by completion percentage
State Options:
open- Active milestonesclosed- Completed milestonesall- All milestones
API Reference:
https://docs.github.com/en/rest/issues/milestones
coderabbitai
Metadata
Metadata
Assignees
Labels
No labels