Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions backend/internal/cli/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,19 @@ func authorizedAgentsJSON(agent string) string {
return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
}

func setSpawnConfigEnv(t *testing.T) testConfig {
t.Helper()
cfg := setConfigEnv(t)
t.Setenv("AO_PROJECT_ID", "")
t.Setenv("AO_SESSION_ID", "")
t.Setenv("AO_ISSUE_ID", "")
return cfg
}

// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
func TestSpawnCommand_MissingProjectContext(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -63,7 +72,7 @@ func TestProjectAddCommand_RequiresPath(t *testing.T) {
}

func TestSpawnClaimPRWiring(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -103,7 +112,7 @@ func TestSpawnClaimPRWiring(t *testing.T) {
}

func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
sessions := map[string]bool{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -176,7 +185,7 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
}

func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -217,7 +226,7 @@ func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
}

func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -257,7 +266,7 @@ func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
}

func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -285,7 +294,7 @@ func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
}

func TestSpawnResolvesProjectFromCWD(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
repo := filepath.Join(t.TempDir(), "repo")
subdir := filepath.Join(repo, "pkg")
if err := os.MkdirAll(subdir, 0o755); err != nil {
Expand Down Expand Up @@ -332,7 +341,7 @@ func TestSpawnResolvesProjectFromCWD(t *testing.T) {
}

func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -374,7 +383,7 @@ func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
}

func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -416,7 +425,7 @@ func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
}

func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -459,7 +468,7 @@ func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
}

func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -487,7 +496,7 @@ func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
}

func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -517,7 +526,7 @@ func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
}

func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -559,7 +568,7 @@ func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
}

func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -602,7 +611,7 @@ func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
}

func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appendPrimaryRequest(&requests, r)
Expand Down Expand Up @@ -633,7 +642,7 @@ func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
}

func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var requests []string
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -668,7 +677,7 @@ func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
}

func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
cfg := setConfigEnv(t)
cfg := setSpawnConfigEnv(t)
var req spawnRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/domain/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ type PullRequest struct {
Review ReviewDecision
Mergeability Mergeability
UpdatedAt time.Time
// StateChangedAt is when the current normalized PR lifecycle state became
// active. It is seeded from provider timestamps and updated when AO observes
// a draft/open/merged/closed transition.
StateChangedAt time.Time

Provider string
Host string
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3005,6 +3005,9 @@ components:
ciObservedAt:
format: date-time
type: string
createdAt:
format: date-time
type: string
deletions:
type: integer
headSha:
Expand Down Expand Up @@ -3038,6 +3041,9 @@ components:
- merged
- closed
type: string
stateChangedAt:
format: date-time
type: string
targetBranch:
type: string
title:
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/httpd/controllers/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ type SessionPRSummary struct {
CI SessionPRCISummary `json:"ci"`
Review SessionPRReviewSummary `json:"review"`
Mergeability SessionPRMergeabilitySummary `json:"mergeability"`
StateChangedAt time.Time `json:"stateChangedAt,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
ObservedAt time.Time `json:"observedAt,omitempty"`
CIObservedAt time.Time `json:"ciObservedAt,omitempty"`
Expand Down Expand Up @@ -396,6 +398,8 @@ func NewSessionPRSummary(in sessionsvc.PRSummary) SessionPRSummary {
CI: newSessionPRCISummary(in.CI),
Review: newSessionPRReviewSummary(in.Review),
Mergeability: newSessionPRMergeabilitySummary(in.Mergeability),
StateChangedAt: in.StateChangedAt,
CreatedAt: in.CreatedAt,
UpdatedAt: in.UpdatedAt,
ObservedAt: in.ObservedAt,
CIObservedAt: in.CIObservedAt,
Expand Down
22 changes: 16 additions & 6 deletions backend/internal/httpd/controllers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ func (f *fakeSessionService) ListPRSummaries(_ context.Context, id domain.Sessio
Reasons: []string{"conflicts"},
PRURL: "https://github.com/aoagents/agent-orchestrator/pull/142",
},
UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC),
StateChangedAt: time.Date(2026, 6, 4, 11, 30, 0, 0, time.UTC),
CreatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC),
}}, nil
}

Expand Down Expand Up @@ -1211,11 +1213,13 @@ func TestSessionsAPI_PRRoutes(t *testing.T) {
var listed struct {
SessionID string `json:"sessionId"`
PRs []struct {
URL string `json:"url"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
CI struct {
URL string `json:"url"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
StateChangedAt string `json:"stateChangedAt"`
CreatedAt string `json:"createdAt"`
CI struct {
State string `json:"state"`
FailingChecks []struct {
Name string `json:"name"`
Expand Down Expand Up @@ -1253,6 +1257,12 @@ func TestSessionsAPI_PRRoutes(t *testing.T) {
if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" || listed.PRs[0].Title == "" {
t.Fatalf("GET shape = %#v", listed)
}
if listed.PRs[0].StateChangedAt != "2026-06-04T11:30:00Z" {
t.Fatalf("stateChangedAt = %q, want backend-selected PR state time", listed.PRs[0].StateChangedAt)
}
if listed.PRs[0].CreatedAt != "2026-06-04T09:00:00Z" {
t.Fatalf("createdAt = %q, want provider PR creation time", listed.PRs[0].CreatedAt)
}
if checks := listed.PRs[0].CI.FailingChecks; len(checks) != 1 || checks[0].Name != "unit" || checks[0].LogTail != "" {
t.Fatalf("failing checks = %#v", checks)
}
Expand Down
56 changes: 39 additions & 17 deletions backend/internal/service/session/pr_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,27 @@ import (

// 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
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
// StateChangedAt is when the current draft/open/merged/closed state became
// active. It is backend-selected from durable PR/provider facts.
StateChangedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
ObservedAt time.Time
CIObservedAt time.Time
Expand Down Expand Up @@ -142,6 +146,8 @@ func summarizePR(pr domain.PullRequest, checks []domain.PullRequestCheck, review
CI: summarizeCI(pr, checks),
Review: summarizeReview(pr, comments, reviews),
Mergeability: summarizeMergeability(pr, threads),
StateChangedAt: summarizePRStateChangedAt(pr),
CreatedAt: pr.CreatedAtProvider,
UpdatedAt: pr.UpdatedAt,
ObservedAt: pr.ObservedAt,
CIObservedAt: pr.CIObservedAt,
Expand Down Expand Up @@ -172,6 +178,22 @@ func summarizeCI(pr domain.PullRequest, checks []domain.PullRequestCheck) PRCISu
return out
}

func summarizePRStateChangedAt(pr domain.PullRequest) time.Time {
if !pr.StateChangedAt.IsZero() {
return pr.StateChangedAt
}
switch pullRequestState(pr) {
case domain.PRStateMerged:
return pr.MergedAtProvider
case domain.PRStateClosed:
return pr.ClosedAtProvider
case domain.PRStateDraft, domain.PRStateOpen:
return pr.CreatedAtProvider
default:
return time.Time{}
}
}

func summarizeReview(pr domain.PullRequest, comments []domain.PullRequestComment, reviews []domain.PullRequestReview) PRReviewSummary {
out := PRReviewSummary{Decision: reviewOrNone(pr.Review)}
if pr.Merged || pr.Closed {
Expand Down
Loading
Loading