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

Commit cdc7820

Browse files
fix: harden workspace and agent concurrency safeguards
Add distributed workspace/cache locking and atomic workspace/runtime/metadata updates to prevent corruption during parallel runs. Improve GitHub API resilience and stale-label recovery so failed workers do not leave issues permanently wedged.
1 parent 7a7a773 commit cdc7820

File tree

12 files changed

+828
-191
lines changed

12 files changed

+828
-191
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ on:
99
branches:
1010
- "**"
1111

12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
1216
env:
1317
ETFS_PROJECT_NAME: ci
1418
APP_IMAGE: ghcr.io/${{ github.repository }}/app:ci

docs/devbook.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@ How do I solve recurring tasks and problems during development?
55

66
## Building the frontend
77

8-
- `bash bin/build-frontend.sh`
8+
- `mise run frontend`
99

1010

1111
## Updating all dependencies
1212

13-
- `composer update --with-dependencies`
14-
- `nvm use && npm update`
15-
- `php bin/console importmap:update`
13+
- PHP deps: `mise run composer update --with-dependencies`
14+
- Node deps: `mise run npm update` (or `mise run npm install`)
15+
- Importmap: `mise run console importmap:update`
1616

1717

1818
## Changing the database schema with migrations
1919

2020
- Create new or edit existing entities
21-
- Run `php bin/console make:migration`
21+
- Run `mise run console make:migration`
2222

2323

2424
## Connect to the local database
2525

26-
- `bash bin/connect-to-db.sh`
26+
- `mise run db`

src/GithubIntegration/Infrastructure/Service/GithubApiClient.php

Lines changed: 129 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@
1010
use DateTimeImmutable;
1111
use Psr\Log\LoggerInterface;
1212
use RuntimeException;
13+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
14+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
15+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
1316
use Symfony\Contracts\HttpClient\HttpClientInterface;
1417
use Symfony\Contracts\HttpClient\ResponseInterface;
1518

1619
class GithubApiClient
1720
{
18-
private const string BASE_URL = 'https://api.github.com';
21+
private const string BASE_URL = 'https://api.github.com';
22+
private const float DEFAULT_TIMEOUT_SECONDS = 15.0;
23+
private const float DEFAULT_MAX_DURATION_SECONDS = 30.0;
24+
private const int DEFAULT_GET_RETRY_ATTEMPTS = 3;
25+
private const int RETRY_BASE_DELAY_MS = 200;
26+
private const int RETRY_MAX_DELAY_MS = 2_000;
1927

2028
public function __construct(
2129
private readonly HttpClientInterface $httpClient,
@@ -32,7 +40,8 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
3240
$page = 1;
3341

3442
do {
35-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues", $token, [
43+
/** @var list<array<string, mixed>> $data */
44+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues", $token, [
3645
'query' => [
3746
'labels' => $labelName,
3847
'state' => 'open',
@@ -41,9 +50,6 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
4150
],
4251
]);
4352

44-
/** @var list<array<string, mixed>> $data */
45-
$data = $response->toArray();
46-
4753
foreach ($data as $issueData) {
4854
if (array_key_exists('pull_request', $issueData)) {
4955
continue;
@@ -74,16 +80,14 @@ public function getIssueComments(string $owner, string $repo, string $token, int
7480
$page = 1;
7581

7682
do {
77-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", $token, [
83+
/** @var list<array<string, mixed>> $data */
84+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", $token, [
7885
'query' => [
7986
'per_page' => 100,
8087
'page' => $page,
8188
],
8289
]);
8390

84-
/** @var list<array<string, mixed>> $data */
85-
$data = $response->toArray();
86-
8791
foreach ($data as $commentData) {
8892
/** @var array<string, mixed>|null $userData */
8993
$userData = $commentData['user'] ?? null;
@@ -104,9 +108,10 @@ public function getIssueComments(string $owner, string $repo, string $token, int
104108

105109
public function addLabelToIssue(string $owner, string $repo, string $token, int $issueNumber, string $labelName): void
106110
{
107-
$this->request('POST', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels", $token, [
111+
$response = $this->request('POST', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels", $token, [
108112
'json' => ['labels' => [$labelName]],
109113
]);
114+
$response->getContent();
110115

111116
$this->logger->info('Added label to issue', [
112117
'owner' => $owner,
@@ -121,15 +126,16 @@ public function removeLabelFromIssue(string $owner, string $repo, string $token,
121126
$encodedLabel = rawurlencode($labelName);
122127

123128
try {
124-
$this->request('DELETE', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels/{$encodedLabel}", $token);
129+
$response = $this->request('DELETE', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels/{$encodedLabel}", $token);
130+
$response->getContent();
125131

126132
$this->logger->info('Removed label from issue', [
127133
'owner' => $owner,
128134
'repo' => $repo,
129135
'issue' => $issueNumber,
130136
'label' => $labelName,
131137
]);
132-
} catch (\Symfony\Component\HttpClient\Exception\ClientException $e) {
138+
} catch (ClientExceptionInterface $e) {
133139
if ($e->getResponse()->getStatusCode() === 404) {
134140
$this->logger->debug('Label not present on issue, nothing to remove', [
135141
'issue' => $issueNumber,
@@ -146,20 +152,21 @@ public function removeLabelFromIssue(string $owner, string $repo, string $token,
146152
public function createLabel(string $owner, string $repo, string $token, string $labelName, string $color, string $description = ''): void
147153
{
148154
try {
149-
$this->request('POST', "/repos/{$owner}/{$repo}/labels", $token, [
155+
$response = $this->request('POST', "/repos/{$owner}/{$repo}/labels", $token, [
150156
'json' => [
151157
'name' => $labelName,
152158
'color' => $color,
153159
'description' => $description,
154160
],
155161
]);
162+
$response->getContent();
156163

157164
$this->logger->info('Created label', [
158165
'owner' => $owner,
159166
'repo' => $repo,
160167
'label' => $labelName,
161168
]);
162-
} catch (\Symfony\Component\HttpClient\Exception\ClientException $e) {
169+
} catch (ClientExceptionInterface $e) {
163170
if ($e->getResponse()->getStatusCode() === 422) {
164171
$this->logger->debug('Label already exists', [
165172
'owner' => $owner,
@@ -176,9 +183,10 @@ public function createLabel(string $owner, string $repo, string $token, string $
176183

177184
public function postIssueComment(string $owner, string $repo, string $token, int $issueNumber, string $body): void
178185
{
179-
$this->request('POST', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", $token, [
186+
$response = $this->request('POST', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", $token, [
180187
'json' => ['body' => $body],
181188
]);
189+
$response->getContent();
182190

183191
$this->logger->info('Posted comment on issue', [
184192
'owner' => $owner,
@@ -190,10 +198,8 @@ public function postIssueComment(string $owner, string $repo, string $token, int
190198

191199
public function getAuthenticatedUserLogin(string $token): string
192200
{
193-
$response = $this->request('GET', '/user', $token);
194-
195201
/** @var array<string, mixed> $data */
196-
$data = $response->toArray();
202+
$data = $this->requestJsonWithRetry('GET', '/user', $token);
197203

198204
return $this->stringVal($data, 'login');
199205
}
@@ -204,17 +210,15 @@ public function findOpenPullRequestForIssue(string $owner, string $repo, string
204210
$page = 1;
205211

206212
do {
207-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/pulls", $token, [
213+
/** @var list<array<string, mixed>> $data */
214+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/pulls", $token, [
208215
'query' => [
209216
'state' => 'open',
210217
'per_page' => 100,
211218
'page' => $page,
212219
],
213220
]);
214221

215-
/** @var list<array<string, mixed>> $data */
216-
$data = $response->toArray();
217-
218222
foreach ($data as $prData) {
219223
$body = $this->stringVal($prData, 'body');
220224
if (str_contains($body, $closesPattern)) {
@@ -247,16 +251,14 @@ public function getPullRequestReviewComments(string $owner, string $repo, string
247251
$page = 1;
248252

249253
do {
250-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/comments", $token, [
254+
/** @var list<array<string, mixed>> $data */
255+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/comments", $token, [
251256
'query' => [
252257
'per_page' => 100,
253258
'page' => $page,
254259
],
255260
]);
256261

257-
/** @var list<array<string, mixed>> $data */
258-
$data = $response->toArray();
259-
260262
foreach ($data as $commentData) {
261263
/** @var array<string, mixed>|null $userData */
262264
$userData = $commentData['user'] ?? null;
@@ -281,16 +283,14 @@ public function getPullRequestLatestCommitCommittedAt(string $owner, string $rep
281283
$page = 1;
282284

283285
do {
284-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/commits", $token, [
286+
/** @var list<array<string, mixed>> $data */
287+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/pulls/{$prNumber}/commits", $token, [
285288
'query' => [
286289
'per_page' => 100,
287290
'page' => $page,
288291
],
289292
]);
290293

291-
/** @var list<array<string, mixed>> $data */
292-
$data = $response->toArray();
293-
294294
foreach ($data as $commitData) {
295295
/** @var array<string, mixed>|null $commitMetadata */
296296
$commitMetadata = $commitData['commit'] ?? null;
@@ -324,18 +324,16 @@ public function getPullRequestLatestCommitCommittedAt(string $owner, string $rep
324324
public function getIssueByNumber(string $owner, string $repo, string $token, int $issueNumber): ?RawGithubIssue
325325
{
326326
try {
327-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}", $token);
328-
} catch (\Symfony\Component\HttpClient\Exception\ClientException $e) {
327+
/** @var array<string, mixed> $data */
328+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}", $token);
329+
} catch (ClientExceptionInterface $e) {
329330
if ($e->getResponse()->getStatusCode() === 404) {
330331
return null;
331332
}
332333

333334
throw $e;
334335
}
335336

336-
/** @var array<string, mixed> $data */
337-
$data = $response->toArray();
338-
339337
if (array_key_exists('pull_request', $data)) {
340338
return null;
341339
}
@@ -350,10 +348,8 @@ public function getIssueByNumber(string $owner, string $repo, string $token, int
350348

351349
public function getIssueState(string $owner, string $repo, string $token, int $issueNumber): string
352350
{
353-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}", $token);
354-
355351
/** @var array<string, mixed> $data */
356-
$data = $response->toArray();
352+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}", $token);
357353

358354
return $this->stringVal($data, 'state');
359355
}
@@ -364,16 +360,14 @@ public function getLabelAddedAt(string $owner, string $repo, string $token, int
364360
$page = 1;
365361

366362
do {
367-
$response = $this->request('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/events", $token, [
363+
/** @var list<array<string, mixed>> $data */
364+
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues/{$issueNumber}/events", $token, [
368365
'query' => [
369366
'per_page' => 100,
370367
'page' => $page,
371368
],
372369
]);
373370

374-
/** @var list<array<string, mixed>> $data */
375-
$data = $response->toArray();
376-
377371
foreach ($data as $event) {
378372
$eventType = $this->stringVal($event, 'event');
379373
/** @var array<string, mixed>|null $labelData */
@@ -469,6 +463,14 @@ private function parseDateTimeImmutable(string $isoDate): DateTimeImmutable
469463
*/
470464
private function request(string $method, string $path, string $token, array $options = []): ResponseInterface
471465
{
466+
if (!array_key_exists('timeout', $options)) {
467+
$options['timeout'] = self::DEFAULT_TIMEOUT_SECONDS;
468+
}
469+
470+
if (!array_key_exists('max_duration', $options)) {
471+
$options['max_duration'] = self::DEFAULT_MAX_DURATION_SECONDS;
472+
}
473+
472474
/** @var array<string, string> $existingHeaders */
473475
$existingHeaders = is_array($options['headers'] ?? null) ? $options['headers'] : [];
474476
$options['headers'] = array_merge($existingHeaders, [
@@ -479,4 +481,89 @@ private function request(string $method, string $path, string $token, array $opt
479481

480482
return $this->httpClient->request($method, self::BASE_URL . $path, $options);
481483
}
484+
485+
/**
486+
* @param array<string, mixed> $options
487+
*
488+
* @return array<mixed>
489+
*/
490+
private function requestJsonWithRetry(string $method, string $path, string $token, array $options = []): array
491+
{
492+
$maxAttempts = strtoupper($method) === 'GET' ? self::DEFAULT_GET_RETRY_ATTEMPTS : 1;
493+
$attempt = 0;
494+
495+
while (true) {
496+
++$attempt;
497+
498+
try {
499+
$response = $this->request($method, $path, $token, $options);
500+
501+
/** @var array<mixed> $data */
502+
$data = $response->toArray();
503+
504+
return $data;
505+
} catch (TransportExceptionInterface $e) {
506+
if ($attempt >= $maxAttempts) {
507+
throw $e;
508+
}
509+
510+
$this->logger->warning('GitHub API transport error, retrying', [
511+
'method' => $method,
512+
'path' => $path,
513+
'attempt' => $attempt,
514+
'error' => $e->getMessage(),
515+
]);
516+
517+
$this->sleepBeforeRetry($attempt);
518+
} catch (ServerExceptionInterface $e) {
519+
if ($attempt >= $maxAttempts) {
520+
throw $e;
521+
}
522+
523+
$this->logger->warning('GitHub API server error, retrying', [
524+
'method' => $method,
525+
'path' => $path,
526+
'attempt' => $attempt,
527+
'statusCode' => $e->getResponse()->getStatusCode(),
528+
'error' => $e->getMessage(),
529+
]);
530+
531+
$this->sleepBeforeRetry($attempt);
532+
} catch (ClientExceptionInterface $e) {
533+
$statusCode = $e->getResponse()->getStatusCode();
534+
535+
if ($attempt < $maxAttempts && $this->shouldRetryClientStatusCode($statusCode)) {
536+
$this->logger->warning('GitHub API client error, retrying', [
537+
'method' => $method,
538+
'path' => $path,
539+
'attempt' => $attempt,
540+
'statusCode' => $statusCode,
541+
'error' => $e->getMessage(),
542+
]);
543+
544+
$this->sleepBeforeRetry($attempt);
545+
continue;
546+
}
547+
548+
throw $e;
549+
}
550+
}
551+
}
552+
553+
private function shouldRetryClientStatusCode(int $statusCode): bool
554+
{
555+
return $statusCode === 429;
556+
}
557+
558+
private function sleepBeforeRetry(int $attempt): void
559+
{
560+
$delayMs = (int) min(
561+
self::RETRY_MAX_DELAY_MS,
562+
self::RETRY_BASE_DELAY_MS * (2 ** ($attempt - 1)),
563+
);
564+
565+
$jitterMs = random_int(0, max(1, (int) ($delayMs * 0.2)));
566+
567+
usleep(($delayMs + $jitterMs) * 1000);
568+
}
482569
}

0 commit comments

Comments
 (0)