diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 933f4d2..d360b41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,3 +39,24 @@ jobs:
- name: Run tests
run: vendor/bin/pest
+
+ coverage:
+ runs-on: ubuntu-latest
+ name: Code Coverage
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ extensions: dom, curl, libxml, mbstring, zip
+ coverage: pcov
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction --no-progress
+
+ - name: Run tests with coverage
+ run: vendor/bin/pest --coverage --min=100
diff --git a/composer.json b/composer.json
index abb9bb4..66200f7 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,10 @@
"require-dev": {
"laravel/pint": "^1.0",
"pestphp/pest": "^3.0",
- "phpstan/phpstan": "^1.0"
+ "phpstan/extension-installer": "*",
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-deprecation-rules": "^1.2",
+ "phpstan/phpstan-strict-rules": "^1.6"
},
"autoload": {
"psr-4": {
@@ -40,7 +43,8 @@
"preferred-install": "dist",
"optimize-autoloader": true,
"allow-plugins": {
- "pestphp/pest-plugin": true
+ "pestphp/pest-plugin": true,
+ "phpstan/extension-installer": true
}
},
"extra": {
diff --git a/phpstan.neon b/phpstan.neon
index 94e9bd2..8d15a22 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,5 @@
parameters:
- level: 8
+ level: 9
paths:
- src
excludePaths:
@@ -7,6 +7,4 @@ parameters:
# in the non-Laravel test environment. The class is only used in Laravel apps.
- src/PrServiceProvider.php
tmpDir: build/phpstan
- ignoreErrors:
- -
- identifier: missingType.iterableValue
+ reportUnmatchedIgnoredErrors: true
diff --git a/phpunit.xml b/phpunit.xml
index cd9b795..6464938 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -15,5 +15,9 @@
src
+
+ src/PrServiceProvider.php
+ src/Contracts
+
diff --git a/src/Contracts/Assignable.php b/src/Contracts/Assignable.php
new file mode 100644
index 0000000..c1a5995
--- /dev/null
+++ b/src/Contracts/Assignable.php
@@ -0,0 +1,27 @@
+ $assignees Usernames to assign
+ */
+ public function assign(array $assignees): static;
+
+ /**
+ * Remove assignees from this entity.
+ *
+ * @param array $assignees Usernames to unassign
+ */
+ public function unassign(array $assignees): static;
+}
diff --git a/src/Contracts/Auditable.php b/src/Contracts/Auditable.php
new file mode 100644
index 0000000..3ec0fce
--- /dev/null
+++ b/src/Contracts/Auditable.php
@@ -0,0 +1,20 @@
+
+ */
+ public function timeline(): array;
+}
diff --git a/src/Contracts/Checkable.php b/src/Contracts/Checkable.php
new file mode 100644
index 0000000..5fd47e2
--- /dev/null
+++ b/src/Contracts/Checkable.php
@@ -0,0 +1,22 @@
+
+ */
+ public function checks(): array;
+}
diff --git a/src/Contracts/Closeable.php b/src/Contracts/Closeable.php
new file mode 100644
index 0000000..957d501
--- /dev/null
+++ b/src/Contracts/Closeable.php
@@ -0,0 +1,23 @@
+
+ */
+ public function comments(): array;
+
+ /**
+ * Add a comment to this entity.
+ */
+ public function comment(string $body): static;
+}
diff --git a/src/Contracts/Diffable.php b/src/Contracts/Diffable.php
new file mode 100644
index 0000000..b6a128f
--- /dev/null
+++ b/src/Contracts/Diffable.php
@@ -0,0 +1,27 @@
+
+ */
+ public function files(): array;
+}
diff --git a/src/Contracts/HasCommits.php b/src/Contracts/HasCommits.php
new file mode 100644
index 0000000..fe2ff2a
--- /dev/null
+++ b/src/Contracts/HasCommits.php
@@ -0,0 +1,22 @@
+
+ */
+ public function commits(): array;
+}
diff --git a/src/Contracts/Labelable.php b/src/Contracts/Labelable.php
new file mode 100644
index 0000000..bd6faaa
--- /dev/null
+++ b/src/Contracts/Labelable.php
@@ -0,0 +1,25 @@
+ $labels
+ */
+ public function addLabels(array $labels): static;
+
+ /**
+ * Remove a label from this entity.
+ */
+ public function removeLabel(string $label): static;
+}
diff --git a/src/Contracts/Mergeable.php b/src/Contracts/Mergeable.php
new file mode 100644
index 0000000..9289aad
--- /dev/null
+++ b/src/Contracts/Mergeable.php
@@ -0,0 +1,20 @@
+ $attributes
+ */
public function create(string $repository, array $attributes): PullRequest;
public function for(string $repository): QueryBuilder;
public function query(): QueryBuilder;
+ /**
+ * @param array $data
+ */
public function update(string $repository, int $number, array $data): PullRequest;
public function merge(string $repository, int $number, ?string $commitMessage = null, ?string $mergeMethod = null): bool;
public function close(string $repository, int $number): PullRequest;
+ /**
+ * @param array $filters
+ * @return array
+ */
public function list(string $repository, array $filters = []): array;
}
diff --git a/src/Contracts/Reviewable.php b/src/Contracts/Reviewable.php
new file mode 100644
index 0000000..0c6d4a5
--- /dev/null
+++ b/src/Contracts/Reviewable.php
@@ -0,0 +1,40 @@
+
+ */
+ public function reviews(): array;
+
+ /**
+ * Approve this entity.
+ */
+ public function approve(?string $body = null): static;
+
+ /**
+ * Request changes on this entity.
+ */
+ public function requestChanges(string $body): static;
+
+ /**
+ * Submit a review with a specific event type.
+ *
+ * @param string $event APPROVE, REQUEST_CHANGES, or COMMENT
+ * @param array $comments Inline comments
+ */
+ public function submitReview(string $event, ?string $body = null, array $comments = []): static;
+}
diff --git a/src/DataTransferObjects/Base.php b/src/DataTransferObjects/Base.php
index a71bdeb..03f3105 100644
--- a/src/DataTransferObjects/Base.php
+++ b/src/DataTransferObjects/Base.php
@@ -13,6 +13,9 @@ public function __construct(
public readonly Repository $repo,
) {}
+ /**
+ * @param array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -23,6 +26,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/CheckRun.php b/src/DataTransferObjects/CheckRun.php
index 729f4f1..29562dc 100644
--- a/src/DataTransferObjects/CheckRun.php
+++ b/src/DataTransferObjects/CheckRun.php
@@ -18,6 +18,9 @@ public function __construct(
public readonly ?DateTimeImmutable $completedAt,
) {}
+ /**
+ * @param array{id: int, name: string, status: string, conclusion: string|null, html_url: string, started_at: string, completed_at?: string|null} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -43,9 +46,12 @@ public function isSuccessful(): bool
public function isFailed(): bool
{
- return in_array($this->conclusion, ['failure', 'timed_out', 'action_required']);
+ return in_array($this->conclusion, ['failure', 'timed_out', 'action_required'], true);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Comment.php b/src/DataTransferObjects/Comment.php
index 74b06d3..908fdbf 100644
--- a/src/DataTransferObjects/Comment.php
+++ b/src/DataTransferObjects/Comment.php
@@ -17,6 +17,9 @@ public function __construct(
public readonly DateTimeImmutable $updatedAt,
) {}
+ /**
+ * @param array{id: int, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, body: string, html_url: string, created_at: string, updated_at: string} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -29,6 +32,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Commit.php b/src/DataTransferObjects/Commit.php
index 161454b..924bd5c 100644
--- a/src/DataTransferObjects/Commit.php
+++ b/src/DataTransferObjects/Commit.php
@@ -16,6 +16,9 @@ public function __construct(
public readonly ?User $githubCommitter,
) {}
+ /**
+ * @param array{sha: string, commit: array{message: string, author: array{name: string, email: string, date: string}, committer: array{name: string, email: string, date: string}}, html_url: string, author?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null, committer?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -29,6 +32,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/CommitAuthor.php b/src/DataTransferObjects/CommitAuthor.php
index 5982dbf..ae00fc9 100644
--- a/src/DataTransferObjects/CommitAuthor.php
+++ b/src/DataTransferObjects/CommitAuthor.php
@@ -14,6 +14,9 @@ public function __construct(
public readonly DateTimeImmutable $date,
) {}
+ /**
+ * @param array{name: string, email: string, date: string} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -23,6 +26,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/File.php b/src/DataTransferObjects/File.php
index 536e638..1279bd8 100644
--- a/src/DataTransferObjects/File.php
+++ b/src/DataTransferObjects/File.php
@@ -20,6 +20,9 @@ public function __construct(
public readonly ?string $previousFilename,
) {}
+ /**
+ * @param array{sha: string, filename: string, status: string, additions: int, deletions: int, changes: int, blob_url: string, raw_url: string, contents_url: string, patch?: string|null, previous_filename?: string|null} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -57,6 +60,9 @@ public function isRenamed(): bool
return $this->status === 'renamed';
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Head.php b/src/DataTransferObjects/Head.php
index 9671ae0..2d8eae3 100644
--- a/src/DataTransferObjects/Head.php
+++ b/src/DataTransferObjects/Head.php
@@ -13,6 +13,9 @@ public function __construct(
public readonly Repository $repo,
) {}
+ /**
+ * @param array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -23,6 +26,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Label.php b/src/DataTransferObjects/Label.php
index a7a9fac..d1a4687 100644
--- a/src/DataTransferObjects/Label.php
+++ b/src/DataTransferObjects/Label.php
@@ -13,6 +13,9 @@ public function __construct(
public readonly ?string $description,
) {}
+ /**
+ * @param array{id: int, name: string, color: string, description?: string|null} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -23,6 +26,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/PullRequest.php b/src/DataTransferObjects/PullRequest.php
index 6efe855..c702d6d 100644
--- a/src/DataTransferObjects/PullRequest.php
+++ b/src/DataTransferObjects/PullRequest.php
@@ -8,6 +8,11 @@
class PullRequest
{
+ /**
+ * @param array $assignees
+ * @param array $requestedReviewers
+ * @param array $labels
+ */
public function __construct(
public readonly int $number,
public readonly string $title,
@@ -32,6 +37,9 @@ public function __construct(
public readonly Base $base,
) {}
+ /**
+ * @param array{number: int, title: string, body?: string|null, state: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, html_url: string, created_at: string, updated_at: string, closed_at?: string|null, merged_at?: string|null, merge_commit_sha?: string|null, draft?: bool, additions?: int|null, deletions?: int|null, changed_files?: int|null, assignee?: array{id: int, login: string, avatar_url: string, html_url: string, type: string}|null, assignees?: array, requested_reviewers?: array, labels?: array, head: array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}}, base: array{ref: string, sha: string, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, repo: array{id: int, name: string, full_name: string, html_url: string, private: bool}}} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -79,6 +87,9 @@ public function isDraft(): bool
return $this->draft;
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Repository.php b/src/DataTransferObjects/Repository.php
index 39e61b8..a0ae2ae 100644
--- a/src/DataTransferObjects/Repository.php
+++ b/src/DataTransferObjects/Repository.php
@@ -14,6 +14,9 @@ public function __construct(
public readonly bool $private,
) {}
+ /**
+ * @param array{id: int, name: string, full_name: string, html_url: string, private: bool} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -25,6 +28,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/Review.php b/src/DataTransferObjects/Review.php
index 4e66956..7898ade 100644
--- a/src/DataTransferObjects/Review.php
+++ b/src/DataTransferObjects/Review.php
@@ -17,6 +17,9 @@ public function __construct(
public readonly DateTimeImmutable $submittedAt,
) {}
+ /**
+ * @param array{id: int, user: array{id: int, login: string, avatar_url: string, html_url: string, type: string}, body?: string|null, state: string, html_url: string, submitted_at: string} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -44,6 +47,9 @@ public function isCommented(): bool
return $this->state === 'COMMENTED';
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/DataTransferObjects/User.php b/src/DataTransferObjects/User.php
index 20b2202..51ddcd2 100644
--- a/src/DataTransferObjects/User.php
+++ b/src/DataTransferObjects/User.php
@@ -14,6 +14,9 @@ public function __construct(
public readonly string $type,
) {}
+ /**
+ * @param array{id: int, login: string, avatar_url: string, html_url: string, type: string} $data
+ */
public static function fromArray(array $data): self
{
return new self(
@@ -25,6 +28,9 @@ public static function fromArray(array $data): self
);
}
+ /**
+ * @return array
+ */
public function toArray(): array
{
return [
diff --git a/src/PullRequest.php b/src/PullRequest.php
index abbc2bc..e56eaa7 100644
--- a/src/PullRequest.php
+++ b/src/PullRequest.php
@@ -5,30 +5,43 @@
namespace ConduitUI\Pr;
use ConduitUi\GitHubConnector\Connector;
+use ConduitUI\Pr\Contracts\Assignable;
+use ConduitUI\Pr\Contracts\Auditable;
+use ConduitUI\Pr\Contracts\Checkable;
+use ConduitUI\Pr\Contracts\Closeable;
+use ConduitUI\Pr\Contracts\Commentable;
+use ConduitUI\Pr\Contracts\Diffable;
+use ConduitUI\Pr\Contracts\HasCommits;
+use ConduitUI\Pr\Contracts\Labelable;
+use ConduitUI\Pr\Contracts\Mergeable;
+use ConduitUI\Pr\Contracts\Reviewable;
use ConduitUI\Pr\DataTransferObjects\CheckRun;
use ConduitUI\Pr\DataTransferObjects\Comment;
use ConduitUI\Pr\DataTransferObjects\Commit;
use ConduitUI\Pr\DataTransferObjects\File;
use ConduitUI\Pr\DataTransferObjects\PullRequest as PullRequestData;
use ConduitUI\Pr\DataTransferObjects\Review;
+use ConduitUI\Pr\Requests\AddAssignees;
use ConduitUI\Pr\Requests\AddIssueLabels;
use ConduitUI\Pr\Requests\CreateIssueComment;
use ConduitUI\Pr\Requests\CreatePullRequestComment;
use ConduitUI\Pr\Requests\CreatePullRequestReview;
use ConduitUI\Pr\Requests\GetCommitCheckRuns;
use ConduitUI\Pr\Requests\GetIssueComments;
+use ConduitUI\Pr\Requests\GetIssueTimeline;
use ConduitUI\Pr\Requests\GetPullRequestComments;
use ConduitUI\Pr\Requests\GetPullRequestCommits;
use ConduitUI\Pr\Requests\GetPullRequestDiff;
use ConduitUI\Pr\Requests\GetPullRequestFiles;
use ConduitUI\Pr\Requests\GetPullRequestReviews;
use ConduitUI\Pr\Requests\MergePullRequest;
+use ConduitUI\Pr\Requests\RemoveAssignees;
use ConduitUI\Pr\Requests\RemoveIssueLabel;
use ConduitUI\Pr\Requests\RemoveReviewers;
use ConduitUI\Pr\Requests\RequestReviewers;
use ConduitUI\Pr\Requests\UpdatePullRequest;
-class PullRequest
+class PullRequest implements Assignable, Auditable, Checkable, Closeable, Commentable, Diffable, HasCommits, Labelable, Mergeable, Reviewable
{
public function __construct(
protected Connector $connector,
@@ -37,12 +50,12 @@ public function __construct(
public readonly PullRequestData $data,
) {}
- public function approve(?string $body = null): self
+ public function approve(?string $body = null): static
{
return $this->submitReview('APPROVE', $body);
}
- public function requestChanges(string $body): self
+ public function requestChanges(string $body): static
{
return $this->submitReview('REQUEST_CHANGES', $body);
}
@@ -52,7 +65,7 @@ public function requestChanges(string $body): self
*
* @param array $comments
*/
- public function submitReview(string $event, ?string $body = null, array $comments = []): self
+ public function submitReview(string $event, ?string $body = null, array $comments = []): static
{
$this->connector->send(new CreatePullRequestReview(
$this->owner,
@@ -66,7 +79,7 @@ public function submitReview(string $event, ?string $body = null, array $comment
return $this;
}
- public function comment(string $body, ?int $line = null, ?string $path = null): self
+ public function comment(string $body, ?int $line = null, ?string $path = null): static
{
if ($line !== null && $path !== null) {
$this->connector->send(new CreatePullRequestComment(
@@ -89,7 +102,7 @@ public function comment(string $body, ?int $line = null, ?string $path = null):
return $this;
}
- public function merge(string $method = 'merge', ?string $title = null, ?string $message = null): self
+ public function merge(string $method = 'merge', ?string $title = null, ?string $message = null): static
{
$payload = ['merge_method' => $method];
@@ -111,7 +124,7 @@ public function merge(string $method = 'merge', ?string $title = null, ?string $
return $this;
}
- public function close(): self
+ public function close(): static
{
$this->connector->send(new UpdatePullRequest(
$this->owner,
@@ -123,7 +136,7 @@ public function close(): self
return $this;
}
- public function reopen(): self
+ public function reopen(): static
{
$this->connector->send(new UpdatePullRequest(
$this->owner,
@@ -138,7 +151,7 @@ public function reopen(): self
/**
* @param array $attributes
*/
- public function update(array $attributes): self
+ public function update(array $attributes): static
{
$this->connector->send(new UpdatePullRequest(
$this->owner,
@@ -161,9 +174,13 @@ public function reviews(): array
$this->data->number
));
+ /** @var array> $items */
+ $items = $response->json();
+
return array_values(array_map(
- fn (array $data) => Review::fromArray($data),
- $response->json()
+ /** @param array $data */
+ fn (mixed $data): Review => Review::fromArray($data), // @phpstan-ignore-line
+ $items
));
}
@@ -178,9 +195,13 @@ public function comments(): array
$this->data->number
));
+ /** @var array> $items */
+ $items = $response->json();
+
return array_values(array_map(
- fn (array $data) => Comment::fromArray($data),
- $response->json()
+ /** @param array $data */
+ fn (mixed $data): Comment => Comment::fromArray($data), // @phpstan-ignore-line
+ $items
));
}
@@ -195,14 +216,17 @@ public function files(): array
$this->data->number
));
+ /** @var array> $items */
+ $items = $response->json();
+
return array_values(array_map(
- fn (array $data) => File::fromArray($data),
- $response->json()
+ /** @param array $data */
+ fn (mixed $data): File => File::fromArray($data), // @phpstan-ignore-line
+ $items
));
}
/**
-/**
* Get the raw diff text for this pull request.
*/
public function diff(): string
@@ -227,10 +251,14 @@ public function checks(): array
$this->data->head->sha
));
- $checkRuns = $response->json()['check_runs'] ?? [];
+ /** @var array $json */
+ $json = $response->json();
+ /** @var array> $checkRuns */
+ $checkRuns = $json['check_runs'] ?? [];
return array_values(array_map(
- fn (array $data) => CheckRun::fromArray($data),
+ /** @param array $data */
+ fn (mixed $data): CheckRun => CheckRun::fromArray($data), // @phpstan-ignore-line
$checkRuns
));
}
@@ -242,6 +270,7 @@ public function checks(): array
*/
public function commits(): array
{
+ /** @var array> $allCommits */
$allCommits = [];
$page = 1;
$perPage = 100;
@@ -255,9 +284,10 @@ public function commits(): array
$page
));
+ /** @var array> $commits */
$commits = $response->json();
- if (empty($commits)) {
+ if ($commits === []) {
break;
}
@@ -266,7 +296,8 @@ public function commits(): array
} while (count($commits) === $perPage);
return array_values(array_map(
- fn (array $data) => Commit::fromArray($data),
+ /** @param array $data */
+ fn (mixed $data): Commit => Commit::fromArray($data), // @phpstan-ignore-line
$allCommits
));
}
@@ -278,6 +309,7 @@ public function commits(): array
*/
public function issueComments(): array
{
+ /** @var array> $allComments */
$allComments = [];
$page = 1;
$perPage = 100;
@@ -291,9 +323,10 @@ public function issueComments(): array
$page
));
+ /** @var array> $comments */
$comments = $response->json();
- if (empty($comments)) {
+ if ($comments === []) {
break;
}
@@ -302,7 +335,8 @@ public function issueComments(): array
} while (count($comments) === $perPage);
return array_values(array_map(
- fn (array $data) => Comment::fromArray($data),
+ /** @param array $data */
+ fn (mixed $data): Comment => Comment::fromArray($data), // @phpstan-ignore-line
$allComments
));
}
@@ -310,7 +344,7 @@ public function issueComments(): array
/**
* @param array $labels
*/
- public function addLabels(array $labels): self
+ public function addLabels(array $labels): static
{
$this->connector->send(new AddIssueLabels(
$this->owner,
@@ -322,7 +356,7 @@ public function addLabels(array $labels): self
return $this;
}
- public function removeLabel(string $label): self
+ public function removeLabel(string $label): static
{
$this->connector->send(new RemoveIssueLabel(
$this->owner,
@@ -338,7 +372,7 @@ public function removeLabel(string $label): self
* @param array $reviewers
* @param array $teamReviewers
*/
- public function addReviewers(array $reviewers, array $teamReviewers = []): self
+ public function addReviewers(array $reviewers, array $teamReviewers = []): static
{
$this->connector->send(new RequestReviewers(
$this->owner,
@@ -355,7 +389,7 @@ public function addReviewers(array $reviewers, array $teamReviewers = []): self
* @param array $reviewers
* @param array $teamReviewers
*/
- public function removeReviewers(array $reviewers, array $teamReviewers = []): self
+ public function removeReviewers(array $reviewers, array $teamReviewers = []): static
{
$this->connector->send(new RemoveReviewers(
$this->owner,
@@ -368,9 +402,72 @@ public function removeReviewers(array $reviewers, array $teamReviewers = []): se
return $this;
}
+ /**
+ * @param array $assignees
+ */
+ public function assign(array $assignees): static
+ {
+ $this->connector->send(new AddAssignees(
+ $this->owner,
+ $this->repo,
+ $this->data->number,
+ $assignees
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param array $assignees
+ */
+ public function unassign(array $assignees): static
+ {
+ $this->connector->send(new RemoveAssignees(
+ $this->owner,
+ $this->repo,
+ $this->data->number,
+ $assignees
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Get the timeline of events for this pull request (paginated, fetches all pages).
+ *
+ * @return array
+ */
+ public function timeline(): array
+ {
+ $allEvents = [];
+ $page = 1;
+ $perPage = 100;
+
+ do {
+ $response = $this->connector->send(new GetIssueTimeline(
+ $this->owner,
+ $this->repo,
+ $this->data->number,
+ $perPage,
+ $page
+ ));
+
+ $events = $response->json();
+
+ if ($events === []) {
+ break;
+ }
+
+ $allEvents = array_merge($allEvents, $events);
+ $page++;
+ } while (count($events) === $perPage);
+
+ return $allEvents;
+ }
+
public function __get(string $name): mixed
{
- return $this->data->{$name};
+ return $this->data->{$name}; // @phpstan-ignore-line Variable property access is intentional for magic getter
}
/**
diff --git a/src/PullRequests.php b/src/PullRequests.php
index d63ced4..21a0990 100644
--- a/src/PullRequests.php
+++ b/src/PullRequests.php
@@ -45,20 +45,6 @@ public static function setConnector(Connector $connector): void
self::$service = new GitHubPrService($connector);
}
- /**
- * Get the default connector or throw an exception
- */
- protected static function connector(): Connector
- {
- if (self::$defaultConnector === null) {
- throw new \RuntimeException(
- 'GitHub connector not configured. Call PullRequests::setConnector() or setService() first.'
- );
- }
-
- return self::$defaultConnector;
- }
-
/**
* Get the PR service or throw an exception
*/
@@ -118,11 +104,14 @@ public function get(int $number): PullRequest
$number
));
+ /** @var array $data */
+ $data = $response->json();
+
return new PullRequest(
$this->connector,
$this->owner,
$this->repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($data) // @phpstan-ignore-line
);
}
@@ -147,11 +136,14 @@ public function list(array $filters = []): array
));
return array_values(array_map(
- fn ($pr) => new PullRequest(
+ /**
+ * @param array $pr
+ */
+ fn (mixed $pr) => new PullRequest(
$this->connector,
$this->owner,
$this->repo,
- PullRequestData::fromArray($pr)
+ PullRequestData::fromArray($pr) // @phpstan-ignore-line
),
$response->json()
));
@@ -190,11 +182,14 @@ public function createPullRequest(array $data): PullRequest
$data
));
+ /** @var array $responseData */
+ $responseData = $response->json();
+
return new PullRequest(
$this->connector,
$this->owner,
$this->repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($responseData) // @phpstan-ignore-line
);
}
@@ -212,11 +207,14 @@ public function update(int $number, array $data): PullRequest
$data
));
+ /** @var array $responseData */
+ $responseData = $response->json();
+
return new PullRequest(
$this->connector,
$this->owner,
$this->repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($responseData) // @phpstan-ignore-line
);
}
@@ -237,7 +235,7 @@ public function merge(int $number, ?string $commitMessage = null, ?string $merge
$data
));
- return $response->json()['merged'] ?? false;
+ return (bool) ($response->json()['merged'] ?? false);
}
/**
diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php
index daf06d9..641727e 100644
--- a/src/QueryBuilder.php
+++ b/src/QueryBuilder.php
@@ -101,10 +101,13 @@ public function page(int $page): self
*/
public function get(): array
{
- if (! $this->owner || ! $this->repo) {
+ if ($this->owner === null || $this->repo === null) {
throw new \InvalidArgumentException('Repository is required. Use repository("owner/repo") first.');
}
+ $owner = $this->owner;
+ $repo = $this->repo;
+
$params = array_merge($this->filters, [
'sort' => $this->sort,
'direction' => $this->direction,
@@ -117,17 +120,20 @@ public function get(): array
}
$response = $this->connector->send(new ListPullRequests(
- $this->owner,
- $this->repo,
+ $owner,
+ $repo,
$params
));
return array_values(array_map(
- fn (array $data) => new PullRequest(
+ /**
+ * @param array $data
+ */
+ fn (mixed $data) => new PullRequest(
$this->connector,
- $this->owner,
- $this->repo,
- PullRequestData::fromArray($data)
+ $owner,
+ $repo,
+ PullRequestData::fromArray($data) // @phpstan-ignore-line
),
$response->json()
));
diff --git a/src/Requests/AddAssignees.php b/src/Requests/AddAssignees.php
new file mode 100644
index 0000000..c71cc5f
--- /dev/null
+++ b/src/Requests/AddAssignees.php
@@ -0,0 +1,40 @@
+ $assignees
+ */
+ public function __construct(
+ protected string $owner,
+ protected string $repo,
+ protected int $number,
+ protected array $assignees,
+ ) {}
+
+ public function resolveEndpoint(): string
+ {
+ return "/repos/{$this->owner}/{$this->repo}/issues/{$this->number}/assignees";
+ }
+
+ /**
+ * @return array
+ */
+ protected function defaultBody(): array
+ {
+ return ['assignees' => $this->assignees];
+ }
+}
diff --git a/src/Requests/CreateIssueComment.php b/src/Requests/CreateIssueComment.php
index 15b104e..0108470 100644
--- a/src/Requests/CreateIssueComment.php
+++ b/src/Requests/CreateIssueComment.php
@@ -19,7 +19,7 @@ public function __construct(
protected string $owner,
protected string $repo,
protected int $number,
- protected string $body,
+ protected string $commentBody,
) {}
public function resolveEndpoint(): string
@@ -32,6 +32,6 @@ public function resolveEndpoint(): string
*/
protected function defaultBody(): array
{
- return ['body' => $this->body];
+ return ['body' => $this->commentBody];
}
}
diff --git a/src/Requests/CreatePullRequestComment.php b/src/Requests/CreatePullRequestComment.php
index 20ed3e3..7356d1d 100644
--- a/src/Requests/CreatePullRequestComment.php
+++ b/src/Requests/CreatePullRequestComment.php
@@ -19,7 +19,7 @@ public function __construct(
protected string $owner,
protected string $repo,
protected int $number,
- protected string $body,
+ protected string $commentBody,
protected string $path,
protected int $line,
) {}
@@ -35,7 +35,7 @@ public function resolveEndpoint(): string
protected function defaultBody(): array
{
return [
- 'body' => $this->body,
+ 'body' => $this->commentBody,
'path' => $this->path,
'line' => $this->line,
];
diff --git a/src/Requests/CreatePullRequestReview.php b/src/Requests/CreatePullRequestReview.php
index c04dd50..dde7c85 100644
--- a/src/Requests/CreatePullRequestReview.php
+++ b/src/Requests/CreatePullRequestReview.php
@@ -43,7 +43,7 @@ protected function defaultBody(): array
$payload['body'] = $this->reviewBody;
}
- if (! empty($this->comments)) {
+ if ($this->comments !== []) {
$payload['comments'] = $this->comments;
}
diff --git a/src/Requests/GetIssueTimeline.php b/src/Requests/GetIssueTimeline.php
new file mode 100644
index 0000000..6223504
--- /dev/null
+++ b/src/Requests/GetIssueTimeline.php
@@ -0,0 +1,47 @@
+owner}/{$this->repo}/issues/{$this->number}/timeline";
+ }
+
+ /**
+ * @return array
+ */
+ protected function defaultQuery(): array
+ {
+ return [
+ 'per_page' => $this->perPage,
+ 'page' => $this->page,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function defaultHeaders(): array
+ {
+ return [
+ 'Accept' => 'application/vnd.github.mockingbird-preview+json',
+ ];
+ }
+}
diff --git a/src/Requests/ListPullRequests.php b/src/Requests/ListPullRequests.php
index bae4cbf..607b8c7 100644
--- a/src/Requests/ListPullRequests.php
+++ b/src/Requests/ListPullRequests.php
@@ -24,6 +24,6 @@ public function resolveEndpoint(): string
{
$query = http_build_query($this->filters);
- return "/repos/{$this->owner}/{$this->repo}/pulls".($query ? "?{$query}" : '');
+ return "/repos/{$this->owner}/{$this->repo}/pulls".($query !== '' ? "?{$query}" : '');
}
}
diff --git a/src/Requests/RemoveAssignees.php b/src/Requests/RemoveAssignees.php
new file mode 100644
index 0000000..00fa553
--- /dev/null
+++ b/src/Requests/RemoveAssignees.php
@@ -0,0 +1,40 @@
+ $assignees
+ */
+ public function __construct(
+ protected string $owner,
+ protected string $repo,
+ protected int $number,
+ protected array $assignees,
+ ) {}
+
+ public function resolveEndpoint(): string
+ {
+ return "/repos/{$this->owner}/{$this->repo}/issues/{$this->number}/assignees";
+ }
+
+ /**
+ * @return array
+ */
+ protected function defaultBody(): array
+ {
+ return ['assignees' => $this->assignees];
+ }
+}
diff --git a/src/Requests/RemoveReviewers.php b/src/Requests/RemoveReviewers.php
index c52ec31..899d056 100644
--- a/src/Requests/RemoveReviewers.php
+++ b/src/Requests/RemoveReviewers.php
@@ -39,11 +39,11 @@ protected function defaultBody(): array
{
$payload = [];
- if (! empty($this->reviewers)) {
+ if ($this->reviewers !== []) {
$payload['reviewers'] = $this->reviewers;
}
- if (! empty($this->teamReviewers)) {
+ if ($this->teamReviewers !== []) {
$payload['team_reviewers'] = $this->teamReviewers;
}
diff --git a/src/Requests/RequestReviewers.php b/src/Requests/RequestReviewers.php
index cb028c8..16b3120 100644
--- a/src/Requests/RequestReviewers.php
+++ b/src/Requests/RequestReviewers.php
@@ -39,11 +39,11 @@ protected function defaultBody(): array
{
$payload = [];
- if (! empty($this->reviewers)) {
+ if ($this->reviewers !== []) {
$payload['reviewers'] = $this->reviewers;
}
- if (! empty($this->teamReviewers)) {
+ if ($this->teamReviewers !== []) {
$payload['team_reviewers'] = $this->teamReviewers;
}
diff --git a/src/Services/GitHubPrService.php b/src/Services/GitHubPrService.php
index 8e3e8d3..108b207 100644
--- a/src/Services/GitHubPrService.php
+++ b/src/Services/GitHubPrService.php
@@ -27,11 +27,14 @@ public function find(string $repository, int $number): PullRequest
$response = $this->connector->send(new GetPullRequest($owner, $repo, $number));
+ /** @var array $data */
+ $data = $response->json();
+
return new PullRequest(
$this->connector,
$owner,
$repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($data) // @phpstan-ignore-line
);
}
@@ -44,11 +47,14 @@ public function create(string $repository, array $attributes): PullRequest
$response = $this->connector->send(new CreatePullRequest($owner, $repo, $attributes));
+ /** @var array $data */
+ $data = $response->json();
+
return new PullRequest(
$this->connector,
$owner,
$repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($data) // @phpstan-ignore-line
);
}
@@ -71,11 +77,14 @@ public function update(string $repository, int $number, array $data): PullReques
$response = $this->connector->send(new UpdatePullRequest($owner, $repo, $number, $data));
+ /** @var array $responseData */
+ $responseData = $response->json();
+
return new PullRequest(
$this->connector,
$owner,
$repo,
- PullRequestData::fromArray($response->json())
+ PullRequestData::fromArray($responseData) // @phpstan-ignore-line
);
}
@@ -90,7 +99,7 @@ public function merge(string $repository, int $number, ?string $commitMessage =
$response = $this->connector->send(new MergePullRequest($owner, $repo, $number, $data));
- return $response->json()['merged'] ?? false;
+ return (bool) ($response->json()['merged'] ?? false);
}
public function close(string $repository, int $number): PullRequest
@@ -115,11 +124,14 @@ public function list(string $repository, array $filters = []): array
$response = $this->connector->send(new ListPullRequests($owner, $repo, $mergedFilters));
return array_values(array_map(
- fn ($pr) => new PullRequest(
+ /**
+ * @param array $pr
+ */
+ fn (mixed $pr) => new PullRequest(
$this->connector,
$owner,
$repo,
- PullRequestData::fromArray($pr)
+ PullRequestData::fromArray($pr) // @phpstan-ignore-line
),
$response->json()
));
diff --git a/tests/Unit/DataTransferObjects/CheckRunTest.php b/tests/Unit/DataTransferObjects/CheckRunTest.php
new file mode 100644
index 0000000..385d424
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/CheckRunTest.php
@@ -0,0 +1,124 @@
+ 1,
+ 'name' => 'PHPStan',
+ 'status' => 'completed',
+ 'conclusion' => 'success',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->id)->toBe(1)
+ ->and($checkRun->name)->toBe('PHPStan')
+ ->and($checkRun->status)->toBe('completed')
+ ->and($checkRun->conclusion)->toBe('success');
+});
+
+it('can create check run with null conclusion', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'in_progress',
+ 'conclusion' => null,
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($checkRun->conclusion)->toBeNull()
+ ->and($checkRun->completedAt)->toBeNull();
+});
+
+it('can check if check run is completed', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'completed',
+ 'conclusion' => 'success',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->isCompleted())->toBeTrue();
+});
+
+it('can check if check run is successful', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'completed',
+ 'conclusion' => 'success',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->isSuccessful())->toBeTrue();
+});
+
+it('can check if check run failed', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'completed',
+ 'conclusion' => 'failure',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->isFailed())->toBeTrue()
+ ->and($checkRun->isSuccessful())->toBeFalse();
+});
+
+it('can check if check run timed out', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'completed',
+ 'conclusion' => 'timed_out',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->isFailed())->toBeTrue();
+});
+
+it('can check if check run action required', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'Tests',
+ 'status' => 'completed',
+ 'conclusion' => 'action_required',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ expect($checkRun->isFailed())->toBeTrue();
+});
+
+it('can convert check run to array', function () {
+ $checkRun = CheckRun::fromArray([
+ 'id' => 1,
+ 'name' => 'PHPStan',
+ 'status' => 'completed',
+ 'conclusion' => 'success',
+ 'html_url' => 'https://github.com/owner/repo/runs/1',
+ 'started_at' => '2025-01-01T10:00:00Z',
+ 'completed_at' => '2025-01-01T10:05:00Z',
+ ]);
+
+ $array = $checkRun->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['name'])->toBe('PHPStan');
+});
diff --git a/tests/Unit/DataTransferObjects/CommentTest.php b/tests/Unit/DataTransferObjects/CommentTest.php
new file mode 100644
index 0000000..1abcbb5
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/CommentTest.php
@@ -0,0 +1,48 @@
+ 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'body' => 'This is a comment',
+ 'html_url' => 'https://github.com/owner/repo/issues/1#issuecomment-1',
+ 'created_at' => '2025-01-01T10:00:00Z',
+ 'updated_at' => '2025-01-01T11:00:00Z',
+ ]);
+
+ expect($comment->id)->toBe(1)
+ ->and($comment->body)->toBe('This is a comment')
+ ->and($comment->user->login)->toBe('testuser');
+});
+
+it('can convert comment to array', function () {
+ $comment = Comment::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'body' => 'Test comment',
+ 'html_url' => 'https://github.com/owner/repo/issues/1#issuecomment-1',
+ 'created_at' => '2025-01-01T10:00:00Z',
+ 'updated_at' => '2025-01-01T11:00:00Z',
+ ]);
+
+ $array = $comment->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['body'])->toBe('Test comment');
+});
diff --git a/tests/Unit/DataTransferObjects/HeadBaseTest.php b/tests/Unit/DataTransferObjects/HeadBaseTest.php
new file mode 100644
index 0000000..6b9f6ca
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/HeadBaseTest.php
@@ -0,0 +1,108 @@
+ 'feature-branch',
+ 'sha' => 'abc123def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ]);
+
+ expect($head->ref)->toBe('feature-branch')
+ ->and($head->sha)->toBe('abc123def456')
+ ->and($head->user->login)->toBe('testuser')
+ ->and($head->repo->fullName)->toBe('owner/repo');
+});
+
+it('can create base from array', function () {
+ $base = Base::fromArray([
+ 'ref' => 'main',
+ 'sha' => 'def456abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ]);
+
+ expect($base->ref)->toBe('main')
+ ->and($base->sha)->toBe('def456abc123');
+});
+
+it('can convert head to array', function () {
+ $head = Head::fromArray([
+ 'ref' => 'feature',
+ 'sha' => 'abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ]);
+
+ $array = $head->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['ref'])->toBe('feature');
+});
+
+it('can convert base to array', function () {
+ $base = Base::fromArray([
+ 'ref' => 'main',
+ 'sha' => 'def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ]);
+
+ $array = $base->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['ref'])->toBe('main');
+});
diff --git a/tests/Unit/DataTransferObjects/LabelTest.php b/tests/Unit/DataTransferObjects/LabelTest.php
new file mode 100644
index 0000000..4120ecc
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/LabelTest.php
@@ -0,0 +1,43 @@
+ 1,
+ 'name' => 'bug',
+ 'color' => 'ff0000',
+ 'description' => 'Something is broken',
+ ]);
+
+ expect($label->id)->toBe(1)
+ ->and($label->name)->toBe('bug')
+ ->and($label->color)->toBe('ff0000')
+ ->and($label->description)->toBe('Something is broken');
+});
+
+it('can create label with null description', function () {
+ $label = Label::fromArray([
+ 'id' => 1,
+ 'name' => 'enhancement',
+ 'color' => '00ff00',
+ ]);
+
+ expect($label->description)->toBeNull();
+});
+
+it('can convert label to array', function () {
+ $label = Label::fromArray([
+ 'id' => 1,
+ 'name' => 'bug',
+ 'color' => 'ff0000',
+ 'description' => 'Something is broken',
+ ]);
+
+ $array = $label->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['name'])->toBe('bug');
+});
diff --git a/tests/Unit/DataTransferObjects/RepositoryTest.php b/tests/Unit/DataTransferObjects/RepositoryTest.php
new file mode 100644
index 0000000..bf22f36
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/RepositoryTest.php
@@ -0,0 +1,48 @@
+ 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ]);
+
+ expect($repo->id)->toBe(1)
+ ->and($repo->name)->toBe('repo')
+ ->and($repo->fullName)->toBe('owner/repo')
+ ->and($repo->htmlUrl)->toBe('https://github.com/owner/repo')
+ ->and($repo->private)->toBeFalse();
+});
+
+it('can create private repository', function () {
+ $repo = Repository::fromArray([
+ 'id' => 1,
+ 'name' => 'private-repo',
+ 'full_name' => 'owner/private-repo',
+ 'html_url' => 'https://github.com/owner/private-repo',
+ 'private' => true,
+ ]);
+
+ expect($repo->private)->toBeTrue();
+});
+
+it('can convert repository to array', function () {
+ $repo = Repository::fromArray([
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ]);
+
+ $array = $repo->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['full_name'])->toBe('owner/repo');
+});
diff --git a/tests/Unit/DataTransferObjects/ReviewTest.php b/tests/Unit/DataTransferObjects/ReviewTest.php
new file mode 100644
index 0000000..edd79d8
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/ReviewTest.php
@@ -0,0 +1,130 @@
+ 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'body' => 'LGTM!',
+ 'state' => 'APPROVED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($review->id)->toBe(1)
+ ->and($review->body)->toBe('LGTM!')
+ ->and($review->state)->toBe('APPROVED')
+ ->and($review->user->login)->toBe('reviewer');
+});
+
+it('can create review with null body', function () {
+ $review = Review::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'state' => 'APPROVED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($review->body)->toBeNull();
+});
+
+it('can convert review to array', function () {
+ $review = Review::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'body' => 'Needs work',
+ 'state' => 'CHANGES_REQUESTED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ $array = $review->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['state'])->toBe('CHANGES_REQUESTED');
+});
+
+it('can check if review is approved', function () {
+ $review = Review::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'body' => 'LGTM',
+ 'state' => 'APPROVED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($review->isApproved())->toBeTrue()
+ ->and($review->isChangesRequested())->toBeFalse()
+ ->and($review->isCommented())->toBeFalse();
+});
+
+it('can check if review has changes requested', function () {
+ $review = Review::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'body' => 'Please fix',
+ 'state' => 'CHANGES_REQUESTED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($review->isChangesRequested())->toBeTrue()
+ ->and($review->isApproved())->toBeFalse()
+ ->and($review->isCommented())->toBeFalse();
+});
+
+it('can check if review is commented', function () {
+ $review = Review::fromArray([
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer',
+ 'type' => 'User',
+ ],
+ 'body' => 'Just a comment',
+ 'state' => 'COMMENTED',
+ 'html_url' => 'https://github.com/owner/repo/pull/1#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ]);
+
+ expect($review->isCommented())->toBeTrue()
+ ->and($review->isApproved())->toBeFalse()
+ ->and($review->isChangesRequested())->toBeFalse();
+});
diff --git a/tests/Unit/DataTransferObjects/UserTest.php b/tests/Unit/DataTransferObjects/UserTest.php
new file mode 100644
index 0000000..a30a7db
--- /dev/null
+++ b/tests/Unit/DataTransferObjects/UserTest.php
@@ -0,0 +1,37 @@
+ 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ]);
+
+ expect($user->id)->toBe(1)
+ ->and($user->login)->toBe('testuser')
+ ->and($user->avatarUrl)->toBe('https://example.com/avatar.jpg')
+ ->and($user->htmlUrl)->toBe('https://github.com/testuser')
+ ->and($user->type)->toBe('User');
+});
+
+it('can convert user to array', function () {
+ $user = User::fromArray([
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ]);
+
+ $array = $user->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['id'])->toBe(1)
+ ->and($array['login'])->toBe('testuser');
+});
diff --git a/tests/Unit/InterfaceTest.php b/tests/Unit/InterfaceTest.php
new file mode 100644
index 0000000..e801a6d
--- /dev/null
+++ b/tests/Unit/InterfaceTest.php
@@ -0,0 +1,90 @@
+toImplement(Commentable::class);
+});
+
+it('implements Labelable interface', function () {
+ expect(PullRequest::class)->toImplement(Labelable::class);
+});
+
+it('implements Assignable interface', function () {
+ expect(PullRequest::class)->toImplement(Assignable::class);
+});
+
+it('implements Closeable interface', function () {
+ expect(PullRequest::class)->toImplement(Closeable::class);
+});
+
+it('implements Auditable interface', function () {
+ expect(PullRequest::class)->toImplement(Auditable::class);
+});
+
+it('implements Reviewable interface', function () {
+ expect(PullRequest::class)->toImplement(Reviewable::class);
+});
+
+it('implements Mergeable interface', function () {
+ expect(PullRequest::class)->toImplement(Mergeable::class);
+});
+
+it('implements Checkable interface', function () {
+ expect(PullRequest::class)->toImplement(Checkable::class);
+});
+
+it('implements Diffable interface', function () {
+ expect(PullRequest::class)->toImplement(Diffable::class);
+});
+
+it('implements HasCommits interface', function () {
+ expect(PullRequest::class)->toImplement(HasCommits::class);
+});
+
+it('can be type-hinted as Commentable', function () {
+ $acceptsCommentable = function (Commentable $entity): string {
+ return $entity::class;
+ };
+
+ $pr = createTestPr();
+ expect($acceptsCommentable($pr))->toBe(PullRequest::class);
+});
+
+it('can be type-hinted as Reviewable', function () {
+ $acceptsReviewable = function (Reviewable $entity): string {
+ return $entity::class;
+ };
+
+ $pr = createTestPr();
+ expect($acceptsReviewable($pr))->toBe(PullRequest::class);
+});
+
+it('can be type-hinted as Checkable', function () {
+ $acceptsCheckable = function (Checkable $entity): string {
+ return $entity::class;
+ };
+
+ $pr = createTestPr();
+ expect($acceptsCheckable($pr))->toBe(PullRequest::class);
+});
+
+function createTestPr(): PullRequest
+{
+ $connector = createMockConnector([]);
+ $prData = createTestPullRequestData();
+
+ return new PullRequest($connector, 'owner', 'repo', $prData);
+}
diff --git a/tests/Unit/PullRequestTest.php b/tests/Unit/PullRequestTest.php
index 1b88dd6..cd27c2f 100644
--- a/tests/Unit/PullRequestTest.php
+++ b/tests/Unit/PullRequestTest.php
@@ -253,6 +253,65 @@
->and($pr->changedFiles)->toBeNull();
});
+it('can check if pull request is draft', function () {
+ $data = [
+ 'number' => 123,
+ 'title' => 'Draft PR',
+ 'state' => 'open',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'html_url' => 'https://github.com/owner/repo/pull/123',
+ 'created_at' => '2025-01-01T00:00:00Z',
+ 'updated_at' => '2025-01-01T00:00:00Z',
+ 'draft' => true,
+ 'head' => [
+ 'ref' => 'feature-branch',
+ 'sha' => 'abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ 'base' => [
+ 'ref' => 'main',
+ 'sha' => 'def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ ];
+
+ $pr = PullRequest::fromArray($data);
+
+ expect($pr->isDraft())->toBeTrue();
+});
+
it('can serialize pull request with stats to array', function () {
$data = [
'number' => 123,
@@ -317,3 +376,123 @@
->and($array['deletions'])->toBe(75)
->and($array['changed_files'])->toBe(10);
});
+
+it('can handle pull request with assignees and labels', function () {
+ $data = [
+ 'number' => 123,
+ 'title' => 'Test PR',
+ 'state' => 'open',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'html_url' => 'https://github.com/owner/repo/pull/123',
+ 'created_at' => '2025-01-01T00:00:00Z',
+ 'updated_at' => '2025-01-01T00:00:00Z',
+ 'draft' => false,
+ 'assignee' => [
+ 'id' => 2,
+ 'login' => 'assignee1',
+ 'avatar_url' => 'https://example.com/avatar2.jpg',
+ 'html_url' => 'https://github.com/assignee1',
+ 'type' => 'User',
+ ],
+ 'assignees' => [
+ [
+ 'id' => 2,
+ 'login' => 'assignee1',
+ 'avatar_url' => 'https://example.com/avatar2.jpg',
+ 'html_url' => 'https://github.com/assignee1',
+ 'type' => 'User',
+ ],
+ [
+ 'id' => 3,
+ 'login' => 'assignee2',
+ 'avatar_url' => 'https://example.com/avatar3.jpg',
+ 'html_url' => 'https://github.com/assignee2',
+ 'type' => 'User',
+ ],
+ ],
+ 'requested_reviewers' => [
+ [
+ 'id' => 4,
+ 'login' => 'reviewer1',
+ 'avatar_url' => 'https://example.com/avatar4.jpg',
+ 'html_url' => 'https://github.com/reviewer1',
+ 'type' => 'User',
+ ],
+ ],
+ 'labels' => [
+ [
+ 'id' => 1,
+ 'name' => 'bug',
+ 'color' => 'ff0000',
+ 'description' => 'Bug fix',
+ ],
+ [
+ 'id' => 2,
+ 'name' => 'enhancement',
+ 'color' => '00ff00',
+ ],
+ ],
+ 'head' => [
+ 'ref' => 'feature-branch',
+ 'sha' => 'abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ 'base' => [
+ 'ref' => 'main',
+ 'sha' => 'def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ ];
+
+ $pr = PullRequest::fromArray($data);
+
+ expect($pr->assignee)->not->toBeNull()
+ ->and($pr->assignee->login)->toBe('assignee1')
+ ->and($pr->assignees)->toHaveCount(2)
+ ->and($pr->assignees[0]->login)->toBe('assignee1')
+ ->and($pr->assignees[1]->login)->toBe('assignee2')
+ ->and($pr->requestedReviewers)->toHaveCount(1)
+ ->and($pr->requestedReviewers[0]->login)->toBe('reviewer1')
+ ->and($pr->labels)->toHaveCount(2)
+ ->and($pr->labels[0]->name)->toBe('bug')
+ ->and($pr->labels[1]->name)->toBe('enhancement');
+
+ // Test toArray includes these fields
+ $array = $pr->toArray();
+ expect($array['assignee'])->not->toBeNull()
+ ->and($array['assignees'])->toHaveCount(2)
+ ->and($array['requested_reviewers'])->toHaveCount(1)
+ ->and($array['labels'])->toHaveCount(2);
+});
diff --git a/tests/Unit/PullRequestWrapperTest.php b/tests/Unit/PullRequestWrapperTest.php
index eaae286..d56386f 100644
--- a/tests/Unit/PullRequestWrapperTest.php
+++ b/tests/Unit/PullRequestWrapperTest.php
@@ -476,3 +476,241 @@ function createTestPullRequestData(): PullRequestData
expect($checks)->toBeArray()
->and($checks)->toBeEmpty();
});
+
+it('can assign users to pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->assign(['user1', 'user2']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can unassign users from pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->unassign(['user1']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can get timeline from pull request', function () {
+ $mockTimeline = [
+ [
+ 'event' => 'labeled',
+ 'label' => ['name' => 'bug'],
+ 'created_at' => '2025-01-01T10:00:00Z',
+ ],
+ [
+ 'event' => 'commented',
+ 'body' => 'This is a comment',
+ 'created_at' => '2025-01-01T11:00:00Z',
+ ],
+ ];
+
+ $connector = createMockConnector([$mockTimeline]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $timeline = $pr->timeline();
+
+ expect($timeline)->toBeArray()
+ ->and($timeline)->toHaveCount(2)
+ ->and($timeline[0]['event'])->toBe('labeled')
+ ->and($timeline[1]['event'])->toBe('commented');
+});
+
+it('returns empty array when no timeline events', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $timeline = $pr->timeline();
+
+ expect($timeline)->toBeArray()
+ ->and($timeline)->toBeEmpty();
+});
+
+it('can add labels to pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->addLabels(['bug', 'enhancement']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can remove label from pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->removeLabel('bug');
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can add reviewers to pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->addReviewers(['reviewer1'], ['team1']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can remove reviewers from pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->removeReviewers(['reviewer1'], ['team1']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can close pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->close();
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can reopen pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->reopen();
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can update pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->update(['title' => 'New title']);
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can merge pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->merge('squash', 'Commit title', 'Commit message');
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can add comment to pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->comment('This is a comment');
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can add inline comment to pull request', function () {
+ $connector = createMockConnector([[]]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $result = $pr->comment('Inline comment', 10, 'src/file.php');
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can get reviews from pull request', function () {
+ $mockReviews = [
+ [
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'reviewer1',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/reviewer1',
+ 'type' => 'User',
+ ],
+ 'body' => 'Looks good!',
+ 'state' => 'APPROVED',
+ 'html_url' => 'https://github.com/owner/repo/pull/123#pullrequestreview-1',
+ 'submitted_at' => '2025-01-01T10:00:00Z',
+ ],
+ ];
+
+ $connector = createMockConnector([$mockReviews]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $reviews = $pr->reviews();
+
+ expect($reviews)->toBeArray()
+ ->and($reviews)->toHaveCount(1)
+ ->and($reviews[0]->state)->toBe('APPROVED');
+});
+
+it('can get pull request review comments', function () {
+ $mockComments = [
+ [
+ 'id' => 1,
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'commenter',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/commenter',
+ 'type' => 'User',
+ ],
+ 'body' => 'Nice code!',
+ 'html_url' => 'https://github.com/owner/repo/pull/123#discussion_r1',
+ 'created_at' => '2025-01-01T10:00:00Z',
+ 'updated_at' => '2025-01-01T10:00:00Z',
+ ],
+ ];
+
+ $connector = createMockConnector([$mockComments]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $comments = $pr->comments();
+
+ expect($comments)->toBeArray()
+ ->and($comments)->toHaveCount(1)
+ ->and($comments[0])->toBeInstanceOf(Comment::class)
+ ->and($comments[0]->body)->toBe('Nice code!');
+});
+
+it('can convert pull request to array', function () {
+ $connector = createMockConnector([]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ $array = $pr->toArray();
+
+ expect($array)->toBeArray()
+ ->and($array['number'])->toBe(123)
+ ->and($array['title'])->toBe('Test PR');
+});
+
+it('can access pull request data via magic getter', function () {
+ $connector = createMockConnector([]);
+ $prData = createTestPullRequestData();
+ $pr = new PullRequest($connector, 'owner', 'repo', $prData);
+
+ expect($pr->number)->toBe(123)
+ ->and($pr->title)->toBe('Test PR')
+ ->and($pr->state)->toBe('open');
+});
diff --git a/tests/Unit/PullRequestsFacadeTest.php b/tests/Unit/PullRequestsFacadeTest.php
new file mode 100644
index 0000000..268e63b
--- /dev/null
+++ b/tests/Unit/PullRequestsFacadeTest.php
@@ -0,0 +1,268 @@
+ $data
+ */
+ public function __construct(private array $data)
+ {
+ // Skip parent constructor
+ }
+
+ public function json(string|int|null $key = null, mixed $default = null): mixed
+ {
+ if ($key !== null) {
+ return $this->data[$key] ?? $default;
+ }
+
+ return $this->data;
+ }
+}
+
+class PullRequestsFacadeTestConnector extends Connector
+{
+ private int $callIndex = 0;
+
+ /**
+ * @var array>
+ */
+ protected array $mockResponses = [];
+
+ /**
+ * @param array> $responses
+ */
+ public function __construct(array $responses = [])
+ {
+ parent::__construct('test-token');
+ $this->mockResponses = $responses;
+ }
+
+ public function send(Request $request, ...$args): Response
+ {
+ $response = $this->mockResponses[$this->callIndex++] ?? [];
+
+ return new PullRequestsFacadeMockResponse($response);
+ }
+}
+
+function createFacadeTestConnector(array $responses = []): Connector
+{
+ return new PullRequestsFacadeTestConnector($responses);
+}
+
+function createFacadeMockPrData(): array
+{
+ return [
+ 'number' => 1,
+ 'title' => 'Test PR',
+ 'body' => 'Test body',
+ 'state' => 'open',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'html_url' => 'https://github.com/owner/repo/pull/1',
+ 'created_at' => '2025-01-01T00:00:00Z',
+ 'updated_at' => '2025-01-01T00:00:00Z',
+ 'draft' => false,
+ 'head' => [
+ 'ref' => 'feature',
+ 'sha' => 'abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ 'base' => [
+ 'ref' => 'main',
+ 'sha' => 'def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ ];
+}
+
+beforeEach(function () {
+ // Reset static state before each test
+ PullRequests::setService(new GitHubPrService(createFacadeTestConnector([createFacadeMockPrData()])));
+});
+
+it('throws exception when connector not configured', function () {
+ // Create a fresh instance without setting connector
+ $reflection = new ReflectionClass(PullRequests::class);
+ $serviceProperty = $reflection->getProperty('service');
+ $serviceProperty->setAccessible(true);
+ $serviceProperty->setValue(null, null);
+
+ $connectorProperty = $reflection->getProperty('defaultConnector');
+ $connectorProperty->setAccessible(true);
+ $connectorProperty->setValue(null, null);
+
+ PullRequests::find('owner/repo', 1);
+})->throws(RuntimeException::class, 'PR service not configured');
+
+it('can set connector via deprecated method', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ PullRequests::setConnector($connector);
+
+ $pr = PullRequests::find('owner/repo', 1);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('can set service directly', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ $service = new GitHubPrService($connector);
+ PullRequests::setService($service);
+
+ $pr = PullRequests::find('owner/repo', 1);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('static for returns query builder', function () {
+ $builder = PullRequests::for('owner/repo');
+
+ expect($builder)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('static find returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ PullRequests::setConnector($connector);
+
+ $pr = PullRequests::find('owner/repo', 1);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('static create returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ PullRequests::setConnector($connector);
+
+ $pr = PullRequests::create('owner/repo', [
+ 'title' => 'Test',
+ 'head' => 'feature',
+ 'base' => 'main',
+ ]);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('static query returns query builder', function () {
+ $builder = PullRequests::query();
+
+ expect($builder)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('instance get returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $pr = $prs->get(1);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('instance list returns array of pull requests', function () {
+ $connector = createFacadeTestConnector([[createFacadeMockPrData()]]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $results = $prs->list();
+
+ expect($results)->toBeArray()
+ ->and($results[0])->toBeInstanceOf(PullRequest::class);
+});
+
+it('instance open returns open pull requests', function () {
+ $connector = createFacadeTestConnector([[createFacadeMockPrData()]]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $results = $prs->open();
+
+ expect($results)->toBeArray();
+});
+
+it('instance closed returns closed pull requests', function () {
+ $connector = createFacadeTestConnector([[createFacadeMockPrData()]]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $results = $prs->closed();
+
+ expect($results)->toBeArray();
+});
+
+it('instance createPullRequest returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $pr = $prs->createPullRequest([
+ 'title' => 'Test',
+ 'head' => 'feature',
+ 'base' => 'main',
+ ]);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('instance update returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $pr = $prs->update(1, ['title' => 'Updated']);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
+
+it('instance merge returns boolean', function () {
+ $connector = createFacadeTestConnector([['merged' => true]]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $result = $prs->merge(1, 'Merge commit', 'squash');
+
+ expect($result)->toBeTrue();
+});
+
+it('instance close returns pull request', function () {
+ $connector = createFacadeTestConnector([createFacadeMockPrData()]);
+ $prs = new PullRequests($connector, 'owner', 'repo');
+
+ $pr = $prs->close(1);
+
+ expect($pr)->toBeInstanceOf(PullRequest::class);
+});
diff --git a/tests/Unit/QueryBuilderTest.php b/tests/Unit/QueryBuilderTest.php
new file mode 100644
index 0000000..bea01c1
--- /dev/null
+++ b/tests/Unit/QueryBuilderTest.php
@@ -0,0 +1,262 @@
+> $data
+ */
+ public function __construct(private array $data)
+ {
+ // Skip parent constructor
+ }
+
+ public function json(string|int|null $key = null, mixed $default = null): mixed
+ {
+ return $this->data;
+ }
+}
+
+class QueryBuilderTestConnector extends Connector
+{
+ /**
+ * @var array>
+ */
+ protected array $mockResponse = [];
+
+ /**
+ * @param array> $response
+ */
+ public function __construct(array $response = [])
+ {
+ parent::__construct('test-token');
+ $this->mockResponse = $response;
+ }
+
+ public function send(Request $request, ...$args): Response
+ {
+ return new QueryBuilderMockResponse($this->mockResponse);
+ }
+}
+
+function createQueryBuilderConnector(array $response = []): Connector
+{
+ return new QueryBuilderTestConnector($response);
+}
+
+function createMockPrData(): array
+{
+ return [
+ 'number' => 1,
+ 'title' => 'Test PR',
+ 'body' => 'Test body',
+ 'state' => 'open',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'html_url' => 'https://github.com/owner/repo/pull/1',
+ 'created_at' => '2025-01-01T00:00:00Z',
+ 'updated_at' => '2025-01-01T00:00:00Z',
+ 'draft' => false,
+ 'head' => [
+ 'ref' => 'feature',
+ 'sha' => 'abc123',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ 'base' => [
+ 'ref' => 'main',
+ 'sha' => 'def456',
+ 'user' => [
+ 'id' => 1,
+ 'login' => 'testuser',
+ 'avatar_url' => 'https://example.com/avatar.jpg',
+ 'html_url' => 'https://github.com/testuser',
+ 'type' => 'User',
+ ],
+ 'repo' => [
+ 'id' => 1,
+ 'name' => 'repo',
+ 'full_name' => 'owner/repo',
+ 'html_url' => 'https://github.com/owner/repo',
+ 'private' => false,
+ ],
+ ],
+ ];
+}
+
+it('can set repository', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo');
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter by state', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->state('closed');
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter by open state', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->open();
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter by closed state', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->closed();
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter all states', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->all();
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter by author', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->author('testuser');
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can filter by label', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->label('bug');
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can set order by', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->orderBy('updated', 'asc');
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can set limit', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->take(10);
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can set page', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->page(2);
+
+ expect($result)->toBeInstanceOf(QueryBuilder::class);
+});
+
+it('can get pull requests', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $results = $builder->repository('owner/repo')->get();
+
+ expect($results)->toBeArray()
+ ->and($results)->toHaveCount(1)
+ ->and($results[0])->toBeInstanceOf(PullRequest::class);
+});
+
+it('throws exception when repository not set', function () {
+ $connector = createQueryBuilderConnector([]);
+ $builder = new QueryBuilder($connector);
+
+ $builder->get();
+})->throws(InvalidArgumentException::class, 'Repository is required');
+
+it('can get first pull request', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->first();
+
+ expect($result)->toBeInstanceOf(PullRequest::class);
+});
+
+it('returns null when no pull requests found', function () {
+ $connector = createQueryBuilderConnector([]);
+ $builder = new QueryBuilder($connector);
+
+ $result = $builder->repository('owner/repo')->first();
+
+ expect($result)->toBeNull();
+});
+
+it('can count pull requests', function () {
+ $connector = createQueryBuilderConnector([createMockPrData(), createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $count = $builder->repository('owner/repo')->count();
+
+ expect($count)->toBe(2);
+});
+
+it('can chain multiple filters', function () {
+ $connector = createQueryBuilderConnector([createMockPrData()]);
+ $builder = new QueryBuilder($connector);
+
+ $results = $builder
+ ->repository('owner/repo')
+ ->open()
+ ->author('testuser')
+ ->label('bug')
+ ->orderBy('updated', 'asc')
+ ->take(10)
+ ->page(1)
+ ->get();
+
+ expect($results)->toBeArray()
+ ->and($results)->toHaveCount(1);
+});
diff --git a/tests/Unit/RequestsTest.php b/tests/Unit/RequestsTest.php
new file mode 100644
index 0000000..595455c
--- /dev/null
+++ b/tests/Unit/RequestsTest.php
@@ -0,0 +1,296 @@
+resolveEndpoint())->toBe('/repos/owner/repo/issues/123/assignees');
+});
+
+it('RemoveAssignees has correct endpoint', function () {
+ $request = new RemoveAssignees('owner', 'repo', 123, ['user1']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/assignees');
+});
+
+it('GetIssueTimeline has correct endpoint', function () {
+ $request = new GetIssueTimeline('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/timeline');
+});
+
+it('AddIssueLabels has correct endpoint', function () {
+ $request = new AddIssueLabels('owner', 'repo', 123, ['bug', 'enhancement']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/labels');
+});
+
+it('CreateIssueComment has correct endpoint', function () {
+ $request = new CreateIssueComment('owner', 'repo', 123, 'Test comment');
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/comments');
+});
+
+it('CreatePullRequest has correct endpoint', function () {
+ $request = new CreatePullRequest('owner', 'repo', ['title' => 'Test', 'head' => 'feature', 'base' => 'main']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls');
+});
+
+it('CreatePullRequestComment has correct endpoint', function () {
+ $request = new CreatePullRequestComment('owner', 'repo', 123, 'Test comment', 'file.php', 10);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/comments');
+});
+
+it('CreatePullRequestReview has correct endpoint', function () {
+ $request = new CreatePullRequestReview('owner', 'repo', 123, 'APPROVE', 'LGTM');
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews');
+});
+
+it('GetCommitCheckRuns has correct endpoint', function () {
+ $request = new GetCommitCheckRuns('owner', 'repo', 'abc123');
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/commits/abc123/check-runs');
+});
+
+it('GetIssueComments has correct endpoint', function () {
+ $request = new GetIssueComments('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/comments?per_page=100&page=1');
+});
+
+it('GetPullRequest has correct endpoint', function () {
+ $request = new GetPullRequest('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123');
+});
+
+it('GetPullRequestComments has correct endpoint', function () {
+ $request = new GetPullRequestComments('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/comments');
+});
+
+it('GetPullRequestCommits has correct endpoint', function () {
+ $request = new GetPullRequestCommits('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/commits?per_page=100&page=1');
+});
+
+it('GetPullRequestDiff has correct endpoint', function () {
+ $request = new GetPullRequestDiff('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123');
+});
+
+it('GetPullRequestFiles has correct endpoint', function () {
+ $request = new GetPullRequestFiles('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/files');
+});
+
+it('GetPullRequestReviews has correct endpoint', function () {
+ $request = new GetPullRequestReviews('owner', 'repo', 123);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews');
+});
+
+it('ListPullRequests has correct endpoint', function () {
+ $request = new ListPullRequests('owner', 'repo', ['state' => 'open']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls?state=open');
+});
+
+it('MergePullRequest has correct endpoint', function () {
+ $request = new MergePullRequest('owner', 'repo', 123, ['merge_method' => 'squash']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/merge');
+});
+
+it('RemoveIssueLabel has correct endpoint', function () {
+ $request = new RemoveIssueLabel('owner', 'repo', 123, 'bug');
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/issues/123/labels/bug');
+});
+
+it('RemoveReviewers has correct endpoint', function () {
+ $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], ['team1']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers');
+});
+
+it('RequestReviewers has correct endpoint', function () {
+ $request = new RequestReviewers('owner', 'repo', 123, ['user1'], ['team1']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers');
+});
+
+it('UpdatePullRequest has correct endpoint', function () {
+ $request = new UpdatePullRequest('owner', 'repo', 123, ['title' => 'Updated']);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123');
+});
+
+it('CreatePullRequestReview includes comments when provided', function () {
+ $comments = [
+ ['path' => 'file.php', 'line' => 10, 'body' => 'Fix this'],
+ ];
+ $request = new CreatePullRequestReview('owner', 'repo', 123, 'REQUEST_CHANGES', 'Please fix', $comments);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/reviews');
+});
+
+it('RequestReviewers handles empty team reviewers', function () {
+ $request = new RequestReviewers('owner', 'repo', 123, ['user1'], []);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers');
+});
+
+it('RemoveReviewers handles empty team reviewers', function () {
+ $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], []);
+
+ expect($request->resolveEndpoint())->toBe('/repos/owner/repo/pulls/123/requested_reviewers');
+});
+
+// Body method tests
+it('AddAssignees has correct body', function () {
+ $request = new AddAssignees('owner', 'repo', 123, ['user1', 'user2']);
+
+ expect($request->body()->all())->toBe(['assignees' => ['user1', 'user2']]);
+});
+
+it('RemoveAssignees has correct body', function () {
+ $request = new RemoveAssignees('owner', 'repo', 123, ['user1']);
+
+ expect($request->body()->all())->toBe(['assignees' => ['user1']]);
+});
+
+it('AddIssueLabels has correct body', function () {
+ $request = new AddIssueLabels('owner', 'repo', 123, ['bug', 'enhancement']);
+
+ expect($request->body()->all())->toBe(['labels' => ['bug', 'enhancement']]);
+});
+
+it('CreateIssueComment has correct body', function () {
+ $request = new CreateIssueComment('owner', 'repo', 123, 'Test comment');
+
+ expect($request->body()->all())->toBe(['body' => 'Test comment']);
+});
+
+it('CreatePullRequest has correct body', function () {
+ $data = ['title' => 'Test', 'head' => 'feature', 'base' => 'main'];
+ $request = new CreatePullRequest('owner', 'repo', $data);
+
+ expect($request->body()->all())->toBe($data);
+});
+
+it('CreatePullRequestComment has correct body', function () {
+ $request = new CreatePullRequestComment('owner', 'repo', 123, 'Test comment', 'file.php', 10);
+
+ expect($request->body()->all())->toBe([
+ 'body' => 'Test comment',
+ 'path' => 'file.php',
+ 'line' => 10,
+ ]);
+});
+
+it('MergePullRequest has correct body', function () {
+ $request = new MergePullRequest('owner', 'repo', 123, ['merge_method' => 'squash']);
+
+ expect($request->body()->all())->toBe(['merge_method' => 'squash']);
+});
+
+it('UpdatePullRequest has correct body', function () {
+ $request = new UpdatePullRequest('owner', 'repo', 123, ['title' => 'Updated']);
+
+ expect($request->body()->all())->toBe(['title' => 'Updated']);
+});
+
+it('RequestReviewers has correct body with reviewers', function () {
+ $request = new RequestReviewers('owner', 'repo', 123, ['user1', 'user2'], []);
+
+ expect($request->body()->all())->toBe(['reviewers' => ['user1', 'user2']]);
+});
+
+it('RequestReviewers has correct body with team reviewers', function () {
+ $request = new RequestReviewers('owner', 'repo', 123, [], ['team1']);
+
+ expect($request->body()->all())->toBe(['team_reviewers' => ['team1']]);
+});
+
+it('RequestReviewers has correct body with both', function () {
+ $request = new RequestReviewers('owner', 'repo', 123, ['user1'], ['team1']);
+
+ expect($request->body()->all())->toBe([
+ 'reviewers' => ['user1'],
+ 'team_reviewers' => ['team1'],
+ ]);
+});
+
+it('RemoveReviewers has correct body with reviewers', function () {
+ $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], []);
+
+ expect($request->body()->all())->toBe(['reviewers' => ['user1']]);
+});
+
+it('RemoveReviewers has correct body with team reviewers', function () {
+ $request = new RemoveReviewers('owner', 'repo', 123, [], ['team1']);
+
+ expect($request->body()->all())->toBe(['team_reviewers' => ['team1']]);
+});
+
+it('RemoveReviewers has correct body with both', function () {
+ $request = new RemoveReviewers('owner', 'repo', 123, ['user1'], ['team1']);
+
+ expect($request->body()->all())->toBe([
+ 'reviewers' => ['user1'],
+ 'team_reviewers' => ['team1'],
+ ]);
+});
+
+// Query and Headers tests
+it('GetIssueTimeline has correct query parameters', function () {
+ $request = new GetIssueTimeline('owner', 'repo', 123, 50, 2);
+
+ expect($request->query()->all())->toBe(['per_page' => 50, 'page' => 2]);
+});
+
+it('GetIssueTimeline has correct headers', function () {
+ $request = new GetIssueTimeline('owner', 'repo', 123);
+
+ expect($request->headers()->all())->toHaveKey('Accept')
+ ->and($request->headers()->get('Accept'))->toBe('application/vnd.github.mockingbird-preview+json');
+});
+
+it('GetPullRequestDiff has correct headers', function () {
+ $request = new GetPullRequestDiff('owner', 'repo', 123);
+
+ expect($request->headers()->all())->toHaveKey('Accept')
+ ->and($request->headers()->get('Accept'))->toBe('application/vnd.github.v3.diff');
+});