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

Commit 94b886a

Browse files
wip
1 parent a811f05 commit 94b886a

File tree

5 files changed

+238
-19
lines changed

5 files changed

+238
-19
lines changed

src/GithubIntegration/Facade/GithubIntegrationFacade.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ public function getPullRequestComments(string $githubUrl, string $githubToken, i
136136
return $allComments;
137137
}
138138

139+
public function getPullRequestLatestCommitCommittedAt(string $githubUrl, string $githubToken, int $prNumber): ?DateTimeImmutable
140+
{
141+
$parsed = GithubUrlParser::parse($githubUrl);
142+
143+
return $this->githubApiClient->getPullRequestLatestCommitCommittedAt(
144+
$parsed->owner,
145+
$parsed->repo,
146+
$githubToken,
147+
$prNumber,
148+
);
149+
}
150+
139151
private function rawCommentToDto(RawGithubComment $comment): GithubCommentDto
140152
{
141153
$createdAt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i:sP', $comment->createdAt);

src/GithubIntegration/Facade/GithubIntegrationFacadeInterface.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,10 @@ public function getOpenPullRequestForIssue(string $githubUrl, string $githubToke
5656
* @return list<GithubCommentDto>
5757
*/
5858
public function getPullRequestComments(string $githubUrl, string $githubToken, int $prNumber): array;
59+
60+
/**
61+
* Returns the newest commit committer timestamp in a pull request, or null
62+
* when the PR has no commits or no committer timestamps are available.
63+
*/
64+
public function getPullRequestLatestCommitCommittedAt(string $githubUrl, string $githubToken, int $prNumber): ?DateTimeImmutable;
5965
}

src/GithubIntegration/Infrastructure/Service/GithubApiClient.php

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,56 @@ public function getPullRequestReviewComments(string $owner, string $repo, string
275275
return $comments;
276276
}
277277

278+
public function getPullRequestLatestCommitCommittedAt(string $owner, string $repo, string $token, int $prNumber): ?DateTimeImmutable
279+
{
280+
$latestCommittedAt = null;
281+
$page = 1;
282+
283+
do {
284+
$response = $this->request('GET', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/commits", $token, [
285+
'query' => [
286+
'per_page' => 100,
287+
'page' => $page,
288+
],
289+
]);
290+
291+
/** @var list<array<string, mixed>> $data */
292+
$data = $response->toArray();
293+
294+
foreach ($data as $commitData) {
295+
/** @var array<string, mixed>|null $commitMetadata */
296+
$commitMetadata = $commitData['commit'] ?? null;
297+
if (!is_array($commitMetadata)) {
298+
continue;
299+
}
300+
301+
/** @var array<string, mixed>|null $committerMetadata */
302+
$committerMetadata = $commitMetadata['committer'] ?? null;
303+
if (!is_array($committerMetadata)) {
304+
continue;
305+
}
306+
307+
$committerDateRaw = $this->stringVal($committerMetadata, 'date');
308+
if ($committerDateRaw === '') {
309+
continue;
310+
}
311+
312+
$committedAt = $this->parseDateTimeImmutable($committerDateRaw);
313+
if ($latestCommittedAt === null || $committedAt > $latestCommittedAt) {
314+
$latestCommittedAt = $committedAt;
315+
}
316+
}
317+
318+
++$page;
319+
} while (count($data) === 100);
320+
321+
return $latestCommittedAt;
322+
}
323+
278324
public function getLabelAddedAt(string $owner, string $repo, string $token, int $issueNumber, string $labelName): ?DateTimeImmutable
279325
{
280-
$page = 1;
326+
$latestAddedAt = null;
327+
$page = 1;
281328

282329
do {
283330
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/events", $token, [
@@ -298,15 +345,21 @@ public function getLabelAddedAt(string $owner, string $repo, string $token, int
298345

299346
if ($eventType === 'labeled' && $eventLabelName === $labelName) {
300347
$createdAt = $this->stringVal($event, 'created_at');
301-
302-
return $this->parseDateTimeImmutable($createdAt);
348+
if ($createdAt === '') {
349+
continue;
350+
}
351+
352+
$labelAddedAt = $this->parseDateTimeImmutable($createdAt);
353+
if ($latestAddedAt === null || $labelAddedAt > $latestAddedAt) {
354+
$latestAddedAt = $labelAddedAt;
355+
}
303356
}
304357
}
305358

306359
++$page;
307360
} while (count($data) === 100);
308361

309-
return null;
362+
return $latestAddedAt;
310363
}
311364

312365
/**

src/TeamleaderAgent/Infrastructure/Handler/ProcessProductConfigHandler.php

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -173,33 +173,75 @@ private function resumeImplementation(string $githubUrl, string $githubToken, st
173173
$botLogin = $this->githubIntegrationFacade->getAuthenticatedUserLogin($githubToken);
174174
$prComments = $this->githubIntegrationFacade->getPullRequestComments($githubUrl, $githubToken, $pr->number);
175175

176-
if ($prComments === []) {
177-
$this->logger->info('[Teamleader] No PR comments found, skipping resume-implementation', [
176+
$latestNonAgentCommentAt = $this->findLatestNonAgentCommentAt($prComments, $botLogin);
177+
if ($latestNonAgentCommentAt === null) {
178+
$this->logger->info('[Teamleader] No non-agent PR comment found, skipping resume-implementation', [
178179
'issueNumber' => $issueNumber,
179180
'prNumber' => $pr->number,
181+
'botLogin' => $botLogin,
180182
]);
181183

182184
return;
183185
}
184186

185-
$latestComment = $prComments[count($prComments) - 1];
187+
$latestCommitCommittedAt = $this->githubIntegrationFacade->getPullRequestLatestCommitCommittedAt(
188+
$githubUrl,
189+
$githubToken,
190+
$pr->number,
191+
);
186192

187-
if ($latestComment->authorLogin === $botLogin) {
188-
$this->logger->info('[Teamleader] Latest PR comment is from the bot, skipping resume-implementation', [
189-
'issueNumber' => $issueNumber,
190-
'prNumber' => $pr->number,
191-
'latestAuthor' => $latestComment->authorLogin,
192-
'botLogin' => $botLogin,
193+
if ($latestCommitCommittedAt !== null) {
194+
$shouldResume = $latestNonAgentCommentAt > $latestCommitCommittedAt;
195+
196+
$this->logger->info('[Teamleader] PR feedback freshness check against latest commit', [
197+
'issueNumber' => $issueNumber,
198+
'prNumber' => $pr->number,
199+
'latestNonAgentCommentAt' => $latestNonAgentCommentAt->format('c'),
200+
'latestCommitCommittedAt' => $latestCommitCommittedAt->format('c'),
201+
'shouldResume' => $shouldResume,
193202
]);
194203

195-
return;
204+
if (!$shouldResume) {
205+
return;
206+
}
207+
} else {
208+
$implementationDoneAddedAt = $this->githubIntegrationFacade->getLabelAddedAt(
209+
$githubUrl,
210+
$githubToken,
211+
$issueNumber,
212+
GithubLabel::ImplementationDone,
213+
);
214+
215+
if ($implementationDoneAddedAt === null) {
216+
$this->logger->warning('[Teamleader] Latest commit and implementation-done label timestamps unavailable, skipping', [
217+
'issueNumber' => $issueNumber,
218+
'prNumber' => $pr->number,
219+
'latestNonAgentCommentAt' => $latestNonAgentCommentAt->format('c'),
220+
]);
221+
222+
return;
223+
}
224+
225+
$shouldResume = $latestNonAgentCommentAt > $implementationDoneAddedAt;
226+
227+
$this->logger->info('[Teamleader] PR feedback freshness fallback check against implementation-done label', [
228+
'issueNumber' => $issueNumber,
229+
'prNumber' => $pr->number,
230+
'latestNonAgentCommentAt' => $latestNonAgentCommentAt->format('c'),
231+
'implementationDoneAddedAt' => $implementationDoneAddedAt->format('c'),
232+
'shouldResume' => $shouldResume,
233+
]);
234+
235+
if (!$shouldResume) {
236+
return;
237+
}
196238
}
197239

198-
$this->logger->info('[Teamleader] PR feedback detected from non-bot user, resuming implementation', [
199-
'issueNumber' => $issueNumber,
200-
'prNumber' => $pr->number,
201-
'latestAuthor' => $latestComment->authorLogin,
202-
'botLogin' => $botLogin,
240+
$this->logger->info('[Teamleader] New PR feedback detected after code state, resuming implementation', [
241+
'issueNumber' => $issueNumber,
242+
'prNumber' => $pr->number,
243+
'latestNonAgentCommentAt' => $latestNonAgentCommentAt->format('c'),
244+
'latestCommitCommittedAt' => $latestCommitCommittedAt?->format('c'),
203245
]);
204246

205247
$this->githubIntegrationFacade->removeLabelFromIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::ImplementationDone);
@@ -222,6 +264,26 @@ private function resumeImplementation(string $githubUrl, string $githubToken, st
222264
]);
223265
}
224266

267+
/**
268+
* @param list<GithubCommentDto> $comments
269+
*/
270+
private function findLatestNonAgentCommentAt(array $comments, string $botLogin): ?DateTimeImmutable
271+
{
272+
$latest = null;
273+
274+
foreach ($comments as $comment) {
275+
if ($comment->authorLogin === $botLogin) {
276+
continue;
277+
}
278+
279+
if ($latest === null || $comment->createdAt > $latest) {
280+
$latest = $comment->createdAt;
281+
}
282+
}
283+
284+
return $latest;
285+
}
286+
225287
private function findLinkedPullRequest(string $githubUrl, string $githubToken, int $issueNumber): ?GithubPullRequestDto
226288
{
227289
$issueComments = $this->githubIntegrationFacade->getIssueComments($githubUrl, $githubToken, $issueNumber);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use App\GithubIntegration\Infrastructure\Service\GithubApiClient;
6+
use Psr\Log\NullLogger;
7+
use Symfony\Component\HttpClient\MockHttpClient;
8+
use Symfony\Component\HttpClient\Response\MockResponse;
9+
10+
describe('GithubApiClient', function (): void {
11+
it('returns the newest PR commit committer timestamp', function (): void {
12+
$httpClient = new MockHttpClient([
13+
new MockResponse(json_encode([
14+
[
15+
'commit' => [
16+
'committer' => [
17+
'date' => '2026-02-22T10:00:00Z',
18+
],
19+
],
20+
],
21+
[
22+
'commit' => [
23+
'committer' => [
24+
'date' => '2026-02-22T12:30:00Z',
25+
],
26+
],
27+
],
28+
[
29+
'commit' => [
30+
'committer' => [
31+
'date' => '2026-02-22T11:00:00Z',
32+
],
33+
],
34+
],
35+
], JSON_THROW_ON_ERROR)),
36+
]);
37+
38+
$client = new GithubApiClient($httpClient, new NullLogger());
39+
40+
$result = $client->getPullRequestLatestCommitCommittedAt('dx-tooling', 'sitebuilder-webapp', 'token', 123);
41+
42+
expect($result)->not()->toBeNull();
43+
expect($result?->format('c'))->toBe('2026-02-22T12:30:00+00:00');
44+
});
45+
46+
it('returns null when PR commits endpoint returns no commits', function (): void {
47+
$httpClient = new MockHttpClient([
48+
new MockResponse(json_encode([], JSON_THROW_ON_ERROR)),
49+
]);
50+
51+
$client = new GithubApiClient($httpClient, new NullLogger());
52+
53+
$result = $client->getPullRequestLatestCommitCommittedAt('dx-tooling', 'sitebuilder-webapp', 'token', 123);
54+
55+
expect($result)->toBeNull();
56+
});
57+
58+
it('returns the newest label-added timestamp for an issue label', function (): void {
59+
$httpClient = new MockHttpClient([
60+
new MockResponse(json_encode([
61+
[
62+
'event' => 'labeled',
63+
'label' => ['name' => 'prdb:implementation-done'],
64+
'created_at' => '2026-02-22T09:00:00Z',
65+
],
66+
[
67+
'event' => 'labeled',
68+
'label' => ['name' => 'prdb:implementation-done'],
69+
'created_at' => '2026-02-22T11:15:00Z',
70+
],
71+
[
72+
'event' => 'labeled',
73+
'label' => ['name' => 'prdb:planning-done'],
74+
'created_at' => '2026-02-22T12:00:00Z',
75+
],
76+
], JSON_THROW_ON_ERROR)),
77+
]);
78+
79+
$client = new GithubApiClient($httpClient, new NullLogger());
80+
81+
$result = $client->getLabelAddedAt('dx-tooling', 'sitebuilder-webapp', 'token', 321, 'prdb:implementation-done');
82+
83+
expect($result)->not()->toBeNull();
84+
expect($result?->format('c'))->toBe('2026-02-22T11:15:00+00:00');
85+
});
86+
});

0 commit comments

Comments
 (0)