// service.go implements the change-request application service, including the // merge gate (Service.Merge). Auth delegates to ProjectAccess (owner+admin // write), mirroring workspace's narrow-interface approach so this package does // not depend on internal/project wholesale. package changerequest import ( "context" "time" "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/vcs" ) // WorkspaceLookup resolves a workspace's project + remote URL + default branch // for RepoRef derivation and target-branch defaulting. Narrow interface over // workspace.Repository so we avoid importing the full workspace service. type WorkspaceLookup interface { GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (WorkspaceMeta, error) } // WorkspaceMeta is the workspace projection the change-request service needs. type WorkspaceMeta struct { ProjectID uuid.UUID GitRemoteURL string DefaultBranch string } // ProjectAccess is the auth interface (same shape as workspace.ProjectAccess): // ResolveByID returns (canRead, canWrite, err) for a project. type ProjectAccess interface { ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) } // Config controls the merge gate policy. type Config struct { RequireCIPass bool // when true, merge also requires ci_state=='success' MergeMethod string // default host merge method ("merge"|"squash"|"rebase"); "merge" when empty Host string // bare host the provider serves, for RepoRef host check; empty => skip } type service struct { repo Repository ws WorkspaceLookup rec audit.Recorder provider vcs.Provider pa ProjectAccess cfg Config } // NewService constructs the change-request Service. func NewService(repo Repository, ws WorkspaceLookup, rec audit.Recorder, provider vcs.Provider, pa ProjectAccess, cfg Config) Service { if cfg.MergeMethod == "" { cfg.MergeMethod = "merge" } return &service{repo: repo, ws: ws, rec: rec, provider: provider, pa: pa, cfg: cfg} } // Create opens a host PR and persists the change_requests row. func (s *service) Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateInput) (*ChangeRequest, error) { meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) if err != nil { return nil, err } if err := s.guardWrite(ctx, c, meta.ProjectID); err != nil { return nil, err } if in.SourceBranch == "" { return nil, errs.New(errs.CodeInvalidInput, "source_branch required") } if in.Title == "" { return nil, errs.New(errs.CodeInvalidInput, "title required") } target := in.TargetBranch if target == "" { target = meta.DefaultBranch } if target == "" { target = "main" } if in.SourceBranch == target { return nil, errs.New(errs.CodeInvalidInput, "source_branch and target_branch must differ") } repoRef, err := vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host) if err != nil { return nil, err } pr, err := s.provider.CreatePR(ctx, vcs.CreatePRInput{ Repo: repoRef, Head: in.SourceBranch, Base: target, Title: in.Title, Body: in.Description, }) if err != nil { return nil, errs.Wrap(err, errs.CodeUpstream, "open pull request") } cr := &ChangeRequest{ ID: uuid.New(), ProjectID: meta.ProjectID, WorkspaceID: wsID, RequirementID: in.RequirementID, IssueID: in.IssueID, Title: in.Title, Description: in.Description, SourceBranch: in.SourceBranch, TargetBranch: target, CIState: CIState(pr.CIState), Provider: "gitea", ExternalURL: pr.HTMLURL, CreatedBy: c.UserID, } if cr.CIState == "" { cr.CIState = CIUnknown } extID := pr.Number cr.ExternalID = &extID var created *ChangeRequest err = s.repo.InTx(ctx, func(tx Tx) error { n, nerr := tx.NextNumber(ctx, meta.ProjectID) if nerr != nil { return nerr } cr.Number = n c2, cerr := tx.Create(ctx, cr) if cerr != nil { return cerr } created = c2 return nil }) if err != nil { return nil, err } s.audit(ctx, &c.UserID, "change_request.created", created.ID.String(), map[string]any{ "number": created.Number, "external_id": extID, "source": in.SourceBranch, "target": target, }) return created, nil } func (s *service) Get(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) { cr, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { return nil, err } return cr, nil } func (s *service) GetByNumber(ctx context.Context, c Caller, projectSlug string, number int) (*ChangeRequest, error) { cr, err := s.repo.GetByNumber(ctx, projectSlug, number) if err != nil { return nil, err } if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { return nil, err } return cr, nil } func (s *service) ListByWorkspace(ctx context.Context, c Caller, wsID uuid.UUID) ([]*ChangeRequest, error) { meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) if err != nil { return nil, err } if err := s.guardRead(ctx, c, meta.ProjectID); err != nil { return nil, err } return s.repo.ListByWorkspace(ctx, wsID) } // Review records a human verdict. Owner/admin only. Not an MCP tool. func (s *service) Review(ctx context.Context, c Caller, id uuid.UUID, verdict Verdict, note string) (*ChangeRequest, error) { if !verdict.IsValid() || verdict == VerdictPending { return nil, errs.New(errs.CodeInvalidInput, "invalid review verdict") } cr, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil { return nil, err } if cr.State != StateOpen { return nil, errs.New(errs.CodeFailedPrecondition, "cannot review a non-open change request") } updated, err := s.repo.UpdateReview(ctx, id, verdict, c.UserID) if err != nil { return nil, err } if note != "" && updated.ExternalID != nil { if repoRef, perr := s.repoRefFor(ctx, updated.WorkspaceID); perr == nil { _ = s.provider.Comment(ctx, repoRef, *updated.ExternalID, "Review ("+string(verdict)+"): "+note) } } s.audit(ctx, &c.UserID, "change_request.reviewed", id.String(), map[string]any{ "verdict": string(verdict), }) return updated, nil } // Merge is the gate. It requires review_verdict=='approved' (and ci success // when RequireCIPass and not admin), then merges the host PR. func (s *service) Merge(ctx context.Context, c Caller, id uuid.UUID, method string) (*ChangeRequest, error) { cr, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil { return nil, err } if cr.State != StateOpen { return nil, errs.New(errs.CodeFailedPrecondition, "change request is not open") } // THE GATE: review must be approved. if cr.ReviewVerdict != VerdictApproved { return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved") } // Optional CI gate; admins may override. if s.cfg.RequireCIPass && !c.IsAdmin && cr.CIState != CISuccess { return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: CI not passing") } if cr.ExternalID == nil { return nil, errs.New(errs.CodeFailedPrecondition, "change request has no external PR") } repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID) if err != nil { return nil, err } mm := method if mm == "" { mm = s.cfg.MergeMethod } mergeSHA, err := s.provider.Merge(ctx, repoRef, *cr.ExternalID, vcs.MergeInput{Method: mm}) if err != nil { return nil, errs.Wrap(err, errs.CodeFailedPrecondition, "host merge failed") } now := time.Now() updated, err := s.repo.UpdateState(ctx, id, StateMerged, mergeSHA, &now) if err != nil { return nil, err } s.audit(ctx, &c.UserID, "change_request.merged", id.String(), map[string]any{ "method": mm, "merge_sha": mergeSHA, }) return updated, nil } // SyncStatus refreshes ci_state/state from the host PR. func (s *service) SyncStatus(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) { cr, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { return nil, err } if cr.ExternalID == nil { return cr, nil } repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID) if err != nil { return nil, err } pr, err := s.provider.GetPR(ctx, repoRef, *cr.ExternalID) if err != nil { return nil, errs.Wrap(err, errs.CodeUpstream, "fetch pull request status") } newState := cr.State switch { case pr.Merged: newState = StateMerged case pr.State == "closed": newState = StateClosed default: newState = StateOpen } ci := CIState(pr.CIState) if !validCI(ci) { ci = CIUnknown } return s.repo.UpdateCI(ctx, id, ci, newState) } // ===== helpers ===== func (s *service) repoRefFor(ctx context.Context, wsID uuid.UUID) (vcs.RepoRef, error) { meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) if err != nil { return vcs.RepoRef{}, err } return vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host) } func (s *service) guardRead(ctx context.Context, c Caller, projectID uuid.UUID) error { canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID) if err != nil { return err } if !canRead { return errs.New(errs.CodeForbidden, "no read access") } return nil } func (s *service) guardWrite(ctx context.Context, c Caller, projectID uuid.UUID) error { _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID) if err != nil { return err } if !canWrite { return errs.New(errs.CodeForbidden, "no write access") } return nil } func (s *service) audit(ctx context.Context, uid *uuid.UUID, action, id string, meta map[string]any) { _ = s.rec.Record(ctx, audit.Entry{ UserID: uid, Action: action, TargetType: "change_request", TargetID: id, Metadata: meta, }) } func validCI(ci CIState) bool { switch ci { case CIUnknown, CIPending, CISuccess, CIFailure: return true } return false }