You've already forked agentic-coding-workflow
313 lines
9.9 KiB
Go
313 lines
9.9 KiB
Go
package changerequest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/vcs"
|
|
)
|
|
|
|
// ===== fakes =====
|
|
|
|
type fakeRepo struct {
|
|
items map[uuid.UUID]*ChangeRequest
|
|
nextNum int
|
|
}
|
|
|
|
func newFakeRepo() *fakeRepo { return &fakeRepo{items: map[uuid.UUID]*ChangeRequest{}, nextNum: 1} }
|
|
|
|
func (f *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (*ChangeRequest, error) {
|
|
cr, ok := f.items[id]
|
|
if !ok {
|
|
return nil, errs.New(errs.CodeNotFound, "not found")
|
|
}
|
|
cp := *cr
|
|
return &cp, nil
|
|
}
|
|
func (f *fakeRepo) GetByNumber(_ context.Context, _ string, number int) (*ChangeRequest, error) {
|
|
for _, cr := range f.items {
|
|
if cr.Number == number {
|
|
cp := *cr
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, errs.New(errs.CodeNotFound, "not found")
|
|
}
|
|
func (f *fakeRepo) ListByWorkspace(_ context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) {
|
|
var out []*ChangeRequest
|
|
for _, cr := range f.items {
|
|
if cr.WorkspaceID == wsID {
|
|
cp := *cr
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeRepo) ListByProject(_ context.Context, pid uuid.UUID) ([]*ChangeRequest, error) {
|
|
var out []*ChangeRequest
|
|
for _, cr := range f.items {
|
|
if cr.ProjectID == pid {
|
|
cp := *cr
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeRepo) UpdateReview(_ context.Context, id uuid.UUID, v Verdict, by uuid.UUID) (*ChangeRequest, error) {
|
|
cr := f.items[id]
|
|
cr.ReviewVerdict = v
|
|
cr.ReviewedBy = &by
|
|
now := time.Now()
|
|
cr.ReviewedAt = &now
|
|
cp := *cr
|
|
return &cp, nil
|
|
}
|
|
func (f *fakeRepo) UpdateState(_ context.Context, id uuid.UUID, st State, sha string, at *time.Time) (*ChangeRequest, error) {
|
|
cr := f.items[id]
|
|
cr.State = st
|
|
cr.MergeCommitSHA = sha
|
|
cr.MergedAt = at
|
|
cp := *cr
|
|
return &cp, nil
|
|
}
|
|
func (f *fakeRepo) UpdateCI(_ context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) {
|
|
cr := f.items[id]
|
|
cr.CIState = ci
|
|
cr.State = st
|
|
cp := *cr
|
|
return &cp, nil
|
|
}
|
|
func (f *fakeRepo) InTx(ctx context.Context, fn func(Tx) error) error {
|
|
return fn(&fakeTx{r: f})
|
|
}
|
|
|
|
type fakeTx struct{ r *fakeRepo }
|
|
|
|
func (t *fakeTx) NextNumber(_ context.Context, _ uuid.UUID) (int, error) {
|
|
n := t.r.nextNum
|
|
t.r.nextNum++
|
|
return n, nil
|
|
}
|
|
func (t *fakeTx) Create(_ context.Context, cr *ChangeRequest) (*ChangeRequest, error) {
|
|
cp := *cr
|
|
// mirror the DB INSERT defaults baked into the query (state/verdict).
|
|
cp.State = StateOpen
|
|
cp.ReviewVerdict = VerdictPending
|
|
if cp.Provider == "" {
|
|
cp.Provider = "gitea"
|
|
}
|
|
t.r.items[cr.ID] = &cp
|
|
out := cp
|
|
return &out, nil
|
|
}
|
|
|
|
type fakeProvider struct {
|
|
createPR *vcs.PullRequest
|
|
getPR *vcs.PullRequest
|
|
mergeSHA string
|
|
mergeErr error
|
|
mergeCalled bool
|
|
}
|
|
|
|
func (p *fakeProvider) CreatePR(_ context.Context, _ vcs.CreatePRInput) (*vcs.PullRequest, error) {
|
|
if p.createPR != nil {
|
|
return p.createPR, nil
|
|
}
|
|
return &vcs.PullRequest{Number: 1, State: "open", HTMLURL: "http://h/pr/1", CIState: "unknown"}, nil
|
|
}
|
|
func (p *fakeProvider) GetPR(_ context.Context, _ vcs.RepoRef, _ int64) (*vcs.PullRequest, error) {
|
|
if p.getPR != nil {
|
|
return p.getPR, nil
|
|
}
|
|
return &vcs.PullRequest{Number: 1, State: "open", CIState: "success"}, nil
|
|
}
|
|
func (p *fakeProvider) Comment(_ context.Context, _ vcs.RepoRef, _ int64, _ string) error { return nil }
|
|
func (p *fakeProvider) Merge(_ context.Context, _ vcs.RepoRef, _ int64, _ vcs.MergeInput) (string, error) {
|
|
p.mergeCalled = true
|
|
if p.mergeErr != nil {
|
|
return "", p.mergeErr
|
|
}
|
|
if p.mergeSHA == "" {
|
|
return "merged-sha", nil
|
|
}
|
|
return p.mergeSHA, nil
|
|
}
|
|
func (p *fakeProvider) ListIssues(_ context.Context, _ vcs.RepoRef, _ vcs.IssueListOptions) ([]vcs.Issue, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type fakeWS struct {
|
|
meta WorkspaceMeta
|
|
err error
|
|
}
|
|
|
|
func (f *fakeWS) GetWorkspaceMeta(_ context.Context, _ uuid.UUID) (WorkspaceMeta, error) {
|
|
return f.meta, f.err
|
|
}
|
|
|
|
type fakePA struct {
|
|
canRead, canWrite bool
|
|
}
|
|
|
|
func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) {
|
|
return f.canRead, f.canWrite, nil
|
|
}
|
|
|
|
type nopRec struct{}
|
|
|
|
func (nopRec) Record(_ context.Context, _ audit.Entry) error { return nil }
|
|
|
|
var (
|
|
tProj = uuid.New()
|
|
tWS = uuid.New()
|
|
tUser = uuid.New()
|
|
)
|
|
|
|
func newSvc(t *testing.T, prov *fakeProvider, pa *fakePA, cfg Config) (*service, *fakeRepo) {
|
|
t.Helper()
|
|
repo := newFakeRepo()
|
|
ws := &fakeWS{meta: WorkspaceMeta{ProjectID: tProj, GitRemoteURL: "https://git.jerryyan.net/owner/repo.git", DefaultBranch: "main"}}
|
|
svc := NewService(repo, ws, nopRec{}, prov, pa, cfg).(*service)
|
|
return svc, repo
|
|
}
|
|
|
|
func caller() Caller { return Caller{UserID: tUser} }
|
|
|
|
// ===== tests =====
|
|
|
|
func TestCreate_PersistsPending(t *testing.T) {
|
|
prov := &fakeProvider{createPR: &vcs.PullRequest{Number: 7, State: "open", HTMLURL: "http://h/7", CIState: "pending"}}
|
|
svc, _ := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
|
|
|
|
cr, err := svc.Create(context.Background(), caller(), tWS, CreateInput{
|
|
SourceBranch: "feature", Title: "T",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, VerdictPending, cr.ReviewVerdict)
|
|
assert.Equal(t, StateOpen, cr.State)
|
|
assert.Equal(t, "main", cr.TargetBranch)
|
|
require.NotNil(t, cr.ExternalID)
|
|
assert.Equal(t, int64(7), *cr.ExternalID)
|
|
assert.Equal(t, 1, cr.Number)
|
|
assert.Equal(t, CIPending, cr.CIState)
|
|
}
|
|
|
|
func TestCreate_RequiresWrite(t *testing.T) {
|
|
svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
|
|
_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "f", Title: "T"})
|
|
require.Error(t, err)
|
|
ae, _ := errs.As(err)
|
|
assert.Equal(t, errs.CodeForbidden, ae.Code)
|
|
}
|
|
|
|
func TestCreate_SameBranchRejected(t *testing.T) {
|
|
svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
|
|
_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "main", Title: "T"})
|
|
require.Error(t, err)
|
|
ae, _ := errs.As(err)
|
|
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
|
|
}
|
|
|
|
// seed inserts an open, approved-able CR into the repo for merge tests.
|
|
func seedCR(repo *fakeRepo, verdict Verdict, ci CIState) *ChangeRequest {
|
|
id := uuid.New()
|
|
ext := int64(7)
|
|
cr := &ChangeRequest{
|
|
ID: id, ProjectID: tProj, WorkspaceID: tWS, Number: 1,
|
|
Title: "T", SourceBranch: "feature", TargetBranch: "main",
|
|
State: StateOpen, ReviewVerdict: verdict, CIState: ci,
|
|
Provider: "gitea", ExternalID: &ext, CreatedBy: tUser,
|
|
}
|
|
repo.items[id] = cr
|
|
return cr
|
|
}
|
|
|
|
func TestMerge_DeniedWhenNotApproved(t *testing.T) {
|
|
for _, v := range []Verdict{VerdictPending, VerdictChangesRequested, VerdictRejected} {
|
|
prov := &fakeProvider{}
|
|
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
|
|
cr := seedCR(repo, v, CISuccess)
|
|
|
|
_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
|
|
require.Error(t, err, v)
|
|
ae, _ := errs.As(err)
|
|
assert.Equal(t, errs.CodeFailedPrecondition, ae.Code, v)
|
|
assert.False(t, prov.mergeCalled, "provider.Merge must NOT be called when verdict=%s", v)
|
|
}
|
|
}
|
|
|
|
func TestMerge_DeniedWhenCINotPassing(t *testing.T) {
|
|
prov := &fakeProvider{}
|
|
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
|
|
cr := seedCR(repo, VerdictApproved, CIFailure)
|
|
|
|
_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
|
|
require.Error(t, err)
|
|
ae, _ := errs.As(err)
|
|
assert.Equal(t, errs.CodeFailedPrecondition, ae.Code)
|
|
assert.False(t, prov.mergeCalled)
|
|
}
|
|
|
|
func TestMerge_AdminBypassesCI(t *testing.T) {
|
|
prov := &fakeProvider{mergeSHA: "sha-x"}
|
|
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
|
|
cr := seedCR(repo, VerdictApproved, CIFailure)
|
|
|
|
out, err := svc.Merge(context.Background(), Caller{UserID: tUser, IsAdmin: true}, cr.ID, "")
|
|
require.NoError(t, err)
|
|
assert.True(t, prov.mergeCalled)
|
|
assert.Equal(t, StateMerged, out.State)
|
|
assert.Equal(t, "sha-x", out.MergeCommitSHA)
|
|
}
|
|
|
|
func TestMerge_AllowedWhenApproved(t *testing.T) {
|
|
prov := &fakeProvider{mergeSHA: "sha-y"}
|
|
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
|
|
cr := seedCR(repo, VerdictApproved, CISuccess)
|
|
|
|
out, err := svc.Merge(context.Background(), caller(), cr.ID, "squash")
|
|
require.NoError(t, err)
|
|
assert.True(t, prov.mergeCalled)
|
|
assert.Equal(t, StateMerged, out.State)
|
|
assert.NotNil(t, out.MergedAt)
|
|
assert.Equal(t, "sha-y", out.MergeCommitSHA)
|
|
}
|
|
|
|
func TestReview_SetsReviewer(t *testing.T) {
|
|
svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
|
|
cr := seedCR(repo, VerdictPending, CIUnknown)
|
|
|
|
out, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "lgtm")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, VerdictApproved, out.ReviewVerdict)
|
|
require.NotNil(t, out.ReviewedBy)
|
|
assert.Equal(t, tUser, *out.ReviewedBy)
|
|
}
|
|
|
|
func TestReview_RequiresWrite(t *testing.T) {
|
|
svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
|
|
cr := seedCR(repo, VerdictPending, CIUnknown)
|
|
_, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "")
|
|
require.Error(t, err)
|
|
ae, _ := errs.As(err)
|
|
assert.Equal(t, errs.CodeForbidden, ae.Code)
|
|
}
|
|
|
|
func TestSyncStatus_RefreshesCIAndState(t *testing.T) {
|
|
prov := &fakeProvider{getPR: &vcs.PullRequest{Number: 7, State: "closed", Merged: true, CIState: "success", MergeCommitSHA: "z"}}
|
|
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
|
|
cr := seedCR(repo, VerdictApproved, CIUnknown)
|
|
|
|
out, err := svc.SyncStatus(context.Background(), caller(), cr.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, CISuccess, out.CIState)
|
|
assert.Equal(t, StateMerged, out.State)
|
|
}
|