You've already forked agentic-coding-workflow
feat(workspace): GitOpsService for status/commit/push on main+worktree
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"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/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialResolver 是 service 共享的解密回调,避免 GitOpsService 直接持有 *crypto.Encryptor。
|
||||||
|
type CredentialResolver func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error)
|
||||||
|
|
||||||
|
type gitOpsService struct {
|
||||||
|
repo Repository
|
||||||
|
rec audit.Recorder
|
||||||
|
gitr git.Runner
|
||||||
|
pa ProjectAccess
|
||||||
|
cred CredentialResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitOpsService 构造 GitOpsService。cred 由 WorkspaceService.loadDecryptedCredential 适配过来。
|
||||||
|
func NewGitOpsService(repo Repository, rec audit.Recorder, gitr git.Runner, pa ProjectAccess, cred CredentialResolver) GitOpsService {
|
||||||
|
return &gitOpsService{repo: repo, rec: rec, gitr: gitr, pa: pa, cred: cred}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main =====
|
||||||
|
|
||||||
|
func (s *gitOpsService) StatusOnMain(ctx context.Context, c Caller, wsID uuid.UUID) ([]git.FileStatus, error) {
|
||||||
|
ws, err := s.guardWorkspaceRead(ctx, c, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st, err := s.gitr.Status(ctx, ws.MainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git status")
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) CommitOnMain(ctx context.Context, c Caller, wsID uuid.UUID, in CommitInput) (string, error) {
|
||||||
|
ws, err := s.guardWorkspaceWrite(ctx, c, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sha, err := s.gitr.Commit(ctx, ws.MainPath, in.Message, in.AddAll)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err, errs.CodeGitCmdFailed, "git commit")
|
||||||
|
}
|
||||||
|
s.auditCommit(ctx, &c.UserID, wsID.String(), "main", in.Message, sha)
|
||||||
|
if in.Push {
|
||||||
|
cred, err := s.cred(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
if err := s.gitr.Push(ctx, ws.MainPath, ws.DefaultBranch, cred); err != nil {
|
||||||
|
return sha, errs.Wrap(err, errs.CodeGitCmdFailed, "git push")
|
||||||
|
}
|
||||||
|
s.auditPush(ctx, &c.UserID, wsID.String(), ws.DefaultBranch)
|
||||||
|
}
|
||||||
|
return sha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error {
|
||||||
|
ws, err := s.guardWorkspaceWrite(ctx, c, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cred, err := s.cred(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.gitr.Push(ctx, ws.MainPath, ws.DefaultBranch, cred); err != nil {
|
||||||
|
return errs.Wrap(err, errs.CodeGitCmdFailed, "git push")
|
||||||
|
}
|
||||||
|
s.auditPush(ctx, &c.UserID, wsID.String(), ws.DefaultBranch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Worktree =====
|
||||||
|
|
||||||
|
func (s *gitOpsService) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) {
|
||||||
|
wt, ws, err := s.guardWorktreeRead(ctx, c, wtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = ws
|
||||||
|
st, err := s.gitr.Status(ctx, wt.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git status")
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) CommitOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, in CommitInput) (string, error) {
|
||||||
|
wt, ws, err := s.guardWorktreeWrite(ctx, c, wtID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sha, err := s.gitr.Commit(ctx, wt.Path, in.Message, in.AddAll)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err, errs.CodeGitCmdFailed, "git commit")
|
||||||
|
}
|
||||||
|
s.auditCommit(ctx, &c.UserID, ws.ID.String(), wt.Branch, in.Message, sha)
|
||||||
|
if in.Push {
|
||||||
|
cred, err := s.cred(ctx, ws.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
if err := s.gitr.Push(ctx, wt.Path, wt.Branch, cred); err != nil {
|
||||||
|
return sha, errs.Wrap(err, errs.CodeGitCmdFailed, "git push")
|
||||||
|
}
|
||||||
|
s.auditPush(ctx, &c.UserID, ws.ID.String(), wt.Branch)
|
||||||
|
}
|
||||||
|
return sha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) PushOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) error {
|
||||||
|
wt, ws, err := s.guardWorktreeWrite(ctx, c, wtID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cred, err := s.cred(ctx, ws.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.gitr.Push(ctx, wt.Path, wt.Branch, cred); err != nil {
|
||||||
|
return errs.Wrap(err, errs.CodeGitCmdFailed, "git push")
|
||||||
|
}
|
||||||
|
s.auditPush(ctx, &c.UserID, ws.ID.String(), wt.Branch)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== guards =====
|
||||||
|
|
||||||
|
func (s *gitOpsService) guardWorkspaceRead(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) {
|
||||||
|
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !canRead {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "no read access")
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) guardWorkspaceWrite(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) {
|
||||||
|
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !canWrite {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "no write access")
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) guardWorktreeRead(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, *Workspace, error) {
|
||||||
|
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ws, err := s.guardWorkspaceRead(ctx, c, wt.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return wt, ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) guardWorktreeWrite(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, *Workspace, error) {
|
||||||
|
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ws, err := s.guardWorkspaceWrite(ctx, c, wt.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return wt, ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) auditCommit(ctx context.Context, uid *uuid.UUID, wsID, target, msg, sha string) {
|
||||||
|
_ = s.rec.Record(ctx, audit.Entry{
|
||||||
|
UserID: uid, Action: "workspace.commit", TargetType: "workspace", TargetID: wsID,
|
||||||
|
Metadata: map[string]any{"target": target, "sha": sha, "msg": msg},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gitOpsService) auditPush(ctx context.Context, uid *uuid.UUID, wsID, branch string) {
|
||||||
|
_ = s.rec.Record(ctx, audit.Entry{
|
||||||
|
UserID: uid, Action: "workspace.push", TargetType: "workspace", TargetID: wsID,
|
||||||
|
Metadata: map[string]any{"branch": branch},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newGitOpsSvc(t *testing.T) (*gitOpsService, *fakeRepo, *fakeGit, uuid.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
gitr := &fakeGit{}
|
||||||
|
repo := newFakeRepo()
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo.wss[wsID] = &Workspace{ID: wsID, ProjectID: pa.pid, MainPath: "/data/main", DefaultBranch: "main"}
|
||||||
|
cred := func(_ context.Context, _ uuid.UUID) (*git.Credential, error) {
|
||||||
|
return &git.Credential{Kind: git.CredentialNone}, nil
|
||||||
|
}
|
||||||
|
svc := NewGitOpsService(repo, noopAudit{}, gitr, pa, cred).(*gitOpsService)
|
||||||
|
return svc, repo, gitr, wsID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitOps_CommitOnMain_NoPush(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, _, _, wsID := newGitOpsSvc(t)
|
||||||
|
sha, err := svc.CommitOnMain(context.Background(), Caller{UserID: uuid.New()}, wsID, CommitInput{Message: "x", AddAll: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitOps_CommitOnMain_WithPush(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, _, _, wsID := newGitOpsSvc(t)
|
||||||
|
_, err := svc.CommitOnMain(context.Background(), Caller{UserID: uuid.New()}, wsID, CommitInput{Message: "x", Push: true, AddAll: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitOps_CommitOnWorktree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, repo, _, wsID := newGitOpsSvc(t)
|
||||||
|
wtID := uuid.New()
|
||||||
|
repo.wts[wtID] = &Worktree{ID: wtID, WorkspaceID: wsID, Branch: "feat", Path: "/data/feat", Status: WorktreeStatusIdle}
|
||||||
|
sha, err := svc.CommitOnWorktree(context.Background(), Caller{UserID: uuid.New()}, wtID, CommitInput{Message: "y", AddAll: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitOps_StatusOnMain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
svc, _, _, wsID := newGitOpsSvc(t)
|
||||||
|
st, err := svc.StatusOnMain(context.Background(), Caller{UserID: uuid.New()}, wsID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, st)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user