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
65 changes: 65 additions & 0 deletions pkg/core/engine/scm.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ func (e *Engine) pushSCMCommits() error {

changedSCM := map[string][]string{}

inconclusiveSCM := e.inconclusiveSCMBranches()

allScm := map[string]map[string]*scm.ScmHandler{}
logrus.Infof("\n\n%s\n", strings.ToTitle("Pushing Git changes"))
logrus.Infof("%s\n\n", strings.Repeat("=", len("Pushing Git changes")+1))
Expand Down Expand Up @@ -169,6 +171,11 @@ func (e *Engine) pushSCMCommits() error {

scmHandler := *scmHandlerPtr

if slices.Contains(inconclusiveSCM[url], branch) {
logrus.Debugf("Not publishing branch %q to %q as at least one of its target(s) didn't run during this execution\n", branch, redact.URL(url))
continue
}

isRemoteBranchUpToDate, err := scmHandler.IsRemoteBranchUpToDate()
if err != nil {
errs = append(errs, fmt.Sprintf("checking remote branch status for %q on branch %q: %s", redact.URL(url), branch, err.Error()))
Expand Down Expand Up @@ -210,6 +217,7 @@ func (e *Engine) pruneSCMBranches() error {
errs := []string{}

allScm := e.getUniqueTargetSCMTargets()
inconclusiveSCM := e.inconclusiveSCMBranches()

logrus.Debugf("Cleaning working branches")

Expand All @@ -227,6 +235,16 @@ func (e *Engine) pruneSCMBranches() error {

_, workingBranch, targetBranch := scmHandler.GetBranches()

/*
Deleting a working branch closes the pull request associated with it so it
must only happen when Updatecli knows that the working branch isn't needed
anymore.
*/
if slices.Contains(inconclusiveSCM[url], branch) {
logrus.Debugf("Not cleaning working branch %q on %q as at least one of its target(s) didn't run during this execution\n", workingBranch, redact.URL(url))
continue
}

if workingBranch == targetBranch {
logrus.Debugf("Skipping cleaning working branch %q on %q (same as target branch)\n", workingBranch, redact.URL(url))
continue
Expand All @@ -252,6 +270,53 @@ func (e *Engine) pruneSCMBranches() error {
return nil
}

/*
inconclusiveSCMBranches returns, per repository url, the working branches having at least
one target which didn't run to completion during this execution, such as a target skipped
because its source failed.

In that situation Updatecli doesn't know which content the working branch should have, so
it must not publish nor delete it: doing so would remove the changes published by a
previous execution and close the associated pull request, which would then be reopened by
a later execution.
*/
func (e *Engine) inconclusiveSCMBranches() map[string][]string {
inconclusive := map[string][]string{}

for id := range e.Pipelines {
pipeline := e.Pipelines[id]

for id := range pipeline.Targets {
target := pipeline.Targets[id]

// Sanity check, skip if no SCM is configured
if target.Scm == nil {
continue
}

targetResult := ""
if target.Result != nil {
targetResult = target.Result.Result
}

// The target ran so we know the state that it expects
if targetResult == result.SUCCESS || targetResult == result.ATTENTION {
continue
}

s := *target.Scm
url := s.GetURL()
_, branch, _ := s.GetBranches()

if !slices.Contains(inconclusive[url], branch) {
inconclusive[url] = append(inconclusive[url], branch)
}
}
}

return inconclusive
}

// getUniqueTargetSCMTargets retrieves all the target scm configurations
func (e *Engine) getUniqueTargetSCMTargets() (result map[string]map[string]*scm.ScmHandler) {
for id := range e.Pipelines {
Expand Down
176 changes: 176 additions & 0 deletions pkg/core/engine/scm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package engine

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/updatecli/updatecli/pkg/core/pipeline"
"github.com/updatecli/updatecli/pkg/core/pipeline/scm"
"github.com/updatecli/updatecli/pkg/core/pipeline/target"
"github.com/updatecli/updatecli/pkg/core/result"
)

// mockPushScm implements the scm methods used while pushing pending git changes.
type mockPushScm struct {
scm.ScmHandler
url string
workingBranch string
targetBranch string
// remoteBranchUpToDate reports if the remote branch already contains the local commits
remoteBranchUpToDate bool

pushCounter int
cleanWorkingBranchCounter int
}

func (m *mockPushScm) GetURL() string {
return m.url
}

func (m *mockPushScm) GetBranches() (sourceBranch, workingBranch, targetBranch string) {
return m.targetBranch, m.workingBranch, m.targetBranch
}

func (m *mockPushScm) CleanWorkingBranch() (bool, error) {
m.cleanWorkingBranchCounter++
return true, nil
}

func (m *mockPushScm) IsRemoteBranchUpToDate() (bool, error) {
return m.remoteBranchUpToDate, nil
}

func (m *mockPushScm) Checkout() error {
return nil
}

func (m *mockPushScm) Push() (bool, error) {
m.pushCounter++
return true, nil
}

func TestPushSCMCommits(t *testing.T) {
testdata := []struct {
name string
// targetResult is the result of the only target attached to the scm
targetResult string
// expectedPushCounter reports how many times the working branch was published
expectedPushCounter int
}{
{
/*
All targets ran so Updatecli knows the content that the working branch
must have, such as a working branch reset because the change is not
needed anymore.
*/
name: "local branch of a fully executed pipeline is published",
targetResult: result.SUCCESS,
expectedPushCounter: 1,
},
{
/*
The target didn't run, for example because its source failed, so the
working branch doesn't hold the expected content.
Publishing it would remove the changes pushed by a previous execution and
would then close the associated pull request.
*/
name: "local branch is not published when a target didn't run",
targetResult: result.SKIPPED,
expectedPushCounter: 0,
},
{
name: "local branch is not published when a target failed",
targetResult: result.FAILURE,
expectedPushCounter: 0,
},
}

for _, tt := range testdata {
t.Run(tt.name, func(t *testing.T) {
mockScm := mockPushScm{
url: "https://github.com/updatecli/updatecli.git",
workingBranch: "updatecli_main",
targetBranch: "main",
remoteBranchUpToDate: false,
}

var scmHandler scm.ScmHandler = &mockScm

e := Engine{
Pipelines: []*pipeline.Pipeline{
{
Name: "test",
Targets: map[string]target.Target{
"default": {
Scm: &scmHandler,
Result: &result.Target{Result: tt.targetResult},
},
},
},
},
}

gotErr := e.pushSCMCommits()

require.NoError(t, gotErr)
assert.Equal(t, tt.expectedPushCounter, mockScm.pushCounter)
})
}
}

func TestPruneSCMBranches(t *testing.T) {
testdata := []struct {
name string
// targetResult is the result of the only target attached to the scm
targetResult string
// expectedCleanWorkingBranchCounter reports how many times the working branch cleanup ran
expectedCleanWorkingBranchCounter int
}{
{
name: "working branch of a fully executed pipeline can be cleaned",
targetResult: result.SUCCESS,
expectedCleanWorkingBranchCounter: 1,
},
{
/*
Deleting the working branch closes the pull request associated with it, so
it must not happen based on an execution which didn't run its target(s).
*/
name: "working branch is not cleaned when a target didn't run",
targetResult: result.SKIPPED,
expectedCleanWorkingBranchCounter: 0,
},
}

for _, tt := range testdata {
t.Run(tt.name, func(t *testing.T) {
mockScm := mockPushScm{
url: "https://github.com/updatecli/updatecli.git",
workingBranch: "updatecli_main",
targetBranch: "main",
}

var scmHandler scm.ScmHandler = &mockScm

e := Engine{
Pipelines: []*pipeline.Pipeline{
{
Name: "test",
Targets: map[string]target.Target{
"default": {
Scm: &scmHandler,
Result: &result.Target{Result: tt.targetResult},
},
},
},
},
}

gotErr := e.pruneSCMBranches()

require.NoError(t, gotErr)
assert.Equal(t, tt.expectedCleanWorkingBranchCounter, mockScm.cleanWorkingBranchCounter)
})
}
}
8 changes: 8 additions & 0 deletions pkg/core/pipeline/action/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ type Action struct {
Scm *scm.Scm
Handler ActionHandler
Report reports.Action
/*
Published is set to true when the action has been created or updated during the
current Updatecli execution.

Such an action must not be cleaned up by the same execution as the cleanup relies
on remote information which may not reflect what Updatecli just published.
*/
Published bool
}

// Validate ensures that an action configuration has required parameters.
Expand Down
39 changes: 30 additions & 9 deletions pkg/core/pipeline/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ func (p *Pipeline) RunActions(ctx context.Context) error {

isBranchReset = false

/*
The action has just been published so the remote information used by the clean
stage may not reflect it yet.
The clean stage is only meant to detect actions published by previous
Updatecli executions.
*/
action.Published = true

p.Actions[id] = action
}
return nil
Expand All @@ -242,15 +250,28 @@ func (p *Pipeline) RunCleanActions(ctx context.Context) error {
return nil
}

for _, action := range p.Actions {
if !p.Options.Target.DryRun {
if action.Handler != nil {
// At least we try to clean existing pullrequest
err := action.Handler.CleanAction(ctx, &action.Report)
if err != nil {
errs = append(errs, err.Error())
}
}
for id := range p.Actions {
action := p.Actions[id]

if p.Options.Target.DryRun {
continue
}

if action.Handler == nil {
continue
}

// An action published by the current execution must not be cleaned up by that
// same execution.
if action.Published {
logrus.Debugf("Action %q published during this execution, skipping its cleanup", id)
continue
}

// At least we try to clean existing pullrequest
err := action.Handler.CleanAction(ctx, &action.Report)
if err != nil {
errs = append(errs, err.Error())
}
}

Expand Down
Loading
Loading