From 97c39551e6b6c2b02b169dbb3c51c70aadb1b2ca Mon Sep 17 00:00:00 2001 From: whoisasx Date: Sun, 19 Jul 2026 20:09:49 +0530 Subject: [PATCH] fix: expose pr activity timing --- backend/internal/cli/spawn_test.go | 43 +++++--- backend/internal/domain/pr.go | 4 + backend/internal/httpd/apispec/openapi.yaml | 6 ++ backend/internal/httpd/controllers/dto.go | 4 + .../httpd/controllers/sessions_test.go | 22 ++-- .../internal/service/session/pr_summary.go | 56 +++++++--- .../internal/service/session/service_test.go | 61 +++++++++++ backend/internal/session_manager/manager.go | 9 ++ backend/internal/storage/sqlite/gen/models.go | 1 + backend/internal/storage/sqlite/gen/pr.sql.go | 44 ++++++-- .../migrations/0025_pr_state_changed_at.sql | 18 ++++ .../internal/storage/sqlite/queries/pr.sql | 32 ++++-- .../storage/sqlite/store/pr_facts_test.go | 102 ++++++++++++++++++ .../internal/storage/sqlite/store/pr_store.go | 20 ++++ .../storage/sqlite/store/store_test.go | 2 +- frontend/src/api/schema.ts | 4 + .../components/SessionInspector.test.tsx | 63 ++++++++++- .../renderer/components/SessionInspector.tsx | 53 +++++---- frontend/src/renderer/lib/mock-data.ts | 2 + frontend/src/renderer/lib/pr-display.test.ts | 36 ++++++- 20 files changed, 504 insertions(+), 78 deletions(-) create mode 100644 backend/internal/storage/sqlite/migrations/0025_pr_state_changed_at.sql diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index f48c5a99b6..0fffaac08f 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -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) @@ -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) @@ -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) { @@ -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) { @@ -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) { @@ -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) @@ -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 { @@ -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) { @@ -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) { @@ -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) { @@ -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) @@ -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) @@ -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) { @@ -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) { @@ -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) @@ -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) { @@ -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") diff --git a/backend/internal/domain/pr.go b/backend/internal/domain/pr.go index 4bf4e5fa66..709693778c 100644 --- a/backend/internal/domain/pr.go +++ b/backend/internal/domain/pr.go @@ -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 diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 07de1ea912..0db1f850e8 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -3005,6 +3005,9 @@ components: ciObservedAt: format: date-time type: string + createdAt: + format: date-time + type: string deletions: type: integer headSha: @@ -3038,6 +3041,9 @@ components: - merged - closed type: string + stateChangedAt: + format: date-time + type: string targetBranch: type: string title: diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index c9f1fb0f70..6c50f93c4e 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -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"` @@ -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, diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ab65d3ce8c..42771b45a7 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -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 } @@ -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"` @@ -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) } diff --git a/backend/internal/service/session/pr_summary.go b/backend/internal/service/session/pr_summary.go index ad8250b286..d1996e5af7 100644 --- a/backend/internal/service/session/pr_summary.go +++ b/backend/internal/service/session/pr_summary.go @@ -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 @@ -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, @@ -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 { diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index e4cb350238..bb9a64dd36 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -1148,6 +1148,67 @@ func TestListPRSummariesOmitsRawLogsAndReviewBodies(t *testing.T) { } } +func TestListPRSummariesExposesPRStateChangedAt(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker} + createdAt := time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC) + readyAt := time.Date(2026, 6, 4, 10, 30, 0, 0, time.UTC) + mergedAt := time.Date(2026, 6, 4, 11, 0, 0, 0, time.UTC) + observedAt := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC) + stList := &multiPRFakeStore{fakeStore: st, prs: []domain.PullRequest{ + { + URL: "draft", + SessionID: "mer-1", + Number: 1, + Draft: true, + CreatedAtProvider: createdAt, + UpdatedAtProvider: readyAt, + UpdatedAt: observedAt, + }, + { + URL: "ready", + SessionID: "mer-1", + Number: 2, + StateChangedAt: readyAt, + CreatedAtProvider: createdAt, + UpdatedAtProvider: readyAt.Add(15 * time.Minute), + UpdatedAt: observedAt, + }, + { + URL: "merged", + SessionID: "mer-1", + Number: 3, + Merged: true, + CreatedAtProvider: createdAt, + MergedAtProvider: mergedAt, + UpdatedAt: observedAt, + }, + }} + + got, err := (&Service{store: stList}).ListPRSummaries(context.Background(), "mer-1") + if err != nil { + t.Fatal(err) + } + byURL := map[string]PRSummary{} + for _, pr := range got { + byURL[pr.URL] = pr + } + if !byURL["draft"].StateChangedAt.Equal(createdAt) { + t.Fatalf("draft stateChangedAt = %s, want created time %s", byURL["draft"].StateChangedAt, createdAt) + } + if !byURL["ready"].StateChangedAt.Equal(readyAt) { + t.Fatalf("ready stateChangedAt = %s, want ready/open transition %s", byURL["ready"].StateChangedAt, readyAt) + } + if !byURL["merged"].StateChangedAt.Equal(mergedAt) { + t.Fatalf("merged stateChangedAt = %s, want merged time %s", byURL["merged"].StateChangedAt, mergedAt) + } + for _, url := range []string{"draft", "ready", "merged"} { + if !byURL[url].CreatedAt.Equal(createdAt) { + t.Fatalf("%s createdAt = %s, want provider creation time %s", url, byURL[url].CreatedAt, createdAt) + } + } +} + func TestListPRSummariesSuppressesFailingChecksUnlessCIFailing(t *testing.T) { st := newFakeStore() st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 68625b7bec..8ccb1ab580 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -846,6 +846,15 @@ func (m *Manager) RestoreWithMode(ctx context.Context, id domain.SessionID) (Res return m.relaunchRestoredSession(ctx, rec, project, ws) } +// Restore relaunches a torn-down session and returns the updated session row. +func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) { + res, err := m.RestoreWithMode(ctx, id) + if err != nil { + return domain.SessionRecord{}, err + } + return res.Session, nil +} + func (m *Manager) relaunchRestoredSession(ctx context.Context, rec domain.SessionRecord, project domain.ProjectRecord, ws ports.WorkspaceInfo) (RestoreResult, error) { agent, ok := m.agents.Agent(rec.Harness) if !ok { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 1140f53ccf..0b924da084 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -73,6 +73,7 @@ type PR struct { CIObservedAt sql.NullTime ReviewObservedAt sql.NullTime LastNudgeSignature string + StateChangedAt sql.NullTime } type PRCheck struct { diff --git a/backend/internal/storage/sqlite/gen/pr.sql.go b/backend/internal/storage/sqlite/gen/pr.sql.go index 4a92810d0a..113a3b7652 100644 --- a/backend/internal/storage/sqlite/gen/pr.sql.go +++ b/backend/internal/storage/sqlite/gen/pr.sql.go @@ -14,10 +14,15 @@ import ( ) const claimPRForSession = `-- name: ClaimPRForSession :exec -INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET session_id = excluded.session_id, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN excluded.updated_at + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, review_decision = excluded.review_decision, updated_at = excluded.updated_at ` @@ -31,6 +36,7 @@ type ClaimPRForSessionParams struct { CIState domain.CIState Mergeability domain.Mergeability UpdatedAt time.Time + StateChangedAt sql.NullTime } func (q *Queries) ClaimPRForSession(ctx context.Context, arg ClaimPRForSessionParams) error { @@ -43,6 +49,7 @@ func (q *Queries) ClaimPRForSession(ctx context.Context, arg ClaimPRForSessionPa arg.CIState, arg.Mergeability, arg.UpdatedAt, + arg.StateChangedAt, ) return err } @@ -99,7 +106,7 @@ func (q *Queries) GetDisplayPRFactsBySession(ctx context.Context, sessionID doma } const getPR = `-- name: GetPR :one -SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, provider_state, provider_mergeable, provider_merge_state_status, html_url, created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at, last_nudge_signature FROM pr WHERE url = ? +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, provider_state, provider_mergeable, provider_merge_state_status, html_url, created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at, last_nudge_signature, state_changed_at FROM pr WHERE url = ? ` func (q *Queries) GetPR(ctx context.Context, url string) (PR, error) { @@ -145,6 +152,7 @@ func (q *Queries) GetPR(ctx context.Context, url string) (PR, error) { &i.CIObservedAt, &i.ReviewObservedAt, &i.LastNudgeSignature, + &i.StateChangedAt, ) return i, err } @@ -255,7 +263,7 @@ func (q *Queries) ListPRFactsBySession(ctx context.Context, sessionID domain.Ses } const listPRsBySession = `-- name: ListPRsBySession :many -SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, provider_state, provider_mergeable, provider_merge_state_status, html_url, created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at, last_nudge_signature FROM pr +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, provider_state, provider_mergeable, provider_merge_state_status, html_url, created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at, last_nudge_signature, state_changed_at FROM pr WHERE session_id = ? ORDER BY updated_at DESC ` @@ -309,6 +317,7 @@ func (q *Queries) ListPRsBySession(ctx context.Context, sessionID domain.Session &i.CIObservedAt, &i.ReviewObservedAt, &i.LastNudgeSignature, + &i.StateChangedAt, ); err != nil { return nil, err } @@ -339,12 +348,17 @@ func (q *Queries) UpdatePRLastNudgeSignature(ctx context.Context, arg UpdatePRLa const upsertLegacyPR = `-- name: UpsertLegacyPR :exec INSERT INTO pr ( - url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, + url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at, is_draft, is_merged, is_closed ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET number = excluded.number, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN excluded.updated_at + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, pr_state = excluded.pr_state, review_decision = excluded.review_decision, ci_state = excluded.ci_state, @@ -364,6 +378,7 @@ type UpsertLegacyPRParams struct { CIState domain.CIState Mergeability domain.Mergeability UpdatedAt time.Time + StateChangedAt sql.NullTime IsDraft int64 IsMerged int64 IsClosed int64 @@ -379,6 +394,7 @@ func (q *Queries) UpsertLegacyPR(ctx context.Context, arg UpsertLegacyPRParams) arg.CIState, arg.Mergeability, arg.UpdatedAt, + arg.StateChangedAt, arg.IsDraft, arg.IsMerged, arg.IsClosed, @@ -388,7 +404,7 @@ func (q *Queries) UpsertLegacyPR(ctx context.Context, arg UpsertLegacyPRParams) const upsertPR = `-- name: UpsertPR :exec INSERT INTO pr ( - url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, + url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, @@ -396,9 +412,19 @@ INSERT INTO pr ( created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET number = excluded.number, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN + CASE + WHEN excluded.pr_state = 'merged' THEN COALESCE(excluded.merged_at_provider, excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + WHEN excluded.pr_state = 'closed' THEN COALESCE(excluded.closed_at_provider, excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + ELSE COALESCE(excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + END + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, pr_state = excluded.pr_state, review_decision = excluded.review_decision, ci_state = excluded.ci_state, @@ -445,6 +471,7 @@ type UpsertPRParams struct { CIState domain.CIState Mergeability domain.Mergeability UpdatedAt time.Time + StateChangedAt sql.NullTime Provider string Host string Repo string @@ -487,6 +514,7 @@ func (q *Queries) UpsertPR(ctx context.Context, arg UpsertPRParams) error { arg.CIState, arg.Mergeability, arg.UpdatedAt, + arg.StateChangedAt, arg.Provider, arg.Host, arg.Repo, diff --git a/backend/internal/storage/sqlite/migrations/0025_pr_state_changed_at.sql b/backend/internal/storage/sqlite/migrations/0025_pr_state_changed_at.sql new file mode 100644 index 0000000000..3ba5290d08 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0025_pr_state_changed_at.sql @@ -0,0 +1,18 @@ +-- Summary: persist when the current normalized PR lifecycle state became active. +-- +goose Up +-- +goose StatementBegin +ALTER TABLE pr ADD COLUMN state_changed_at TIMESTAMP; + +UPDATE pr +SET state_changed_at = CASE + WHEN pr_state = 'merged' AND merged_at_provider IS NOT NULL THEN merged_at_provider + WHEN pr_state = 'closed' AND closed_at_provider IS NOT NULL THEN closed_at_provider + WHEN created_at_provider IS NOT NULL THEN created_at_provider +END +WHERE state_changed_at IS NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE pr DROP COLUMN state_changed_at; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/pr.sql b/backend/internal/storage/sqlite/queries/pr.sql index 8767b703c0..7d02cf4932 100644 --- a/backend/internal/storage/sqlite/queries/pr.sql +++ b/backend/internal/storage/sqlite/queries/pr.sql @@ -1,6 +1,6 @@ -- name: UpsertPR :exec INSERT INTO pr ( - url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, + url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at, provider, host, repo, source_branch, target_branch, head_sha, title, additions, deletions, changed_files, author, base_sha, merge_commit_sha, is_draft, is_merged, is_closed, @@ -8,9 +8,19 @@ INSERT INTO pr ( created_at_provider, updated_at_provider, merged_at_provider, closed_at_provider, metadata_hash, ci_hash, review_hash, observed_at, ci_observed_at, review_observed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET number = excluded.number, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN + CASE + WHEN excluded.pr_state = 'merged' THEN COALESCE(excluded.merged_at_provider, excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + WHEN excluded.pr_state = 'closed' THEN COALESCE(excluded.closed_at_provider, excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + ELSE COALESCE(excluded.updated_at_provider, excluded.observed_at, excluded.updated_at) + END + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, pr_state = excluded.pr_state, review_decision = excluded.review_decision, ci_state = excluded.ci_state, @@ -49,12 +59,17 @@ ON CONFLICT (url) DO UPDATE SET -- name: UpsertLegacyPR :exec INSERT INTO pr ( - url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, + url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at, is_draft, is_merged, is_closed ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET number = excluded.number, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN excluded.updated_at + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, pr_state = excluded.pr_state, review_decision = excluded.review_decision, ci_state = excluded.ci_state, @@ -127,10 +142,15 @@ WHERE pr.session_id = ? ORDER BY pr.updated_at DESC; -- name: ClaimPRForSession :exec -INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at, state_changed_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url) DO UPDATE SET session_id = excluded.session_id, + state_changed_at = CASE + WHEN pr.pr_state != excluded.pr_state THEN excluded.updated_at + WHEN pr.state_changed_at IS NULL THEN excluded.state_changed_at + ELSE pr.state_changed_at + END, review_decision = excluded.review_decision, updated_at = excluded.updated_at; diff --git a/backend/internal/storage/sqlite/store/pr_facts_test.go b/backend/internal/storage/sqlite/store/pr_facts_test.go index a9e2019897..ad20094bc7 100644 --- a/backend/internal/storage/sqlite/store/pr_facts_test.go +++ b/backend/internal/storage/sqlite/store/pr_facts_test.go @@ -79,3 +79,105 @@ func TestListPRFactsForSessionProjectsAllPRsNewestFirst(t *testing.T) { t.Fatalf("no-PR session = %d facts, want 0", len(got)) } } + +func TestPRStateChangedAtPersistsOnlyOnStateTransitions(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + createdAt := time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC) + updatedAt := time.Date(2026, 6, 4, 9, 30, 0, 0, time.UTC) + readyAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC) + laterAt := time.Date(2026, 6, 4, 11, 0, 0, 0, time.UTC) + pr := domain.PullRequest{ + URL: "https://github.com/acme/repo/pull/7", + SessionID: r.ID, + Number: 7, + Draft: true, + CreatedAtProvider: createdAt, + UpdatedAtProvider: updatedAt, + UpdatedAt: updatedAt, + ObservedAt: updatedAt, + } + if err := s.WriteSCMObservation(ctx, pr, nil, nil, nil, nil, ports.ReviewWritePreserve); err != nil { + t.Fatal(err) + } + got, ok, err := s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("GetPR after draft write: ok=%v err=%v", ok, err) + } + if !got.StateChangedAt.Equal(createdAt) { + t.Fatalf("draft stateChangedAt = %s, want created time %s", got.StateChangedAt, createdAt) + } + + pr.Title = "metadata-only update" + pr.UpdatedAtProvider = laterAt + pr.UpdatedAt = laterAt + pr.ObservedAt = laterAt + if err := s.WriteSCMObservation(ctx, pr, nil, nil, nil, nil, ports.ReviewWritePreserve); err != nil { + t.Fatal(err) + } + got, ok, err = s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("GetPR after same-state write: ok=%v err=%v", ok, err) + } + if !got.StateChangedAt.Equal(createdAt) { + t.Fatalf("same-state stateChangedAt = %s, want preserved %s", got.StateChangedAt, createdAt) + } + + pr.Draft = false + pr.UpdatedAtProvider = readyAt + pr.UpdatedAt = readyAt + pr.ObservedAt = readyAt + if err := s.WriteSCMObservation(ctx, pr, nil, nil, nil, nil, ports.ReviewWritePreserve); err != nil { + t.Fatal(err) + } + got, ok, err = s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("GetPR after ready write: ok=%v err=%v", ok, err) + } + if !got.StateChangedAt.Equal(readyAt) { + t.Fatalf("ready stateChangedAt = %s, want ready time %s", got.StateChangedAt, readyAt) + } +} + +func TestPRStateChangedAtFillsWhenProviderCreatedAtArrives(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + discoveredAt := time.Date(2026, 6, 4, 9, 30, 0, 0, time.UTC) + createdAt := time.Date(2026, 6, 4, 8, 45, 0, 0, time.UTC) + laterAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC) + pr := domain.PullRequest{ + URL: "https://github.com/acme/repo/pull/8", + SessionID: r.ID, + Number: 8, + UpdatedAt: discoveredAt, + ObservedAt: discoveredAt, + } + if err := s.WriteSCMObservation(ctx, pr, nil, nil, nil, nil, ports.ReviewWritePreserve); err != nil { + t.Fatal(err) + } + got, ok, err := s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("GetPR after discovery write: ok=%v err=%v", ok, err) + } + if !got.StateChangedAt.IsZero() { + t.Fatalf("discovery stateChangedAt = %s, want unset without provider creation time", got.StateChangedAt) + } + + pr.CreatedAtProvider = createdAt + pr.UpdatedAt = laterAt + pr.ObservedAt = laterAt + if err := s.WriteSCMObservation(ctx, pr, nil, nil, nil, nil, ports.ReviewWritePreserve); err != nil { + t.Fatal(err) + } + got, ok, err = s.GetPR(ctx, pr.URL) + if err != nil || !ok { + t.Fatalf("GetPR after provider-created write: ok=%v err=%v", ok, err) + } + if !got.StateChangedAt.Equal(createdAt) { + t.Fatalf("same-state stateChangedAt = %s, want provider creation time %s", got.StateChangedAt, createdAt) + } +} diff --git a/backend/internal/storage/sqlite/store/pr_store.go b/backend/internal/storage/sqlite/store/pr_store.go index f3ec47f796..8ab6764606 100644 --- a/backend/internal/storage/sqlite/store/pr_store.go +++ b/backend/internal/storage/sqlite/store/pr_store.go @@ -70,6 +70,7 @@ func (s *Store) ClaimPR(ctx context.Context, pr domain.PullRequest, checks []dom if err := q.ClaimPRForSession(ctx, gen.ClaimPRForSessionParams{ URL: pr.URL, SessionID: pr.SessionID, Number: int64(pr.Number), PRState: prState(pr), ReviewDecision: reviewOrDefault(pr.Review), CIState: ciOrDefault(pr.CI), Mergeability: mergeabilityOrDefault(pr.Mergeability), UpdatedAt: pr.UpdatedAt, + StateChangedAt: nullTime(initialPRStateChangedAt(pr)), }); err != nil { return err } @@ -331,6 +332,7 @@ func genPRParams(r domain.PullRequest) gen.UpsertPRParams { CIState: ciOrDefault(r.CI), Mergeability: mergeabilityOrDefault(r.Mergeability), UpdatedAt: r.UpdatedAt, + StateChangedAt: nullTime(initialPRStateChangedAt(r)), Provider: r.Provider, Host: r.Host, Repo: r.Repo, @@ -374,6 +376,7 @@ func genLegacyPRParams(r domain.PullRequest) gen.UpsertLegacyPRParams { CIState: ciOrDefault(r.CI), Mergeability: mergeabilityOrDefault(r.Mergeability), UpdatedAt: r.UpdatedAt, + StateChangedAt: nullTime(initialPRStateChangedAt(r)), IsDraft: boolInt(r.Draft), IsMerged: boolInt(r.Merged), IsClosed: boolInt(r.Closed), @@ -401,6 +404,22 @@ func mergeabilityOrDefault(v domain.Mergeability) domain.Mergeability { return v } +func initialPRStateChangedAt(r domain.PullRequest) time.Time { + if !r.StateChangedAt.IsZero() { + return r.StateChangedAt + } + switch prState(r) { + case domain.PRStateMerged: + return r.MergedAtProvider + case domain.PRStateClosed: + return r.ClosedAtProvider + case domain.PRStateDraft, domain.PRStateOpen: + return r.CreatedAtProvider + default: + return time.Time{} + } +} + func prRowFromGen(p gen.PR) domain.PullRequest { return domain.PullRequest{ URL: p.URL, @@ -413,6 +432,7 @@ func prRowFromGen(p gen.PR) domain.PullRequest { Review: p.ReviewDecision, Mergeability: p.Mergeability, UpdatedAt: p.UpdatedAt, + StateChangedAt: timeFromNull(p.StateChangedAt), Provider: p.Provider, Host: p.Host, Repo: p.Repo, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index cfeb23f7d9..8e1492d4fa 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -347,7 +347,7 @@ func TestPRCRUD(t *testing.T) { pr := domain.PullRequest{ URL: "https://gh/pr/1", SessionID: r.ID, Number: 1, - Review: domain.ReviewRequired, CI: domain.CIFailing, Mergeability: domain.MergeBlocked, UpdatedAt: now, + Review: domain.ReviewRequired, CI: domain.CIFailing, Mergeability: domain.MergeBlocked, UpdatedAt: now, StateChangedAt: now, } if err := s.WritePR(ctx, pr, nil, nil); err != nil { t.Fatal(err) diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 03c95adbed..9616c5945c 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -1150,6 +1150,8 @@ export interface components { ci: components["schemas"]["SessionPRCISummary"]; /** Format: date-time */ ciObservedAt?: string; + /** Format: date-time */ + createdAt?: string; deletions: number; headSha: string; htmlUrl?: string; @@ -1166,6 +1168,8 @@ export interface components { sourceBranch: string; /** @enum {string} */ state: "draft" | "open" | "merged" | "closed"; + /** Format: date-time */ + stateChangedAt?: string; targetBranch: string; title: string; /** Format: date-time */ diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 4a6d17e05e..2bb10dcba2 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionInspector } from "./SessionInspector"; import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace"; +import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; const { getMock, postMock } = vi.hoisted(() => ({ getMock: vi.fn(), @@ -185,7 +186,7 @@ describe("SessionInspector PR section", () => { it("links each PR to its url", () => { renderWithQuery(); - const links = screen.getAllByRole("link", { name: /Open/ }); + const links = prSection("Pull requests (2)").getAllByRole("link", { name: "Open" }); expect(links.map((a) => a.getAttribute("href"))).toEqual([ "https://example.com/pr/41", "https://example.com/pr/42", @@ -367,6 +368,66 @@ describe("SessionInspector Activity section", () => { expect(within(activityRow).getByText("Changes Requested")).toBeInTheDocument(); }); + it("links PR milestones to the backend PR URL and created timestamp", async () => { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000).toISOString(); + const summary = { + url: "https://api.github.com/repos/acme/repo/pulls/7", + htmlUrl: "https://github.com/acme/repo/pull/7", + number: 7, + title: "Ready for review", + state: "open", + provider: "github", + repo: "acme/repo", + author: "ada", + sourceBranch: "feat/ns", + targetBranch: "main", + headSha: "abc123", + additions: 4, + deletions: 1, + changedFiles: 2, + ci: { state: "passing", failingChecks: [] }, + review: { decision: "none", hasUnresolvedHumanComments: false, unresolvedBy: [] }, + mergeability: { + state: "mergeable", + reasons: [], + prUrl: "https://github.com/acme/repo/pull/7", + conflictFiles: [], + }, + createdAt: oneHourAgo, + stateChangedAt: fifteenMinutesAgo, + updatedAt: fifteenMinutesAgo, + observedAt: fifteenMinutesAgo, + ciObservedAt: fifteenMinutesAgo, + reviewObservedAt: fifteenMinutesAgo, + } satisfies SessionPRSummary & { createdAt: string; stateChangedAt: string }; + getMock.mockImplementation(async (path: string) => { + if (path === "/api/v1/sessions/{sessionId}/pr") { + return { data: { sessionId: "sess-1", prs: [summary] }, error: undefined }; + } + return { data: { reviewerHandleId: "", reviews: [] }, error: undefined }; + }); + + renderWithQuery( + , + ); + + await waitFor(() => { + expect(screen.getByRole("link", { name: "Opened PR #7" })).toHaveAttribute( + "href", + "https://github.com/acme/repo/pull/7", + ); + }); + const link = screen.getByRole("link", { name: "Opened PR #7" }); + const row = link.closest("[data-testid='inspector-timeline-event']") as HTMLElement; + expect(within(row).getByText("1h ago")).toBeInTheDocument(); + }); + it("orders timeline milestones around the combined current state row", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 37f2280d53..27a02b7cf4 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -254,7 +254,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
- +
@@ -305,7 +305,7 @@ const timelineNodeTone: Record = { warn: "bg-warning shadow-timeline-dot", }; -function ActivityTimeline({ session }: { session: WorkspaceSession }) { +function ActivityTimeline({ prs, session }: { prs: SessionPRSummary[]; session: WorkspaceSession }) { const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = []; events.push({ @@ -314,28 +314,19 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { ts: formatTimeCompact(session.createdAt ?? session.updatedAt), }); - const prs = sortedPRs(session); for (const pr of prs.filter((pr) => pr.state === "draft")) { events.push({ tone: "neutral", - node: ( - <> - Draft PR #{pr.number} - - ), - ts: null, + node: , + ts: prStateTime(pr), }); } for (const pr of prs.filter((pr) => pr.state !== "draft")) { events.push({ tone: "neutral", - node: ( - <> - Opened PR #{pr.number} - - ), - ts: null, + node: , + ts: prCreatedTime(pr), }); } @@ -364,12 +355,8 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { for (const pr of prs.filter((pr) => pr.state === "merged")) { events.push({ tone: "good", - node: ( - <> - Merged PR #{pr.number} - - ), - ts: null, + node: , + ts: prStateTime(pr), }); } @@ -403,6 +390,30 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { ); } +function PRTimelineLink({ pr, verb }: { pr: SessionPRSummary; verb: "Draft" | "Opened" | "Merged" }) { + return ( + + {verb} + PR #{pr.number} + + ); +} + +function prStateTime(pr: SessionPRSummary): string | null { + return pr.stateChangedAt ? formatTimeCompact(pr.stateChangedAt) : null; +} + +function prCreatedTime(pr: SessionPRSummary): string | null { + return pr.createdAt ? formatTimeCompact(pr.createdAt) : null; +} + type ScmTimelineState = "ci_failed" | "changes_requested" | "conflict"; const CONFLICT_PILL = { label: "Conflict", tone: "var(--color-danger)", breathe: false }; diff --git a/frontend/src/renderer/lib/mock-data.ts b/frontend/src/renderer/lib/mock-data.ts index 72f157112a..5119939237 100644 --- a/frontend/src/renderer/lib/mock-data.ts +++ b/frontend/src/renderer/lib/mock-data.ts @@ -252,6 +252,8 @@ const prSummary = (sessionId: string, number: number, overrides: Partial = {}): SessionPRSummary => ({ url: "https://github.com/acme/repo/pull/7", @@ -69,6 +70,39 @@ describe("prBrowserUrl", () => { }); }); +describe("sessionPRDisplaySummaries", () => { + it("leaves timing absent for fallback PR facts until the API summary provides provider times", () => { + const session: WorkspaceSession = { + id: "sess-1", + workspaceId: "ws-1", + workspaceName: "repo", + title: "Fix timing", + provider: "codex", + branch: "feat/timing", + status: "review_pending", + updatedAt: "2026-06-15T12:00:00Z", + prs: [ + { + url: "https://github.com/acme/repo/pull/7", + number: 7, + state: "open", + ci: "passing", + review: "none", + mergeability: "mergeable", + reviewComments: false, + updatedAt: "2026-06-15T11:00:00Z", + }, + ], + }; + + const [fallback] = sessionPRDisplaySummaries(session); + + expect(fallback.updatedAt).toBe("2026-06-15T11:00:00Z"); + expect(fallback.createdAt).toBeUndefined(); + expect(fallback.stateChangedAt).toBeUndefined(); + }); +}); + describe("prSummaryParts", () => { it("always returns CI, Merge, and Review parts", () => { expect(prSummaryParts(summary()).map((part) => part.label)).toEqual(["CI", "Merge", "Review"]);