From 77c01daf42d20e0d179c7c2bc297dc3673f25d15 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 27 May 2026 18:34:42 +0530 Subject: [PATCH] feat: handle draft PR lifecycle state --- backend/internal/domain/decide/decide.go | 13 ++++-- backend/internal/domain/decide/decide_test.go | 44 ++++++++++++++++--- backend/internal/domain/decide/types.go | 3 +- backend/internal/domain/lifecycle.go | 1 + backend/internal/domain/status.go | 17 ++++++- backend/internal/domain/status_test.go | 16 +++++++ backend/internal/lifecycle/manager.go | 14 ++++-- backend/internal/lifecycle/manager_test.go | 28 ++++++++++++ 8 files changed, 120 insertions(+), 16 deletions(-) diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index e7f2c4457..583b80d04 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -89,8 +89,9 @@ func ResolveProbeDecision(in ProbeInput) LifecycleDecision { } // ResolveOpenPRDecision walks the PR pipeline ladder. CI failure dominates -// everything, then requested changes, then the approval/merge states, then a -// pending review, then a stalled (idle-beyond-threshold) PR, else plain open. +// everything. Draft PRs then surface as draft and do not enter the review or +// merge states. Open PRs continue through requested changes, approval/merge +// states, pending review, stalled (idle-beyond-threshold), then plain open. func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { // evidence is a stable, timestamp-free summary " # " // for logs/traceability; it folds in the PR identity inputs (Number/URL). @@ -104,13 +105,17 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { } return s } + prState := domain.PROpen + if in.Draft { + prState = domain.PRDraft + } base := func(status domain.SessionStatus, cond string, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { return LifecycleDecision{ Status: status, Evidence: evidence(cond), SessionState: ss, SessionReason: sr, - PRState: domain.PROpen, + PRState: prState, PRReason: prReason, } } @@ -118,6 +123,8 @@ func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { switch { case in.CIFailing: return base(domain.StatusCIFailed, "ci_failing", domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) + case in.Draft: + return base(domain.StatusDraft, "draft", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) case in.ChangesRequested: return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) case in.Mergeable: diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index d6e027f1e..9af6e596c 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -155,11 +155,12 @@ func TestResolveProbeDecision(t *testing.T) { func TestResolveOpenPRDecision(t *testing.T) { tests := []struct { - name string - in OpenPRInput - wantStatus domain.SessionStatus - wantPR domain.PRReason - wantState domain.SessionState + name string + in OpenPRInput + wantStatus domain.SessionStatus + wantPR domain.PRReason + wantPRState domain.PRState + wantState domain.SessionState }{ { name: "ci failing dominates everything", @@ -168,6 +169,22 @@ func TestResolveOpenPRDecision(t *testing.T) { wantPR: domain.PRReasonCIFailing, wantState: domain.SessionWorking, }, + { + name: "draft with failing CI maps to ci_failed", + in: OpenPRInput{Draft: true, CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, + wantStatus: domain.StatusCIFailed, + wantPR: domain.PRReasonCIFailing, + wantPRState: domain.PRDraft, + wantState: domain.SessionWorking, + }, + { + name: "draft ignores review and merge states", + in: OpenPRInput{Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, + wantStatus: domain.StatusDraft, + wantPR: domain.PRReasonInProgress, + wantPRState: domain.PRDraft, + wantState: domain.SessionWorking, + }, { name: "changes requested before approval states", in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true}, @@ -235,8 +252,12 @@ func TestResolveOpenPRDecision(t *testing.T) { if got.PRReason != tt.wantPR { t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) } - if got.PRState != domain.PROpen { - t.Errorf("PRState = %q, want %q", got.PRState, domain.PROpen) + wantPRState := tt.wantPRState + if wantPRState == "" { + wantPRState = domain.PROpen + } + if got.PRState != wantPRState { + t.Errorf("PRState = %q, want %q", got.PRState, wantPRState) } if got.SessionState != tt.wantState { t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) @@ -287,6 +308,8 @@ func TestDecidersDeriveConsistently(t *testing.T) { var decisions []LifecycleDecision for _, in := range []OpenPRInput{ + {Draft: true, CIFailing: true}, + {Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, {CIFailing: true}, {ChangesRequested: true}, {Approved: true, Mergeable: true}, @@ -363,6 +386,13 @@ func TestResolveTerminalPRStateDecision(t *testing.T) { wantState: domain.SessionWorking, wantReason: domain.ReasonTaskInProgress, }, + { + name: "non-terminal draft is a working no-op", + pr: domain.PRDraft, + wantStatus: domain.StatusWorking, + wantState: domain.SessionWorking, + wantReason: domain.ReasonTaskInProgress, + }, } for _, tt := range tests { diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 7ac4adf1d..e4fa92cab 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -52,8 +52,9 @@ const ( ProcessIndeterminate ProcessLiveness = "indeterminate" ) -// OpenPRInput drives the PR pipeline ladder for an open PR. +// OpenPRInput drives the PR pipeline ladder for an open or draft PR. type OpenPRInput struct { + Draft bool CIFailing bool ChangesRequested bool Approved bool diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index 567a47693..2df4bb13a 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -90,6 +90,7 @@ type PRState string const ( PRNone PRState = "none" + PRDraft PRState = "draft" PROpen PRState = "open" PRMerged PRState = "merged" PRClosed PRState = "closed" diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index b12b2b9f6..aff5ba1f1 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -9,6 +9,7 @@ const ( StatusWorking SessionStatus = "working" StatusDetecting SessionStatus = "detecting" StatusPROpen SessionStatus = "pr_open" + StatusDraft SessionStatus = "draft" StatusCIFailed SessionStatus = "ci_failed" StatusReviewPending SessionStatus = "review_pending" StatusChangesRequested SessionStatus = "changes_requested" @@ -32,8 +33,9 @@ const ( // 1. Terminal / hard session states (done, terminated, needs_input, stuck, // detecting, not_started) map directly — these OUTRANK PR facts. // 2. Otherwise a merged PR wins. -// 3. Otherwise an open PR maps by its reason. -// 4. Otherwise fall through to the SOFT session state (idle/working). +// 3. Otherwise a draft PR maps to draft, except CI failure still dominates. +// 4. Otherwise an open PR maps by its reason. +// 5. Otherwise fall through to the SOFT session state (idle/working). // // So "PR facts dominate session facts" applies only to the soft states: an idle // or working session with an open, CI-failing PR displays as ci_failed — but a @@ -59,6 +61,10 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusMerged } + if l.PR.State == PRDraft { + return draftPRStatus(l.PR.Reason) + } + if l.PR.State == PROpen { return openPRStatus(l.PR.Reason) } @@ -82,6 +88,13 @@ func terminatedStatus(r SessionReason) SessionStatus { } } +func draftPRStatus(r PRReason) SessionStatus { + if r == PRReasonCIFailing { + return StatusCIFailed + } + return StatusDraft +} + func openPRStatus(r PRReason) SessionStatus { switch r { case PRReasonCIFailing: diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 12b0ade05..dc2c96e77 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -49,6 +49,22 @@ func TestDeriveLegacyStatus(t *testing.T) { }, want: StatusCIFailed, }, + { + name: "draft PR with failing CI maps to ci_failed", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PRDraft, Reason: PRReasonCIFailing}, + }, + want: StatusCIFailed, + }, + { + name: "draft PR ignores review and merge reasons", + in: CanonicalSessionLifecycle{ + Session: SessionSubstate{State: SessionWorking}, + PR: PRSubstate{State: PRDraft, Reason: PRReasonMergeReady}, + }, + want: StatusDraft, + }, { name: "open PR approved", in: CanonicalSessionLifecycle{ diff --git a/backend/internal/lifecycle/manager.go b/backend/internal/lifecycle/manager.go index 2581fea0e..86c36b84c 100644 --- a/backend/internal/lifecycle/manager.go +++ b/backend/internal/lifecycle/manager.go @@ -196,9 +196,9 @@ func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.Session } // ApplySCMObservation maps PR facts onto the PR axis. A failed fetch is dropped -// (failed probe != "no PR"). An open PR writes only the PR sub-state — the -// session axis stays owned by activity, and DeriveLegacyStatus surfaces the PR -// reason for display. A terminal PR (merged/closed) also parks the session. +// (failed probe != "no PR"). An open or draft PR writes only the PR sub-state — +// the session axis stays owned by activity, and DeriveLegacyStatus surfaces the +// PR reason for display. A terminal PR (merged/closed) also parks the session. func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, f ports.SCMFacts) error { tr, err := m.mutate(ctx, id, func(cur domain.CanonicalSessionLifecycle, exists bool) (ports.LifecyclePatch, bool, error) { if !exists || !f.Fetched { @@ -206,6 +206,14 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID, } switch f.PRState { + case domain.PRDraft: + in := openPRInput(f) + in.Draft = true + d := decide.ResolveOpenPRDecision(in) + var patch ports.LifecyclePatch + changed := setPRIfChanged(&patch, cur, d, f) + return patch, changed, nil + case domain.PROpen: d := decide.ResolveOpenPRDecision(openPRInput(f)) var patch ports.LifecyclePatch diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index d0a97125a..d98afa942 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -263,6 +263,34 @@ func TestApplySCMObservation(t *testing.T) { } }) + t.Run("draft PR writes draft or ci_failed without review states", func(t *testing.T) { + cases := []struct { + name string + facts ports.SCMFacts + wantReason domain.PRReason + wantStatus domain.SessionStatus + }{ + {"draft with failing CI", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, CISummary: ports.CIFailing}, domain.PRReasonCIFailing, domain.StatusCIFailed}, + {"draft ignores review and merge facts", ports.SCMFacts{Fetched: true, PRState: domain.PRDraft, ReviewDecision: ports.ReviewApproved, Mergeability: ports.Mergeability{Mergeable: true}}, domain.PRReasonInProgress, domain.StatusDraft}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mgr, store := newManager() + store.seed(sid, lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)) + if err := mgr.ApplySCMObservation(context.Background(), sid, c.facts); err != nil { + t.Fatalf("apply: %v", err) + } + l := mustLoad(t, store) + if l.PR.State != domain.PRDraft || l.PR.Reason != c.wantReason { + t.Errorf("pr = %v/%v, want draft/%v", l.PR.State, l.PR.Reason, c.wantReason) + } + if got := domain.DeriveLegacyStatus(l); got != c.wantStatus { + t.Errorf("display = %v, want %v", got, c.wantStatus) + } + }) + } + }) + t.Run("merged PR parks the session and displays merged", func(t *testing.T) { mgr, store := newManager() seed := lc(domain.SessionWorking, domain.ReasonTaskInProgress, domain.RuntimeAlive)