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

317 lines
8.3 KiB
Go

package session
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
)
// PRSummary is the user-facing SCM read model for one PR owned by a session.
type PRSummary struct {
URL string
HTMLURL string
Number int
Title string
State domain.PRState
Provider string
Repo string
Author string
SourceBranch string
TargetBranch string
HeadSHA string
Additions int
Deletions int
ChangedFiles int
CI PRCISummary
Review PRReviewSummary
Mergeability PRMergeabilitySummary
UpdatedAt time.Time
ObservedAt time.Time
CIObservedAt time.Time
ReviewObservedAt time.Time
}
// PRCISummary describes the latest CI status and failing checks for a PR.
type PRCISummary struct {
State domain.CIState
FailingChecks []PRFailingCheck
}
// PRFailingCheck is one failed or cancelled CI check for a PR.
type PRFailingCheck struct {
Name string
Status domain.PRCheckStatus
Conclusion string
URL string
}
// PRReviewSummary describes the latest review decision and unresolved comments.
type PRReviewSummary struct {
Decision domain.ReviewDecision
HasUnresolvedHumanComments bool
UnresolvedBy []PRUnresolvedReviewer
}
// PRUnresolvedReviewer groups unresolved human comments by reviewer.
type PRUnresolvedReviewer struct {
ReviewerID string
Count int
Links []PRReviewCommentLink
}
// PRReviewCommentLink points to one unresolved review comment.
type PRReviewCommentLink struct {
URL string
File string
Line int
}
// PRMergeabilitySummary describes whether a PR can be merged and why.
type PRMergeabilitySummary struct {
State domain.Mergeability
Reasons []string
PRURL string
ConflictFiles []PRConflictFile
}
// PRConflictFile is one file involved in a PR merge conflict.
type PRConflictFile struct {
Path string
URL string
}
// ListPRSummaries returns all PRs owned by a session with concise SCM details
// assembled from persisted PR/check/review facts.
func (s *Service) ListPRSummaries(ctx context.Context, id domain.SessionID) ([]PRSummary, error) {
if _, ok, err := s.store.GetSession(ctx, id); err != nil {
return nil, fmt.Errorf("get %s: %w", id, err)
} else if !ok {
return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
prs, err := s.store.ListPRsBySession(ctx, id)
if err != nil {
return nil, err
}
out := make([]PRSummary, 0, len(prs))
for _, pr := range prs {
checks, err := s.store.ListChecks(ctx, pr.URL)
if err != nil {
return nil, err
}
threads, err := s.store.ListPRReviewThreads(ctx, pr.URL)
if err != nil {
return nil, err
}
comments, err := s.store.ListPRComments(ctx, pr.URL)
if err != nil {
return nil, err
}
out = append(out, summarizePR(pr, checks, threads, comments))
}
sortPRSummaries(out)
return out, nil
}
func summarizePR(pr domain.PullRequest, checks []domain.PullRequestCheck, threads []domain.PullRequestReviewThread, comments []domain.PullRequestComment) PRSummary {
return PRSummary{
URL: pr.URL,
HTMLURL: firstNonEmpty(pr.HTMLURL, pr.URL),
Number: pr.Number,
Title: pr.Title,
State: pullRequestState(pr),
Provider: firstNonEmpty(pr.Provider, "github"),
Repo: pr.Repo,
Author: pr.Author,
SourceBranch: pr.SourceBranch,
TargetBranch: pr.TargetBranch,
HeadSHA: pr.HeadSHA,
Additions: pr.Additions,
Deletions: pr.Deletions,
ChangedFiles: pr.ChangedFiles,
CI: summarizeCI(pr, checks),
Review: summarizeReview(pr, comments),
Mergeability: summarizeMergeability(pr, threads),
UpdatedAt: pr.UpdatedAt,
ObservedAt: pr.ObservedAt,
CIObservedAt: pr.CIObservedAt,
ReviewObservedAt: pr.ReviewObservedAt,
}
}
func summarizeCI(pr domain.PullRequest, checks []domain.PullRequestCheck) PRCISummary {
state := ciOrUnknown(pr.CI)
out := PRCISummary{State: state}
if state != domain.CIFailing || pr.Merged || pr.Closed {
return out
}
for _, ch := range checks {
if ch.Status != domain.PRCheckFailed && ch.Status != domain.PRCheckCancelled {
continue
}
if pr.HeadSHA != "" && ch.CommitHash != "" && !strings.EqualFold(ch.CommitHash, pr.HeadSHA) {
continue
}
out.FailingChecks = append(out.FailingChecks, PRFailingCheck{
Name: ch.Name,
Status: ch.Status,
Conclusion: ch.Conclusion,
URL: ch.URL,
})
}
return out
}
func summarizeReview(pr domain.PullRequest, comments []domain.PullRequestComment) PRReviewSummary {
out := PRReviewSummary{Decision: reviewOrNone(pr.Review)}
if pr.Merged || pr.Closed {
return out
}
byReviewer := map[string]int{}
order := []string{}
links := map[string][]PRReviewCommentLink{}
for _, c := range comments {
if c.Resolved || c.IsBot {
continue
}
reviewer := strings.TrimSpace(c.Author)
if reviewer == "" {
reviewer = "unknown"
}
if _, ok := byReviewer[reviewer]; !ok {
order = append(order, reviewer)
}
byReviewer[reviewer]++
links[reviewer] = append(links[reviewer], PRReviewCommentLink{
URL: c.URL,
File: c.File,
Line: c.Line,
})
}
sort.Strings(order)
for _, reviewer := range order {
out.UnresolvedBy = append(out.UnresolvedBy, PRUnresolvedReviewer{
ReviewerID: reviewer,
Count: byReviewer[reviewer],
Links: links[reviewer],
})
}
out.HasUnresolvedHumanComments = len(out.UnresolvedBy) > 0
return out
}
func summarizeMergeability(pr domain.PullRequest, _ []domain.PullRequestReviewThread) PRMergeabilitySummary {
return PRMergeabilitySummary{
State: mergeabilityOrUnknown(pr.Mergeability),
Reasons: mergeabilityReasons(pr),
PRURL: firstNonEmpty(pr.HTMLURL, pr.URL),
}
}
func mergeabilityReasons(pr domain.PullRequest) []string {
if pr.Merged || pr.Closed {
return nil
}
if pr.Mergeability != domain.MergeConflicting && pr.Mergeability != domain.MergeBlocked && pr.Mergeability != domain.MergeUnstable {
return nil
}
reasons := map[string]bool{}
add := func(reason string) {
if reason != "" {
reasons[reason] = true
}
}
if pr.Mergeability == domain.MergeConflicting || containsAny(pr.ProviderMergeable, "conflict", "dirty") || containsAny(pr.ProviderMergeStateStatus, "conflict", "dirty") {
add("conflicts")
}
if containsAny(pr.ProviderMergeStateStatus, "behind") {
add("behind_base")
}
if pr.Draft {
add("draft")
}
if pr.CI == domain.CIFailing {
add("ci_failing")
}
if pr.Review == domain.ReviewChangesRequest {
add("changes_requested")
}
if pr.Review == domain.ReviewRequired {
add("review_required")
}
if pr.Mergeability == domain.MergeBlocked && len(reasons) == 0 {
add("blocked_by_provider")
}
if pr.Mergeability == domain.MergeUnstable && len(reasons) == 0 {
add("blocked_by_provider")
}
out := make([]string, 0, len(reasons))
for reason := range reasons {
out = append(out, reason)
}
sort.Strings(out)
return out
}
func containsAny(s string, needles ...string) bool {
s = strings.ToLower(s)
for _, needle := range needles {
if strings.Contains(s, needle) {
return true
}
}
return false
}
func sortPRSummaries(prs []PRSummary) {
sort.SliceStable(prs, func(i, j int) bool {
ia, ja := prSummaryActive(prs[i]), prSummaryActive(prs[j])
if ia != ja {
return ia
}
return prs[i].UpdatedAt.After(prs[j].UpdatedAt)
})
}
func prSummaryActive(pr PRSummary) bool {
return pr.State != domain.PRStateMerged && pr.State != domain.PRStateClosed
}
func pullRequestState(pr domain.PullRequest) domain.PRState {
switch {
case pr.Merged:
return domain.PRStateMerged
case pr.Closed:
return domain.PRStateClosed
case pr.Draft:
return domain.PRStateDraft
default:
return domain.PRStateOpen
}
}
func ciOrUnknown(state domain.CIState) domain.CIState {
if state == "" {
return domain.CIUnknown
}
return state
}
func reviewOrNone(decision domain.ReviewDecision) domain.ReviewDecision {
if decision == "" {
return domain.ReviewNone
}
return decision
}
func mergeabilityOrUnknown(state domain.Mergeability) domain.Mergeability {
if state == "" {
return domain.MergeUnknown
}
return state
}