From 87fdff71a5b993f41b5a12edb8d39bdc8c95252c Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Sat, 13 Dec 2025 19:30:26 -0700 Subject: [PATCH] Add Issue Events and Timeline functionality Implements comprehensive support for GitHub Issue Events and Timeline tracking: - Event data object with support for labeled, unlabeled, assigned, unassigned, milestoned, demilestoned, closed, and reopened events - TimelineEvent data object with extended properties for comments, cross-references, and state changes - ManagesIssueEvents trait with methods to list issue events, get specific events, and list timeline events - Full test coverage for both data objects with validation of all event types - Integration with IssuesService via ManagesIssueEventsInterface API endpoints covered: - GET /repos/{owner}/{repo}/issues/{issue_number}/events - GET /repos/{owner}/{repo}/issues/{issue_number}/timeline - GET /repos/{owner}/{repo}/issues/events/{event_id} Closes #10 --- src/Contracts/IssuesServiceInterface.php | 2 +- src/Contracts/ManagesIssueEventsInterface.php | 23 ++ src/Data/Event.php | 75 +++++++ src/Data/TimelineEvent.php | 106 ++++++++++ src/Services/IssuesService.php | 2 + src/Traits/ManagesIssueEvents.php | 47 +++++ tests/Unit/Data/EventTest.php | 183 ++++++++++++++++ tests/Unit/Data/TimelineEventTest.php | 196 ++++++++++++++++++ 8 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/Contracts/ManagesIssueEventsInterface.php create mode 100644 src/Data/Event.php create mode 100644 src/Data/TimelineEvent.php create mode 100644 src/Traits/ManagesIssueEvents.php create mode 100644 tests/Unit/Data/EventTest.php create mode 100644 tests/Unit/Data/TimelineEventTest.php diff --git a/src/Contracts/IssuesServiceInterface.php b/src/Contracts/IssuesServiceInterface.php index 74f6e1ff..24ffecf3 100644 --- a/src/Contracts/IssuesServiceInterface.php +++ b/src/Contracts/IssuesServiceInterface.php @@ -4,4 +4,4 @@ namespace ConduitUI\Issue\Contracts; -interface IssuesServiceInterface extends ManagesIssueAssigneesInterface, ManagesIssueLabelsInterface, ManagesIssuesInterface {} +interface IssuesServiceInterface extends ManagesIssueAssigneesInterface, ManagesIssueEventsInterface, ManagesIssueLabelsInterface, ManagesIssuesInterface {} diff --git a/src/Contracts/ManagesIssueEventsInterface.php b/src/Contracts/ManagesIssueEventsInterface.php new file mode 100644 index 00000000..386ab4b4 --- /dev/null +++ b/src/Contracts/ManagesIssueEventsInterface.php @@ -0,0 +1,23 @@ + + */ + public function listIssueEvents(string $owner, string $repo, int $issueNumber, array $filters = []): Collection; + + public function getIssueEvent(string $owner, string $repo, int $eventId): Event; + + /** + * @return \Illuminate\Support\Collection + */ + public function listIssueTimeline(string $owner, string $repo, int $issueNumber, array $filters = []): Collection; +} diff --git a/src/Data/Event.php b/src/Data/Event.php new file mode 100644 index 00000000..bf05b081 --- /dev/null +++ b/src/Data/Event.php @@ -0,0 +1,75 @@ + $this->id, + 'event' => $this->event, + 'actor' => $this->actor?->toArray(), + 'commit_id' => $this->commitId, + 'commit_url' => $this->commitUrl, + 'created_at' => $this->createdAt->format('c'), + 'label' => $this->label?->toArray(), + 'assignee' => $this->assignee?->toArray(), + 'milestone' => $this->milestone, + 'rename' => $this->rename, + ], fn ($value) => $value !== null); + } + + public function isLabelEvent(): bool + { + return in_array($this->event, ['labeled', 'unlabeled']); + } + + public function isAssigneeEvent(): bool + { + return in_array($this->event, ['assigned', 'unassigned']); + } + + public function isMilestoneEvent(): bool + { + return in_array($this->event, ['milestoned', 'demilestoned']); + } + + public function isStateEvent(): bool + { + return in_array($this->event, ['closed', 'reopened']); + } +} diff --git a/src/Data/TimelineEvent.php b/src/Data/TimelineEvent.php new file mode 100644 index 00000000..95ab12ac --- /dev/null +++ b/src/Data/TimelineEvent.php @@ -0,0 +1,106 @@ + $this->id, + 'event' => $this->event, + 'actor' => $this->actor?->toArray(), + 'created_at' => $this->createdAt->format('c'), + 'commit_id' => $this->commitId, + 'commit_url' => $this->commitUrl, + 'label' => $this->label?->toArray(), + 'assignee' => $this->assignee?->toArray(), + 'milestone' => $this->milestone, + 'rename' => $this->rename, + 'body' => $this->body, + 'user' => $this->user?->toArray(), + 'updated_at' => $this->updatedAt?->format('c'), + 'author_association' => $this->authorAssociation, + 'source' => $this->source, + 'state' => $this->state, + 'state_reason' => $this->stateReason, + ], fn ($value) => $value !== null); + } + + public function isComment(): bool + { + return $this->event === 'commented'; + } + + public function isLabelEvent(): bool + { + return in_array($this->event, ['labeled', 'unlabeled']); + } + + public function isAssigneeEvent(): bool + { + return in_array($this->event, ['assigned', 'unassigned']); + } + + public function isMilestoneEvent(): bool + { + return in_array($this->event, ['milestoned', 'demilestoned']); + } + + public function isStateEvent(): bool + { + return in_array($this->event, ['closed', 'reopened']); + } + + public function isCrossReferenced(): bool + { + return $this->event === 'cross-referenced'; + } +} diff --git a/src/Services/IssuesService.php b/src/Services/IssuesService.php index 2d380d59..b7c65bab 100644 --- a/src/Services/IssuesService.php +++ b/src/Services/IssuesService.php @@ -7,12 +7,14 @@ use ConduitUi\GitHubConnector\Connector; use ConduitUI\Issue\Contracts\IssuesServiceInterface; use ConduitUI\Issue\Traits\ManagesIssueAssignees; +use ConduitUI\Issue\Traits\ManagesIssueEvents; use ConduitUI\Issue\Traits\ManagesIssueLabels; use ConduitUI\Issue\Traits\ManagesIssues; class IssuesService implements IssuesServiceInterface { use ManagesIssueAssignees; + use ManagesIssueEvents; use ManagesIssueLabels; use ManagesIssues; diff --git a/src/Traits/ManagesIssueEvents.php b/src/Traits/ManagesIssueEvents.php new file mode 100644 index 00000000..ec502c60 --- /dev/null +++ b/src/Traits/ManagesIssueEvents.php @@ -0,0 +1,47 @@ + + */ + public function listIssueEvents(string $owner, string $repo, int $issueNumber, array $filters = []): Collection + { + $response = $this->connector->send( + $this->connector->get("/repos/{$owner}/{$repo}/issues/{$issueNumber}/events", $filters) + ); + + return collect($response->json()) + ->map(fn (array $data) => Event::fromArray($data)); + } + + public function getIssueEvent(string $owner, string $repo, int $eventId): Event + { + $response = $this->connector->send( + $this->connector->get("/repos/{$owner}/{$repo}/issues/events/{$eventId}") + ); + + return Event::fromArray($response->json()); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function listIssueTimeline(string $owner, string $repo, int $issueNumber, array $filters = []): Collection + { + $response = $this->connector->send( + $this->connector->get("/repos/{$owner}/{$repo}/issues/{$issueNumber}/timeline", $filters) + ); + + return collect($response->json()) + ->map(fn (array $data) => TimelineEvent::fromArray($data)); + } +} diff --git a/tests/Unit/Data/EventTest.php b/tests/Unit/Data/EventTest.php new file mode 100644 index 00000000..1fc6b95a --- /dev/null +++ b/tests/Unit/Data/EventTest.php @@ -0,0 +1,183 @@ + 123, + 'event' => 'labeled', + 'actor' => [ + 'id' => 456, + 'login' => 'testuser', + 'avatar_url' => 'https://github.com/testuser.png', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'commit_id' => 'abc123', + 'commit_url' => 'https://github.com/owner/repo/commit/abc123', + 'created_at' => '2023-01-01T12:00:00Z', + 'label' => [ + 'id' => 789, + 'name' => 'bug', + 'color' => 'fc2929', + 'description' => 'Something is broken', + ], + ]; + + $event = Event::fromArray($data); + + expect($event->id)->toBe(123); + expect($event->event)->toBe('labeled'); + expect($event->actor)->toBeInstanceOf(User::class); + expect($event->actor->login)->toBe('testuser'); + expect($event->commitId)->toBe('abc123'); + expect($event->commitUrl)->toBe('https://github.com/owner/repo/commit/abc123'); + expect($event->label)->toBeInstanceOf(Label::class); + expect($event->label->name)->toBe('bug'); +}); + +test('can convert event to array', function () { + $actor = new User(456, 'testuser', 'https://github.com/testuser.png', 'https://github.com/testuser', 'User'); + $label = new Label(789, 'bug', 'fc2929', 'Something is broken'); + + $event = new Event( + id: 123, + event: 'labeled', + actor: $actor, + commitId: 'abc123', + commitUrl: 'https://github.com/owner/repo/commit/abc123', + createdAt: new DateTime('2023-01-01T12:00:00Z'), + label: $label, + ); + + $array = $event->toArray(); + + expect($array['id'])->toBe(123); + expect($array['event'])->toBe('labeled'); + expect($array['actor'])->toBeArray(); + expect($array['actor']['login'])->toBe('testuser'); + expect($array['commit_id'])->toBe('abc123'); + expect($array['label'])->toBeArray(); + expect($array['label']['name'])->toBe('bug'); +}); + +test('can check if event is label event', function () { + $event = new Event( + id: 123, + event: 'labeled', + actor: null, + commitId: null, + commitUrl: null, + createdAt: new DateTime, + ); + + expect($event->isLabelEvent())->toBeTrue(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if event is assignee event', function () { + $event = new Event( + id: 123, + event: 'assigned', + actor: null, + commitId: null, + commitUrl: null, + createdAt: new DateTime, + ); + + expect($event->isAssigneeEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if event is milestone event', function () { + $event = new Event( + id: 123, + event: 'milestoned', + actor: null, + commitId: null, + commitUrl: null, + createdAt: new DateTime, + ); + + expect($event->isMilestoneEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if event is state event', function () { + $event = new Event( + id: 123, + event: 'closed', + actor: null, + commitId: null, + commitUrl: null, + createdAt: new DateTime, + ); + + expect($event->isStateEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); +}); + +test('can create event from array with null actor', function () { + $data = [ + 'id' => 123, + 'event' => 'closed', + 'created_at' => '2023-01-01T12:00:00Z', + ]; + + $event = Event::fromArray($data); + + expect($event->id)->toBe(123); + expect($event->event)->toBe('closed'); + expect($event->actor)->toBeNull(); + expect($event->commitId)->toBeNull(); + expect($event->label)->toBeNull(); +}); + +test('can create event with assignee', function () { + $data = [ + 'id' => 123, + 'event' => 'assigned', + 'created_at' => '2023-01-01T12:00:00Z', + 'assignee' => [ + 'id' => 456, + 'login' => 'assignee', + 'avatar_url' => 'https://github.com/assignee.png', + 'html_url' => 'https://github.com/assignee', + 'type' => 'User', + ], + ]; + + $event = Event::fromArray($data); + + expect($event->assignee)->toBeInstanceOf(User::class); + expect($event->assignee->login)->toBe('assignee'); +}); + +test('can create event with milestone', function () { + $data = [ + 'id' => 123, + 'event' => 'milestoned', + 'created_at' => '2023-01-01T12:00:00Z', + 'milestone' => [ + 'title' => 'v1.0', + 'number' => 1, + ], + ]; + + $event = Event::fromArray($data); + + expect($event->milestone)->toBeArray(); + expect($event->milestone['title'])->toBe('v1.0'); +}); diff --git a/tests/Unit/Data/TimelineEventTest.php b/tests/Unit/Data/TimelineEventTest.php new file mode 100644 index 00000000..6115b759 --- /dev/null +++ b/tests/Unit/Data/TimelineEventTest.php @@ -0,0 +1,196 @@ + 123, + 'event' => 'labeled', + 'actor' => [ + 'id' => 456, + 'login' => 'testuser', + 'avatar_url' => 'https://github.com/testuser.png', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'created_at' => '2023-01-01T12:00:00Z', + 'label' => [ + 'id' => 789, + 'name' => 'bug', + 'color' => 'fc2929', + 'description' => 'Something is broken', + ], + ]; + + $event = TimelineEvent::fromArray($data); + + expect($event->id)->toBe(123); + expect($event->event)->toBe('labeled'); + expect($event->actor)->toBeInstanceOf(User::class); + expect($event->actor->login)->toBe('testuser'); + expect($event->label)->toBeInstanceOf(Label::class); + expect($event->label->name)->toBe('bug'); +}); + +test('can convert timeline event to array', function () { + $actor = new User(456, 'testuser', 'https://github.com/testuser.png', 'https://github.com/testuser', 'User'); + $label = new Label(789, 'bug', 'fc2929', 'Something is broken'); + + $event = new TimelineEvent( + id: 123, + event: 'labeled', + actor: $actor, + createdAt: new DateTime('2023-01-01T12:00:00Z'), + label: $label, + ); + + $array = $event->toArray(); + + expect($array['id'])->toBe(123); + expect($array['event'])->toBe('labeled'); + expect($array['actor'])->toBeArray(); + expect($array['actor']['login'])->toBe('testuser'); + expect($array['label'])->toBeArray(); + expect($array['label']['name'])->toBe('bug'); +}); + +test('can check if timeline event is comment', function () { + $event = new TimelineEvent( + id: 123, + event: 'commented', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isComment())->toBeTrue(); +}); + +test('can check if timeline event is label event', function () { + $event = new TimelineEvent( + id: 123, + event: 'labeled', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isLabelEvent())->toBeTrue(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if timeline event is assignee event', function () { + $event = new TimelineEvent( + id: 123, + event: 'assigned', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isAssigneeEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if timeline event is milestone event', function () { + $event = new TimelineEvent( + id: 123, + event: 'milestoned', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isMilestoneEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isStateEvent())->toBeFalse(); +}); + +test('can check if timeline event is state event', function () { + $event = new TimelineEvent( + id: 123, + event: 'closed', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isStateEvent())->toBeTrue(); + expect($event->isLabelEvent())->toBeFalse(); + expect($event->isAssigneeEvent())->toBeFalse(); + expect($event->isMilestoneEvent())->toBeFalse(); +}); + +test('can check if timeline event is cross-referenced', function () { + $event = new TimelineEvent( + id: 123, + event: 'cross-referenced', + actor: null, + createdAt: new DateTime, + ); + + expect($event->isCrossReferenced())->toBeTrue(); +}); + +test('can create timeline event with comment body', function () { + $data = [ + 'id' => 123, + 'event' => 'commented', + 'created_at' => '2023-01-01T12:00:00Z', + 'updated_at' => '2023-01-02T12:00:00Z', + 'user' => [ + 'id' => 456, + 'login' => 'testuser', + 'avatar_url' => 'https://github.com/testuser.png', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'body' => 'This is a comment', + 'author_association' => 'OWNER', + ]; + + $event = TimelineEvent::fromArray($data); + + expect($event->body)->toBe('This is a comment'); + expect($event->user)->toBeInstanceOf(User::class); + expect($event->updatedAt)->toBeInstanceOf(DateTime::class); + expect($event->authorAssociation)->toBe('OWNER'); +}); + +test('can create timeline event with state change', function () { + $data = [ + 'id' => 123, + 'event' => 'closed', + 'created_at' => '2023-01-01T12:00:00Z', + 'state' => 'closed', + 'state_reason' => 'completed', + ]; + + $event = TimelineEvent::fromArray($data); + + expect($event->state)->toBe('closed'); + expect($event->stateReason)->toBe('completed'); +}); + +test('can create timeline event with source', function () { + $data = [ + 'id' => 123, + 'event' => 'cross-referenced', + 'created_at' => '2023-01-01T12:00:00Z', + 'source' => [ + 'type' => 'issue', + 'issue' => [ + 'number' => 456, + ], + ], + ]; + + $event = TimelineEvent::fromArray($data); + + expect($event->source)->toBeArray(); + expect($event->source['type'])->toBe('issue'); +});