From e31710f04eeda2c2f067568b4eb0a93c63030cdd Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sat, 2 May 2026 08:18:18 +0800 Subject: [PATCH] feat(workspace): GitOpsService for status/commit/push on main+worktree --- internal/workspace/gitops_service.go | 200 ++++++++++++++++++++++ internal/workspace/gitops_service_test.go | 58 +++++++ 2 files changed, 258 insertions(+) create mode 100644 internal/workspace/gitops_service.go create mode 100644 internal/workspace/gitops_service_test.go diff --git a/internal/workspace/gitops_service.go b/internal/workspace/gitops_service.go new file mode 100644 index 0000000..111f68f --- /dev/null +++ b/internal/workspace/gitops_service.go @@ -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}, + }) +} diff --git a/internal/workspace/gitops_service_test.go b/internal/workspace/gitops_service_test.go new file mode 100644 index 0000000..d40c260 --- /dev/null +++ b/internal/workspace/gitops_service_test.go @@ -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) +}