diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 22463caa1c..d78c093b0a 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "strings" ) @@ -89,6 +90,54 @@ func IsNotFork(err error) bool { return errors.Is(err, ErrNotFork) } +// IsTransient reports whether err represents a transient failure that +// may succeed on retry. It checks for: +// - non-fast-forward race conditions (ErrNonFastForward) +// - forge-specific API errors that self-report transient-ness via the +// transientReporter interface (e.g., HTTP 429, 500–504) +// - HTTP client/network timeouts +// - unexpected connection closures (io.EOF, io.ErrUnexpectedEOF) +// +// Callers can use this to decide whether retrying an operation is +// worthwhile before falling back to a log-and-continue strategy. +func IsTransient(err error) bool { + if err == nil { + return false + } + if IsNonFastForward(err) { + return true + } + // Forge-specific error types (github.APIError, gitlab.APIError, + // jira.APIError) implement this interface to self-report whether + // the status code indicates a transient server-side failure. + type transientReporter interface { + IsTransient() bool + } + var te transientReporter + if errors.As(err, &te) { + return te.IsTransient() + } + // Context cancellation / deadline errors are not transient — + // they reflect caller intent, not a server-side failure. + // context.DeadlineExceeded implements Timeout() bool (returning + // true), so this guard must come before the Timeout() check. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } + // HTTP client timeout (e.g. net/http.Client.Timeout exceeded). + var timeout interface{ Timeout() bool } + if errors.As(err, &timeout) && timeout.Timeout() { + return true + } + // Unexpected connection closure — the server dropped the connection + // before a full response was read. Common under load or during + // transient GCP/GitHub infrastructure issues. + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + return false +} + // ErrNotSupported indicates that the forge implementation does not // support the requested operation. var ErrNotSupported = errors.New("operation not supported by this forge") diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 01b7704a2d..b2525231d5 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -80,6 +80,15 @@ func (e *APIError) Error() string { return s } +// IsTransient reports whether the API error represents a transient +// failure that may succeed on retry: server errors (500–504) and +// rate limits (429). This method satisfies the transientReporter +// interface used by forge.IsTransient. +func (e *APIError) IsTransient() bool { + return e.StatusCode == http.StatusTooManyRequests || + (e.StatusCode >= 500 && e.StatusCode <= 504) +} + // Unwrap returns sentinel errors for well-known API responses. // // ErrBranchProtected is intentionally NOT mapped here. Branch protection diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index bc62d3c0c9..12f3702e1b 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -2665,6 +2665,34 @@ func TestIsTransientStatus(t *testing.T) { } } +func TestAPIError_IsTransient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code int + want bool + }{ + {name: "429 rate limit", code: 429, want: true}, + {name: "500 internal server error", code: 500, want: true}, + {name: "502 bad gateway", code: 502, want: true}, + {name: "503 service unavailable", code: 503, want: true}, + {name: "504 gateway timeout", code: 504, want: true}, + {name: "200 OK", code: 200, want: false}, + {name: "401 unauthorized", code: 401, want: false}, + {name: "403 forbidden", code: 403, want: false}, + {name: "404 not found", code: 404, want: false}, + {name: "422 unprocessable entity", code: 422, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := &APIError{StatusCode: tt.code, Message: http.StatusText(tt.code)} + assert.Equal(t, tt.want, err.IsTransient()) + }) + } +} + func TestIsRetryable_PrimaryRateLimitAs403(t *testing.T) { // GitHub sometimes returns primary rate limits as 403 with body // containing "API rate limit exceeded" instead of 429. This must diff --git a/internal/forge/gitlab/gitlab.go b/internal/forge/gitlab/gitlab.go index f4ba2f3528..41c0a601f5 100644 --- a/internal/forge/gitlab/gitlab.go +++ b/internal/forge/gitlab/gitlab.go @@ -122,6 +122,15 @@ func (e *APIError) Error() string { return fmt.Sprintf("gitlab api: %d %s", e.StatusCode, e.Message) } +// IsTransient reports whether the API error represents a transient +// failure that may succeed on retry: server errors (500–504) and +// rate limits (429). This method satisfies the transientReporter +// interface used by forge.IsTransient. +func (e *APIError) IsTransient() bool { + return e.StatusCode == http.StatusTooManyRequests || + (e.StatusCode >= 500 && e.StatusCode <= 504) +} + func (e *APIError) Unwrap() error { if e.StatusCode == http.StatusNotFound { return forge.ErrNotFound diff --git a/internal/forge/jira/client.go b/internal/forge/jira/client.go index 2cabdb497c..9baaf36549 100644 --- a/internal/forge/jira/client.go +++ b/internal/forge/jira/client.go @@ -142,6 +142,15 @@ func (e *APIError) Error() string { return fmt.Sprintf("jira api: %d %s", e.StatusCode, e.Message) } +// IsTransient reports whether the API error represents a transient +// failure that may succeed on retry: server errors (500–504) and +// rate limits (429). This method satisfies the transientReporter +// interface used by forge.IsTransient. +func (e *APIError) IsTransient() bool { + return e.StatusCode == http.StatusTooManyRequests || + (e.StatusCode >= 500 && e.StatusCode <= 504) +} + func (e *APIError) Unwrap() error { if e.StatusCode == http.StatusNotFound { return forge.ErrNotFound diff --git a/internal/forge/transient_test.go b/internal/forge/transient_test.go new file mode 100644 index 0000000000..fd6b441670 --- /dev/null +++ b/internal/forge/transient_test.go @@ -0,0 +1,145 @@ +package forge + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +// fakeTransientErr implements the transientReporter interface used by +// forge.IsTransient to let forge-specific API errors self-report +// transient-ness. +type fakeTransientErr struct { + transient bool +} + +func (e *fakeTransientErr) Error() string { return "fake error" } +func (e *fakeTransientErr) IsTransient() bool { + return e.transient +} + +// fakeTimeoutErr implements the Timeout() interface to simulate HTTP +// client timeout errors. +type fakeTimeoutErr struct { + timeout bool +} + +func (e *fakeTimeoutErr) Error() string { return "timeout error" } +func (e *fakeTimeoutErr) Timeout() bool { return e.timeout } +func (e *fakeTimeoutErr) Temporary() bool { return e.timeout } + +func TestIsTransient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "ErrNonFastForward", + err: ErrNonFastForward, + want: true, + }, + { + name: "wrapped ErrNonFastForward", + err: fmt.Errorf("commit failed: %w", ErrNonFastForward), + want: true, + }, + { + name: "transient reporter true", + err: &fakeTransientErr{transient: true}, + want: true, + }, + { + name: "transient reporter false", + err: &fakeTransientErr{transient: false}, + want: false, + }, + { + name: "wrapped transient reporter", + err: fmt.Errorf("api call: %w", &fakeTransientErr{transient: true}), + want: true, + }, + { + name: "context.DeadlineExceeded is not transient", + err: context.DeadlineExceeded, + want: false, + }, + { + name: "wrapped context.DeadlineExceeded is not transient", + err: fmt.Errorf("timed out: %w", context.DeadlineExceeded), + want: false, + }, + { + name: "context.Canceled is not transient", + err: context.Canceled, + want: false, + }, + { + name: "wrapped context.Canceled is not transient", + err: fmt.Errorf("canceled: %w", context.Canceled), + want: false, + }, + { + name: "timeout error", + err: &fakeTimeoutErr{timeout: true}, + want: true, + }, + { + name: "non-timeout error with Timeout method", + err: &fakeTimeoutErr{timeout: false}, + want: false, + }, + { + name: "io.EOF", + err: io.EOF, + want: true, + }, + { + name: "wrapped io.EOF", + err: fmt.Errorf("read body: %w", io.EOF), + want: true, + }, + { + name: "io.ErrUnexpectedEOF", + err: io.ErrUnexpectedEOF, + want: true, + }, + { + name: "ErrNotFound is not transient", + err: ErrNotFound, + want: false, + }, + { + name: "ErrForbidden is not transient", + err: ErrForbidden, + want: false, + }, + { + name: "ErrBranchProtected is not transient", + err: ErrBranchProtected, + want: false, + }, + { + name: "generic error is not transient", + err: errors.New("something broke"), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsTransient(tt.err)) + }) + } +} diff --git a/pkg/behaviourtest/steps/cleanup.go b/pkg/behaviourtest/steps/cleanup.go index 686aa26bc9..da2546c8d7 100644 --- a/pkg/behaviourtest/steps/cleanup.go +++ b/pkg/behaviourtest/steps/cleanup.go @@ -6,24 +6,66 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/pkg/behaviourtest/world" ) +// cleanupMaxAttempts is the maximum number of attempts for a cleanup +// operation before giving up. Overridable in tests to avoid real waits. +var cleanupMaxAttempts = 3 + +// cleanupBaseDelay is the base delay between cleanup retry attempts. +// Actual delay doubles with each attempt (exponential backoff). +// Overridable in tests to avoid real waits. +var cleanupBaseDelay = 500 * time.Millisecond + +// cleanupRetry runs fn up to cleanupMaxAttempts times, retrying only on +// transient errors (as determined by forge.IsTransient). Non-transient +// errors and nil are returned immediately. When all retries are +// exhausted, the last error is returned so the caller can log it. +func cleanupRetry(logf func(string, ...any), desc string, fn func() error) error { + var lastErr error + for attempt := range cleanupMaxAttempts { + lastErr = fn() + if lastErr == nil { + return nil + } + if !forge.IsTransient(lastErr) { + return lastErr + } + if attempt < cleanupMaxAttempts-1 { + delay := cleanupBaseDelay * time.Duration(1< 0 { - if err := w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, w.IssueNumber); err != nil { - worldLogf(w, "behaviour cleanup: close issue #%d: %v", w.IssueNumber, err) + desc := fmt.Sprintf("close issue #%d", w.IssueNumber) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, w.IssueNumber) + }); err != nil { + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } if w.ForkPRNumber > 0 { // Fork PRs are opened against the base repo, so close on base repo. - if err := w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, w.ForkPRNumber); err != nil { - worldLogf(w, "behaviour cleanup: close fork PR #%d: %v", w.ForkPRNumber, err) + desc := fmt.Sprintf("close fork PR #%d", w.ForkPRNumber) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, w.ForkPRNumber) + }); err != nil { + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } @@ -41,8 +83,14 @@ func CleanupScenario(w *world.World) { for _, n := range w.CreatedPRNumbers { seenPR[n] = true } - if prs, err := w.SCM.ListOpenChangeProposals(ctx, w.RepoOwner, w.RepoName); err != nil { - worldLogf(w, "behaviour cleanup: list open PRs for namespace sweep: %v", err) + var prs []forge.ChangeProposal + desc := "list open PRs for namespace sweep" + if err := cleanupRetry(w.Logf, desc, func() error { + var listErr error + prs, listErr = w.SCM.ListOpenChangeProposals(ctx, w.RepoOwner, w.RepoName) + return listErr + }); err != nil { + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } else { for _, pr := range prs { if !strings.HasPrefix(pr.Head, namespacePrefix) || seenPR[pr.Number] { @@ -63,8 +111,11 @@ func CleanupScenario(w *world.World) { continue } closedPR[number] = true - if err := w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, number); err != nil { - worldLogf(w, "behaviour cleanup: close PR #%d: %v", number, err) + desc := fmt.Sprintf("close PR #%d", number) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.CloseIssue(ctx, w.RepoOwner, w.RepoName, number) + }); err != nil { + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } deletedBranch := make(map[string]bool, len(w.CreatedBranches)) @@ -73,9 +124,12 @@ func CleanupScenario(w *world.World) { continue } deletedBranch[branch] = true - if err := w.SCM.DeleteBranch(ctx, w.RepoOwner, w.RepoName, branch); err != nil { + desc := fmt.Sprintf("delete branch %s", branch) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.DeleteBranch(ctx, w.RepoOwner, w.RepoName, branch) + }); err != nil { if !forge.IsNotFound(err) { - worldLogf(w, "behaviour cleanup: delete branch %s: %v", branch, err) + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } } @@ -87,16 +141,22 @@ func CleanupScenario(w *world.World) { // still attempt branch deletion first so partial failures leave // less debris. if w.ForkPRBranch != "" && w.ForkOwner != "" && w.ForkRepo != "" { - if err := w.SCM.DeleteBranch(ctx, w.ForkOwner, w.ForkRepo, w.ForkPRBranch); err != nil { + desc := fmt.Sprintf("delete fork branch %s", w.ForkPRBranch) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.DeleteBranch(ctx, w.ForkOwner, w.ForkRepo, w.ForkPRBranch) + }); err != nil { if !forge.IsNotFound(err) { - worldLogf(w, "behaviour cleanup: delete fork branch %s: %v", w.ForkPRBranch, err) + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } } if w.ForkOwner != "" && w.ForkRepo != "" && w.ForkRepo != w.RepoName { - if err := w.SCM.DeleteRepo(ctx, w.ForkOwner, w.ForkRepo); err != nil { + desc := fmt.Sprintf("delete fork repo %s/%s", w.ForkOwner, w.ForkRepo) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.DeleteRepo(ctx, w.ForkOwner, w.ForkRepo) + }); err != nil { if !forge.IsNotFound(err) { - worldLogf(w, "behaviour cleanup: delete fork repo %s/%s: %v", w.ForkOwner, w.ForkRepo, err) + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } } @@ -106,9 +166,12 @@ func CleanupScenario(w *world.World) { // (same lifecycle as fork repos). Guard against deleting the enrolled // test repo itself. if w.URLHarnessRepoOwner != "" && w.URLHarnessRepoName != "" && w.URLHarnessRepoName != w.RepoName { - if err := w.SCM.DeleteRepo(ctx, w.URLHarnessRepoOwner, w.URLHarnessRepoName); err != nil { + desc := fmt.Sprintf("delete harness-hosting repo %s/%s", w.URLHarnessRepoOwner, w.URLHarnessRepoName) + if err := cleanupRetry(w.Logf, desc, func() error { + return w.SCM.DeleteRepo(ctx, w.URLHarnessRepoOwner, w.URLHarnessRepoName) + }); err != nil { if !forge.IsNotFound(err) { - worldLogf(w, "behaviour cleanup: delete harness-hosting repo %s/%s: %v", w.URLHarnessRepoOwner, w.URLHarnessRepoName, err) + worldLogf(w, "behaviour cleanup: %s: %v", desc, err) } } } @@ -136,7 +199,9 @@ func CleanupScenario(w *world.World) { // because the kill switch is a repo-level config that affects all // harnesses. if w.KillSwitchActivated { - if err := DeactivateKillSwitch(w); err != nil { + if err := cleanupRetry(w.Logf, "deactivate kill switch", func() error { + return DeactivateKillSwitch(w) + }); err != nil { worldLogf(w, "behaviour cleanup: deactivate kill switch: %v", err) } } @@ -146,8 +211,10 @@ func CleanupScenario(w *world.World) { if w.Org == "" || w.RepoName == "" { worldLogf(w, "behaviour cleanup: clear dummy script: no repo configured; call 'Given the enrolled test repository' first") } else { - empty := []byte("ops: []\n") - if err := w.SCM.CommitFile(ctx, w.Org, w.RepoName, w.BehaviourScriptPath(), "behaviour: clear dummy agent script", empty); err != nil { + if err := cleanupRetry(w.Logf, "clear dummy script", func() error { + empty := []byte("ops: []\n") + return w.SCM.CommitFile(ctx, w.Org, w.RepoName, w.BehaviourScriptPath(), "behaviour: clear dummy agent script", empty) + }); err != nil { worldLogf(w, "behaviour cleanup: clear dummy script: %v", err) } } diff --git a/pkg/behaviourtest/steps/cleanup_test.go b/pkg/behaviourtest/steps/cleanup_test.go index 0264f13c00..b17edeb588 100644 --- a/pkg/behaviourtest/steps/cleanup_test.go +++ b/pkg/behaviourtest/steps/cleanup_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -810,3 +811,241 @@ func TestCleanupScenario_BranchScenarioSweep_RunsWithoutBranchSteps(t *testing.T } assert.Contains(t, closed, 71) } + +// --- Retry helper tests --- + +func TestCleanupRetry_SucceedsImmediately(t *testing.T) { + speedUpCleanupRetries(t) + + calls := 0 + err := cleanupRetry(nil, "test-op", func() error { + calls++ + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 1, calls) +} + +func TestCleanupRetry_TransientThenSuccess(t *testing.T) { + speedUpCleanupRetries(t) + + calls := 0 + transientErr := &fakeTransientError{msg: "503 service unavailable"} + err := cleanupRetry(nil, "test-op", func() error { + calls++ + if calls < 3 { + return transientErr + } + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 3, calls) +} + +func TestCleanupRetry_TransientExhausted(t *testing.T) { + speedUpCleanupRetries(t) + + transientErr := &fakeTransientError{msg: "503 service unavailable"} + calls := 0 + var logged []string + logf := func(format string, args ...any) { + logged = append(logged, fmt.Sprintf(format, args...)) + } + + err := cleanupRetry(logf, "test-op", func() error { + calls++ + return transientErr + }) + assert.ErrorIs(t, err, transientErr) + assert.Equal(t, 3, calls) // default cleanupMaxAttempts + // Should have logged retry attempts (attempts 1 and 2, but not the last) + assert.Len(t, logged, 2) + assert.Contains(t, logged[0], "transient error") + assert.Contains(t, logged[0], "attempt 1/3") +} + +func TestCleanupRetry_NonTransientNoRetry(t *testing.T) { + speedUpCleanupRetries(t) + + nonTransientErr := fmt.Errorf("401 unauthorized") + calls := 0 + err := cleanupRetry(nil, "test-op", func() error { + calls++ + return nonTransientErr + }) + assert.ErrorIs(t, err, nonTransientErr) + assert.Equal(t, 1, calls, "non-transient error should not be retried") +} + +func TestCleanupScenario_RetriesTransientCloseIssue(t *testing.T) { + speedUpCleanupRetries(t) + + calls := 0 + transientErr := &fakeTransientError{msg: "503"} + scm := &fakeRetryCleanupSCM{ + closeIssueFn: func(_ context.Context, _, _ string, _ int) error { + calls++ + if calls == 1 { + return transientErr + } + return nil + }, + } + w := &world.World{ + RepoOwner: "org", + RepoName: "repo", + IssueNumber: 42, + SCM: scm, + } + CleanupScenario(w) + assert.Equal(t, 2, calls, "should have retried on transient error") +} + +func TestCleanupScenario_RetriesTransientCommitFile(t *testing.T) { + speedUpCleanupRetries(t) + + calls := 0 + transientErr := &fakeTransientError{msg: "503"} + scm := &fakeRetryCleanupSCM{ + commitFileFn: func(_ context.Context, _, _, _, _ string, _ []byte) error { + calls++ + if calls == 1 { + return transientErr + } + return nil + }, + } + w := &world.World{ + Org: "org", + RepoOwner: "org", + RepoName: "repo", + DummyOps: []runtime.BehaviourOperation{{Op: "echo", Args: "hello"}}, + SCM: scm, + } + CleanupScenario(w) + assert.Equal(t, 2, calls, "should have retried commit on transient error") +} + +// speedUpCleanupRetries sets cleanupBaseDelay to 1ms for the duration +// of the test so retries don't slow down the test suite. +func speedUpCleanupRetries(t *testing.T) { + t.Helper() + origDelay := cleanupBaseDelay + cleanupBaseDelay = 1 * time.Millisecond + t.Cleanup(func() { cleanupBaseDelay = origDelay }) +} + +// fakeTransientError implements the transientReporter interface +// that forge.IsTransient checks, making it report as transient. +type fakeTransientError struct { + msg string +} + +func (e *fakeTransientError) Error() string { return e.msg } +func (e *fakeTransientError) IsTransient() bool { return true } + +// fakeRetryCleanupSCM is an scm.Driver implementation that uses +// function callbacks for methods exercised in retry tests. Methods +// without callbacks return nil. +type fakeRetryCleanupSCM struct { + closeIssueFn func(ctx context.Context, owner, repo string, number int) error + commitFileFn func(ctx context.Context, owner, repo, path, msg string, content []byte) error + deleteRepoFn func(ctx context.Context, owner, repo string) error +} + +func (f *fakeRetryCleanupSCM) CloseIssue(ctx context.Context, owner, repo string, number int) error { + if f.closeIssueFn != nil { + return f.closeIssueFn(ctx, owner, repo, number) + } + return nil +} + +func (f *fakeRetryCleanupSCM) CommitFile(ctx context.Context, owner, repo, path, msg string, content []byte) error { + if f.commitFileFn != nil { + return f.commitFileFn(ctx, owner, repo, path, msg, content) + } + return nil +} + +func (f *fakeRetryCleanupSCM) DeleteRepo(ctx context.Context, owner, repo string) error { + if f.deleteRepoFn != nil { + return f.deleteRepoFn(ctx, owner, repo) + } + return nil +} + +func (f *fakeRetryCleanupSCM) DeleteBranch(context.Context, string, string, string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) ListOpenChangeProposals(context.Context, string, string) ([]forge.ChangeProposal, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) CreateIssue(context.Context, string, string, string, string, ...string) (*forge.Issue, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) AddIssueLabels(context.Context, string, string, int, ...string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) AddComment(context.Context, string, string, int, string) (*forge.IssueComment, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) GetIssue(context.Context, string, string, int) (*forge.Issue, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) GetFileContent(context.Context, string, string, string) ([]byte, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) CreateBranch(context.Context, string, string, string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) CommitFileToBranch(context.Context, string, string, string, string, string, []byte) error { + return nil +} + +func (f *fakeRetryCleanupSCM) CreateChangeProposal(context.Context, string, string, string, string, string, string) (*forge.ChangeProposal, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) SubmitPullRequestReview(context.Context, string, string, int, string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) CreateRepo(context.Context, string, string, string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) ListComments(context.Context, string, string, int) ([]forge.IssueComment, error) { + return nil, nil +} + +func (f *fakeRetryCleanupSCM) EnsureRepoPublic(context.Context, string, string) error { + return nil +} + +func (f *fakeRetryCleanupSCM) GetDefaultBranch(context.Context, string, string) (string, error) { + return "main", nil +} + +func (f *fakeRetryCleanupSCM) GetBranchRef(context.Context, string, string, string) (string, error) { + return "abc123", nil +} + +func (f *fakeRetryCleanupSCM) CreateFork(context.Context, string, string, string) (string, error) { + return "", nil +} + +func (f *fakeRetryCleanupSCM) CommitFileToFork(context.Context, string, string, string, string, string, []byte) error { + return nil +} + +func (f *fakeRetryCleanupSCM) CreateForkChangeProposal(context.Context, string, string, string, string, string, string, string, string) (*forge.ChangeProposal, error) { + return nil, nil +}