This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+338
View File
@@ -0,0 +1,338 @@
// 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
}