Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions pkg/behaviourtest/steps/fork.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package steps

import (
"context"
"fmt"
"time"

"github.com/cucumber/godog"

"github.com/fullsend-ai/fullsend/pkg/behaviourtest/world"
)

func registerForkSteps(ctx *godog.ScenarioContext, w *world.World) {
ctx.Step(`^a fork "([^"]+)" of the enrolled test repository$`, func(forkName string) error {
return givenFork(w, forkName)
})
ctx.Step(`^a fork pull request is opened$`, func() error {
return whenForkPullRequestOpened(w)
})
ctx.Step(`^a commit is pushed to the fork pull request$`, func() error {
return whenCommitPushedToForkPR(w)
})
}

// givenFork creates a fork of the enrolled test repository if absent, or
// reuses it if it already exists. The fork is created within the same
// organization as the source repository.
func givenFork(w *world.World, forkName string) error {
if w.RepoOwner == "" || w.RepoName == "" {
w.RepoOwner = w.Org
w.RepoName = w.Install.TestRepo()
w.RepoFull = w.Org + "/" + w.RepoName
}

ctx := context.Background()
forkRepo, err := w.SCM.CreateFork(ctx, w.RepoOwner, w.RepoName, forkName)
if err != nil {
return fmt.Errorf("creating fork %q: %w", forkName, err)
}
w.ForkOwner = w.RepoOwner
w.ForkRepo = forkRepo
return nil
}

// whenForkPullRequestOpened commits a file to a new branch on the fork
// and opens a cross-fork pull request against the base repository.
func whenForkPullRequestOpened(w *world.World) error {
if w.ForkOwner == "" || w.ForkRepo == "" {
return fmt.Errorf("no fork created: use 'Given a fork' first")
}

w.ScenarioStart = time.Now()
branch := fmt.Sprintf("behaviour-fork-pr-%d", time.Now().UnixNano())

ctx := context.Background()
msg := fmt.Sprintf("behaviour fork pr %s", branch)
if err := w.SCM.CommitFileToFork(ctx, w.ForkOwner, w.ForkRepo, branch, "behaviour/fork-pr.txt", msg, []byte("behaviour fork test\n")); err != nil {
return fmt.Errorf("committing to fork branch: %w", err)
}

pr, err := w.SCM.CreateForkChangeProposal(ctx, w.RepoOwner, w.RepoName, "Behaviour fork test PR", "behaviour fork", w.ForkOwner, branch, "main")
if err != nil {
return fmt.Errorf("creating fork pull request: %w", err)
}
w.ForkPRNumber = pr.Number
w.ForkPRBranch = branch
return nil
}

// whenCommitPushedToForkPR pushes an additional commit to the head branch
// of an existing fork pull request.
func whenCommitPushedToForkPR(w *world.World) error {
if w.ForkPRNumber == 0 {
return fmt.Errorf("no fork pull request opened")
}

w.ScenarioStart = time.Now()
ctx := context.Background()

msg := fmt.Sprintf("behaviour: push to fork PR #%d", w.ForkPRNumber)
content := []byte(fmt.Sprintf("pushed at %d\n", time.Now().UnixNano()))
if err := w.SCM.CommitFileToFork(ctx, w.ForkOwner, w.ForkRepo, w.ForkPRBranch, "behaviour/fork-push.txt", msg, content); err != nil {
return fmt.Errorf("pushing commit to fork PR: %w", err)
}
return nil
}
237 changes: 237 additions & 0 deletions pkg/behaviourtest/steps/fork_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package steps

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/fullsend-ai/fullsend/internal/forge"
"github.com/fullsend-ai/fullsend/pkg/behaviourtest/world"
)

func TestGivenFork_SetsWorldState(t *testing.T) {
w := &world.World{
RepoOwner: "org",
RepoName: "repo",
SCM: &fakeForkSCM{forkRepo: "repo-fork"},
}
err := givenFork(w, "repo-fork")
require.NoError(t, err)
assert.Equal(t, "org", w.ForkOwner)
assert.Equal(t, "repo-fork", w.ForkRepo)
}

func TestGivenFork_AutoFillsRepoFromInstall(t *testing.T) {
w := &world.World{
Org: "auto-org",
Install: &fakeInstallState{testRepo: "auto-repo"},
SCM: &fakeForkSCM{forkRepo: "auto-repo-fork"},
}
err := givenFork(w, "auto-repo-fork")
require.NoError(t, err)
assert.Equal(t, "auto-org", w.RepoOwner)
assert.Equal(t, "auto-repo", w.RepoName)
assert.Equal(t, "auto-org/auto-repo", w.RepoFull)
assert.Equal(t, "auto-org", w.ForkOwner)
assert.Equal(t, "auto-repo-fork", w.ForkRepo)
}

func TestGivenFork_CreateForkError(t *testing.T) {
w := &world.World{
RepoOwner: "org",
RepoName: "repo",
SCM: &fakeForkSCM{createForkErr: fmt.Errorf("fork conflict")},
}
err := givenFork(w, "repo-fork")
require.Error(t, err)
assert.Contains(t, err.Error(), "creating fork")
assert.Contains(t, err.Error(), "fork conflict")
}

func TestWhenForkPullRequestOpened_RequiresFork(t *testing.T) {
w := &world.World{}
err := whenForkPullRequestOpened(w)
require.Error(t, err)
assert.Contains(t, err.Error(), "no fork created")
}

func TestWhenForkPullRequestOpened_CommitError(t *testing.T) {
w := &world.World{
ForkOwner: "org",
ForkRepo: "repo-fork",
RepoOwner: "org",
RepoName: "repo",
SCM: &fakeForkSCM{commitToForkErr: fmt.Errorf("commit failed")},
}
err := whenForkPullRequestOpened(w)
require.Error(t, err)
assert.Contains(t, err.Error(), "committing to fork branch")
}

func TestWhenForkPullRequestOpened_CreatePRError(t *testing.T) {
w := &world.World{
ForkOwner: "org",
ForkRepo: "repo-fork",
RepoOwner: "org",
RepoName: "repo",
SCM: &fakeForkSCM{createForkPRErr: fmt.Errorf("PR creation failed")},
}
err := whenForkPullRequestOpened(w)
require.Error(t, err)
assert.Contains(t, err.Error(), "creating fork pull request")
}

func TestWhenCommitPushedToForkPR_RequiresPR(t *testing.T) {
w := &world.World{}
err := whenCommitPushedToForkPR(w)
require.Error(t, err)
assert.Contains(t, err.Error(), "no fork pull request opened")
}

func TestWhenCommitPushedToForkPR_CommitError(t *testing.T) {
w := &world.World{
ForkPRNumber: 10,
ForkOwner: "org",
ForkRepo: "repo-fork",
ForkPRBranch: "test-branch",
SCM: &fakeForkSCM{commitToForkErr: fmt.Errorf("push failed")},
}
err := whenCommitPushedToForkPR(w)
require.Error(t, err)
assert.Contains(t, err.Error(), "pushing commit to fork PR")
}

// TestForkSteps_WorldStateTransitions verifies the full fork lifecycle:
// fork created -> PR opened -> commit pushed, checking world state after each step.
func TestForkSteps_WorldStateTransitions(t *testing.T) {
scmDriver := &fakeForkSCM{forkRepo: "test-repo-fork", prNumber: 42}
w := &world.World{
Org: "test-org",
RepoOwner: "test-org",
RepoName: "test-repo",
RepoFull: "test-org/test-repo",
SCM: scmDriver,
}

// Step 1: Given a fork
err := givenFork(w, "test-repo-fork")
require.NoError(t, err)
assert.Equal(t, "test-org", w.ForkOwner)
assert.Equal(t, "test-repo-fork", w.ForkRepo)
assert.True(t, scmDriver.createForkCalled, "CreateFork should have been called")

// Step 2: When a fork pull request is opened
err = whenForkPullRequestOpened(w)
require.NoError(t, err)
assert.Equal(t, 42, w.ForkPRNumber)
assert.NotEmpty(t, w.ForkPRBranch)
assert.False(t, w.ScenarioStart.IsZero())
assert.True(t, scmDriver.commitToForkCalled, "CommitFileToFork should have been called")
assert.True(t, scmDriver.createForkPRCalled, "CreateForkChangeProposal should have been called")

// Step 3: When a commit is pushed to the fork pull request
scmDriver.commitToForkCalled = false // reset to verify second call
err = whenCommitPushedToForkPR(w)
require.NoError(t, err)
assert.True(t, scmDriver.commitToForkCalled, "CommitFileToFork should have been called again")
}

// fakeInstallState implements install.State for fork step unit tests.
type fakeInstallState struct {
testRepo string
}

func (f *fakeInstallState) Mode() string { return "per-org" }
func (f *fakeInstallState) TestRepo() string { return f.testRepo }
func (f *fakeInstallState) ConfigOwner() string { return "" }
func (f *fakeInstallState) ConfigRepo() string { return "" }
func (f *fakeInstallState) ConfigPathPrefix() string { return "" }
func (f *fakeInstallState) TriageWorkflowRepo() string { return "" }
func (f *fakeInstallState) TriageWorkflowFile() string { return "" }
func (f *fakeInstallState) AgentWorkflowFile() string { return "" }
func (f *fakeInstallState) AgentArtifactName() string { return "" }

// fakeForkSCM implements scm.Driver for fork step unit tests.
type fakeForkSCM struct {
forkRepo string
prNumber int
createForkCalled bool
commitToForkCalled bool
createForkPRCalled bool
createForkErr error
commitToForkErr error
createForkPRErr error
}

func (f *fakeForkSCM) CreateFork(_ context.Context, _, _, _ string) (string, error) {
f.createForkCalled = true
if f.createForkErr != nil {
return "", f.createForkErr
}
return f.forkRepo, nil
}

func (f *fakeForkSCM) CommitFileToFork(_ context.Context, _, _, _, _, _ string, _ []byte) error {
f.commitToForkCalled = true
if f.commitToForkErr != nil {
return f.commitToForkErr
}
return nil
}

func (f *fakeForkSCM) CreateForkChangeProposal(_ context.Context, _, _, _, _, _, _, _ string) (*forge.ChangeProposal, error) {
f.createForkPRCalled = true
if f.createForkPRErr != nil {
return nil, f.createForkPRErr
}
return &forge.ChangeProposal{Number: f.prNumber, Head: "test-branch"}, nil
}

// Unused scm.Driver methods -- required for interface satisfaction.

func (f *fakeForkSCM) CreateIssue(context.Context, string, string, string, string, ...string) (*forge.Issue, error) {
return nil, nil
}

func (f *fakeForkSCM) AddIssueLabels(context.Context, string, string, int, ...string) error {
return nil
}

func (f *fakeForkSCM) AddComment(context.Context, string, string, int, string) (*forge.IssueComment, error) {
return nil, nil
}

func (f *fakeForkSCM) GetIssue(context.Context, string, string, int) (*forge.Issue, error) {
return nil, nil
}

func (f *fakeForkSCM) GetFileContent(context.Context, string, string, string) ([]byte, error) {
return nil, nil
}

func (f *fakeForkSCM) CommitFile(context.Context, string, string, string, string, []byte) error {
return nil
}

func (f *fakeForkSCM) CreateBranch(context.Context, string, string, string) error {
return nil
}

func (f *fakeForkSCM) CommitFileToBranch(context.Context, string, string, string, string, string, []byte) error {
return nil
}

func (f *fakeForkSCM) CreateChangeProposal(context.Context, string, string, string, string, string, string) (*forge.ChangeProposal, error) {
return nil, nil
}

func (f *fakeForkSCM) SubmitPullRequestReview(context.Context, string, string, int, string) error {
return nil
}

func (f *fakeForkSCM) CloseIssue(context.Context, string, string, int) error {
return nil
}
1 change: 1 addition & 0 deletions pkg/behaviourtest/steps/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ func Register(ctx *godog.ScenarioContext, w *world.World) {
registerDummyAgentSteps(ctx, w)
registerTriageSteps(ctx, w)
registerDispatchSteps(ctx, w)
registerForkSteps(ctx, w)
}
4 changes: 4 additions & 0 deletions pkg/behaviourtest/suite/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ func resetScenarioWorld(w *world.World) {
w.TriageTriggerEvent = ""
w.WorkflowRun = nil
w.ArtifactDir = ""
w.ForkOwner = ""
w.ForkRepo = ""
w.ForkPRNumber = 0
w.ForkPRBranch = ""
}

func tagNames(tags []*messages.PickleTag) []string {
Expand Down
8 changes: 8 additions & 0 deletions pkg/behaviourtest/suite/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ func TestResetScenarioWorld_ClearsSharedState(t *testing.T) {
DispatchAgent: "dispatch",
IssueNumber: 1,
ArtifactDir: "/tmp/x",
ForkOwner: "org",
ForkRepo: "repo-fork",
ForkPRNumber: 42,
ForkPRBranch: "branch",
}
resetScenarioWorld(w)
assert.Equal(t, 0, w.PRNumber)
assert.Equal(t, "", w.DispatchAgent)
assert.Equal(t, 0, w.IssueNumber)
assert.Equal(t, "", w.ArtifactDir)
assert.False(t, w.ScenarioStart.IsZero())
assert.Equal(t, "", w.ForkOwner)
assert.Equal(t, "", w.ForkRepo)
assert.Equal(t, 0, w.ForkPRNumber)
assert.Equal(t, "", w.ForkPRBranch)
}

func TestSkipErrorForTagNames(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/behaviourtest/world/world.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ type World struct {

DispatchAgent string
PRNumber int

// Fork context — set by fork step definitions.
ForkOwner string
ForkRepo string
ForkPRNumber int
ForkPRBranch string
}

const BehaviourScriptRepoPath = "behaviour/current-scenario.yaml"
Expand Down
Loading
Loading