You've already forked agentic-coding-workflow
339 lines
9.8 KiB
Go
339 lines
9.8 KiB
Go
// 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
|
|
}
|