You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user