agent-orchestrator/backend/internal/service/session/claim_pr.go

414 lines
14 KiB
Go

package session
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var (
// ErrInvalidPRRef is returned when a claim request does not name a GitHub PR URL or positive PR number.
ErrInvalidPRRef = errors.New("session: invalid pr ref")
// ErrPRNotFound is returned when the SCM provider has no matching pull request.
ErrPRNotFound = errors.New("session: pr not found")
// ErrPRNotOpen is returned when a PR is draft, merged, or closed and therefore cannot be claimed.
ErrPRNotOpen = errors.New("session: pr not open")
// ErrSCMUnavailable is returned when live SCM facts cannot be fetched.
ErrSCMUnavailable = errors.New("session: scm unavailable")
// ErrProjectMismatch is returned when the PR repository does not match the session project repository.
ErrProjectMismatch = errors.New("session: pr project mismatch")
// ErrSessionNotClaimable is returned when an orchestrator session tries to claim a PR.
ErrSessionNotClaimable = errors.New("session: not claimable")
// ErrSessionNoWorkspace is returned when a session has no workspace path to associate with PR work.
ErrSessionNoWorkspace = errors.New("session: no workspace")
)
// ClaimPROptions controls PR claim conflict behavior.
type ClaimPROptions struct {
AllowTakeover bool
}
// ClaimPRResult is the session PR read model returned after a claim.
type ClaimPRResult struct {
PRs []domain.PRFacts
BranchChanged bool
TakenOverFrom []domain.SessionID
DonorWasTerminated bool
}
// ListPRs returns all PRs currently owned by a session, ordered for display.
func (s *Service) ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) {
_, ok, err := s.store.GetSession(ctx, id)
if err != nil {
return nil, fmt.Errorf("get %s: %w", id, err)
}
if !ok {
return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
return s.listPRFacts(ctx, id)
}
// ClaimPR attaches a live GitHub PR to a worker session and persists the current SCM facts atomically.
func (s *Service) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts ClaimPROptions) (ClaimPRResult, error) {
rec, ok, err := s.store.GetSession(ctx, id)
if err != nil {
return ClaimPRResult{}, fmt.Errorf("get %s: %w", id, err)
}
if !ok {
return ClaimPRResult{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
if rec.IsTerminated {
return ClaimPRResult{}, sessionmanagerAPIError("SESSION_TERMINATED", "Session is terminated")
}
if rec.Kind == domain.KindOrchestrator {
return ClaimPRResult{}, ErrSessionNotClaimable
}
if strings.TrimSpace(rec.Metadata.WorkspacePath) == "" {
return ClaimPRResult{}, ErrSessionNoWorkspace
}
project, ok, err := s.store.GetProject(ctx, string(rec.ProjectID))
if err != nil {
return ClaimPRResult{}, fmt.Errorf("project %s: %w", rec.ProjectID, err)
}
if !ok {
return ClaimPRResult{}, apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
}
prURL, number, err := normalizePRRef(ref, project.RepoOriginURL)
if err != nil {
return ClaimPRResult{}, err
}
if err := requireSameGitHubRepo(prURL, project.RepoOriginURL); err != nil {
return ClaimPRResult{}, err
}
if s.scm == nil || s.prClaimer == nil {
return ClaimPRResult{}, ErrSCMUnavailable
}
repo, err := scmRepoForClaim(s.scm, project.RepoOriginURL, prURL)
if err != nil {
return ClaimPRResult{}, err
}
refSpec := ports.SCMPRRef{Repo: repo, Number: number, URL: prURL}
obs, err := s.fetchClaimObservation(ctx, refSpec)
if err != nil {
return ClaimPRResult{}, err
}
if obs.PR.Number == 0 {
obs.PR.Number = number
}
if obs.PR.URL == "" {
obs.PR.URL = prURL
}
if obs.PR.Draft || obs.PR.Merged || obs.PR.Closed {
return ClaimPRResult{}, ErrPRNotOpen
}
reviewMode, err := s.enrichClaimReviews(ctx, refSpec, &obs)
if err != nil {
return ClaimPRResult{}, err
}
now := s.clock().UTC()
pr, checks, reviews, threads, comments := claimRowsFromSCM(id, obs, now)
outcome, err := s.prClaimer.ClaimPR(ctx, pr, checks, reviews, threads, comments, reviewMode, opts.AllowTakeover)
if err != nil {
return ClaimPRResult{}, err
}
prs, err := s.listPRFacts(ctx, id)
if err != nil {
return ClaimPRResult{}, err
}
prs = claimedFirst(prs, prURL)
// TODO: implement workspace branch checkout. Until then, leave BranchChanged
// false and let CLI output omit the checkout line rather than claiming the
// session was already on the PR branch.
res := ClaimPRResult{PRs: prs, BranchChanged: false, DonorWasTerminated: outcome.OwnerTerminated}
if outcome.PreviousOwner != "" && outcome.PreviousOwner != id {
res.TakenOverFrom = []domain.SessionID{outcome.PreviousOwner}
}
return res, nil
}
func (s *Service) fetchClaimObservation(ctx context.Context, ref ports.SCMPRRef) (ports.SCMObservation, error) {
batch, err := s.scm.FetchPullRequests(ctx, []ports.SCMPRRef{ref})
if err != nil {
if errors.Is(err, ports.ErrSCMNotFound) {
return ports.SCMObservation{}, ErrPRNotFound
}
return ports.SCMObservation{}, fmt.Errorf("%w: %w", ErrSCMUnavailable, err)
}
if len(batch) == 0 {
return ports.SCMObservation{}, ErrPRNotFound
}
obs := batch[0]
if !obs.Fetched {
return ports.SCMObservation{}, ErrSCMUnavailable
}
return obs, nil
}
func (s *Service) enrichClaimReviews(ctx context.Context, ref ports.SCMPRRef, obs *ports.SCMObservation) (ports.ReviewWriteMode, error) {
review, err := s.scm.FetchReviewThreads(ctx, ref)
if err != nil {
if errors.Is(err, ports.ErrSCMNotFound) {
return ports.ReviewWritePreserve, ErrPRNotFound
}
return ports.ReviewWritePreserve, fmt.Errorf("%w: %w", ErrSCMUnavailable, err)
}
if review.Decision != "" {
obs.Review.Decision = review.Decision
}
obs.Review.Threads = review.Threads
obs.Review.Reviews = review.Reviews
obs.Review.Partial = review.Partial
if review.Partial {
return ports.ReviewWriteMerge, nil
}
return ports.ReviewWriteReplace, nil
}
func scmRepoForClaim(provider scmProvider, projectOrigin, prURL string) (ports.SCMRepo, error) {
if repo, ok := provider.ParseRepository(projectOrigin); ok {
return repo, nil
}
owner, name, _, err := parseGitHubPRURL(prURL)
if err != nil {
return ports.SCMRepo{}, ErrInvalidPRRef
}
return ports.SCMRepo{Provider: "github", Host: "github.com", Owner: owner, Name: name, Repo: owner + "/" + name}, nil
}
func claimRowsFromSCM(sessionID domain.SessionID, obs ports.SCMObservation, now time.Time) (domain.PullRequest, []domain.PullRequestCheck, []domain.PullRequestReview, []domain.PullRequestReviewThread, []domain.PullRequestComment) {
observedAt := obs.ObservedAt
if observedAt.IsZero() {
observedAt = now
}
pr := domain.PullRequest{
URL: firstNonEmpty(obs.PR.URL, obs.PR.HTMLURL),
SessionID: sessionID,
Number: obs.PR.Number,
Draft: obs.PR.Draft,
Merged: obs.PR.Merged,
Closed: obs.PR.Closed,
CI: domain.CIState(firstNonEmpty(obs.CI.Summary, string(domain.CIUnknown))),
Review: domain.ReviewDecision(firstNonEmpty(obs.Review.Decision, string(domain.ReviewNone))),
Mergeability: domain.Mergeability(firstNonEmpty(obs.Mergeability.State, string(domain.MergeUnknown))),
UpdatedAt: now,
Provider: obs.Provider,
Host: obs.Host,
Repo: obs.Repo,
SourceBranch: obs.PR.SourceBranch,
TargetBranch: obs.PR.TargetBranch,
HeadSHA: obs.PR.HeadSHA,
Title: obs.PR.Title,
Additions: obs.PR.Additions,
Deletions: obs.PR.Deletions,
ChangedFiles: obs.PR.ChangedFiles,
Author: obs.PR.Author,
BaseSHA: obs.PR.BaseSHA,
MergeCommitSHA: obs.PR.MergeCommitSHA,
ProviderState: obs.PR.ProviderState,
ProviderMergeable: obs.PR.ProviderMergeable,
ProviderMergeStateStatus: obs.PR.ProviderMergeStateStatus,
HTMLURL: obs.PR.HTMLURL,
CreatedAtProvider: obs.PR.CreatedAtProvider,
UpdatedAtProvider: obs.PR.UpdatedAtProvider,
MergedAtProvider: obs.PR.MergedAtProvider,
ClosedAtProvider: obs.PR.ClosedAtProvider,
ObservedAt: observedAt,
CIObservedAt: observedAt,
ReviewObservedAt: observedAt,
}
checks := make([]domain.PullRequestCheck, 0, len(obs.CI.Checks))
for _, ch := range obs.CI.Checks {
checks = append(checks, domain.PullRequestCheck{Name: ch.Name, CommitHash: obs.CI.HeadSHA, Status: domain.PRCheckStatus(ch.Status), Conclusion: ch.Conclusion, URL: ch.URL, Details: ch.ProviderID, LogTail: ch.LogTail, CreatedAt: now})
}
reviews := make([]domain.PullRequestReview, 0, len(obs.Review.Reviews))
for _, review := range obs.Review.Reviews {
submittedAt := review.SubmittedAt
if submittedAt.IsZero() {
submittedAt = now
}
reviews = append(reviews, domain.PullRequestReview{
ID: review.ID,
Author: review.Author,
State: domain.ReviewDecision(firstNonEmpty(review.State, string(domain.ReviewNone))),
URL: review.URL,
IsBot: review.IsBot,
SubmittedAt: submittedAt,
})
}
threads := make([]domain.PullRequestReviewThread, 0, len(obs.Review.Threads))
commentCount := 0
for _, th := range obs.Review.Threads {
commentCount += len(th.Comments)
}
comments := make([]domain.PullRequestComment, 0, commentCount)
for _, th := range obs.Review.Threads {
threads = append(threads, domain.PullRequestReviewThread{ThreadID: th.ID, Path: th.Path, Line: th.Line, Resolved: th.Resolved, IsBot: th.IsBot, UpdatedAt: now})
for _, c := range th.Comments {
comments = append(comments, domain.PullRequestComment{ThreadID: th.ID, ID: c.ID, Author: c.Author, File: th.Path, Line: th.Line, Body: c.Body, URL: c.URL, Resolved: th.Resolved, IsBot: c.IsBot || th.IsBot, CreatedAt: now})
}
}
return pr, checks, reviews, threads, comments
}
func sessionmanagerAPIError(code, message string) error {
return apierr.Conflict(code, message, nil)
}
func (s *Service) listPRFacts(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) {
prs, err := s.store.ListPRsBySession(ctx, id)
if err != nil {
return nil, err
}
facts := make([]domain.PRFacts, 0, len(prs))
for _, pr := range prs {
comments, err := s.store.ListPRComments(ctx, pr.URL)
if err != nil {
return nil, err
}
facts = append(facts, pullRequestFacts(pr, comments))
}
sortPRFacts(facts)
return facts, nil
}
func pullRequestFacts(pr domain.PullRequest, comments []domain.PullRequestComment) domain.PRFacts {
unresolved := false
for _, c := range comments {
if !c.Resolved {
unresolved = true
break
}
}
return domain.PRFacts{URL: pr.URL, Number: pr.Number, Draft: pr.Draft, Merged: pr.Merged, Closed: pr.Closed, CI: pr.CI, Review: pr.Review, Mergeability: pr.Mergeability, ReviewComments: unresolved, UpdatedAt: pr.UpdatedAt}
}
func sortPRFacts(prs []domain.PRFacts) {
sort.SliceStable(prs, func(i, j int) bool {
ia, ja := prActive(prs[i]), prActive(prs[j])
if ia != ja {
return ia
}
return prs[i].UpdatedAt.After(prs[j].UpdatedAt)
})
}
func prActive(pr domain.PRFacts) bool { return !pr.Merged && !pr.Closed }
func claimedFirst(prs []domain.PRFacts, prURL string) []domain.PRFacts {
idx := -1
for i, pr := range prs {
if pr.URL == prURL {
idx = i
break
}
}
if idx <= 0 {
return prs
}
claimed := prs[idx]
copy(prs[1:idx+1], prs[0:idx])
prs[0] = claimed
return prs
}
func normalizePRRef(ref, repoOrigin string) (string, int, error) {
ref = strings.TrimPrefix(strings.TrimSpace(ref), "#")
if ref == "" {
return "", 0, ErrInvalidPRRef
}
if n, err := strconv.Atoi(ref); err == nil && n > 0 {
owner, repo, err := githubRepoFromURL(repoOrigin)
if err != nil {
return "", 0, ErrInvalidPRRef
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, n), n, nil
}
owner, repo, n, err := parseGitHubPRURL(ref)
if err != nil || owner == "" || repo == "" || n <= 0 {
return "", 0, ErrInvalidPRRef
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, n), n, nil
}
func requireSameGitHubRepo(prURL, repoOrigin string) error {
if strings.TrimSpace(repoOrigin) == "" {
return nil
}
po, pr, _, err := parseGitHubPRURL(prURL)
if err != nil {
return ErrInvalidPRRef
}
ro, rr, err := githubRepoFromURL(repoOrigin)
if err != nil {
return ErrInvalidPRRef
}
if !strings.EqualFold(po, ro) || !strings.EqualFold(pr, rr) {
return ErrProjectMismatch
}
return nil
}
func parseGitHubPRURL(raw string) (string, string, int, error) {
u, err := url.Parse(raw)
if err != nil {
return "", "", 0, err
}
if !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "github.com") {
return "", "", 0, ErrInvalidPRRef
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 4 || parts[2] != "pull" {
return "", "", 0, ErrInvalidPRRef
}
n, err := strconv.Atoi(parts[3])
if err != nil || n <= 0 {
return "", "", 0, ErrInvalidPRRef
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), n, nil
}
func githubRepoFromURL(raw string) (string, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", ErrInvalidPRRef
}
if strings.HasPrefix(raw, "git@github.com:") {
path := strings.TrimPrefix(raw, "git@github.com:")
parts := strings.Split(strings.TrimSuffix(path, ".git"), "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
return parts[0], parts[1], nil
}
return "", "", ErrInvalidPRRef
}
u, err := url.Parse(raw)
if err != nil {
return "", "", err
}
if !strings.EqualFold(u.Hostname(), "github.com") {
return "", "", ErrInvalidPRRef
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", ErrInvalidPRRef
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}