Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 7851eab

Browse files
feat: enforce ownership on enabled issues and draft PRs
Auto-assign the token-authenticated GitHub user on open enabled issues and apply the same ownership policy when ProductBuilder opens PRs. PRs are now created as drafts and best-effort reviewer requests target the issue author when it is not a self-review case.
1 parent f7247a6 commit 7851eab

File tree

11 files changed

+781
-14
lines changed

11 files changed

+781
-14
lines changed

src/GithubIntegration/Facade/Dto/GithubIssueDto.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
{
99
/**
1010
* @param list<string> $labelNames
11+
* @param list<string> $assigneeLogins
1112
*/
1213
public function __construct(
1314
public int $number,
1415
public string $title,
1516
public string $body,
1617
public array $labelNames,
18+
public array $assigneeLogins = [],
19+
public string $authorLogin = '',
1720
) {
1821
}
1922
}

src/GithubIntegration/Facade/GithubIntegrationFacade.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public function getIssuesWithLabel(string $githubUrl, string $githubToken, Githu
3434
$issue->title,
3535
$issue->body,
3636
$issue->labels,
37+
$issue->assigneeLogins,
38+
$issue->authorLogin,
3739
),
3840
$rawIssues,
3941
);
@@ -54,6 +56,8 @@ public function getIssueByNumber(string $githubUrl, string $githubToken, int $is
5456
$rawIssue->title,
5557
$rawIssue->body,
5658
$rawIssue->labels,
59+
$rawIssue->assigneeLogins,
60+
$rawIssue->authorLogin,
5761
);
5862
}
5963

@@ -78,6 +82,19 @@ public function postIssueComment(string $githubUrl, string $githubToken, int $is
7882
$this->githubApiClient->postIssueComment($parsed->owner, $parsed->repo, $githubToken, $issueNumber, $body);
7983
}
8084

85+
public function addIssueAssignee(string $githubUrl, string $githubToken, int $issueNumber, string $assigneeLogin): void
86+
{
87+
$parsed = GithubUrlParser::parse($githubUrl);
88+
89+
$this->githubApiClient->addIssueAssignee(
90+
$parsed->owner,
91+
$parsed->repo,
92+
$githubToken,
93+
$issueNumber,
94+
$assigneeLogin,
95+
);
96+
}
97+
8198
public function ensureLabelsExist(string $githubUrl, string $githubToken): void
8299
{
83100
$parsed = GithubUrlParser::parse($githubUrl);
@@ -188,13 +205,27 @@ public function isIssueClosed(string $githubUrl, string $githubToken, int $issue
188205
return $state === 'closed';
189206
}
190207

208+
public function requestPullRequestReviewer(string $githubUrl, string $githubToken, int $prNumber, string $reviewerLogin): void
209+
{
210+
$parsed = GithubUrlParser::parse($githubUrl);
211+
212+
$this->githubApiClient->requestPullRequestReviewer(
213+
$parsed->owner,
214+
$parsed->repo,
215+
$githubToken,
216+
$prNumber,
217+
$reviewerLogin,
218+
);
219+
}
220+
191221
public function createPullRequest(
192222
string $githubUrl,
193223
string $githubToken,
194224
string $title,
195225
string $body,
196226
string $headBranch,
197227
?string $baseBranch = null,
228+
bool $draft = false,
198229
): GithubPullRequestDto {
199230
$parsed = GithubUrlParser::parse($githubUrl);
200231

@@ -206,6 +237,7 @@ public function createPullRequest(
206237
$body,
207238
$headBranch,
208239
$baseBranch,
240+
$draft,
209241
);
210242

211243
return new GithubPullRequestDto(

src/GithubIntegration/Facade/GithubIntegrationFacadeInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public function removeLabelFromIssue(string $githubUrl, string $githubToken, int
2828

2929
public function postIssueComment(string $githubUrl, string $githubToken, int $issueNumber, string $body): void;
3030

31+
public function addIssueAssignee(string $githubUrl, string $githubToken, int $issueNumber, string $assigneeLogin): void;
32+
3133
/**
3234
* Ensures all prdb:* labels exist in the repository. Idempotent.
3335
*/
@@ -79,6 +81,8 @@ public function getPullRequestLatestCommitCommittedAt(string $githubUrl, string
7981
*/
8082
public function isIssueClosed(string $githubUrl, string $githubToken, int $issueNumber): bool;
8183

84+
public function requestPullRequestReviewer(string $githubUrl, string $githubToken, int $prNumber, string $reviewerLogin): void;
85+
8286
/**
8387
* Creates a pull request. When $baseBranch is null, repository default branch is used.
8488
*/
@@ -89,5 +93,6 @@ public function createPullRequest(
8993
string $body,
9094
string $headBranch,
9195
?string $baseBranch = null,
96+
bool $draft = false,
9297
): GithubPullRequestDto;
9398
}

src/GithubIntegration/Infrastructure/Service/Dto/RawGithubIssue.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
{
99
/**
1010
* @param list<string> $labels
11+
* @param list<string> $assigneeLogins
1112
*/
1213
public function __construct(
1314
public int $number,
1415
public string $title,
1516
public string $body,
1617
public array $labels,
18+
public array $assigneeLogins,
19+
public string $authorLogin,
1720
) {
1821
}
1922
}

src/GithubIntegration/Infrastructure/Service/GithubApiClient.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
6262
$this->stringVal($issueData, 'title'),
6363
$this->stringVal($issueData, 'body'),
6464
$labelNames,
65+
$this->extractAssigneeLogins($issueData),
66+
$this->extractAuthorLogin($issueData),
6567
);
6668
}
6769

@@ -196,6 +198,36 @@ public function postIssueComment(string $owner, string $repo, string $token, int
196198
]);
197199
}
198200

201+
public function addIssueAssignee(string $owner, string $repo, string $token, int $issueNumber, string $assigneeLogin): void
202+
{
203+
$response = $this->request('POST', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/assignees", $token, [
204+
'json' => ['assignees' => [$assigneeLogin]],
205+
]);
206+
$response->getContent();
207+
208+
$this->logger->info('Added issue assignee', [
209+
'owner' => $owner,
210+
'repo' => $repo,
211+
'issue' => $issueNumber,
212+
'assignee' => $assigneeLogin,
213+
]);
214+
}
215+
216+
public function requestPullRequestReviewer(string $owner, string $repo, string $token, int $prNumber, string $reviewerLogin): void
217+
{
218+
$response = $this->request('POST', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/requested_reviewers", $token, [
219+
'json' => ['reviewers' => [$reviewerLogin]],
220+
]);
221+
$response->getContent();
222+
223+
$this->logger->info('Requested pull request reviewer', [
224+
'owner' => $owner,
225+
'repo' => $repo,
226+
'prNumber' => $prNumber,
227+
'reviewer' => $reviewerLogin,
228+
]);
229+
}
230+
199231
public function getAuthenticatedUserLogin(string $token): string
200232
{
201233
/** @var array<string, mixed> $data */
@@ -343,6 +375,8 @@ public function getIssueByNumber(string $owner, string $repo, string $token, int
343375
$this->stringVal($data, 'title'),
344376
$this->stringVal($data, 'body'),
345377
$this->extractLabelNames($data),
378+
$this->extractAssigneeLogins($data),
379+
$this->extractAuthorLogin($data),
346380
);
347381
}
348382

@@ -362,6 +396,7 @@ public function createPullRequest(
362396
string $body,
363397
string $headBranch,
364398
?string $baseBranch = null,
399+
bool $draft = false,
365400
): RawGithubPullRequest {
366401
$base = $baseBranch;
367402
if ($base === null || trim($base) === '') {
@@ -375,6 +410,7 @@ public function createPullRequest(
375410
'body' => $body,
376411
'head' => $headBranch,
377412
'base' => $base,
413+
'draft' => $draft,
378414
],
379415
]);
380416

@@ -528,6 +564,49 @@ private function extractLabelNames(array $data): array
528564
return $names;
529565
}
530566

567+
/**
568+
* @param array<string, mixed> $data
569+
*
570+
* @return list<string>
571+
*/
572+
private function extractAssigneeLogins(array $data): array
573+
{
574+
$assigneesRaw = $data['assignees'] ?? [];
575+
if (!is_array($assigneesRaw)) {
576+
return [];
577+
}
578+
579+
$assigneeLogins = [];
580+
foreach ($assigneesRaw as $assigneeData) {
581+
if (!is_array($assigneeData)) {
582+
continue;
583+
}
584+
/** @var array<string, mixed> $assigneeData */
585+
$login = $this->stringVal($assigneeData, 'login');
586+
if ($login === '') {
587+
continue;
588+
}
589+
$assigneeLogins[] = $login;
590+
}
591+
592+
return array_values(array_unique($assigneeLogins));
593+
}
594+
595+
/**
596+
* @param array<string, mixed> $data
597+
*/
598+
private function extractAuthorLogin(array $data): string
599+
{
600+
$userRaw = $data['user'] ?? null;
601+
if (!is_array($userRaw)) {
602+
return '';
603+
}
604+
605+
$login = $userRaw['login'] ?? null;
606+
607+
return is_string($login) ? $login : '';
608+
}
609+
531610
private function parseDateTimeImmutable(string $isoDate): DateTimeImmutable
532611
{
533612
$result = DateTimeImmutable::createFromFormat(DATE_RFC3339_EXTENDED, $isoDate);

src/ImplementationAgent/Infrastructure/Handler/ImplementIssueHandler.php

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ private function doInvoke(ImplementIssueMessage $message, ProductConfigDto $conf
179179
$config->githubUrl,
180180
$config->githubToken,
181181
$message->issueNumber,
182+
$issueDto,
182183
$message->prNumber,
183184
);
184185
} catch (Throwable $e) {
@@ -313,17 +314,23 @@ private function handleOutcome(
313314
string $githubUrl,
314315
string $githubToken,
315316
int $issueNumber,
317+
GithubIssueDto $issueDto,
316318
?int $prNumber = null,
317319
): void {
318320
match ($outcome) {
319-
ImplementationOutcome::PrCreated => $this->handlePrCreated($resultText, $githubUrl, $githubToken, $issueNumber),
321+
ImplementationOutcome::PrCreated => $this->handlePrCreated($resultText, $githubUrl, $githubToken, $issueNumber, $issueDto),
320322
ImplementationOutcome::RevisionPushed => $this->handleRevisionPushed($githubUrl, $githubToken, $issueNumber, $prNumber),
321323
ImplementationOutcome::Error => $this->handleError($resultText, $githubUrl, $githubToken, $issueNumber),
322324
};
323325
}
324326

325-
private function handlePrCreated(string $resultText, string $githubUrl, string $githubToken, int $issueNumber): void
326-
{
327+
private function handlePrCreated(
328+
string $resultText,
329+
string $githubUrl,
330+
string $githubToken,
331+
int $issueNumber,
332+
GithubIssueDto $issueDto,
333+
): void {
327334
$metadata = ImplementationOutcome::extractPrMetadata($resultText);
328335
if ($metadata === null) {
329336
$this->handleError(
@@ -348,9 +355,20 @@ private function handlePrCreated(string $resultText, string $githubUrl, string $
348355
$metadata->title,
349356
$body,
350357
$metadata->headBranch,
358+
null,
359+
true,
351360
);
352361
$prUrl = $pullRequest->htmlUrl;
353362

363+
$tokenUserLogin = $this->ensureIssueAssigneeForTokenUser($githubUrl, $githubToken, $issueNumber);
364+
$this->requestIssueAuthorAsReviewer(
365+
$githubUrl,
366+
$githubToken,
367+
$pullRequest->number,
368+
$issueDto->authorLogin,
369+
$tokenUserLogin,
370+
);
371+
354372
$this->githubIntegrationFacade->postIssueComment(
355373
$githubUrl,
356374
$githubToken,
@@ -365,6 +383,7 @@ private function handlePrCreated(string $resultText, string $githubUrl, string $
365383
$this->logger->info('[ImplementationAgent] PR opened by ProductBuilder and posted', [
366384
'issueNumber' => $issueNumber,
367385
'prUrl' => $prUrl,
386+
'isDraft' => true,
368387
]);
369388
}
370389

@@ -423,4 +442,66 @@ private function applyErrorLabels(string $githubUrl, string $githubToken, int $i
423442
$this->githubIntegrationFacade->removeLabelFromIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::CoordinationOngoing);
424443
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::ImplementationErrored);
425444
}
445+
446+
private function ensureIssueAssigneeForTokenUser(string $githubUrl, string $githubToken, int $issueNumber): ?string
447+
{
448+
try {
449+
$tokenUserLogin = $this->githubIntegrationFacade->getAuthenticatedUserLogin($githubToken);
450+
if ($tokenUserLogin === '') {
451+
return null;
452+
}
453+
454+
$this->githubIntegrationFacade->addIssueAssignee($githubUrl, $githubToken, $issueNumber, $tokenUserLogin);
455+
456+
$this->logger->info('[ImplementationAgent] Ensured token user assignee for issue before/after PR creation', [
457+
'issueNumber' => $issueNumber,
458+
'assignee' => $tokenUserLogin,
459+
]);
460+
461+
return $tokenUserLogin;
462+
} catch (Throwable $e) {
463+
$this->logger->warning('[ImplementationAgent] Failed to ensure token user assignee, continuing', [
464+
'issueNumber' => $issueNumber,
465+
'error' => $e->getMessage(),
466+
]);
467+
468+
return null;
469+
}
470+
}
471+
472+
private function requestIssueAuthorAsReviewer(
473+
string $githubUrl,
474+
string $githubToken,
475+
int $prNumber,
476+
string $issueAuthorLogin,
477+
?string $tokenUserLogin,
478+
): void {
479+
if ($issueAuthorLogin === '') {
480+
return;
481+
}
482+
483+
if ($tokenUserLogin !== null && strcasecmp($issueAuthorLogin, $tokenUserLogin) === 0) {
484+
return;
485+
}
486+
487+
try {
488+
$this->githubIntegrationFacade->requestPullRequestReviewer(
489+
$githubUrl,
490+
$githubToken,
491+
$prNumber,
492+
$issueAuthorLogin,
493+
);
494+
495+
$this->logger->info('[ImplementationAgent] Requested issue author as PR reviewer', [
496+
'prNumber' => $prNumber,
497+
'reviewer' => $issueAuthorLogin,
498+
]);
499+
} catch (Throwable $e) {
500+
$this->logger->warning('[ImplementationAgent] Failed to request issue author as PR reviewer, continuing', [
501+
'prNumber' => $prNumber,
502+
'reviewer' => $issueAuthorLogin,
503+
'error' => $e->getMessage(),
504+
]);
505+
}
506+
}
426507
}

0 commit comments

Comments
 (0)