diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 2488d8f4..d8085209 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -94,7 +94,7 @@ func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) err return nil } -func (f *fakeSessionService) ListPRs(context.Context, domain.SessionID) ([]domain.PRFacts, error) { +func (f *fakeSessionService) ListPRSummaries(context.Context, domain.SessionID) ([]sessionsvc.PRSummary, error) { return nil, nil } diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 7e289b37..91986fc2 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -59,6 +59,6 @@ type SessionRecord struct { // plus the derived display Status. type Session struct { SessionRecord - Status SessionStatus `json:"status"` + Status SessionStatus `json:"status" enum:"working,pr_open,draft,ci_failed,review_pending,changes_requested,approved,mergeable,merged,needs_input,idle,terminated,no_signal"` TerminalHandleID string `json:"terminalHandleId,omitempty"` } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 3e067d4d..d2e26300 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1340,7 +1340,7 @@ components: properties: prs: items: - $ref: '#/components/schemas/SessionPRFacts' + $ref: '#/components/schemas/SessionPRSummary' type: array sessionId: type: string @@ -1709,6 +1709,20 @@ components: projectId: type: string status: + enum: + - working + - pr_open + - draft + - ci_failed + - review_pending + - changes_requested + - approved + - mergeable + - merged + - needs_input + - idle + - terminated + - no_signal type: string terminalHandleId: type: string @@ -1725,15 +1739,57 @@ components: - updatedAt - status type: object + SessionPRCISummary: + properties: + failingChecks: + items: + $ref: '#/components/schemas/SessionPRFailingCheck' + type: array + state: + enum: + - unknown + - pending + - passing + - failing + type: string + required: + - state + - failingChecks + type: object + SessionPRConflictFile: + properties: + path: + type: string + url: + type: string + required: + - path + type: object SessionPRFacts: properties: ci: + enum: + - unknown + - pending + - passing + - failing type: string mergeability: + enum: + - unknown + - mergeable + - conflicting + - blocked + - unstable type: string number: type: integer review: + enum: + - none + - approved + - changes_requested + - review_required type: string reviewComments: type: boolean @@ -1759,6 +1815,158 @@ components: - reviewComments - updatedAt type: object + SessionPRFailingCheck: + properties: + conclusion: + type: string + name: + type: string + status: + enum: + - failed + - cancelled + type: string + url: + type: string + required: + - name + - status + - conclusion + type: object + SessionPRMergeabilitySummary: + properties: + conflictFiles: + items: + $ref: '#/components/schemas/SessionPRConflictFile' + type: array + prUrl: + type: string + reasons: + items: + type: string + type: array + state: + enum: + - unknown + - mergeable + - conflicting + - blocked + - unstable + type: string + required: + - state + - reasons + - prUrl + type: object + SessionPRReviewCommentLink: + properties: + file: + type: string + line: + type: integer + url: + type: string + type: object + SessionPRReviewSummary: + properties: + decision: + enum: + - none + - approved + - changes_requested + - review_required + type: string + hasUnresolvedHumanComments: + type: boolean + unresolvedBy: + items: + $ref: '#/components/schemas/SessionPRUnresolvedReviewer' + type: array + required: + - decision + - hasUnresolvedHumanComments + - unresolvedBy + type: object + SessionPRSummary: + properties: + author: + type: string + ci: + $ref: '#/components/schemas/SessionPRCISummary' + ciObservedAt: + format: date-time + type: string + headSha: + type: string + htmlUrl: + type: string + mergeability: + $ref: '#/components/schemas/SessionPRMergeabilitySummary' + number: + type: integer + observedAt: + format: date-time + type: string + provider: + enum: + - github + type: string + repo: + type: string + review: + $ref: '#/components/schemas/SessionPRReviewSummary' + reviewObservedAt: + format: date-time + type: string + sourceBranch: + type: string + state: + enum: + - draft + - open + - merged + - closed + type: string + targetBranch: + type: string + title: + type: string + updatedAt: + format: date-time + type: string + url: + type: string + required: + - url + - number + - title + - state + - provider + - repo + - author + - sourceBranch + - targetBranch + - headSha + - ci + - review + - mergeability + - updatedAt + type: object + SessionPRUnresolvedReviewer: + properties: + count: + type: integer + links: + items: + $ref: '#/components/schemas/SessionPRReviewCommentLink' + type: array + reviewerId: + type: string + required: + - reviewerId + - count + - links + type: object SessionResponse: properties: session: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 2aeca734..ddc2c24e 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -130,38 +130,46 @@ var schemaNames = map[string]string{ "DomainAgentConfig": "AgentConfig", "DomainRoleOverride": "RoleOverride", // httpd/controllers (wire envelopes) - "ControllersListProjectsResponse": "ListProjectsResponse", - "ControllersProjectResponse": "ProjectResponse", - "ControllersGetProjectResponse": "ProjectGetResponse", - "ControllersProjectOrDegraded": "ProjectOrDegraded", - "ControllersListSessionsQuery": "ListSessionsQuery", - "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", - "ControllersListSessionsResponse": "ListSessionsResponse", - "ControllersSpawnSessionRequest": "SpawnSessionRequest", - "ControllersSessionResponse": "SessionResponse", - "ControllersRenameSessionRequest": "RenameSessionRequest", - "ControllersRenameSessionResponse": "RenameSessionResponse", - "ControllersRestoreSessionResponse": "RestoreSessionResponse", - "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", - "ControllersCleanupSkippedSession": "CleanupSkippedSession", - "ControllersKillSessionResponse": "KillSessionResponse", - "ControllersRollbackSessionResponse": "RollbackSessionResponse", - "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", - "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", - "ControllersClaimPRResponse": "ClaimPRResponse", - "ControllersClaimPRRequest": "ClaimPRRequest", - "ControllersSessionPRFacts": "SessionPRFacts", - "ControllersListSessionPRsResponse": "ListSessionPRsResponse", - "ControllersSetActivityRequest": "SetActivityRequest", - "ControllersSetActivityResponse": "SetActivityResponse", - "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", - "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", - "ControllersOrchestratorResponse": "OrchestratorResponse", - "ControllersListNotificationsQuery": "ListNotificationsQuery", - "ControllersNotificationStreamQuery": "NotificationStreamQuery", - "ControllersNotificationTarget": "NotificationTarget", - "ControllersNotificationResponse": "NotificationResponse", - "ControllersListNotificationsResponse": "ListNotificationsResponse", + "ControllersListProjectsResponse": "ListProjectsResponse", + "ControllersProjectResponse": "ProjectResponse", + "ControllersGetProjectResponse": "ProjectGetResponse", + "ControllersProjectOrDegraded": "ProjectOrDegraded", + "ControllersListSessionsQuery": "ListSessionsQuery", + "ControllersCleanupSessionsQuery": "CleanupSessionsQuery", + "ControllersListSessionsResponse": "ListSessionsResponse", + "ControllersSpawnSessionRequest": "SpawnSessionRequest", + "ControllersSessionResponse": "SessionResponse", + "ControllersRenameSessionRequest": "RenameSessionRequest", + "ControllersRenameSessionResponse": "RenameSessionResponse", + "ControllersRestoreSessionResponse": "RestoreSessionResponse", + "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", + "ControllersCleanupSkippedSession": "CleanupSkippedSession", + "ControllersKillSessionResponse": "KillSessionResponse", + "ControllersRollbackSessionResponse": "RollbackSessionResponse", + "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", + "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", + "ControllersClaimPRResponse": "ClaimPRResponse", + "ControllersClaimPRRequest": "ClaimPRRequest", + "ControllersSessionPRFacts": "SessionPRFacts", + "ControllersSessionPRSummary": "SessionPRSummary", + "ControllersSessionPRCISummary": "SessionPRCISummary", + "ControllersSessionPRFailingCheck": "SessionPRFailingCheck", + "ControllersSessionPRReviewSummary": "SessionPRReviewSummary", + "ControllersSessionPRUnresolvedReviewer": "SessionPRUnresolvedReviewer", + "ControllersSessionPRReviewCommentLink": "SessionPRReviewCommentLink", + "ControllersSessionPRMergeabilitySummary": "SessionPRMergeabilitySummary", + "ControllersSessionPRConflictFile": "SessionPRConflictFile", + "ControllersListSessionPRsResponse": "ListSessionPRsResponse", + "ControllersSetActivityRequest": "SetActivityRequest", + "ControllersSetActivityResponse": "SetActivityResponse", + "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", + "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", + "ControllersOrchestratorResponse": "OrchestratorResponse", + "ControllersListNotificationsQuery": "ListNotificationsQuery", + "ControllersNotificationStreamQuery": "NotificationStreamQuery", + "ControllersNotificationTarget": "NotificationTarget", + "ControllersNotificationResponse": "NotificationResponse", + "ControllersListNotificationsResponse": "ListNotificationsResponse", // httpd/controllers — PR wire envelopes "ControllersMergePRResponse": "MergePRResponse", "ControllersResolveCommentsRequest": "ResolveCommentsRequest", diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index d2bb10a8..ff13f894 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -7,6 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) // HTTP response envelopes for the projects surface — the SINGLE definition of @@ -199,17 +200,134 @@ type SessionPRFacts struct { URL string `json:"url"` Number int `json:"number"` State string `json:"state" enum:"draft,open,merged,closed"` - CI domain.CIState `json:"ci"` - Review domain.ReviewDecision `json:"review"` - Mergeability domain.Mergeability `json:"mergeability"` + CI domain.CIState `json:"ci" enum:"unknown,pending,passing,failing"` + Review domain.ReviewDecision `json:"review" enum:"none,approved,changes_requested,review_required"` + Mergeability domain.Mergeability `json:"mergeability" enum:"unknown,mergeable,conflicting,blocked,unstable"` ReviewComments bool `json:"reviewComments"` UpdatedAt time.Time `json:"updatedAt"` } +// SessionPRSummary is the concise desktop SCM read model returned by GET +// /sessions/{sessionId}/pr. It intentionally omits CI log tails and review +// comment bodies. +type SessionPRSummary struct { + URL string `json:"url"` + HTMLURL string `json:"htmlUrl,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + State domain.PRState `json:"state" enum:"draft,open,merged,closed"` + Provider string `json:"provider" enum:"github"` + Repo string `json:"repo"` + Author string `json:"author"` + SourceBranch string `json:"sourceBranch"` + TargetBranch string `json:"targetBranch"` + HeadSHA string `json:"headSha"` + CI SessionPRCISummary `json:"ci"` + Review SessionPRReviewSummary `json:"review"` + Mergeability SessionPRMergeabilitySummary `json:"mergeability"` + UpdatedAt time.Time `json:"updatedAt"` + ObservedAt time.Time `json:"observedAt,omitempty"` + CIObservedAt time.Time `json:"ciObservedAt,omitempty"` + ReviewObservedAt time.Time `json:"reviewObservedAt,omitempty"` +} + +type SessionPRCISummary struct { + State domain.CIState `json:"state" enum:"unknown,pending,passing,failing"` + FailingChecks []SessionPRFailingCheck `json:"failingChecks"` +} + +type SessionPRFailingCheck struct { + Name string `json:"name"` + Status domain.PRCheckStatus `json:"status" enum:"failed,cancelled"` + Conclusion string `json:"conclusion"` + URL string `json:"url,omitempty"` +} + +type SessionPRReviewSummary struct { + Decision domain.ReviewDecision `json:"decision" enum:"none,approved,changes_requested,review_required"` + HasUnresolvedHumanComments bool `json:"hasUnresolvedHumanComments"` + UnresolvedBy []SessionPRUnresolvedReviewer `json:"unresolvedBy"` +} + +type SessionPRUnresolvedReviewer struct { + ReviewerID string `json:"reviewerId"` + Count int `json:"count"` + Links []SessionPRReviewCommentLink `json:"links"` +} + +type SessionPRReviewCommentLink struct { + URL string `json:"url,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` +} + +type SessionPRMergeabilitySummary struct { + State domain.Mergeability `json:"state" enum:"unknown,mergeable,conflicting,blocked,unstable"` + Reasons []string `json:"reasons"` + PRURL string `json:"prUrl"` + ConflictFiles []SessionPRConflictFile `json:"conflictFiles,omitempty"` +} + +type SessionPRConflictFile struct { + Path string `json:"path"` + URL string `json:"url,omitempty"` +} + // ListSessionPRsResponse is the body of GET /sessions/{sessionId}/pr. type ListSessionPRsResponse struct { - SessionID domain.SessionID `json:"sessionId"` - PRs []SessionPRFacts `json:"prs"` + SessionID domain.SessionID `json:"sessionId"` + PRs []SessionPRSummary `json:"prs"` +} + +func NewSessionPRSummary(in sessionsvc.PRSummary) SessionPRSummary { + return SessionPRSummary{ + URL: in.URL, + HTMLURL: in.HTMLURL, + Number: in.Number, + Title: in.Title, + State: in.State, + Provider: in.Provider, + Repo: in.Repo, + Author: in.Author, + SourceBranch: in.SourceBranch, + TargetBranch: in.TargetBranch, + HeadSHA: in.HeadSHA, + CI: newSessionPRCISummary(in.CI), + Review: newSessionPRReviewSummary(in.Review), + Mergeability: newSessionPRMergeabilitySummary(in.Mergeability), + UpdatedAt: in.UpdatedAt, + ObservedAt: in.ObservedAt, + CIObservedAt: in.CIObservedAt, + ReviewObservedAt: in.ReviewObservedAt, + } +} + +func newSessionPRCISummary(in sessionsvc.PRCISummary) SessionPRCISummary { + checks := make([]SessionPRFailingCheck, 0, len(in.FailingChecks)) + for _, ch := range in.FailingChecks { + checks = append(checks, SessionPRFailingCheck{Name: ch.Name, Status: ch.Status, Conclusion: ch.Conclusion, URL: ch.URL}) + } + return SessionPRCISummary{State: in.State, FailingChecks: checks} +} + +func newSessionPRReviewSummary(in sessionsvc.PRReviewSummary) SessionPRReviewSummary { + reviewers := make([]SessionPRUnresolvedReviewer, 0, len(in.UnresolvedBy)) + for _, reviewer := range in.UnresolvedBy { + links := make([]SessionPRReviewCommentLink, 0, len(reviewer.Links)) + for _, link := range reviewer.Links { + links = append(links, SessionPRReviewCommentLink{URL: link.URL, File: link.File, Line: link.Line}) + } + reviewers = append(reviewers, SessionPRUnresolvedReviewer{ReviewerID: reviewer.ReviewerID, Count: reviewer.Count, Links: links}) + } + return SessionPRReviewSummary{Decision: in.Decision, HasUnresolvedHumanComments: in.HasUnresolvedHumanComments, UnresolvedBy: reviewers} +} + +func newSessionPRMergeabilitySummary(in sessionsvc.PRMergeabilitySummary) SessionPRMergeabilitySummary { + files := make([]SessionPRConflictFile, 0, len(in.ConflictFiles)) + for _, file := range in.ConflictFiles { + files = append(files, SessionPRConflictFile{Path: file.Path, URL: file.URL}) + } + return SessionPRMergeabilitySummary{State: in.State, Reasons: in.Reasons, PRURL: in.PRURL, ConflictFiles: files} } // ClaimPRRequest is the body of POST /sessions/{sessionId}/pr/claim. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 3d0b2bb9..61c217dd 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -34,7 +34,7 @@ type SessionService interface { Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error) Rename(ctx context.Context, id domain.SessionID, displayName string) error Send(ctx context.Context, id domain.SessionID, message string) error - ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) + ListPRSummaries(ctx context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) } @@ -138,12 +138,12 @@ func (c *SessionsController) listPRs(w http.ResponseWriter, r *http.Request) { apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/pr") return } - prs, err := c.Svc.ListPRs(r.Context(), sessionID(r)) + prs, err := c.Svc.ListPRSummaries(r.Context(), sessionID(r)) if err != nil { envelope.WriteError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, ListSessionPRsResponse{SessionID: sessionID(r), PRs: sessionPRFacts(prs)}) + envelope.WriteJSON(w, http.StatusOK, ListSessionPRsResponse{SessionID: sessionID(r), PRs: sessionPRSummaries(prs)}) } func (c *SessionsController) claimPR(w http.ResponseWriter, r *http.Request) { @@ -444,6 +444,14 @@ func sessionPRFacts(prs []domain.PRFacts) []SessionPRFacts { return out } +func sessionPRSummaries(prs []sessionsvc.PRSummary) []SessionPRSummary { + out := make([]SessionPRSummary, 0, len(prs)) + for _, pr := range prs { + out = append(out, NewSessionPRSummary(pr)) + } + return out +} + func prState(pr domain.PRFacts) string { switch { case pr.Merged: diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 3bc53b60..a7c74533 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -135,6 +135,49 @@ func (f *fakeSessionService) ListPRs(_ context.Context, id domain.SessionID) ([] return []domain.PRFacts{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142", Number: 142, CI: domain.CIPassing, Review: domain.ReviewRequired, Mergeability: domain.MergeMergeable, UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)}}, nil } +func (f *fakeSessionService) ListPRSummaries(_ context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) { + if f.listPRErr != nil { + return nil, f.listPRErr + } + if _, ok := f.sessions[id]; !ok { + return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + return []sessionsvc.PRSummary{{ + URL: "https://github.com/aoagents/agent-orchestrator/pull/142", + HTMLURL: "https://github.com/aoagents/agent-orchestrator/pull/142", + Number: 142, + Title: "Wire SCM summaries", + State: domain.PRStateOpen, + Provider: "github", + Repo: "aoagents/agent-orchestrator", + Author: "ada", + SourceBranch: "codex/scm-observer-v1", + TargetBranch: "main", + HeadSHA: "abc123", + CI: sessionsvc.PRCISummary{State: domain.CIFailing, FailingChecks: []sessionsvc.PRFailingCheck{{ + Name: "unit", + Status: domain.PRCheckFailed, + Conclusion: "failure", + URL: "https://github.com/aoagents/agent-orchestrator/actions/runs/1", + }}}, + Review: sessionsvc.PRReviewSummary{ + Decision: domain.ReviewChangesRequest, + HasUnresolvedHumanComments: true, + UnresolvedBy: []sessionsvc.PRUnresolvedReviewer{{ + ReviewerID: "reviewer-a", + Count: 1, + Links: []sessionsvc.PRReviewCommentLink{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142#discussion_r1", File: "main.go", Line: 12}}, + }}, + }, + Mergeability: sessionsvc.PRMergeabilitySummary{ + State: domain.MergeConflicting, + Reasons: []string{"conflicts"}, + PRURL: "https://github.com/aoagents/agent-orchestrator/pull/142", + }, + UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC), + }}, nil +} + func (f *fakeSessionService) ClaimPR(_ context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) { if f.claimErr != nil { return sessionsvc.ClaimPRResult{}, f.claimErr @@ -389,16 +432,56 @@ func TestSessionsAPI_PRRoutes(t *testing.T) { var listed struct { SessionID string `json:"sessionId"` PRs []struct { - URL string `json:"url"` - Number int `json:"number"` - State string `json:"state"` - UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + CI struct { + State string `json:"state"` + FailingChecks []struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + LogTail string `json:"logTail"` + } `json:"failingChecks"` + } `json:"ci"` + Review struct { + Decision string `json:"decision"` + UnresolvedBy []struct { + ReviewerID string `json:"reviewerId"` + Count int `json:"count"` + Links []struct { + URL string `json:"url"` + File string `json:"file"` + Line int `json:"line"` + Body string `json:"body"` + } `json:"links"` + } `json:"unresolvedBy"` + } `json:"review"` + Mergeability struct { + State string `json:"state"` + Reasons []string `json:"reasons"` + PRURL string `json:"prUrl"` + ConflictFiles []struct { + Path string `json:"path"` + } `json:"conflictFiles"` + } `json:"mergeability"` } `json:"prs"` } mustJSON(t, body, &listed) - if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" { + 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 checks := listed.PRs[0].CI.FailingChecks; len(checks) != 1 || checks[0].Name != "unit" || checks[0].LogTail != "" { + t.Fatalf("failing checks = %#v", checks) + } + if reviewers := listed.PRs[0].Review.UnresolvedBy; len(reviewers) != 1 || reviewers[0].ReviewerID != "reviewer-a" || reviewers[0].Links[0].Body != "" { + t.Fatalf("reviewers = %#v", reviewers) + } + if merge := listed.PRs[0].Mergeability; merge.State != "conflicting" || len(merge.ConflictFiles) != 0 || merge.PRURL == "" { + t.Fatalf("mergeability = %#v", merge) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", `{"pr":"142"}`) if status != http.StatusOK { diff --git a/backend/internal/service/session/pr_summary.go b/backend/internal/service/session/pr_summary.go new file mode 100644 index 00000000..277c2fdf --- /dev/null +++ b/backend/internal/service/session/pr_summary.go @@ -0,0 +1,287 @@ +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 + CI PRCISummary + Review PRReviewSummary + Mergeability PRMergeabilitySummary + UpdatedAt time.Time + ObservedAt time.Time + CIObservedAt time.Time + ReviewObservedAt time.Time +} + +type PRCISummary struct { + State domain.CIState + FailingChecks []PRFailingCheck +} + +type PRFailingCheck struct { + Name string + Status domain.PRCheckStatus + Conclusion string + URL string +} + +type PRReviewSummary struct { + Decision domain.ReviewDecision + HasUnresolvedHumanComments bool + UnresolvedBy []PRUnresolvedReviewer +} + +type PRUnresolvedReviewer struct { + ReviewerID string + Count int + Links []PRReviewCommentLink +} + +type PRReviewCommentLink struct { + URL string + File string + Line int +} + +type PRMergeabilitySummary struct { + State domain.Mergeability + Reasons []string + PRURL string + ConflictFiles []PRConflictFile +} + +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, + CI: summarizeCI(pr.CI, checks), + Review: summarizeReview(pr.Review, comments), + Mergeability: summarizeMergeability(pr, threads), + UpdatedAt: pr.UpdatedAt, + ObservedAt: pr.ObservedAt, + CIObservedAt: pr.CIObservedAt, + ReviewObservedAt: pr.ReviewObservedAt, + } +} + +func summarizeCI(state domain.CIState, checks []domain.PullRequestCheck) PRCISummary { + out := PRCISummary{State: ciOrUnknown(state)} + for _, ch := range checks { + if ch.Status != domain.PRCheckFailed && ch.Status != domain.PRCheckCancelled { + continue + } + out.FailingChecks = append(out.FailingChecks, PRFailingCheck{ + Name: ch.Name, + Status: ch.Status, + Conclusion: ch.Conclusion, + URL: ch.URL, + }) + } + return out +} + +func summarizeReview(decision domain.ReviewDecision, comments []domain.PullRequestComment) PRReviewSummary { + out := PRReviewSummary{Decision: reviewOrNone(decision)} + 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 { + 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 +} diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 00bd6d90..443f43d8 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -21,6 +21,8 @@ type Store interface { RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) + ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error) + ListPRReviewThreads(ctx context.Context, prURL string) ([]domain.PullRequestReviewThread, error) ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error) GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 51fef9ce..b5441e63 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -17,11 +17,21 @@ type fakeStore struct { sessions map[domain.SessionID]domain.SessionRecord pr map[domain.SessionID]domain.PRFacts projects map[string]domain.ProjectRecord + checks map[string][]domain.PullRequestCheck + threads map[string][]domain.PullRequestReviewThread + comments map[string][]domain.PullRequestComment num int } func newFakeStore() *fakeStore { - return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}} + return &fakeStore{ + sessions: map[domain.SessionID]domain.SessionRecord{}, + pr: map[domain.SessionID]domain.PRFacts{}, + projects: map[string]domain.ProjectRecord{}, + checks: map[string][]domain.PullRequestCheck{}, + threads: map[string][]domain.PullRequestReviewThread{}, + comments: map[string][]domain.PullRequestComment{}, + } } func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { @@ -78,8 +88,16 @@ func (f *fakeStore) ListPRsBySession(_ context.Context, id domain.SessionID) ([] return []domain.PullRequest{{URL: pr.URL, SessionID: id, Number: pr.Number, Draft: pr.Draft, Merged: pr.Merged, Closed: pr.Closed, CI: pr.CI, Review: pr.Review, Mergeability: pr.Mergeability, UpdatedAt: pr.UpdatedAt}}, nil } -func (f *fakeStore) ListPRComments(context.Context, string) ([]domain.PullRequestComment, error) { - return nil, nil +func (f *fakeStore) ListChecks(_ context.Context, prURL string) ([]domain.PullRequestCheck, error) { + return append([]domain.PullRequestCheck(nil), f.checks[prURL]...), nil +} + +func (f *fakeStore) ListPRReviewThreads(_ context.Context, prURL string) ([]domain.PullRequestReviewThread, error) { + return append([]domain.PullRequestReviewThread(nil), f.threads[prURL]...), nil +} + +func (f *fakeStore) ListPRComments(_ context.Context, prURL string) ([]domain.PullRequestComment, error) { + return append([]domain.PullRequestComment(nil), f.comments[prURL]...), nil } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { @@ -417,6 +435,67 @@ func TestListPRsOrdersActiveBeforeClosedThenUpdatedDesc(t *testing.T) { } } +func TestListPRSummariesOmitsRawLogsAndReviewBodies(t *testing.T) { + st := newFakeStore() + now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker} + prURL := "https://github.com/acme/repo/pull/7" + stList := &multiPRFakeStore{fakeStore: st, prs: []domain.PullRequest{{ + URL: prURL, + HTMLURL: prURL, + SessionID: "mer-1", + Number: 7, + CI: domain.CIFailing, + Review: domain.ReviewChangesRequest, + Mergeability: domain.MergeConflicting, + Provider: "github", + Repo: "acme/repo", + Title: "Fix dashboard", + Author: "ada", + SourceBranch: "fix/dashboard", + TargetBranch: "main", + HeadSHA: "abc123", + ProviderMergeStateStatus: "dirty", + UpdatedAt: now, + ObservedAt: now.Add(-time.Minute), + CIObservedAt: now.Add(-time.Minute), + ReviewObservedAt: now.Add(-time.Minute), + }}} + stList.checks[prURL] = []domain.PullRequestCheck{ + {Name: "unit", Status: domain.PRCheckFailed, Conclusion: "failure", URL: "https://github.com/acme/repo/actions/runs/1", LogTail: "panic: secret"}, + {Name: "lint", Status: domain.PRCheckPassed, Conclusion: "success", URL: "https://github.com/acme/repo/actions/runs/2"}, + } + stList.comments[prURL] = []domain.PullRequestComment{ + {Author: "reviewer-a", File: "main.go", Line: 12, Body: "raw body must stay private", URL: "https://github.com/acme/repo/pull/7#discussion_r1"}, + {Author: "ci-bot", File: "main.go", Line: 13, Body: "bot body", URL: "https://github.com/acme/repo/pull/7#discussion_r2", IsBot: true}, + {Author: "reviewer-a", File: "test.go", Line: 22, Body: "another raw body", URL: "https://github.com/acme/repo/pull/7#discussion_r3"}, + } + + got, err := (&Service{store: stList}).ListPRSummaries(context.Background(), "mer-1") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("summaries = %+v", got) + } + pr := got[0] + if pr.Title != "Fix dashboard" || pr.State != domain.PRStateOpen || pr.Provider != "github" || pr.Repo != "acme/repo" || pr.HeadSHA != "abc123" { + t.Fatalf("metadata = %+v", pr) + } + if len(pr.CI.FailingChecks) != 1 || pr.CI.FailingChecks[0].Name != "unit" || pr.CI.FailingChecks[0].URL == "" { + t.Fatalf("failing checks = %+v", pr.CI.FailingChecks) + } + if pr.Review.Decision != domain.ReviewChangesRequest || !pr.Review.HasUnresolvedHumanComments || len(pr.Review.UnresolvedBy) != 1 { + t.Fatalf("review = %+v", pr.Review) + } + if reviewer := pr.Review.UnresolvedBy[0]; reviewer.ReviewerID != "reviewer-a" || reviewer.Count != 2 || len(reviewer.Links) != 2 { + t.Fatalf("reviewer = %+v", reviewer) + } + if pr.Mergeability.State != domain.MergeConflicting || len(pr.Mergeability.ConflictFiles) != 0 || !containsString(pr.Mergeability.Reasons, "conflicts") { + t.Fatalf("mergeability = %+v", pr.Mergeability) + } +} + type multiPRFakeStore struct { *fakeStore prs []domain.PullRequest @@ -425,3 +504,12 @@ type multiPRFakeStore struct { func (f *multiPRFakeStore) ListPRsBySession(context.Context, domain.SessionID) ([]domain.PullRequest, error) { return f.prs, nil } + +func containsString(values []string, want string) bool { + for _, got := range values { + if got == want { + return true + } + } + return false +} diff --git a/docs/status.md b/docs/status.md index 70320e07..bed094c5 100644 --- a/docs/status.md +++ b/docs/status.md @@ -56,6 +56,14 @@ surface (`npm run sqlc`, `npm run api`). - Shell: sidebar (projects + sessions, add/remove project), sessions board, session view + inspector, project settings, pull-requests page, spawn-orchestrator flow. +- Desktop status and SCM summary V1: session status comes from + `GET /api/v1/sessions`; visible/active PR context comes from + `GET /api/v1/sessions/{sessionId}/pr`; `GET /api/v1/events` is kept open as + an invalidation stream rather than a full PR payload stream. +- Concise PR summaries include PR identity, CI state with failing check names + and links, human reviewer IDs/counts/links for unresolved review comments, + and mergeability reasons. Raw CI logs and review comment bodies are + intentionally not part of the desktop V1 API/UI. - Terminal pane (xterm) over the mux WebSocket, with a live SSE events connection and port-rebind on daemon restart. @@ -66,8 +74,9 @@ surface (`npm run sqlc`, `npm run api`). nothing at runtime ([#112](https://github.com/aoagents/agent-orchestrator/issues/112)). - **Notifications**: design/in-flight only; no shipped backend notifier or UI center. -- **Live PR/tracker fact surfacing**: the observer writes facts, but exposing - the full `pr_*` / `tracker_*` CDC events to live consumers +- **Full raw PR/tracker fact surfacing**: the SCM observer writes facts and the + desktop consumes concise PR summaries, but exposing the full raw `pr_*` / + `tracker_*` CDC events to live consumers ([#110](https://github.com/aoagents/agent-orchestrator/issues/110)) and in `ao session get` ([#111](https://github.com/aoagents/agent-orchestrator/issues/111)) is still open. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 92a84d15..b794d007 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -475,7 +475,7 @@ export interface components { reviews: components["schemas"]["ReviewRun"][]; }; ListSessionPRsResponse: { - prs: components["schemas"]["SessionPRFacts"][]; + prs: components["schemas"]["SessionPRSummary"][]; sessionId: string; }; ListSessionsResponse: { @@ -620,16 +620,29 @@ export interface components { issueId?: string; kind: string; projectId: string; - status: string; + /** @enum {string} */ + status: "working" | "pr_open" | "draft" | "ci_failed" | "review_pending" | "changes_requested" | "approved" | "mergeable" | "merged" | "needs_input" | "idle" | "terminated" | "no_signal"; terminalHandleId?: string; /** Format: date-time */ updatedAt: string; }; + SessionPRCISummary: { + failingChecks: components["schemas"]["SessionPRFailingCheck"][]; + /** @enum {string} */ + state: "unknown" | "pending" | "passing" | "failing"; + }; + SessionPRConflictFile: { + path: string; + url?: string; + }; SessionPRFacts: { - ci: string; - mergeability: string; + /** @enum {string} */ + ci: "unknown" | "pending" | "passing" | "failing"; + /** @enum {string} */ + mergeability: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; number: number; - review: string; + /** @enum {string} */ + review: "none" | "approved" | "changes_requested" | "review_required"; reviewComments: boolean; /** @enum {string} */ state: "draft" | "open" | "merged" | "closed"; @@ -637,6 +650,62 @@ export interface components { updatedAt: string; url: string; }; + SessionPRFailingCheck: { + conclusion: string; + name: string; + /** @enum {string} */ + status: "failed" | "cancelled"; + url?: string; + }; + SessionPRMergeabilitySummary: { + conflictFiles?: components["schemas"]["SessionPRConflictFile"][]; + prUrl: string; + reasons: string[]; + /** @enum {string} */ + state: "unknown" | "mergeable" | "conflicting" | "blocked" | "unstable"; + }; + SessionPRReviewCommentLink: { + file?: string; + line?: number; + url?: string; + }; + SessionPRReviewSummary: { + /** @enum {string} */ + decision: "none" | "approved" | "changes_requested" | "review_required"; + hasUnresolvedHumanComments: boolean; + unresolvedBy: components["schemas"]["SessionPRUnresolvedReviewer"][]; + }; + SessionPRSummary: { + author: string; + ci: components["schemas"]["SessionPRCISummary"]; + /** Format: date-time */ + ciObservedAt?: string; + headSha: string; + htmlUrl?: string; + mergeability: components["schemas"]["SessionPRMergeabilitySummary"]; + number: number; + /** Format: date-time */ + observedAt?: string; + /** @enum {string} */ + provider: "github"; + repo: string; + review: components["schemas"]["SessionPRReviewSummary"]; + /** Format: date-time */ + reviewObservedAt?: string; + sourceBranch: string; + /** @enum {string} */ + state: "draft" | "open" | "merged" | "closed"; + targetBranch: string; + title: string; + /** Format: date-time */ + updatedAt: string; + url: string; + }; + SessionPRUnresolvedReviewer: { + count: number; + links: components["schemas"]["SessionPRReviewCommentLink"][]; + reviewerId: string; + }; SessionResponse: { session: components["schemas"]["Session"]; }; diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx index a4d01911..4d5ac7d6 100644 --- a/frontend/src/renderer/components/PullRequestsPage.tsx +++ b/frontend/src/renderer/components/PullRequestsPage.tsx @@ -1,8 +1,9 @@ import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { sessionScmSummaryQueryOptions, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import type { WorkspaceSession } from "../types/workspace"; import { DashboardSubhead } from "./DashboardSubhead"; import { Badge } from "./ui/badge"; @@ -10,7 +11,7 @@ import { Button } from "./ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; import { cn } from "../lib/utils"; -type PRState = NonNullable["state"]; +type PRState = SessionPRSummary["state"]; const stateTone: Record = { open: "border-success/40 bg-success/10 text-success", @@ -23,8 +24,7 @@ const stateTone: Record = { const stateRank: Record = { open: 0, draft: 1, merged: 2, closed: 3 }; type PRRow = { - number: number; - state: PRState; + pr: SessionPRSummary; session: WorkspaceSession; }; @@ -37,12 +37,12 @@ export function PullRequestsPage() { const navigate = useNavigate(); const workspaceQuery = useWorkspaceQuery(); const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions); + const prQueries = useQueries({ + queries: sessions.map((session) => sessionScmSummaryQueryOptions(session.id)), + }); const rows: PRRow[] = sessions - .filter((s): s is WorkspaceSession & { pullRequest: NonNullable } => - Boolean(s.pullRequest), - ) - .map((s) => ({ number: s.pullRequest.number, state: s.pullRequest.state, session: s })) - .sort((a, b) => stateRank[a.state] - stateRank[b.state] || a.number - b.number); + .flatMap((session, index) => (prQueries[index]?.data ?? []).map((pr) => ({ pr, session }))) + .sort((a, b) => stateRank[a.pr.state] - stateRank[b.pr.state] || a.pr.number - b.pr.number); return (
@@ -68,7 +68,7 @@ export function PullRequestsPage() { {rows.map((row) => ( void navigate({ @@ -94,7 +94,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { const merge = useMutation({ mutationFn: async () => { const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", { - params: { path: { id: String(row.number) } }, + params: { path: { id: String(row.pr.number) } }, }); if (error) throw new Error(apiErrorMessage(error)); return data; @@ -109,7 +109,7 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { const resolve = useMutation({ mutationFn: async () => { const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", { - params: { path: { id: String(row.number) } }, + params: { path: { id: String(row.pr.number) } }, }); if (error) throw new Error(apiErrorMessage(error)); }, @@ -120,20 +120,31 @@ function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) { onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }), }); - const actionable = row.state === "open" || row.state === "draft"; + const actionable = row.pr.state === "open" || row.pr.state === "draft"; return ( - #{row.number} + #{row.pr.number} -
{row.session.title}
+
{row.pr.title || row.session.title}
- {[row.session.workspaceName, row.session.branch].filter(Boolean).join(" · ")} + {[row.session.workspaceName, row.pr.sourceBranch || row.session.branch, `CI ${row.pr.ci.state}`] + .filter(Boolean) + .join(" · ")}
+ {row.pr.ci.failingChecks.length > 0 ? ( +
+ {row.pr.ci.failingChecks.map((check) => check.name).join(", ")} +
+ ) : row.pr.review.unresolvedBy.length > 0 ? ( +
+ {row.pr.review.unresolvedBy.map((reviewer) => reviewer.reviewerId).join(", ")} +
+ ) : null}
- - {row.state} + + {row.pr.state} e.stopPropagation()}> diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index dee4cf69..b6e02a0e 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -1,16 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react"; -import type { components } from "../../api/schema"; -import { apiClient } from "../lib/api-client"; import { formatTimeCompact } from "../lib/format-time"; +import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import type { SessionStatus, WorkspaceSession } from "../types/workspace"; import { workerDisplayStatus } from "../types/workspace"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; -type PRFacts = components["schemas"]["SessionPRFacts"]; type InspectorView = "summary" | "changes" | "browser"; const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ @@ -54,7 +51,7 @@ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ }, ]; -const prStateTone: Record = { +const prStateTone: Record = { open: "border-success/40 bg-success/10 text-success", draft: "border-border bg-raised text-muted-foreground", merged: "border-accent/40 bg-accent-weak text-accent", @@ -118,18 +115,7 @@ function Section({ title, action, children }: { title: string; action?: ReactNod } function SummaryView({ session }: { session: WorkspaceSession }) { - const hasPr = Boolean(session.pullRequest); - const query = useQuery({ - queryKey: ["session-pr", session.id], - enabled: hasPr, - queryFn: async () => { - const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", { - params: { path: { sessionId: session.id } }, - }); - if (error) return [] as PRFacts[]; - return data?.prs ?? []; - }, - }); + const query = useSessionScmSummary(session.id); const prFacts = query.data?.[0]; const branchLabel = session.branch || `session/${session.id}`; @@ -138,46 +124,163 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
+ prFacts?.htmlUrl || prFacts?.url ? ( + Open ↗ ) : undefined } > - {!hasPr ? ( -

No pull request opened yet.

- ) : query.isLoading ? ( + {query.isLoading ? (

Loading pull request…

+ ) : query.isError ? ( +

Could not load pull request summary.

+ ) : !prFacts ? ( +

No pull request opened yet.

) : (
- {prFacts ? ( -
- - - -
- ) : ( -

No enriched PR facts yet.

- )} +
{prFacts.title || "Untitled PR"}
+
+ + ${prFacts.targetBranch || "—"}`} mono /> + +
)}
+ {prFacts ? ( + <> +
+
+ + +
+ {prFacts.ci.failingChecks.length > 0 ? ( +
+ {prFacts.ci.failingChecks.map((check) => + check.url ? ( + + {check.name} · {check.status} + + ) : ( + + {check.name} · {check.status} + + ), + )} +
+ ) : null} +
+ +
+
+ + +
+ {prFacts.review.unresolvedBy.length > 0 ? ( +
+ {prFacts.review.unresolvedBy.map((reviewer) => ( +
+ {reviewer.reviewerId} + · {reviewer.count} +
+ {reviewer.links.map((link, index) => + link.url ? ( + + {link.file || "comment"} + {link.line ? `:${link.line}` : ""} + + ) : ( + + {link.file || "comment"} + {link.line ? `:${link.line}` : ""} + + ), + )} +
+
+ ))} +
+ ) : null} +
+ +
+ GitHub ↗ + + ) : undefined + } + > +
+ + +
+ {prFacts.mergeability.conflictFiles?.length ? ( +
+ {prFacts.mergeability.conflictFiles.map((file) => + file.url ? ( + + {file.path} + + ) : ( + + {file.path} + + ), + )} +
+ ) : null} +
+ + ) : null} +
@@ -274,6 +377,7 @@ const STATUS_PILL: Record< ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, + unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false }, idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false }, }; diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6649e1d5..9744962e 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -8,6 +8,7 @@ import { workerDisplayStatus, workerSessions, } from "../types/workspace"; +import { useSessionScmSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { DashboardSubhead } from "./DashboardSubhead"; import { cn } from "../lib/utils"; @@ -69,6 +70,7 @@ const BADGE: Record = ci_failed: { label: "CI failed", className: "text-error" }, mergeable: { label: "Ready", className: "text-success" }, done: { label: "Done", className: "text-passive" }, + unknown: { label: "Unknown", className: "text-passive" }, }; export function SessionsBoard({ projectId }: SessionsBoardProps) { @@ -197,6 +199,9 @@ function ZoneColumn({ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { const badge = BADGE[workerDisplayStatus(session)]; const branch = session.branch || `session/${session.id}`; + const prSummary = useSessionScmSummary(session.id).data?.[0]; + const failingChecks = prSummary?.ci.failingChecks.slice(0, 2) ?? []; + const reviewers = prSummary?.review.unresolvedBy.slice(0, 2).map((reviewer) => reviewer.reviewerId) ?? []; return ( ); diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 63539287..53a6b210 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -27,6 +27,7 @@ const STATUS_PILL: Record diff --git a/frontend/src/renderer/hooks/useSessionScmSummary.ts b/frontend/src/renderer/hooks/useSessionScmSummary.ts new file mode 100644 index 00000000..5b26eb8f --- /dev/null +++ b/frontend/src/renderer/hooks/useSessionScmSummary.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient } from "../lib/api-client"; + +export type SessionPRSummary = components["schemas"]["SessionPRSummary"]; + +export const sessionScmSummaryQueryKey = (sessionId?: string) => + sessionId ? (["session-scm-summary", sessionId] as const) : (["session-scm-summary"] as const); + +const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1"; + +export async function fetchSessionScmSummary(sessionId: string): Promise { + const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", { + params: { path: { sessionId } }, + }); + if (error) throw error; + return data?.prs ?? []; +} + +export function sessionScmSummaryQueryOptions(sessionId: string) { + return { + queryKey: sessionScmSummaryQueryKey(sessionId), + enabled: Boolean(sessionId) && !usePreviewData, + queryFn: () => fetchSessionScmSummary(sessionId), + retry: 1, + }; +} + +export function useSessionScmSummary(sessionId?: string) { + return useQuery({ + queryKey: sessionScmSummaryQueryKey(sessionId), + enabled: Boolean(sessionId) && !usePreviewData, + queryFn: () => fetchSessionScmSummary(sessionId!), + retry: 1, + }); +} diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 74a75e57..5f2b98b9 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -54,7 +54,7 @@ describe("useWorkspaceQuery", () => { }, { // Unknown harness/status and no displayName/issueId: falls back - // to codex / working / the session id. + // to codex / unknown / the session id. id: "sess-2", projectId: "proj-1", harness: "mystery-agent", @@ -87,11 +87,11 @@ describe("useWorkspaceQuery", () => { id: "sess-2", title: "sess-2", provider: "codex", - status: "working", + status: "unknown", }); }); - it("marks terminated sessions regardless of their reported status", async () => { + it("preserves backend merged status for terminated merged sessions", async () => { respondWith({ projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined }, sessions: { @@ -100,7 +100,32 @@ describe("useWorkspaceQuery", () => { { id: "sess-1", projectId: "proj-1", - status: "working", + status: "merged", + isTerminated: true, + updatedAt: "2026-06-10T16:15:04Z", + }, + ], + }, + error: undefined, + }, + }); + + const { result } = renderHook(() => useWorkspaceQuery(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.[0].sessions[0].status).toBe("merged"); + }); + + it("falls back to terminated for terminated sessions without a known backend status", async () => { + respondWith({ + projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined }, + sessions: { + data: { + sessions: [ + { + id: "sess-1", + projectId: "proj-1", + status: "bogus", isTerminated: true, updatedAt: "2026-06-10T16:15:04Z", }, diff --git a/frontend/src/renderer/lib/event-transport.test.ts b/frontend/src/renderer/lib/event-transport.test.ts index f69f4186..3673e1e9 100644 --- a/frontend/src/renderer/lib/event-transport.test.ts +++ b/frontend/src/renderer/lib/event-transport.test.ts @@ -97,7 +97,7 @@ describe("createEventTransport", () => { expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:3099/api/v1/events"); }); - it("debounces a workspace invalidation after a status change", () => { + it("debounces workspace and SCM summary invalidation after a status change", () => { vi.useFakeTimers(); try { const queryClient = fakeQueryClient(); @@ -107,7 +107,8 @@ describe("createEventTransport", () => { onStatusHandler(); expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); vi.advanceTimersByTime(200); - expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["workspaces"] }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["session-scm-summary"] }); } finally { vi.useRealTimers(); } diff --git a/frontend/src/renderer/lib/event-transport.ts b/frontend/src/renderer/lib/event-transport.ts index 6607b66f..5c31fd10 100644 --- a/frontend/src/renderer/lib/event-transport.ts +++ b/frontend/src/renderer/lib/event-transport.ts @@ -3,6 +3,7 @@ import { aoBridge } from "./bridge"; import { getApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; import { setEventsConnectionState } from "./events-connection"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { sessionScmSummaryQueryKey } from "../hooks/useSessionScmSummary"; export type EventTransport = { connect: () => () => void; @@ -50,6 +51,7 @@ export function createEventTransport(queryClient: QueryClient): EventTransport { if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + void queryClient.invalidateQueries({ queryKey: sessionScmSummaryQueryKey() }); }, INVALIDATE_DEBOUNCE_MS); }; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 6845c37c..1c7f1625 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -31,18 +31,20 @@ function sessionWith(overrides: Partial): WorkspaceSession { describe("toSessionStatus", () => { it("passes through a known status", () => { expect(toSessionStatus("mergeable")).toBe("mergeable"); + expect(toSessionStatus("no_signal")).toBe("no_signal"); }); - it("overrides to terminated when the session is terminated", () => { - expect(toSessionStatus("working", true)).toBe("terminated"); + it("keeps a backend merged status even when the session is terminated", () => { + expect(toSessionStatus("merged", true)).toBe("merged"); }); - it("falls back to working for an unknown status", () => { - expect(toSessionStatus("bogus")).toBe("working"); + it("uses terminated only as a fallback when a terminated session has no known status", () => { + expect(toSessionStatus(undefined, true)).toBe("terminated"); }); - it("falls back to working when status is undefined", () => { - expect(toSessionStatus(undefined)).toBe("working"); + it("falls back to unknown for an unknown live status", () => { + expect(toSessionStatus("bogus")).toBe("unknown"); + expect(toSessionStatus(undefined)).toBe("unknown"); }); }); @@ -53,6 +55,7 @@ describe("workerDisplayStatus", () => { it.each([ ["needs_input", "needs_you"], + ["no_signal", "needs_you"], ["changes_requested", "needs_you"], ["review_pending", "needs_you"], ["ci_failed", "ci_failed"], @@ -60,6 +63,7 @@ describe("workerDisplayStatus", () => { ["mergeable", "mergeable"], ["merged", "done"], ["terminated", "done"], + ["unknown", "unknown"], ["working", "working"], ["idle", "working"], ] as const)("maps %s to %s", (status, expected) => { @@ -111,9 +115,12 @@ describe("findProjectOrchestrator", () => { }); describe("sessionNeedsAttention", () => { - it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => { - expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); - }); + it.each(["needs_input", "no_signal", "changes_requested", "review_pending", "ci_failed"] as const)( + "is true for %s", + (status) => { + expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true); + }, + ); it("is false for statuses that don't need the user", () => { expect(sessionNeedsAttention(sessionWith({ status: "working" }))).toBe(false); @@ -127,6 +134,7 @@ describe("workerStatusPulses", () => { expect(workerStatusPulses("needs_you")).toBe(true); expect(workerStatusPulses("mergeable")).toBe(false); expect(workerStatusPulses("done")).toBe(false); + expect(workerStatusPulses("unknown")).toBe(false); }); }); @@ -146,11 +154,13 @@ describe("attentionZone", () => { ["mergeable", "merge"], ["approved", "merge"], ["needs_input", "action"], + ["no_signal", "action"], ["ci_failed", "action"], ["changes_requested", "action"], ["review_pending", "pending"], ["pr_open", "pending"], ["draft", "pending"], + ["unknown", "pending"], ["working", "working"], ["idle", "working"], ["merged", "done"], diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index d401f48b..767482ae 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -10,7 +10,9 @@ export type SessionStatus = | "merged" | "needs_input" | "idle" - | "terminated"; + | "terminated" + | "no_signal" + | "unknown"; const sessionStatuses = new Set([ "working", @@ -25,11 +27,12 @@ const sessionStatuses = new Set([ "needs_input", "idle", "terminated", + "no_signal", ]); export function toSessionStatus(status?: string, isTerminated = false): SessionStatus { - if (isTerminated) return "terminated"; - return status && sessionStatuses.has(status as SessionStatus) ? (status as SessionStatus) : "working"; + if (status && sessionStatuses.has(status as SessionStatus)) return status as SessionStatus; + return isTerminated ? "terminated" : "unknown"; } export type AgentProvider = @@ -97,12 +100,13 @@ export type WorkspaceSession = { }; /** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */ -export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done"; +export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done" | "unknown"; export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus { if (session.displayStatus) return session.displayStatus; switch (session.status) { case "needs_input": + case "no_signal": case "changes_requested": case "review_pending": return "needs_you"; @@ -114,6 +118,8 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta case "merged": case "terminated": return "done"; + case "unknown": + return "unknown"; default: return "working"; } @@ -150,6 +156,7 @@ export function sessionIsActive(session: WorkspaceSession): boolean { export function sessionNeedsAttention(session: WorkspaceSession): boolean { return ( session.status === "needs_input" || + session.status === "no_signal" || session.status === "changes_requested" || session.status === "review_pending" || session.status === "ci_failed" @@ -162,6 +169,7 @@ export const workerStatusLabel: Record = { mergeable: "mergeable", ci_failed: "ci failed", done: "done", + unknown: "unknown", }; /** Whether a status should breathe (alive/working). */ @@ -202,6 +210,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone { // Agent waiting on a human (respond) or a problem to investigate (review); // agent-orchestrator collapses these into one "action" zone by default. case "needs_input": + case "no_signal": case "ci_failed": case "changes_requested": return "action"; @@ -209,6 +218,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone { case "review_pending": case "pr_open": case "draft": + case "unknown": return "pending"; // Agents doing their thing — don't interrupt. case "working":