agent-orchestrator/backend/internal/observe/scm/observer.go

1247 lines
43 KiB
Go

// Package scm implements the provider-neutral SCM polling observer. It owns the
// polling loop, ETag/cache checks, semantic diffing, DB persistence, and
// lifecycle notification; provider adapters only normalize provider-specific
// APIs into ports.SCMObservation values.
package scm
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/observe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process"
)
const (
// DefaultTickInterval is the SCM observer's PR/CI polling cadence.
DefaultTickInterval = 30 * time.Second
// DefaultReviewInterval is the minimum interval between review-thread polls
// for a PR whose review state warrants thread refresh.
DefaultReviewInterval = 2 * time.Minute
// DefaultCacheMax bounds each in-memory ETag/review cache map.
DefaultCacheMax = 512
// BatchSize is the maximum number of PRs in one provider batch fetch.
BatchSize = 25
)
// Provider is the normalized SCM provider contract used by the observer.
type Provider interface {
ParseRepository(remote string) (ports.SCMRepo, bool)
RepoPRListGuard(ctx context.Context, repo ports.SCMRepo, etag string) (ports.SCMGuardResult, error)
ListOpenPRsByRepo(ctx context.Context, repo ports.SCMRepo) ([]ports.SCMPRObservation, error)
CommitChecksGuard(ctx context.Context, repo ports.SCMRepo, headSHA, etag string) (ports.SCMGuardResult, error)
FetchPullRequests(ctx context.Context, refs []ports.SCMPRRef) ([]ports.SCMObservation, error)
FetchFailedCheckLogTail(ctx context.Context, repo ports.SCMRepo, check ports.SCMCheckObservation) (string, error)
FetchReviewThreads(ctx context.Context, ref ports.SCMPRRef) (ports.SCMReviewObservation, error)
}
// Store is the persistence contract the observer needs for discovery, local
// hash reads, and transactional SCM writes.
type Store interface {
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
UpsertProject(ctx context.Context, row domain.ProjectRecord) error
ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error)
ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error)
WriteSCMObservation(ctx context.Context, pr domain.PullRequest, checks []domain.PullRequestCheck, threads []domain.PullRequestReviewThread, comments []domain.PullRequestComment, reviewMode ports.ReviewWriteMode) error
}
// Lifecycle is the provider-neutral lifecycle notification sink.
type Lifecycle interface {
ApplySCMObservation(ctx context.Context, sessionID domain.SessionID, obs ports.SCMObservation) error
}
type credentialChecker interface {
SCMCredentialsAvailable(ctx context.Context) (bool, error)
}
// Config holds optional observer knobs. Zero values use production defaults.
type Config struct {
// Tick is the fast PR/CI polling interval. Zero uses DefaultTickInterval.
Tick time.Duration
// ReviewInterval is the slower review-thread refresh interval.
ReviewInterval time.Duration
// Clock supplies timestamps for observations and tests. Nil uses time.Now.
Clock func() time.Time
// Logger receives operational diagnostics for provider/store/lifecycle failures.
Logger *slog.Logger
// CacheMax bounds each in-memory ETag/review cache. Zero uses DefaultCacheMax.
CacheMax int
}
// ObserverCache stores provider ETags and review polling timestamps in memory.
// It is intentionally non-persistent for v1; cold restarts simply revalidate.
type ObserverCache struct {
// RepoPRListETag maps repository keys to the last open-PR-list ETag.
RepoPRListETag map[string]string
// CommitChecksETag maps repo+commit keys to the last check-runs ETag.
CommitChecksETag map[string]string
// LastReviewPollAt maps PR keys to the last review-thread fetch timestamp.
LastReviewPollAt map[string]time.Time
// ReviewRefreshFailed marks PRs whose review-thread refresh failed; the
// next poll retries regardless of the normal review cadence/status rules.
ReviewRefreshFailed map[string]bool
// repoOrder tracks FIFO eviction order for RepoPRListETag.
repoOrder []string
// commitOrder tracks FIFO eviction order for CommitChecksETag.
commitOrder []string
// lastReviewPollOrder tracks FIFO eviction order for LastReviewPollAt.
lastReviewPollOrder []string
// reviewFailedOrder tracks FIFO eviction order for ReviewRefreshFailed.
reviewFailedOrder []string
// max is the maximum number of entries each cache map retains.
max int
}
func newCache(maxEntries int) ObserverCache {
if maxEntries <= 0 {
maxEntries = DefaultCacheMax
}
return ObserverCache{
RepoPRListETag: map[string]string{},
CommitChecksETag: map[string]string{},
LastReviewPollAt: map[string]time.Time{},
ReviewRefreshFailed: map[string]bool{},
max: maxEntries,
}
}
// Observer coordinates provider polling, semantic diffing, persistence, and
// lifecycle notifications for SCM observations.
type Observer struct {
// provider is the SCM adapter used for all provider/network operations.
provider Provider
// store supplies sessions/projects/local PR state and receives transactional writes.
store Store
// lifecycle is notified after successful persistence of meaningful changes.
lifecycle Lifecycle
// tick is the active PR/CI polling cadence.
tick time.Duration
// reviewInterval is the minimum duration between review-thread fetches per PR.
reviewInterval time.Duration
// clock supplies observation timestamps.
clock func() time.Time
// logger receives non-fatal operational failures.
logger *slog.Logger
// credentialsChecked records whether an optional provider credential gate ran.
credentialsChecked bool
// disabled is set after the credential gate reports unavailable credentials.
disabled bool
// Cache holds bounded in-memory provider ETags and review poll timestamps.
Cache ObserverCache
}
// New constructs an Observer with default cadence/cache settings for zero
// values in cfg.
func New(provider Provider, store Store, lifecycle Lifecycle, cfg Config) *Observer {
o := &Observer{provider: provider, store: store, lifecycle: lifecycle, tick: cfg.Tick, reviewInterval: cfg.ReviewInterval, clock: cfg.Clock, logger: cfg.Logger, Cache: newCache(cfg.CacheMax)}
if o.tick <= 0 {
o.tick = DefaultTickInterval
}
if o.reviewInterval <= 0 {
o.reviewInterval = DefaultReviewInterval
}
if o.clock == nil {
o.clock = time.Now
}
if o.logger == nil {
o.logger = slog.Default()
}
return o
}
// Start launches the observer loop. The first Poll runs immediately inside the
// goroutine so daemon startup is not blocked; subsequent polls run on the tick.
//
// The first invocation of poll inside the supervisor also runs checkCredentials
// up front. That way the "scm observer disabled: provider credentials
// unavailable" warning is emitted on a fresh daemon even if discoverSubjects
// has no subjects yet (which would otherwise short-circuit Poll before
// checkCredentials). checkCredentials is guarded by credentialsChecked, so the
// wrap stays once-per-process; a transient error there simply defers the check
// to the next tick.
func (o *Observer) Start(ctx context.Context) <-chan struct{} {
var credentialGate sync.Once
poll := func(ctx context.Context) error {
credentialGate.Do(func() {
if _, err := o.checkCredentials(ctx); err != nil && !errors.Is(err, context.Canceled) {
o.logger.Error("scm observer: initial credential check failed", "err", err)
}
})
return o.Poll(ctx)
}
return observe.StartPollLoop(ctx, o.tick, poll, o.logger, "scm observer")
}
type subject struct {
session domain.SessionRecord
repo ports.SCMRepo
branch string
known domain.PullRequest
hasPR bool
}
// sessionRepo pairs a live session with its parsed repo and branch for per-repo
// branch-prefix discovery of new (including stacked) pull requests.
type sessionRepo struct {
session domain.SessionRecord
repo ports.SCMRepo
branch string
}
type repoGuardState struct {
result ports.SCMGuardResult
hadETag bool
err error
}
type pendingCacheString struct {
key string
value string
}
type refreshSelection struct {
refs []ports.SCMPRRef
subjectsByPR map[string]*subject
commitETags map[string]pendingCacheString
candidateKeys map[string]bool
}
type persistenceOptions struct {
reviewFetched bool
preserveLocalMetadataHash bool
preserveLocalCIHash bool
preserveLocalReviewHash bool
preserveLocalReviewDecision bool
}
// Poll runs one synchronous SCM observation cycle.
func (o *Observer) Poll(ctx context.Context) error {
now := o.clock().UTC()
if err := ctx.Err(); err != nil {
return err
}
if o.disabled {
return nil
}
subjects, sessionRepos, err := o.discoverSubjects(ctx)
if err != nil {
return err
}
if len(sessionRepos) == 0 {
return nil
}
proceed, err := o.checkCredentials(ctx)
if err != nil {
return err
}
if !proceed || o.disabled {
return nil
}
repoGuards := o.guardRepos(ctx, sessionRepos)
repoRefreshOK := pendingRepoRefreshes(repoGuards)
markRepoRefreshFailed := func(repo ports.SCMRepo) {
key := prKey(repo, 0)
if _, ok := repoRefreshOK[key]; ok {
repoRefreshOK[key] = false
}
}
if err := ctx.Err(); err != nil {
return err
}
o.discoverNewPRs(ctx, sessionRepos, subjects, repoGuards, now, markRepoRefreshFailed)
if err := ctx.Err(); err != nil {
return err
}
selection := o.selectRefreshCandidates(ctx, subjects, repoGuards, markRepoRefreshFailed)
observations := map[string]ports.SCMObservation{}
prRefreshOK := map[string]bool{}
for key := range selection.candidateKeys {
prRefreshOK[key] = false
}
for _, chunk := range chunks(selection.refs, BatchSize) {
if err := ctx.Err(); err != nil {
return err
}
batch, err := o.provider.FetchPullRequests(ctx, chunk)
if err != nil {
o.logger.Error("scm observer: GraphQL PR batch failed", "err", err)
for _, ref := range chunk {
markRepoRefreshFailed(ref.Repo)
}
continue
}
chunkSeen := map[string]bool{}
for _, obs := range batch {
obs.ObservedAt = now
key := prKeyFromObs(obs)
if key == "" {
continue
}
observations[key] = obs
chunkSeen[key] = true
}
for _, ref := range chunk {
key := prKey(ref.Repo, ref.Number)
if !chunkSeen[key] {
markRepoRefreshFailed(ref.Repo)
}
}
}
for key, subj := range selection.subjectsByPR {
if err := ctx.Err(); err != nil {
return err
}
obs, ok := observations[key]
if !ok {
continue
}
local := subj.known
o.enrichFailureLogs(ctx, &obs, local)
observations[key] = obs
}
reviewModes := map[string]ports.ReviewWriteMode{}
localOnlyObservations := map[string]bool{}
reviewStale := map[string]bool{}
o.refreshReviews(ctx, subjects, observations, selection.subjectsByPR, reviewModes, localOnlyObservations, reviewStale, now)
if err := ctx.Err(); err != nil {
return err
}
for key, obs := range observations {
if err := ctx.Err(); err != nil {
return err
}
subj, ok := selection.subjectsByPR[key]
if !ok {
continue
}
local := subj.known
reviewMode := reviewModes[key]
opts := persistenceOptions{
reviewFetched: reviewMode != ports.ReviewWritePreserve,
preserveLocalMetadataHash: localOnlyObservations[key],
preserveLocalCIHash: localOnlyObservations[key],
preserveLocalReviewHash: reviewStale[key],
preserveLocalReviewDecision: reviewStale[key],
}
prepared := o.prepareForPersistence(obs, local, opts, now)
if !prepared.Changed.Metadata && !prepared.Changed.CI && !prepared.Changed.Review {
prRefreshOK[key] = true
continue
}
finalPR, finalChecks, finalThreads, finalComments := domainFromObservation(subj.session.ID, prepared, local, opts, now)
pr, checks, threads, comments := finalPR, finalChecks, finalThreads, finalComments
// Lifecycle is allowed to run only after the observed facts are durable,
// but semantic hashes are the observer's acknowledgement cursor. Keep
// changed hashes at their local values until lifecycle succeeds; if the
// daemon restarts after a lifecycle failure, the stale hashes force the
// same observation to be fetched and delivered again.
if o.lifecycle != nil {
pendingOpts := opts
if prepared.Changed.Metadata {
pendingOpts.preserveLocalMetadataHash = true
}
if prepared.Changed.CI {
pendingOpts.preserveLocalCIHash = true
}
if prepared.Changed.Review {
pendingOpts.preserveLocalReviewHash = true
}
pr, checks, threads, comments = domainFromObservation(subj.session.ID, prepared, local, pendingOpts, now)
}
if err := o.store.WriteSCMObservation(ctx, pr, checks, threads, comments, reviewMode); err != nil {
o.logger.Error("scm observer: DB write failed", "session", subj.session.ID, "pr", pr.URL, "err", err)
markRepoRefreshFailed(subj.repo)
continue
}
if o.lifecycle != nil {
if err := o.lifecycle.ApplySCMObservation(ctx, subj.session.ID, prepared); err != nil {
o.logger.Error("scm observer: lifecycle notification failed", "session", subj.session.ID, "pr", firstNonEmpty(prepared.PR.URL, prepared.PR.HTMLURL, local.URL), "err", err)
markRepoRefreshFailed(subj.repo)
continue
}
if err := o.store.WriteSCMObservation(ctx, finalPR, finalChecks, nil, nil, ports.ReviewWritePreserve); err != nil {
o.logger.Error("scm observer: DB lifecycle acknowledgement failed", "session", subj.session.ID, "pr", finalPR.URL, "err", err)
markRepoRefreshFailed(subj.repo)
continue
}
}
prRefreshOK[key] = true
}
for key, ok := range prRefreshOK {
if !ok {
continue
}
if pending, found := selection.commitETags[key]; found {
o.cacheSetString(o.Cache.CommitChecksETag, &o.Cache.commitOrder, pending.key, pending.value)
}
if reviewModes[key] != ports.ReviewWritePreserve {
o.cacheSetTime(o.Cache.LastReviewPollAt, &o.Cache.lastReviewPollOrder, key, now)
}
}
for key, ok := range repoRefreshOK {
if !ok {
continue
}
if etag := repoGuards[key].result.ETag; etag != "" {
o.cacheSetString(o.Cache.RepoPRListETag, &o.Cache.repoOrder, key, etag)
}
}
return nil
}
func (o *Observer) checkCredentials(ctx context.Context) (bool, error) {
var probe observe.CredentialProbe
if checker, ok := o.provider.(credentialChecker); ok {
probe = checker.SCMCredentialsAvailable
}
return observe.CheckCredentialsOnce(ctx, probe, &o.credentialsChecked, &o.disabled, o.logger, "scm observer")
}
// discoverSubjects builds the per-PR refresh subjects (one per open tracked PR)
// and the per-session repo list used for branch-prefix discovery of new PRs. A
// session may own several PRs, so each open tracked PR becomes its own subject;
// merged/closed PRs are not re-fetched since lifecycle already saw the terminal
// transition and the completion rule reads them from the store.
func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, []sessionRepo, error) {
sessions, err := o.store.ListAllSessions(ctx)
if err != nil {
return nil, nil, err
}
projects := map[domain.ProjectID]domain.ProjectRecord{}
out := map[string]*subject{}
var sessionRepos []sessionRepo
for _, sess := range sessions {
if sess.IsTerminated {
continue
}
branch := strings.TrimSpace(sess.Metadata.Branch)
if branch == "" {
continue
}
proj, ok := projects[sess.ProjectID]
if !ok {
p, found, err := o.store.GetProject(ctx, string(sess.ProjectID))
if err != nil {
return nil, nil, err
}
if !found || !p.ArchivedAt.IsZero() {
continue
}
if p.RepoOriginURL == "" && p.Path != "" {
if url := resolveGitOriginURL(p.Path); url != "" {
p.RepoOriginURL = url
if err := o.store.UpsertProject(ctx, p); err != nil {
o.logger.Warn("scm observer: backfill origin URL persist failed", "project", p.ID, "err", err)
}
}
}
projects[sess.ProjectID] = p
proj = p
}
repo, ok := o.provider.ParseRepository(proj.RepoOriginURL)
if !ok {
o.logger.Debug("scm observer: project has no supported SCM origin", "project", proj.ID, "origin", proj.RepoOriginURL)
continue
}
sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, branch: branch})
prs, err := o.store.ListPRsBySession(ctx, sess.ID)
if err != nil {
return nil, nil, err
}
for _, pr := range openTrackedPRs(prs) {
key := prKey(repo, pr.Number)
if existing, ok := out[key]; ok {
o.logger.Warn("scm observer: duplicate tracked PR ownership skipped", "pr", key, "kept_session", existing.session.ID, "skipped_session", sess.ID)
continue
}
out[key] = &subject{session: sess, repo: repo, branch: branch, known: pr, hasPR: true}
}
}
return out, sessionRepos, nil
}
func openTrackedPRs(prs []domain.PullRequest) []domain.PullRequest {
out := make([]domain.PullRequest, 0, len(prs))
for _, pr := range prs {
if pr.Number > 0 && !pr.Merged && !pr.Closed {
out = append(out, pr)
}
}
return out
}
func (o *Observer) guardRepos(ctx context.Context, sessionRepos []sessionRepo) map[string]repoGuardState {
repos := map[string]ports.SCMRepo{}
for _, sr := range sessionRepos {
repos[prKey(sr.repo, 0)] = sr.repo
}
out := map[string]repoGuardState{}
for key, repo := range repos {
prev, had := o.Cache.RepoPRListETag[key]
res, err := o.provider.RepoPRListGuard(ctx, repo, prev)
if err != nil {
o.logger.Error("scm observer: repo PR-list guard failed", "repo", repoFullName(repo), "err", err)
out[key] = repoGuardState{hadETag: had, err: err}
continue
}
out[key] = repoGuardState{result: res, hadETag: had}
}
return out
}
func pendingRepoRefreshes(guards map[string]repoGuardState) map[string]bool {
out := map[string]bool{}
for key, g := range guards {
if g.err == nil && g.result.ETag != "" {
out[key] = true
}
}
return out
}
// discoverNewPRs lists each repo's open PRs once and attaches any not-yet-tracked
// PR to the session that owns its source branch. A session owns a PR when the
// PR's source branch equals the session branch or descends from it (the
// "branch/..." stacking convention). One session may therefore pick up several
// PRs (its root plus stacked children). Repos whose PR-list guard reports
// NotModified against a known ETag are skipped, since nothing new can have
// appeared since the last poll.
func (o *Observer) discoverNewPRs(ctx context.Context, sessionRepos []sessionRepo, subjects map[string]*subject, guards map[string]repoGuardState, now time.Time, markRepoFailed func(ports.SCMRepo)) {
byRepo := map[string][]sessionRepo{}
repos := map[string]ports.SCMRepo{}
for _, sr := range sessionRepos {
key := prKey(sr.repo, 0)
byRepo[key] = append(byRepo[key], sr)
repos[key] = sr.repo
}
for repoKey, repo := range repos {
g := guards[repoKey]
if g.err != nil {
continue
}
if g.result.NotModified && g.hadETag {
continue
}
pulls, err := o.provider.ListOpenPRsByRepo(ctx, repo)
if err != nil {
o.logger.Debug("scm observer: open PR list failed", "repo", repoFullName(repo), "err", err)
if markRepoFailed != nil && !errors.Is(err, ports.ErrSCMNotFound) {
markRepoFailed(repo)
}
continue
}
for _, pr := range pulls {
if pr.Number <= 0 || pr.SourceBranch == "" {
continue
}
// Branch-prefix attribution must only claim PRs whose head branch
// lives in the project repo. A fork PR can carry a head branch whose
// name matches an AO session branch; its commits live in the fork, so
// auto-claiming it would misattribute work. Same-repo PRs always
// report the base repo's full name as their head repo, so anything
// else (including an empty head repo from a deleted fork) is skipped.
if !strings.EqualFold(pr.HeadRepo, repoFullName(repo)) {
continue
}
key := prKey(repo, pr.Number)
if _, ok := subjects[key]; ok {
continue
}
sr, ok := matchSession(byRepo[repoKey], pr.SourceBranch)
if !ok {
continue
}
known := domain.PullRequest{
URL: firstNonEmpty(pr.URL, pr.HTMLURL),
SessionID: sr.session.ID,
Number: pr.Number,
Draft: pr.Draft,
SourceBranch: pr.SourceBranch,
TargetBranch: pr.TargetBranch,
HeadSHA: pr.HeadSHA,
Provider: repo.Provider,
Host: repo.Host,
Repo: repoFullName(repo),
UpdatedAt: now,
}
// Persist the discovered PR as an open baseline row immediately, before
// the refresh/lifecycle pass runs. A session can own several PRs, and a
// terminal observation for one of them triggers a completion check that
// reads every PR of the session from the store. Without this write, an
// open sibling/child discovered in the same poll would not yet be
// durable, and the session could terminate while that PR is still open.
if err := o.store.WriteSCMObservation(ctx, known, nil, nil, nil, ports.ReviewWritePreserve); err != nil {
o.logger.Error("scm observer: persist discovered PR failed", "session", sr.session.ID, "pr", known.URL, "err", err)
if markRepoFailed != nil {
markRepoFailed(repo)
}
continue
}
subjects[key] = &subject{
session: sr.session,
repo: repo,
branch: sr.branch,
known: known,
hasPR: true,
}
}
}
}
// matchSession picks the session that owns sourceBranch. A session owns the
// branch when it is an exact match or a stacked descendant ("branch/..."). The
// default worker branch is a leaf named "<namespace>/root"; for that shape the
// session also owns sibling branches under "<namespace>/..." so Git can create
// child PR branches without colliding with the root ref. When several session
// branches are prefixes of the same source branch the longest (most specific)
// one wins, so a child session claims its own stacked PRs rather than the
// ancestor session.
func matchSession(candidates []sessionRepo, sourceBranch string) (sessionRepo, bool) {
var best sessionRepo
bestLen := -1
for _, sr := range candidates {
if sr.branch == "" {
continue
}
for _, prefix := range sessionBranchPrefixes(sr.branch) {
if prefix == sourceBranch || strings.HasPrefix(sourceBranch, prefix+"/") {
if len(prefix) > bestLen {
best = sr
bestLen = len(prefix)
}
}
}
}
return best, bestLen >= 0
}
func sessionBranchPrefixes(branch string) []string {
prefixes := []string{branch}
if namespace, ok := strings.CutSuffix(branch, "/root"); ok && namespace != "" {
prefixes = append(prefixes, namespace)
}
return prefixes
}
func (o *Observer) selectRefreshCandidates(ctx context.Context, subjects map[string]*subject, guards map[string]repoGuardState, markRepoFailed func(ports.SCMRepo)) refreshSelection {
selection := refreshSelection{
subjectsByPR: map[string]*subject{},
commitETags: map[string]pendingCacheString{},
candidateKeys: map[string]bool{},
}
for _, s := range subjects {
if !s.hasPR || s.known.Number <= 0 {
continue
}
key := prKey(s.repo, s.known.Number)
selection.subjectsByPR[key] = s
candidate := missingLocalState(s.known)
g := guards[prKey(s.repo, 0)]
if g.err == nil && !g.result.NotModified {
candidate = true
}
if s.known.HeadSHA != "" {
commitKey := commitKey(s.repo, s.known.HeadSHA)
prev := o.Cache.CommitChecksETag[commitKey]
res, err := o.provider.CommitChecksGuard(ctx, s.repo, s.known.HeadSHA, prev)
if err != nil {
o.logger.Error("scm observer: commit check-runs guard failed", "pr", s.known.URL, "sha", s.known.HeadSHA, "err", err)
if markRepoFailed != nil {
markRepoFailed(s.repo)
}
} else if !res.NotModified {
candidate = true
if res.ETag != "" {
selection.commitETags[key] = pendingCacheString{key: commitKey, value: res.ETag}
}
}
}
if candidate {
selection.refs = append(selection.refs, ports.SCMPRRef{Repo: s.repo, Number: s.known.Number, URL: s.known.URL})
selection.candidateKeys[key] = true
}
}
return selection
}
func missingLocalState(pr domain.PullRequest) bool {
return pr.URL == "" || pr.HeadSHA == "" || pr.MetadataHash == "" || pr.CIHash == ""
}
func (o *Observer) enrichFailureLogs(ctx context.Context, obs *ports.SCMObservation, local domain.PullRequest) {
if obs.CI.Summary != string(domain.CIFailing) || obs.CI.FailedFingerprint == "" {
return
}
if strings.HasPrefix(local.CIHash, obs.CI.FailedFingerprint+":") {
checks, err := o.store.ListChecks(ctx, local.URL)
if err == nil && applyStoredFailedLogTails(obs, checks) {
return
}
}
tails := make([]string, 0, len(obs.CI.FailedChecks))
checksByProviderID := make(map[string][]int, len(obs.CI.Checks))
for i := range obs.CI.Checks {
key := checkProviderKey(obs.CI.Checks[i])
checksByProviderID[key] = append(checksByProviderID[key], i)
}
for i := range obs.CI.FailedChecks {
tail := obs.CI.FailedChecks[i].LogTail
if tail == "" && obs.CI.FailedChecks[i].ProviderID != "" {
var err error
tail, err = o.provider.FetchFailedCheckLogTail(ctx, ports.SCMRepo{Provider: obs.Provider, Host: obs.Host, Repo: obs.Repo, Owner: ownerOf(obs.Repo), Name: nameOf(obs.Repo)}, obs.CI.FailedChecks[i])
if err != nil {
tail = "<log fetch failed: " + scrubLine(err.Error()) + ">"
}
}
obs.CI.FailedChecks[i].LogTail = tail
if tail != "" {
tails = append(tails, tail)
}
for _, j := range checksByProviderID[checkProviderKey(obs.CI.FailedChecks[i])] {
obs.CI.Checks[j].LogTail = tail
}
}
obs.CI.FailureLogTail = strings.Join(tails, "\n---\n")
}
func checkProviderKey(ch ports.SCMCheckObservation) string {
return ch.Name + "\x00" + ch.ProviderID
}
func applyStoredFailedLogTails(obs *ports.SCMObservation, checks []domain.PullRequestCheck) bool {
tailsByName := map[string]string{}
for _, ch := range checks {
if obs.CI.HeadSHA != "" && ch.CommitHash != "" && ch.CommitHash != obs.CI.HeadSHA {
continue
}
if ch.LogTail != "" && (ch.Status == domain.PRCheckFailed || ch.Status == domain.PRCheckCancelled) {
tailsByName[ch.Name] = ch.LogTail
}
}
if len(tailsByName) == 0 {
return false
}
tails := make([]string, 0, len(obs.CI.FailedChecks))
for i := range obs.CI.FailedChecks {
tail := tailsByName[obs.CI.FailedChecks[i].Name]
if tail == "" {
return false
}
obs.CI.FailedChecks[i].LogTail = tail
tails = append(tails, tail)
}
for i := range obs.CI.Checks {
if tail := tailsByName[obs.CI.Checks[i].Name]; tail != "" {
obs.CI.Checks[i].LogTail = tail
}
}
obs.CI.FailureLogTail = strings.Join(tails, "\n---\n")
return true
}
func (o *Observer) refreshReviews(ctx context.Context, subjects map[string]*subject, observations map[string]ports.SCMObservation, subjectsByPR map[string]*subject, reviewModes map[string]ports.ReviewWriteMode, localOnlyObservations, reviewStale map[string]bool, now time.Time) {
for _, s := range subjects {
if !s.hasPR || s.known.Number <= 0 {
continue
}
pkey := prKey(s.repo, s.known.Number)
obs, hasObs := observations[pkey]
decision := string(s.known.Review)
if hasObs && obs.Review.Decision != "" {
decision = obs.Review.Decision
}
if !o.needsReviewRefresh(pkey, s.known, decision, hasObs, now) {
continue
}
review, err := o.provider.FetchReviewThreads(ctx, ports.SCMPRRef{Repo: s.repo, Number: s.known.Number, URL: s.known.URL})
if err != nil {
o.logger.Error("scm observer: review refresh failed", "pr", s.known.URL, "err", err)
o.cacheSetBool(o.Cache.ReviewRefreshFailed, &o.Cache.reviewFailedOrder, pkey, true)
if hasObs {
obs.Review.Decision = string(s.known.Review)
obs.Review.Threads = nil
observations[pkey] = obs
subjectsByPR[pkey] = s
reviewStale[pkey] = true
}
continue
}
if !hasObs {
checks, err := o.store.ListChecks(ctx, s.known.URL)
if err != nil {
o.logger.Error("scm observer: list local checks for review-only refresh failed", "pr", s.known.URL, "err", err)
}
obs = observationFromLocal(s.repo, s.known, checks)
localOnlyObservations[pkey] = true
}
if review.Decision != "" {
obs.Review.Decision = review.Decision
}
obs.Review.Threads = review.Threads
obs.Review.Partial = review.Partial
obs.ObservedAt = now
observations[pkey] = obs
subjectsByPR[pkey] = s
if review.Partial {
reviewModes[pkey] = ports.ReviewWriteMerge
} else {
reviewModes[pkey] = ports.ReviewWriteReplace
}
cacheDelete(o.Cache.ReviewRefreshFailed, &o.Cache.reviewFailedOrder, pkey)
}
}
func (o *Observer) needsReviewRefresh(key string, local domain.PullRequest, decision string, hasObs bool, now time.Time) bool {
if o.Cache.ReviewRefreshFailed[key] {
return true
}
if local.ReviewHash == "" {
return true
}
if decision == string(domain.ReviewChangesRequest) {
last := o.Cache.LastReviewPollAt[key]
return last.IsZero() || now.Sub(last) >= o.reviewInterval
}
if hasObs && decision != string(local.Review) {
return true
}
if local.ReviewHash != "" && string(local.Review) == string(domain.ReviewChangesRequest) && decision != string(domain.ReviewChangesRequest) {
return true
}
return false
}
func (o *Observer) prepareForPersistence(obs ports.SCMObservation, local domain.PullRequest, opts persistenceOptions, now time.Time) ports.SCMObservation {
metadataHash := metadataSemanticHash(obs)
if opts.preserveLocalMetadataHash {
metadataHash = local.MetadataHash
}
ciHash := ciSemanticHash(obs.CI)
if opts.preserveLocalCIHash {
ciHash = local.CIHash
}
reviewHash := local.ReviewHash
if !opts.preserveLocalReviewHash && (opts.reviewFetched || local.ReviewHash == "" || obs.Review.Decision != string(local.Review)) {
reviewHash = reviewSemanticHash(obs.Review)
}
obs.Changed = ports.SCMChanged{
Metadata: metadataHash != local.MetadataHash,
CI: ciHash != local.CIHash,
Review: reviewHash != local.ReviewHash,
}
obs.PR.State = firstNonEmpty(obs.PR.State, normalizePRState(obs.PR.Draft, obs.PR.Merged, obs.PR.Closed))
obs.ObservedAt = firstTime(obs.ObservedAt, now)
return obs
}
func domainFromObservation(sessionID domain.SessionID, obs ports.SCMObservation, local domain.PullRequest, opts persistenceOptions, now time.Time) (domain.PullRequest, []domain.PullRequestCheck, []domain.PullRequestReviewThread, []domain.PullRequestComment) {
metadataHash := metadataSemanticHash(obs)
if opts.preserveLocalMetadataHash {
metadataHash = local.MetadataHash
}
ciHash := ciSemanticHash(obs.CI)
if opts.preserveLocalCIHash {
ciHash = local.CIHash
}
reviewHash := reviewSemanticHash(obs.Review)
reviewDecision := domain.ReviewDecision(firstNonEmpty(obs.Review.Decision, string(domain.ReviewNone)))
if opts.preserveLocalReviewDecision {
reviewDecision = local.Review
}
if opts.preserveLocalReviewHash {
reviewHash = local.ReviewHash
} else if !opts.reviewFetched && local.ReviewHash != "" && reviewDecision == local.Review {
reviewHash = local.ReviewHash
}
observedAt := obs.ObservedAt
if !obs.Changed.Metadata && !obs.Changed.CI && !local.ObservedAt.IsZero() {
observedAt = local.ObservedAt
}
ciObservedAt := local.CIObservedAt
if obs.Changed.CI || ciObservedAt.IsZero() {
ciObservedAt = obs.ObservedAt
}
reviewObservedAt := local.ReviewObservedAt
if opts.reviewFetched || reviewObservedAt.IsZero() {
reviewObservedAt = obs.ObservedAt
}
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: reviewDecision,
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,
MetadataHash: metadataHash,
CIHash: ciHash,
ReviewHash: reviewHash,
ObservedAt: observedAt,
CIObservedAt: ciObservedAt,
ReviewObservedAt: reviewObservedAt,
}
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})
}
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, SemanticHash: threadSemanticHash(th), 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, threads, comments
}
func observationFromLocal(repo ports.SCMRepo, pr domain.PullRequest, checks []domain.PullRequestCheck) ports.SCMObservation {
return ports.SCMObservation{
Fetched: true,
Provider: firstNonEmpty(pr.Provider, repo.Provider),
Host: firstNonEmpty(pr.Host, repo.Host),
Repo: firstNonEmpty(pr.Repo, repoFullName(repo)),
PR: ports.SCMPRObservation{URL: pr.URL, Number: pr.Number, State: normalizePRState(pr.Draft, pr.Merged, pr.Closed), Draft: pr.Draft, Merged: pr.Merged, Closed: pr.Closed, SourceBranch: pr.SourceBranch, TargetBranch: pr.TargetBranch, HeadSHA: pr.HeadSHA, Title: pr.Title, Additions: pr.Additions, Deletions: pr.Deletions, ChangedFiles: pr.ChangedFiles, Author: pr.Author, BaseSHA: pr.BaseSHA, MergeCommitSHA: pr.MergeCommitSHA, ProviderState: pr.ProviderState, ProviderMergeable: pr.ProviderMergeable, ProviderMergeStateStatus: pr.ProviderMergeStateStatus, HTMLURL: pr.HTMLURL, CreatedAtProvider: pr.CreatedAtProvider, UpdatedAtProvider: pr.UpdatedAtProvider, MergedAtProvider: pr.MergedAtProvider, ClosedAtProvider: pr.ClosedAtProvider},
CI: ciObservationFromLocal(pr, checks),
Review: ports.SCMReviewObservation{Decision: string(pr.Review)},
Mergeability: mergeabilityObservationFromLocal(pr),
}
}
func ciObservationFromLocal(pr domain.PullRequest, checks []domain.PullRequestCheck) ports.SCMCIObservation {
ci := ports.SCMCIObservation{
Summary: firstNonEmpty(string(pr.CI), string(domain.CIUnknown)),
HeadSHA: pr.HeadSHA,
FailedFingerprint: failedFingerprintFromCIHash(pr.CIHash),
}
tails := []string{}
for _, ch := range checks {
if pr.HeadSHA != "" && ch.CommitHash != "" && ch.CommitHash != pr.HeadSHA {
continue
}
if ci.HeadSHA == "" {
ci.HeadSHA = ch.CommitHash
}
check := ports.SCMCheckObservation{
Name: ch.Name,
Status: string(ch.Status),
Conclusion: ch.Conclusion,
URL: ch.URL,
LogTail: ch.LogTail,
ProviderID: ch.Details,
}
ci.Checks = append(ci.Checks, check)
if ch.Status == domain.PRCheckFailed || ch.Status == domain.PRCheckCancelled {
ci.FailedChecks = append(ci.FailedChecks, check)
if ch.LogTail != "" {
tails = append(tails, ch.LogTail)
}
}
}
ci.FailureLogTail = strings.Join(tails, "\n---\n")
return ci
}
func failedFingerprintFromCIHash(hash string) string {
before, _, ok := strings.Cut(hash, ":")
if !ok {
return ""
}
return before
}
func mergeabilityObservationFromLocal(pr domain.PullRequest) ports.SCMMergeabilityObservation {
out := mergeabilityFromProviderFacts(pr.ProviderMergeable, pr.ProviderMergeStateStatus, string(pr.CI), string(pr.Review), pr.Draft)
if pr.Mergeability != "" && out.State != string(pr.Mergeability) {
out = ports.SCMMergeabilityObservation{State: string(pr.Mergeability)}
} else if pr.Mergeability != "" {
out.State = string(pr.Mergeability)
}
switch domain.Mergeability(out.State) {
case domain.MergeMergeable:
out.Mergeable = true
case domain.MergeConflicting:
out.Conflict = true
if len(out.Blockers) == 0 {
out.Blockers = append(out.Blockers, "conflicts")
}
case domain.MergeBlocked:
if len(out.Blockers) == 0 {
out.Blockers = mergeBlockersFromLocal(pr)
}
}
return out
}
func mergeBlockersFromLocal(pr domain.PullRequest) []string {
blockers := []string{}
if pr.Draft {
blockers = append(blockers, "draft")
}
if pr.CI == domain.CIFailing {
blockers = append(blockers, "ci_failing")
}
switch pr.Review {
case domain.ReviewChangesRequest:
blockers = append(blockers, "changes_requested")
case domain.ReviewRequired:
blockers = append(blockers, "review_required")
}
if len(blockers) == 0 {
blockers = append(blockers, "blocked_by_provider")
}
return blockers
}
func mergeabilityFromProviderFacts(providerMergeable, providerMergeState, ci, review string, draft bool) ports.SCMMergeabilityObservation {
state := strings.ToUpper(strings.TrimSpace(providerMergeState))
mergeable := strings.ToUpper(strings.TrimSpace(providerMergeable))
out := ports.SCMMergeabilityObservation{State: string(domain.MergeUnknown)}
addBlocker := func(b string) { out.Blockers = append(out.Blockers, b) }
if state == "DIRTY" || mergeable == "CONFLICTING" {
out.State = string(domain.MergeConflicting)
out.Conflict = true
addBlocker("conflicts")
return out
}
if state == "BEHIND" || state == "BEHIND_BASE" {
out.BehindBase = true
addBlocker("behind_base")
}
if state == "BLOCKED" {
out.State = string(domain.MergeBlocked)
addBlocker("blocked_by_provider")
}
if draft {
out.State = string(domain.MergeBlocked)
addBlocker("draft")
}
if ci == string(domain.CIFailing) {
out.State = string(domain.MergeBlocked)
addBlocker("ci_failing")
}
switch review {
case string(domain.ReviewChangesRequest):
out.State = string(domain.MergeBlocked)
addBlocker("changes_requested")
case string(domain.ReviewRequired):
out.State = string(domain.MergeBlocked)
addBlocker("review_required")
}
if out.State == string(domain.MergeBlocked) {
return out
}
if state == "UNSTABLE" {
out.State = string(domain.MergeUnstable)
return out
}
if mergeable == "MERGEABLE" && (state == "CLEAN" || state == "HAS_HOOKS" || state == "") &&
(review == "" || review == string(domain.ReviewApproved) || review == string(domain.ReviewNone)) && !draft {
out.State = string(domain.MergeMergeable)
out.Mergeable = true
return out
}
return out
}
func chunks[T any](in []T, n int) [][]T {
if n <= 0 || len(in) == 0 {
return nil
}
out := make([][]T, 0, (len(in)+n-1)/n)
for len(in) > 0 {
end := n
if len(in) < end {
end = len(in)
}
out = append(out, in[:end])
in = in[end:]
}
return out
}
func metadataSemanticHash(obs ports.SCMObservation) string {
return stableHash(map[string]any{"provider": obs.Provider, "host": obs.Host, "repo": obs.Repo, "pr": obs.PR, "mergeability": obs.Mergeability})
}
func ciSemanticHash(ci ports.SCMCIObservation) string {
h := stableHash(map[string]any{"summary": ci.Summary, "head": ci.HeadSHA, "checks": ci.Checks, "failed": ci.FailedChecks, "tail": ci.FailureLogTail})
if ci.FailedFingerprint != "" {
return ci.FailedFingerprint + ":" + h
}
return h
}
func reviewSemanticHash(review ports.SCMReviewObservation) string {
type reviewHashPayload struct {
Decision string
Threads []ports.SCMReviewThreadObservation
Partial bool `json:",omitempty"`
}
return stableHash(reviewHashPayload{Decision: review.Decision, Threads: review.Threads, Partial: review.Partial})
}
func threadSemanticHash(th ports.SCMReviewThreadObservation) string {
return stableHash(th)
}
func stableHash(v any) string {
b, err := json.Marshal(v)
if err != nil {
b = []byte(fmt.Sprintf("%#v", v))
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func prKeyFromObs(obs ports.SCMObservation) string {
if obs.Repo == "" || obs.PR.Number <= 0 {
return ""
}
return obs.Provider + ":" + obs.Host + ":" + obs.Repo + "#" + fmt.Sprint(obs.PR.Number)
}
func prKey(repo ports.SCMRepo, number int) string {
base := repo.Provider + ":" + repo.Host + ":" + repoFullName(repo)
if number <= 0 {
return base
}
return base + "#" + fmt.Sprint(number)
}
func commitKey(repo ports.SCMRepo, sha string) string { return prKey(repo, 0) + "@" + sha }
func repoFullName(repo ports.SCMRepo) string {
if repo.Repo != "" {
return repo.Repo
}
return repo.Owner + "/" + repo.Name
}
func ownerOf(full string) string {
parts := strings.SplitN(full, "/", 2)
if len(parts) == 2 {
return parts[0]
}
return ""
}
func nameOf(full string) string {
parts := strings.SplitN(full, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return full
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func firstTime(a, b time.Time) time.Time {
if !a.IsZero() {
return a
}
return b
}
func normalizePRState(draft, merged, closed bool) string {
switch {
case merged:
return string(domain.PRStateMerged)
case closed:
return string(domain.PRStateClosed)
case draft:
return string(domain.PRStateDraft)
default:
return string(domain.PRStateOpen)
}
}
// resolveGitOriginURL runs `git -C path remote get-url origin` and returns the
// trimmed URL, or "" if the command fails (missing repo, no origin remote, etc).
// The observer uses this to backfill projects that were registered before
// project.Add resolved origin URLs at add time.
func resolveGitOriginURL(path string) string {
out, err := aoprocess.Command("git", "-C", path, "remote", "get-url", "origin").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func scrubLine(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
return strings.TrimSpace(s)
}
// cacheSetString / cacheSetTime / cacheSetBool are thin wrappers around the
// generic observe.CacheSet helper, kept on Observer so callers don't need to
// thread o.Cache.max through every invocation. The single shared
// implementation lives in the observe package.
func (o *Observer) cacheSetString(m map[string]string, order *[]string, key, value string) {
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func (o *Observer) cacheSetTime(m map[string]time.Time, order *[]string, key string, value time.Time) {
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func (o *Observer) cacheSetBool(m map[string]bool, order *[]string, key string, value bool) {
observe.CacheSet(m, order, o.Cache.max, key, value)
}
func cacheDelete[V any](m map[string]V, order *[]string, key string) {
observe.CacheDelete(m, order, key)
}