Skip to content

Commit 72ae73b

Browse files
committed
fix: bound release pull request bodies
1 parent c8edf3f commit 72ae73b

11 files changed

Lines changed: 459 additions & 41 deletions

‎internal/orchestrator/constants.go‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,13 @@ const (
7272
// ReleaseBodyOutputFile is the generated markdown consumed by GitHub Releases.
7373
ReleaseBodyOutputFile = "RELEASE_BODY.md"
7474
// ReleaseNotesGitKeepPath is the placeholder file that keeps `.release-notes/` in git.
75-
ReleaseNotesGitKeepPath = ".release-notes/.gitkeep"
76-
changelogFilePath = "CHANGELOG.md"
77-
packageManifestPath = "package.json"
78-
packageLockFilePath = "package-lock.json"
79-
releasePRLabelPending = "release-pending"
80-
releasePRLabelAutomated = "automated"
81-
sagaStepSkipKey = "skip"
82-
releaseArtifactBun = "bun"
75+
ReleaseNotesGitKeepPath = ".release-notes/.gitkeep"
76+
changelogFilePath = "CHANGELOG.md"
77+
packageManifestPath = "package.json"
78+
packageLockFilePath = "package-lock.json"
79+
releasePRLabelPending = "release-pending"
80+
releasePRLabelAutomated = "automated"
81+
createPullRequestStepName = "Create Pull Request"
82+
sagaStepSkipKey = "skip"
83+
releaseArtifactBun = "bun"
8384
)

‎internal/orchestrator/pr_release.go‎

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"github.com/compozy/releasepr/internal/repository"
1414
"github.com/compozy/releasepr/internal/service"
1515
"github.com/compozy/releasepr/internal/usecase"
16-
"github.com/sethvargo/go-retry"
1716
"github.com/spf13/afero"
1817
"go.uber.org/zap"
1918
"golang.org/x/sync/errgroup"
@@ -459,14 +458,9 @@ func (o *PRReleaseOrchestrator) createPullRequest(
459458
}
460459
title := releasePullRequestTitle(version)
461460
labels := []string{releasePRLabelPending, releasePRLabelAutomated}
462-
// Create/Update PR with retry for network failures
463-
return retry.Do(
464-
ctx,
465-
retry.WithMaxRetries(DefaultRetryCount, retry.NewExponential(DefaultRetryDelay)),
466-
func(ctx context.Context) error {
467-
return o.githubRepo.CreateOrUpdatePR(ctx, branchName, "main", title, body, labels)
468-
},
469-
)
461+
return retryOperation(ctx, func(retryCtx context.Context) error {
462+
return o.githubRepo.CreateOrUpdatePR(retryCtx, branchName, "main", title, body, labels)
463+
})
470464
}
471465

472466
func releasePullRequestTitle(version string) string {
@@ -940,7 +934,7 @@ func (o *PRReleaseOrchestrator) addCreatePRStep(
940934
wctx *workflowContext,
941935
) {
942936
saga.AddStep(SagaStep{
943-
Name: "Create Pull Request",
937+
Name: createPullRequestStepName,
944938
Type: domain.OperationTypeCreatePR,
945939
Execute: func(ctx context.Context) (map[string]any, error) {
946940
if wctx.version == "" || cfg.SkipPR || cfg.DryRun {
@@ -972,13 +966,7 @@ func (o *PRReleaseOrchestrator) addCreatePRStep(
972966
zap.String("title", title),
973967
zap.Strings("labels", labels),
974968
)
975-
err = retry.Do(
976-
ctx,
977-
retry.WithMaxRetries(DefaultRetryCount, retry.NewExponential(DefaultRetryDelay)),
978-
func(ctx context.Context) error {
979-
return o.githubRepo.CreateOrUpdatePR(ctx, wctx.branchName, "main", title, body, labels)
980-
},
981-
)
969+
err = o.githubRepo.CreateOrUpdatePR(ctx, wctx.branchName, "main", title, body, labels)
982970
if err != nil {
983971
o.logger(ctx).Error("Failed to create or update PR", zap.Error(err))
984972
return nil, fmt.Errorf("failed to create or update PR from %s to main: %w", wctx.branchName, err)

‎internal/orchestrator/pr_release_test.go‎

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/compozy/releasepr/internal/config"
1111
"github.com/compozy/releasepr/internal/domain"
1212
"github.com/compozy/releasepr/internal/logger"
13+
"github.com/compozy/releasepr/internal/usecase"
1314
"github.com/spf13/afero"
1415
"github.com/stretchr/testify/assert"
1516
"github.com/stretchr/testify/mock"
@@ -208,6 +209,96 @@ func TestPRReleaseOrchestrator_releaseArtifactCommands(t *testing.T) {
208209
require.NoError(t, existsErr)
209210
assert.False(t, exists)
210211
})
212+
t.Run("Should restore archived release notes from newly produced rollback data", func(t *testing.T) {
213+
ctx := testReleaseContext(t)
214+
fsRepo := afero.NewMemMapFs()
215+
activePath := ".release-notes/a.md"
216+
archivedPath := ".release-notes/archive/v1.2.3/a.md"
217+
require.NoError(t, fsRepo.MkdirAll(".release-notes/archive/v1.2.3", 0755))
218+
require.NoError(t, afero.WriteFile(fsRepo, archivedPath, []byte("note"), 0644))
219+
require.NoError(t, afero.WriteFile(fsRepo, ReleaseNotesGitKeepPath, nil, 0644))
220+
gitRepo := new(mockGitExtendedRepository)
221+
gitRepo.On("MoveFile", mock.Anything, archivedPath, activePath).
222+
Run(func(mock.Arguments) {
223+
require.NoError(t, fsRepo.Rename(archivedPath, activePath))
224+
}).
225+
Return(nil).
226+
Once()
227+
compensator := NewCompensatingActions(gitRepo, new(mockGithubExtendedRepository), fsRepo)
228+
rollbackData := usecase.ArchiveReleaseNotesResult{
229+
Moves: []usecase.ArchivedReleaseNoteMove{
230+
{
231+
From: activePath,
232+
To: archivedPath,
233+
},
234+
},
235+
GitKeepCreated: true,
236+
}.ToRollbackData()
237+
238+
err := compensator.RestoreArchivedReleaseNotes(ctx, rollbackData)
239+
240+
require.NoError(t, err)
241+
activeExists, activeErr := afero.Exists(fsRepo, activePath)
242+
require.NoError(t, activeErr)
243+
assert.True(t, activeExists)
244+
archivedExists, archivedErr := afero.Exists(fsRepo, archivedPath)
245+
require.NoError(t, archivedErr)
246+
assert.False(t, archivedExists)
247+
gitKeepExists, gitKeepErr := afero.Exists(fsRepo, ReleaseNotesGitKeepPath)
248+
require.NoError(t, gitKeepErr)
249+
assert.False(t, gitKeepExists)
250+
gitRepo.AssertExpectations(t)
251+
})
252+
}
253+
254+
func TestPRReleaseOrchestrator_createPullRequest(t *testing.T) {
255+
t.Run("Should stop after one permanent GitHub client error", func(t *testing.T) {
256+
t.Parallel()
257+
githubRepo := new(mockGithubExtendedRepository)
258+
githubRepo.On(
259+
"CreateOrUpdatePR",
260+
mock.Anything,
261+
"release/v1.0.0",
262+
"main",
263+
"build: release v1.0.0",
264+
mock.Anything,
265+
mock.Anything,
266+
).Return(githubStatusError(422)).Once()
267+
orchestrator := &PRReleaseOrchestrator{githubRepo: githubRepo}
268+
269+
err := orchestrator.createPullRequest(t.Context(), "v1.0.0", "changelog", "", "release/v1.0.0")
270+
271+
require.Error(t, err)
272+
githubRepo.AssertExpectations(t)
273+
})
274+
t.Run("Should retry a transient GitHub server error", func(t *testing.T) {
275+
t.Parallel()
276+
githubRepo := new(mockGithubExtendedRepository)
277+
githubRepo.On(
278+
"CreateOrUpdatePR",
279+
mock.Anything,
280+
"release/v1.0.0",
281+
"main",
282+
"build: release v1.0.0",
283+
mock.Anything,
284+
mock.Anything,
285+
).Return(githubStatusError(502)).Once()
286+
githubRepo.On(
287+
"CreateOrUpdatePR",
288+
mock.Anything,
289+
"release/v1.0.0",
290+
"main",
291+
"build: release v1.0.0",
292+
mock.Anything,
293+
mock.Anything,
294+
).Return(nil).Once()
295+
orchestrator := &PRReleaseOrchestrator{githubRepo: githubRepo}
296+
297+
err := orchestrator.createPullRequest(t.Context(), "v1.0.0", "changelog", "", "release/v1.0.0")
298+
299+
require.NoError(t, err)
300+
githubRepo.AssertExpectations(t)
301+
})
211302
}
212303

213304
func TestPRReleaseOrchestrator_ExecuteReleaseArtifacts(t *testing.T) {
@@ -671,10 +762,8 @@ func TestPRReleaseOrchestrator_Execute(t *testing.T) {
671762
gitRepo.On("Commit", mock.Anything, mock.Anything).Return(nil).Once()
672763
gitRepo.On("PushBranch", mock.Anything, branchName).Return(nil).Once()
673764

674-
// Fail on PR creation (use mock.Anything for context)
675-
// Note: The retry might not be happening for non-retryable errors
676765
githubRepo.On("CreateOrUpdatePR", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
677-
Return(errors.New("GitHub API error")).
766+
Return(githubStatusError(422)).
678767
Once()
679768

680769
orch := NewPRReleaseOrchestrator(gitRepo, githubRepo, fsRepo, cliffSvc, npmSvc)
@@ -1086,8 +1175,10 @@ func TestPRReleaseOrchestrator_RollbackOnFailure(t *testing.T) {
10861175
// For file status checks during rollback
10871176
gitRepo.On("ListLocalBranches", mock.Anything).
10881177
Return([]string{"main", branchName}, nil).
1178+
Once()
1179+
gitRepo.On("ListLocalBranches", mock.Anything).
1180+
Return([]string{"main"}, nil).
10891181
Maybe()
1090-
// Check if branch exists
10911182
gitRepo.On("RemoteBranchExists", mock.Anything, branchName).
10921183
Return(true, nil).
10931184
Maybe()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package orchestrator
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
8+
"github.com/google/go-github/v74/github"
9+
"github.com/sethvargo/go-retry"
10+
)
11+
12+
func retryOperation(ctx context.Context, operation func(context.Context) error) error {
13+
strategy := retry.WithMaxRetries(DefaultRetryCount, retry.NewExponential(DefaultRetryDelay))
14+
return retry.Do(ctx, strategy, func(retryCtx context.Context) error {
15+
err := operation(retryCtx)
16+
if err == nil {
17+
return nil
18+
}
19+
return classifyRetryError(err)
20+
})
21+
}
22+
23+
func classifyRetryError(err error) error {
24+
if isPermanentRetryError(err) {
25+
return err
26+
}
27+
return retry.RetryableError(err)
28+
}
29+
30+
func isPermanentRetryError(err error) bool {
31+
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
32+
return true
33+
}
34+
var rateLimitError *github.RateLimitError
35+
if errors.As(err, &rateLimitError) {
36+
return false
37+
}
38+
var abuseRateLimitError *github.AbuseRateLimitError
39+
if errors.As(err, &abuseRateLimitError) {
40+
return false
41+
}
42+
var responseError *github.ErrorResponse
43+
if !errors.As(err, &responseError) || responseError.Response == nil {
44+
return false
45+
}
46+
statusCode := responseError.Response.StatusCode
47+
isClientError := statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError
48+
return isClientError && statusCode != http.StatusRequestTimeout && statusCode != http.StatusTooManyRequests
49+
}

‎internal/orchestrator/saga_executor.go‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/compozy/releasepr/internal/logger"
99
"github.com/compozy/releasepr/internal/repository"
1010
"github.com/google/uuid"
11-
"github.com/sethvargo/go-retry"
1211
"go.uber.org/zap"
1312
)
1413

@@ -115,8 +114,7 @@ func (s *SagaExecutor) executeStep(ctx context.Context, step SagaStep) error {
115114
}
116115
}
117116
var rollbackData map[string]any
118-
retryStrategy := retry.WithMaxRetries(DefaultRetryCount, retry.NewExponential(DefaultRetryDelay))
119-
err := retry.Do(ctx, retryStrategy, func(retryCtx context.Context) error {
117+
err := retryOperation(ctx, func(retryCtx context.Context) error {
120118
// Check if context is canceled before executing
121119
select {
122120
case <-retryCtx.Done():
@@ -125,7 +123,7 @@ func (s *SagaExecutor) executeStep(ctx context.Context, step SagaStep) error {
125123
}
126124
data, execErr := step.Execute(retryCtx)
127125
if execErr != nil {
128-
return retry.RetryableError(execErr)
126+
return execErr
129127
}
130128
rollbackData = data
131129
return nil
@@ -190,18 +188,14 @@ func (s *SagaExecutor) rollback(ctx context.Context) error {
190188

191189
// executeCompensation executes a compensating action with retry
192190
func (s *SagaExecutor) executeCompensation(ctx context.Context, step *SagaStep, rollbackData map[string]any) error {
193-
retryStrategy := retry.WithMaxRetries(DefaultRetryCount, retry.NewExponential(DefaultRetryDelay))
194-
return retry.Do(ctx, retryStrategy, func(retryCtx context.Context) error {
191+
return retryOperation(ctx, func(retryCtx context.Context) error {
195192
// Check if context is canceled
196193
select {
197194
case <-retryCtx.Done():
198195
return retryCtx.Err()
199196
default:
200197
}
201-
if err := step.Compensate(retryCtx, rollbackData); err != nil {
202-
return retry.RetryableError(err)
203-
}
204-
return nil
198+
return step.Compensate(retryCtx, rollbackData)
205199
})
206200
}
207201

0 commit comments

Comments
 (0)