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
59 changes: 59 additions & 0 deletions internal/forge/bitbucket/checks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package bitbucket

import (
"context"
"fmt"

"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/gateway/bitbucket"
)

// ChangeChecksState reports the aggregate build status
// for the given pull request.
func (r *Repository) ChangeChecksState(
ctx context.Context, fid forge.ChangeID,
) (forge.ChecksState, error) {
id := mustPR(fid)
pr, err := r.getPullRequest(ctx, id.Number)
if err != nil {
return 0, fmt.Errorf("get pull request: %w", err)
}

if pr.Source.Commit == nil {
return forge.ChecksPassed, nil
}
return r.commitChecksState(ctx, pr.Source.Commit.Hash)
}

func (r *Repository) commitChecksState(
ctx context.Context, commitHash string,
) (forge.ChecksState, error) {
statuses, _, err := r.client.CommitStatusList(
ctx, r.workspace, r.repo, commitHash,
)
if err != nil {
return 0, fmt.Errorf("get commit statuses: %w", err)
}

return aggregateStatuses(statuses.Values), nil
}

func aggregateStatuses(
statuses []bitbucket.CommitStatus,
) forge.ChecksState {
if len(statuses) == 0 {
return forge.ChecksPassed // no checks configured
}

for _, s := range statuses {
switch s.State {
case bitbucket.CommitStatusFailed,
bitbucket.CommitStatusStopped:
return forge.ChecksFailed
case bitbucket.CommitStatusInProgress:
return forge.ChecksPending
}
}

return forge.ChecksPassed
}
18 changes: 18 additions & 0 deletions internal/forge/bitbucket/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ func TestIntegration(t *testing.T) {
change.(*bitbucket.PR),
))
},
// TODO: record fixtures for BitBucket
//
// SetChangeChecksState: func(
// t *testing.T,
// _ *http.Client,
// repo forge.Repository,
// _ forge.ChangeID,
// headHash git.Hash,
// state forge.ChecksState,
// ) {
// require.NoError(t,
// bitbucket.SetChangeChecksState(
// t.Context(),
// repo.(*bitbucket.Repository),
// headHash,
// state,
// ))
// },
SetCommentsPageSize: bitbucket.SetListChangeCommentsPageSize,
Reviewers: []string{cfg.Reviewer},
Assignees: []string{},
Expand Down
27 changes: 27 additions & 0 deletions internal/forge/bitbucket/merge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package bitbucket

import (
"context"
"fmt"

"go.abhg.dev/gs/internal/forge"
)

// MergeChange merges an open pull request into its base branch.
//
// Bitbucket does not support expected-SHA assertions;
// opts.HeadHash is ignored.
func (r *Repository) MergeChange(
ctx context.Context, fid forge.ChangeID,
_ forge.MergeChangeOptions,
) error {
id := mustPR(fid)
if _, _, err := r.client.PullRequestMerge(
ctx, r.workspace, r.repo, id.Number,
); err != nil {
return fmt.Errorf("merge pull request: %w", err)
}

r.log.Debug("Merged pull request", "pr", id.Number)
return nil
}
42 changes: 42 additions & 0 deletions internal/forge/bitbucket/repository_int_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import (
"context"
"fmt"
"net/http"

"go.abhg.dev/gs/internal/forge"
bitbucketapi "go.abhg.dev/gs/internal/gateway/bitbucket"
"go.abhg.dev/gs/internal/git"
)

func prActionPath(
Expand Down Expand Up @@ -36,6 +40,44 @@ func MergeChange(
return nil
}

// SetChangeChecksState sets a synthetic build status
// for integration tests.
func SetChangeChecksState(
ctx context.Context,
repo *Repository,
headHash git.Hash,
state forge.ChecksState,
) error {
_, _, err := repo.client.CommitStatusCreate(
ctx,
repo.workspace,
repo.repo,
headHash.String(),
&bitbucketapi.CommitStatusCreateRequest{
Key: "git-spice-integration",
State: bitbucketStatusState(state),
Description: "Synthetic status for git-spice integration tests",
},
)
if err != nil {
return fmt.Errorf("set commit status: %w", err)
}
return nil
}

func bitbucketStatusState(state forge.ChecksState) string {
switch state {
case forge.ChecksPending:
return bitbucketapi.CommitStatusInProgress
case forge.ChecksPassed:
return bitbucketapi.CommitStatusSuccessful
case forge.ChecksFailed:
return bitbucketapi.CommitStatusFailed
default:
return bitbucketapi.CommitStatusFailed
}
}

func approvePR(
ctx context.Context, repo *Repository, id *PR,
) error {
Expand Down
69 changes: 69 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,21 @@ type Repository interface {
SubmitChange(ctx context.Context, req SubmitChangeRequest) (SubmitChangeResult, error)

EditChange(ctx context.Context, id ChangeID, opts EditChangeOptions) error

// MergeChange merges an open change into its base branch.
MergeChange(ctx context.Context, id ChangeID, opts MergeChangeOptions) error

FindChangesByBranch(ctx context.Context, branch string, opts FindChangesOptions) ([]*FindChangeItem, error)
FindChangeByID(ctx context.Context, id ChangeID) (*FindChangeItem, error)
ChangeStatuses(ctx context.Context, ids []ChangeID) ([]ChangeStatus, error)

// ChangeChecksState reports the aggregate CI/checks
// state for the given change.
//
// If the forge has no CI/checks integration
// or the change has no required checks,
// implementations should return ChecksPassed.
ChangeChecksState(ctx context.Context, id ChangeID) (ChecksState, error)
CommentCountsByChange(ctx context.Context, ids []ChangeID) ([]*CommentCounts, error)

// Post, update, and delete comments on changes.
Expand Down Expand Up @@ -430,6 +442,18 @@ type EditChangeOptions struct {
AddAssignees []string
}

// MergeChangeOptions specifies options for a merge operation.
type MergeChangeOptions struct {
// HeadHash, if non-empty, causes the merge to fail
// if the change's current head commit doesn't match.
// This prevents merging a change whose content
// has changed since the caller last inspected it.
//
// Not all forges support this; unsupported forges
// ignore the field.
HeadHash git.Hash
}

// FindChangeItem is a single result from searching for changes in the
// repository.
type FindChangeItem struct {
Expand Down Expand Up @@ -565,3 +589,48 @@ func (s *ChangeState) UnmarshalText(b []byte) error {
}
return nil
}

// ChecksState represents the aggregate CI/checks
// status for a change.
//
// TODO: Teach this type the names of individual statuses
// so merge failures can report exactly what's blocking the user.
type ChecksState int

const (
// ChecksPending indicates checks are still running.
ChecksPending ChecksState = iota + 1

// ChecksPassed indicates all checks have passed.
ChecksPassed

// ChecksFailed indicates one or more checks failed.
ChecksFailed
)

func (s ChecksState) String() string {
switch s {
case ChecksPending:
return "pending"
case ChecksPassed:
return "passed"
case ChecksFailed:
return "failed"
default:
return fmt.Sprintf("ChecksState(%d)", int(s))
}
}

// GoString returns a Go-syntax representation.
func (s ChecksState) GoString() string {
switch s {
case ChecksPending:
return "ChecksPending"
case ChecksPassed:
return "ChecksPassed"
case ChecksFailed:
return "ChecksFailed"
default:
return fmt.Sprintf("ChecksState(%d)", int(s))
}
}
92 changes: 92 additions & 0 deletions internal/forge/forgetest/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ type (
repo forge.Repository,
changeID forge.ChangeID,
)

// SetChangeChecksStateFunc sets the aggregate checks state
// that a forge reports for a change.
SetChangeChecksStateFunc func(
t *testing.T,
httpClient *http.Client,
repo forge.Repository,
changeID forge.ChangeID,
headHash git.Hash,
state forge.ChecksState,
)
)

// IntegrationConfig configures a forge integration test run.
Expand Down Expand Up @@ -248,6 +259,9 @@ type IntegrationConfig struct {
// CloseChange closes a change without merging.
CloseChange CloseChangeFunc // required

// SetChangeChecksState sets checks state for a change.
SetChangeChecksState SetChangeChecksStateFunc // optional

// Reviewers is a list of usernames that can be added as reviewers to changes.
Reviewers []string // required

Expand Down Expand Up @@ -316,6 +330,7 @@ func RunIntegration(t *testing.T, config IntegrationConfig) {
openRepository: config.OpenRepository,
MergeChange: config.MergeChange,
CloseChange: config.CloseChange,
SetChangeChecksState: config.SetChangeChecksState,
Reviewers: config.Reviewers,
Assignees: config.Assignees,
SetCommentsPageSize: config.SetCommentsPageSize,
Expand Down Expand Up @@ -355,6 +370,14 @@ func RunIntegration(t *testing.T, config IntegrationConfig) {
})
}

if config.SetChangeChecksState != nil {
t.Run("ChangeChecksState", func(t *testing.T) {
t.Parallel()

suite.TestChangeChecksState(t)
})
}

t.Run("FindChangesByBranchDoesNotExist", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -446,6 +469,9 @@ type integrationSuite struct {
// CloseChange closes a change without merging.
CloseChange CloseChangeFunc

// SetChangeChecksState sets checks state for a change.
SetChangeChecksState SetChangeChecksStateFunc

// Reviewers is a list of usernames that can be added as reviewers to changes.
Reviewers []string

Expand Down Expand Up @@ -786,6 +812,72 @@ func (s *integrationSuite) TestChangeStates(t *testing.T) {
assert.NotEmpty(t, statuses[2].HeadHash)
}

// TestChangeChecksState verifies that forges report aggregate checks state
// for newly submitted changes.
func (s *integrationSuite) TestChangeChecksState(t *testing.T) {
tests := []struct {
name string
want forge.ChecksState
}{
{name: "Pending", want: forge.ChecksPending},
{name: "Passed", want: forge.ChecksPassed},
{name: "Failed", want: forge.ChecksFailed},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branchFixture := fixturetest.New(s.Fixtures, "branch", func() string {
return randomString(8)
})
headHashFixture, setHeadHash := fixturetest.Stored[string](
s.Fixtures,
"headHash",
)

branch := branchFixture.Get(t)
if Update() {
testRepo := newTestRepository(t, s.RemoteURL)
testRepo.CheckoutBranch("main")
testRepo.CreateBranch(branch)
testRepo.CheckoutBranch(branch)
testRepo.WriteFile(branch+".txt", randomString(32))
hash := testRepo.AddAllAndCommit("commit for checks " + tt.name)
testRepo.Push(branch)
setHeadHash(hash.String())

t.Cleanup(func() {
testRepo.DeleteRemoteBranch(branch)
})
}

httpClient := s.HTTPClient(t)
repo := s.openRepository(t, httpClient)
headHash := git.Hash(headHashFixture.Get(t))

change, err := repo.SubmitChange(t.Context(), forge.SubmitChangeRequest{
Subject: "Checks " + branch,
Body: "Checks state test",
Base: "main",
Head: branch,
})
require.NoError(t, err, "error creating change")

s.SetChangeChecksState(
t,
httpClient,
repo,
change.ID,
headHash,
tt.want,
)

got, err := repo.ChangeChecksState(t.Context(), change.ID)
require.NoError(t, err, "error fetching checks")
assert.Equal(t, tt.want, got)
})
}
}

// FindChangesByBranch returns no error, and an empty slice
// when the branch does not exist.
func (s *integrationSuite) TestFindChangesByBranchDoesNotExist(t *testing.T) {
Expand Down
Loading
Loading