From 047db6ac005e0ca6a4b7dd36ea23807feebade29 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 15 Sep 2026 20:59:10 -0400 Subject: [PATCH 1/3] test(forge): live-oracle legs for the state-transition op (RIG-3331) Closes T7 of the forge state-transition record: the DL-210 tier-2 live-contract coverage the op shipped without. The 13 committed transition fixtures had golden replay only, and no live leg mentioned transition at all. Ten live legs, one per live-reproducible fixture, plus a cross-op scenario on both providers: create -> close -> an INDEPENDENT GetIssue asserting the state changed -> reopen -> assert reopened. The separate read is the point -- a single-call fixture compare pins one response shape and cannot show the write took effect. Three fixtures stay golden-replay-only and are named in the file with their reason: each needs live repo or team state a test cannot create for itself (a merged PR; two workflow states sharing a name; two completed-type states). The PR-transition legs assert the state fold directly instead of comparing to the fixture, because ghPullDetail populates Changed{Files,Additions,Deletions} and those are not in volatileFields -- a live seeded PR cannot reproduce the pinned counts, so a fixture compare would red on data the transition does not control. Every Linear by-name leg DISCOVERS its target workflow-state name from the live team rather than hardcoding one. A hardcoded name on a team that lacks it fails down the unknown-name path, which would have passed the type-contradiction assertion for the wrong reason. No capture specs for the error fixtures: deriveFixtureHalves derives Want from a successful decode and t.Fatalfs on error, so rejection fixtures stay hand-written -- consistent with every existing wantError fixture. Co-authored-by: Matt Wilkinson --- go/internal/forge/livegithub_test.go | 555 ++++++++++++++++++++++++++- 1 file changed, 553 insertions(+), 2 deletions(-) diff --git a/go/internal/forge/livegithub_test.go b/go/internal/forge/livegithub_test.go index 76081ad33..6ea69cb86 100644 --- a/go/internal/forge/livegithub_test.go +++ b/go/internal/forge/livegithub_test.go @@ -513,6 +513,185 @@ func TestLiveGitHubAuthFailureInvalidates(t *testing.T) { } } +// --- transition oracle (design §T7, DL-210) ---------------------------------- +// +// These legs cover every transition fixture reproducible live. Three are +// golden-replay-only, each needing live state a test cannot create: +// transition_pull_request_reopen_merged (a merged PR), transition_issue_duplicate_name +// (two states sharing a name), transition_issue_ambiguous_default (two +// completed-type states). + +// TestLiveGitHubTransitionIssueClose closes a freshly-created issue (default +// reason) and asserts the decoded issue matches the committed fixture. +func TestLiveGitHubTransitionIssueClose(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + f := liveFixture(t, providerGitHub, "transition_issue_close_default") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return gh.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-tclose-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + + got, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed}) + if err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveGitHubTransitionIssueCloseReason closes with an explicit not_planned +// state_reason on a labeled issue and asserts the decoded issue matches the +// fixture (State closed, the label preserved through the close). +func TestLiveGitHubTransitionIssueCloseReason(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + f := liveFixture(t, providerGitHub, "transition_issue_close_reason") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return gh.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-tclosereason-" + newRunID(), Labels: []string{"bug"}}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + + got, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed, CloseReason: "not_planned"}) + if err != nil { + t.Fatalf("TransitionIssueState close_reason: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveGitHubTransitionIssueReopen closes then reopens an issue and asserts +// the reopen response matches the fixture (State open). +func TestLiveGitHubTransitionIssueReopen(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + f := liveFixture(t, providerGitHub, "transition_issue_reopen") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return gh.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-treopen-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + + // Close first so the reopen is a real state change, not a no-op on an open issue. + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close (setup): %v", err) + } + got, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateOpen}) + if err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveGitHubTransitionPullRequestClose closes a freshly-opened PR. The PR +// transition decodes ghPullDetail, which populates the live diff stats (Changed) +// the committed fixture cannot pin, so the leg asserts the state fold directly +// rather than through assertMatchesFixture. +func TestLiveGitHubTransitionPullRequestClose(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + head := "compass-live-" + newRunID() + seedHeadBranch(t, ctx, author, repo, head) + pr, err := createWithBackoff(ctx, func() (PullRequest, error) { + return gh.CreatePullRequest(ctx, repo, CreatePR{Title: "compass-live-tprclose-" + newRunID(), HeadRef: head, BaseRef: "main", Draft: true}) + }) + if err != nil { + t.Fatalf("CreatePullRequest (setup): %v", err) + } + t.Cleanup(func() { teardownGitHubPR(t, author, repo, pr.Number, head) }) + + got, err := gh.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateClosed}) + if err != nil { + t.Fatalf("TransitionPullRequestState close: %v", err) + } + if got.State != stateClosed { + t.Errorf("TransitionPullRequestState close State = %q, want %q", got.State, stateClosed) + } +} + +// TestLiveGitHubTransitionPullRequestReopen closes then reopens a PR, asserting +// the state fold directly (see TestLiveGitHubTransitionPullRequestClose). +func TestLiveGitHubTransitionPullRequestReopen(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + head := "compass-live-" + newRunID() + seedHeadBranch(t, ctx, author, repo, head) + pr, err := createWithBackoff(ctx, func() (PullRequest, error) { + return gh.CreatePullRequest(ctx, repo, CreatePR{Title: "compass-live-tprreopen-" + newRunID(), HeadRef: head, BaseRef: "main", Draft: true}) + }) + if err != nil { + t.Fatalf("CreatePullRequest (setup): %v", err) + } + t.Cleanup(func() { teardownGitHubPR(t, author, repo, pr.Number, head) }) + + if _, err := gh.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionPullRequestState close (setup): %v", err) + } + got, err := gh.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateOpen}) + if err != nil { + t.Fatalf("TransitionPullRequestState reopen: %v", err) + } + if got.State != stateOpen { + t.Errorf("TransitionPullRequestState reopen State = %q, want %q", got.State, stateOpen) + } +} + +// TestLiveGitHubTransitionCrossOp is the T7 cross-op oracle: create -> close -> +// an INDEPENDENT GetIssue confirming the state changed -> reopen -> GetIssue +// confirming it reopened. The separate read proves the write took effect, which +// a single-call fixture compare cannot show. +func TestLiveGitHubTransitionCrossOp(t *testing.T) { + repo, author, _ := requireLive(t) + ctx := context.Background() + gh := liveGitHub(author) + + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return gh.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-xop-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + closed, err := gh.GetIssue(ctx, repo, issue.Number) + if err != nil { + t.Fatalf("GetIssue after close: %v", err) + } + if closed.State != stateClosed { + t.Errorf("GetIssue after close State = %q, want %q", closed.State, stateClosed) + } + + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateOpen}); err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + reopened, err := gh.GetIssue(ctx, repo, issue.Number) + if err != nil { + t.Fatalf("GetIssue after reopen: %v", err) + } + if reopened.State != stateOpen { + t.Errorf("GetIssue after reopen State = %q, want %q", reopened.State, stateOpen) + } +} + // --- Linear scenarios (co-equal) --------------------------------------------- // TestLiveLinearCreateIssue creates a uniquely-named issue on the test team and @@ -640,6 +819,200 @@ func TestLiveLinearPRUnsupported(t *testing.T) { } } +// TestLiveLinearTransitionIssueClose closes a freshly-created issue via the +// default (completed) mapping and asserts the decoded issue matches the fixture. +func TestLiveLinearTransitionIssueClose(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + f := liveFixture(t, providerLinear, "transition_issue_close_default") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tclose-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + got, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed}) + if err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveLinearTransitionIssueCloseByName closes an issue onto a NAMED workflow +// state and asserts the decoded issue matches the fixture. The name is +// DISCOVERED from the live team rather than hardcoded, so a team whose columns +// are named differently cannot red this leg down the unknown-name path. +func TestLiveLinearTransitionIssueCloseByName(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + named := liveStateNameOfType(t, ctx, ln, team, "canceled") + f := liveFixture(t, providerLinear, "transition_issue_close_by_name") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tclosename-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + got, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed, WorkflowState: named}) + if err != nil { + t.Fatalf("TransitionIssueState close_by_name: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveLinearTransitionIssueReopen closes then reopens an issue via the +// default (unstarted) mapping and asserts the reopen matches the fixture. +func TestLiveLinearTransitionIssueReopen(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + f := liveFixture(t, providerLinear, "transition_issue_reopen_default") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-treopen-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + // Close first so the reopen is a real state change. + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close (setup): %v", err) + } + got, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateOpen}) + if err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + assertMatchesFixture(t, got, f.Response.Want) +} + +// TestLiveLinearTransitionUnknownName asserts the error contract for a target +// workflow-state name that cannot exist on the team: a *StatusError 422 whose +// message names the team, the failure, and the offending name (the fixture's +// RIG is team-specific, so the live leg substitutes the live team key). +func TestLiveLinearTransitionUnknownName(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tunknown-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + _, err = ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed, WorkflowState: "compass-live-nonexistent-state"}) + var se *StatusError + if !errors.As(err, &se) || se.Status != http.StatusUnprocessableEntity { + t.Fatalf("unknown-name transition: want *StatusError 422, got %v", err) + } + for _, sub := range []string{team, "no workflow state named", "compass-live-nonexistent-state"} { + if !strings.Contains(se.Message, sub) { + t.Errorf("unknown-name error %q does not name %q", se.Message, sub) + } + } +} + +// TestLiveLinearTransitionTypeContradiction asserts the error contract for a +// named state whose type contradicts the portable target: closing onto a +// started-type state must be a *StatusError 422 naming the clash. The state is +// DISCOVERED from the live team, never hardcoded — a team without the assumed +// name would otherwise fail down the unknown-name path and pass this assertion +// for the wrong reason. +func TestLiveLinearTransitionTypeContradiction(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + started := liveStateNameOfType(t, ctx, ln, team, "started") + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tcontra-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + _, err = ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed, WorkflowState: started}) + var se *StatusError + if !errors.As(err, &se) || se.Status != http.StatusUnprocessableEntity { + t.Fatalf("type-contradiction transition: want *StatusError 422, got %v", err) + } + for _, sub := range []string{team, started, "started", "contradicts", stateClosed} { + if !strings.Contains(se.Message, sub) { + t.Errorf("type-contradiction error %q does not name %q", se.Message, sub) + } + } +} + +// liveStateNameOfType returns the name of a workflow state of the given Linear +// type on the live team, skipping when the team has none — the by-name contracts +// are unobservable without one, and a skip is loud via CI's assert-ran guard. +func liveStateNameOfType(t *testing.T, ctx context.Context, ln *Linear, team, stateType string) string { + t.Helper() + states, _, err := ln.workflowStatesFor(ctx, team) + if err != nil { + t.Fatalf("workflowStatesFor(%q): %v", team, err) + } + for _, s := range states { + if s.Type == stateType { + return s.Name + } + } + t.Skipf("live linear oracle: team %q has no %s-type workflow state; the by-name leg needs one", team, stateType) + return "" +} + +// TestLiveLinearTransitionCrossOp is the Linear half of the T7 cross-op oracle: +// create -> close -> an INDEPENDENT GetIssue confirming the state changed -> +// reopen -> GetIssue confirming it reopened. +func TestLiveLinearTransitionCrossOp(t *testing.T) { + ts, team := requireLinear(t) + ctx := context.Background() + ln := liveLinear(ts) + + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return ln.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-xop-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, ln, ts, team, issue.Number) }) + + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + closed, err := ln.GetIssue(ctx, team, issue.Number) + if err != nil { + t.Fatalf("GetIssue after close: %v", err) + } + if closed.State != stateClosed { + t.Errorf("GetIssue after close State = %q, want %q", closed.State, stateClosed) + } + + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateOpen}); err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + reopened, err := ln.GetIssue(ctx, team, issue.Number) + if err != nil { + t.Fatalf("GetIssue after reopen: %v", err) + } + if reopened.State != stateOpen { + t.Errorf("GetIssue after reopen State = %q, want %q", reopened.State, stateOpen) + } +} + // --- input/fixture accessors ------------------------------------------------- // body returns the fixture input body, or "" when the input is absent. @@ -1163,13 +1536,125 @@ func githubUpdateSpecs() []captureSpec { return fixtureRequest{Op: "comment_on_issue", Repo: repo, Number: issue.Number, Input: &fixtureInput{Body: body}} }}, + {provider: providerGitHub, name: "transition_issue_close_default", prelude: 0, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + repo, author, _ := requireLive(t) + ctx := context.Background() + setup := setupGitHub(author) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-tclose-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + gh := recordingGitHub(author, rt) + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: repo, Number: issue.Number, + Input: &fixtureInput{State: stateClosed}} + }}, + {provider: providerGitHub, name: "transition_issue_close_reason", prelude: 0, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + repo, author, _ := requireLive(t) + ctx := context.Background() + setup := setupGitHub(author) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-tclosereason-" + newRunID(), Labels: []string{"bug"}}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + gh := recordingGitHub(author, rt) + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed, CloseReason: "not_planned"}); err != nil { + t.Fatalf("TransitionIssueState close_reason: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: repo, Number: issue.Number, + Input: &fixtureInput{State: stateClosed, CloseReason: "not_planned"}} + }}, + {provider: providerGitHub, name: "transition_issue_reopen", prelude: 0, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + repo, author, _ := requireLive(t) + ctx := context.Background() + setup := setupGitHub(author) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, repo, CreateIssue{Title: "compass-live-treopen-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { closeGitHubIssue(t, author, repo, issue.Number) }) + if _, err := setup.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close (setup): %v", err) + } + gh := recordingGitHub(author, rt) + if _, err := gh.TransitionIssueState(ctx, repo, issue.Number, TransitionState{State: stateOpen}); err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: repo, Number: issue.Number, + Input: &fixtureInput{State: stateOpen, CloseReason: "completed"}} + }}, + {provider: providerGitHub, name: "transition_pull_request_close", prelude: 0, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + repo, author, _ := requireLive(t) + ctx := context.Background() + head := "compass-live-" + newRunID() + seedHeadBranch(t, ctx, author, repo, head) + setup := setupGitHub(author) + pr, err := createWithBackoff(ctx, func() (PullRequest, error) { + return setup.CreatePullRequest(ctx, repo, CreatePR{Title: "compass-live-tprclose-" + newRunID(), HeadRef: head, BaseRef: "main", Draft: true}) + }) + if err != nil { + t.Fatalf("CreatePullRequest (setup): %v", err) + } + t.Cleanup(func() { teardownGitHubPR(t, author, repo, pr.Number, head) }) + gh := recordingGitHub(author, rt) + if _, err := gh.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionPullRequestState close: %v", err) + } + return fixtureRequest{Op: "transition_pull_request_state", Repo: repo, Number: pr.Number, + Input: &fixtureInput{State: stateClosed}} + }}, + {provider: providerGitHub, name: "transition_pull_request_reopen", prelude: 0, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + repo, author, _ := requireLive(t) + ctx := context.Background() + head := "compass-live-" + newRunID() + seedHeadBranch(t, ctx, author, repo, head) + setup := setupGitHub(author) + pr, err := createWithBackoff(ctx, func() (PullRequest, error) { + return setup.CreatePullRequest(ctx, repo, CreatePR{Title: "compass-live-tprreopen-" + newRunID(), HeadRef: head, BaseRef: "main", Draft: true}) + }) + if err != nil { + t.Fatalf("CreatePullRequest (setup): %v", err) + } + t.Cleanup(func() { teardownGitHubPR(t, author, repo, pr.Number, head) }) + if _, err := setup.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionPullRequestState close (setup): %v", err) + } + gh := recordingGitHub(author, rt) + if _, err := gh.TransitionPullRequestState(ctx, repo, pr.Number, TransitionState{State: stateOpen}); err != nil { + t.Fatalf("TransitionPullRequestState reopen: %v", err) + } + return fixtureRequest{Op: "transition_pull_request_state", Repo: repo, Number: pr.Number, + Input: &fixtureInput{State: stateOpen}} + }}, } } // linearUpdateSpecs is the Linear half of the capture table. create/comment run // resolveTeamID|resolveIssueID + the actor probe BEFORE the mutation (prelude 2); -// get/list issue reads are single-shot (prelude 0). Each run drives the SAME live -// op its sibling oracle scenario runs, with the same teardown hygiene. +// a transition runs resolveTeamID + workflowStates + resolveIssueID (prelude 3, no +// actor probe — a transition creates no content to attribute); get/list issue +// reads are single-shot (prelude 0). Each run drives the SAME live op its sibling +// oracle scenario runs, with the same teardown hygiene. func linearUpdateSpecs() []captureSpec { return []captureSpec{ {provider: providerLinear, name: "create_issue", prelude: 2, @@ -1248,6 +1733,72 @@ func linearUpdateSpecs() []captureSpec { return fixtureRequest{Op: "comment_on_issue", Repo: team, Number: issue.Number, Input: &fixtureInput{Body: body}} }}, + {provider: providerLinear, name: "transition_issue_close_default", prelude: 3, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + ts, team := requireLinear(t) + ctx := context.Background() + setup := setupLinear(ts) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tclose-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, setup, ts, team, issue.Number) }) + ln := recordingLinear(ts, rt) + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: team, Number: issue.Number, + Input: &fixtureInput{State: stateClosed}} + }}, + {provider: providerLinear, name: "transition_issue_close_by_name", prelude: 3, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + ts, team := requireLinear(t) + ctx := context.Background() + setup := setupLinear(ts) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-tclosename-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, setup, ts, team, issue.Number) }) + // Discovered on the NON-recording setup client, so the lookup spends no + // prelude slot on the recording transport. + named := liveStateNameOfType(t, ctx, setup, team, "canceled") + ln := recordingLinear(ts, rt) + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed, WorkflowState: named}); err != nil { + t.Fatalf("TransitionIssueState close_by_name: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: team, Number: issue.Number, + Input: &fixtureInput{State: stateClosed, WorkflowState: named}} + }}, + {provider: providerLinear, name: "transition_issue_reopen_default", prelude: 3, + run: func(t *testing.T, rt *recordingRoundTripper) fixtureRequest { + t.Helper() + ts, team := requireLinear(t) + ctx := context.Background() + setup := setupLinear(ts) + issue, err := createWithBackoff(ctx, func() (Issue, error) { + return setup.CreateIssue(ctx, team, CreateIssue{Title: "compass-live-treopen-" + newRunID()}) + }) + if err != nil { + t.Fatalf("CreateIssue (setup): %v", err) + } + t.Cleanup(func() { archiveLinearIssue(t, setup, ts, team, issue.Number) }) + if _, err := setup.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateClosed}); err != nil { + t.Fatalf("TransitionIssueState close (setup): %v", err) + } + ln := recordingLinear(ts, rt) + if _, err := ln.TransitionIssueState(ctx, team, issue.Number, TransitionState{State: stateOpen}); err != nil { + t.Fatalf("TransitionIssueState reopen: %v", err) + } + return fixtureRequest{Op: "transition_issue_state", Repo: team, Number: issue.Number, + Input: &fixtureInput{State: stateOpen}} + }}, } } From 28f3621843590a6fa6adc67dbd30fa0e32557ab2 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 15 Sep 2026 21:49:33 -0400 Subject: [PATCH 2/3] fix(forge): declare Linear's team filter variable as ID, not String The live oracle T7 added found this on its first run: every Linear transition 400s against the real API. http 400: Variable "$team" of type "String!" used in position expecting type "ID". `workflowStatesQuery` declares $team as String! and lands it in an id comparator (`filter: {team: {id: {eq: $team}}}`), which Linear types as ID. Every Linear state transition failed before reaching the mutation, so the whole RIG-3331 Linear write path was dead on arrival in production. Golden replay could never catch this. A fixture answers OUR request, so a malformed query replays green forever against its own recorded response -- the prelude entries pin the response bodies, never the query text. Tier 1 proved the decode; only tier 2 could prove the request. The mutation's `$id: String!` is deliberately left alone: Linear types issueUpdate's id argument as String!, and the live error named only $team. The fixed legs now reach the mutation, so the next oracle run proves that half rather than my reading of it. TestLinearIDFilterVariablesAreDeclaredID pins the declared type hermetically. Verified red before the fix (mutant vet-clean first, then the assertion fails naming the String! text) and green after. Co-authored-by: Matt Wilkinson --- go/internal/forge/linear.go | 5 ++++- go/internal/forge/linear_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/go/internal/forge/linear.go b/go/internal/forge/linear.go index fe2d10c22..fcd796ade 100644 --- a/go/internal/forge/linear.go +++ b/go/internal/forge/linear.go @@ -675,7 +675,10 @@ type workflowStateCacheEntry struct { // workflowStatesQuery is the team workflow-state selection, built once so the // page cap has ONE source (workflowStatePageCap) shared by the query and the // truncation guard below rather than a literal repeated in both. -var workflowStatesQuery = fmt.Sprintf(`query CompassTeamWorkflowStates($team: String!) { +// +// $team is ID, not String: it lands in an IDComparator (`id: {eq:}`), and Linear +// rejects a String! variable in that position outright (http 400). +var workflowStatesQuery = fmt.Sprintf(`query CompassTeamWorkflowStates($team: ID!) { workflowStates(filter: {team: {id: {eq: $team}}}, first: %d) { nodes { id name type } } diff --git a/go/internal/forge/linear_test.go b/go/internal/forge/linear_test.go index 7cd3eeea1..da2bf36ae 100644 --- a/go/internal/forge/linear_test.go +++ b/go/internal/forge/linear_test.go @@ -1057,3 +1057,19 @@ func TestLinearTransitionPullRequestStateUnsupported(t *testing.T) { t.Fatalf("err = %v, want ErrUnsupported", err) } } + +// TestLinearIDFilterVariablesAreDeclaredID pins the GraphQL declared TYPE of +// every variable that lands in an id comparator. Linear rejects a String! +// variable in an ID position with an http 400 the fixtures cannot show: a +// golden replay answers our own request, so a malformed query replays green +// forever. Only the live oracle caught it, and only this pins it. +func TestLinearIDFilterVariablesAreDeclaredID(t *testing.T) { + t.Parallel() + + if !strings.Contains(workflowStatesQuery, "$team: ID!") { + t.Errorf("workflowStatesQuery must declare $team as ID! (it filters on team.id.eq); got:\n%s", workflowStatesQuery) + } + if strings.Contains(workflowStatesQuery, "$team: String") { + t.Errorf("workflowStatesQuery declares $team as String, which Linear rejects in an id comparator; got:\n%s", workflowStatesQuery) + } +} From 2aa7dae6560f867b8b43b514db1e673abd339305 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 15 Sep 2026 22:11:34 -0400 Subject: [PATCH 3/3] test(forge): make a board-state skip red the oracle gate (RIG-3331) Review finding on the T7 legs: liveStateNameOfType's comment claimed its skip was caught by CI's assert-ran guard. It was not. The guard greps only the two CREDENTIAL-unset literals, and a package with a skipped test still reports ok -- so a testbed team missing a canceled- or started-type workflow state would have silently skipped both by-name legs with the required check staying green. The exact vacuous-green the surrounding guard exists to prevent, claimed as covered by a comment a future maintainer would trust. Fixed by making it true rather than by softening the comment: the skip now carries liveBoardStateSkipPrefix, a stable one-line literal, and the guard greps it on the same source-derived discipline as the credential skips (sed the constant out of the test file, fail loudly if the extraction comes back empty so the guard cannot drift into checking nothing). Verified the sed reads the new constant verbatim. Also from review, all documentation precision: - the PR-leg bypass rationale named only the diff stats; Draft diverges too (a closed draft stays a draft, the fixture pins false), so a narrower projection would not have worked either -- the fuller reason strengthens the bypass rather than excusing it; - the by-name leg now states what it does NOT prove: forge.Issue folds every closed type to "closed", so the landed column is unobservable live and the resolved stateId is pinned by the golden fixture instead; - the capture table said "one spec per committed fixture", untrue once five fixtures were deliberately left specless. Declined: dropping the inert CloseReason from the reopen capture spec. It is dropped by the provider on a reopen, and it keeps a regenerated fixture byte-identical to the committed one; removing it means editing the fixture in the same breath, which is a separate change from adding coverage. Co-authored-by: Matt Wilkinson --- .github/workflows/ci.yml | 12 +++++++++-- go/internal/forge/livegithub_test.go | 30 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b65482fc3..5f6a7f861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1308,8 +1308,9 @@ jobs: src=internal/forge/livegithub_test.go ghskip=$(sed -n 's/.*liveSkipMessage = "\(.*\)"/\1/p' "$src") lnskip=$(sed -n 's/.*liveLinearSkipMessage = "\(.*\)"/\1/p' "$src") - if [ -z "$ghskip" ] || [ -z "$lnskip" ]; then - echo "::error::could not read the skip messages out of $src (github='$ghskip' linear='$lnskip') — this guard has drifted from the harness and is no longer checking anything" + boardskip=$(sed -n 's/.*liveBoardStateSkipPrefix = "\(.*\)"/\1/p' "$src") + if [ -z "$ghskip" ] || [ -z "$lnskip" ] || [ -z "$boardskip" ]; then + echo "::error::could not read the skip messages out of $src (github='$ghskip' linear='$lnskip' board='$boardskip') — this guard has drifted from the harness and is no longer checking anything" exit 1 fi if grep -qF "$ghskip" /tmp/forge-oracle.log; then @@ -1322,6 +1323,13 @@ jobs: grep -nF "$lnskip" /tmp/forge-oracle.log | head exit 1 fi + # A board-state skip leaves the package reporting ok, so only an + # explicit grep keeps the by-name contracts from silently vanishing. + if grep -qF "$boardskip" /tmp/forge-oracle.log; then + echo "::error::forge live-contract oracle skipped a by-name transition leg — the testbed team's board lacks a needed workflow-state type, so that contract was not asserted" + grep -nF "$boardskip" /tmp/forge-oracle.log | head + exit 1 + fi if ! grep -qE "^ok[[:space:]]+github\.com/RigelBuild/compass/go/internal/forge[[:space:]]" /tmp/forge-oracle.log; then echo "::error::the forge live-contract package did not report ok — it failed, skipped, or never ran" exit 1 diff --git a/go/internal/forge/livegithub_test.go b/go/internal/forge/livegithub_test.go index 6ea69cb86..db38fa74d 100644 --- a/go/internal/forge/livegithub_test.go +++ b/go/internal/forge/livegithub_test.go @@ -597,8 +597,8 @@ func TestLiveGitHubTransitionIssueReopen(t *testing.T) { // TestLiveGitHubTransitionPullRequestClose closes a freshly-opened PR. The PR // transition decodes ghPullDetail, which populates the live diff stats (Changed) -// the committed fixture cannot pin, so the leg asserts the state fold directly -// rather than through assertMatchesFixture. +// and the retained draft flag — neither volatile, neither reproducible by a +// seeded PR — so the leg asserts the state fold directly, not via the fixture. func TestLiveGitHubTransitionPullRequestClose(t *testing.T) { repo, author, _ := requireLive(t) ctx := context.Background() @@ -843,9 +843,10 @@ func TestLiveLinearTransitionIssueClose(t *testing.T) { } // TestLiveLinearTransitionIssueCloseByName closes an issue onto a NAMED workflow -// state and asserts the decoded issue matches the fixture. The name is -// DISCOVERED from the live team rather than hardcoded, so a team whose columns -// are named differently cannot red this leg down the unknown-name path. +// state, DISCOVERED from the live team so a differently-named board cannot red +// this leg down the unknown-name path. Which column it landed on is NOT +// observable here (forge.Issue folds every closed type to "closed"); the golden +// fixture pins the resolved stateId offline. func TestLiveLinearTransitionIssueCloseByName(t *testing.T) { ts, team := requireLinear(t) ctx := context.Background() @@ -956,9 +957,15 @@ func TestLiveLinearTransitionTypeContradiction(t *testing.T) { } } +// liveBoardStateSkipPrefix is the STABLE one-line prefix of the board-state +// skip, greppable by CI's assert-ran guard exactly as the credential literals +// are — a skipped subtest still lets the package report ok, so without this the +// leg would silently assert nothing and the required check would stay green. +const liveBoardStateSkipPrefix = "live linear oracle: the testbed team lacks a workflow-state type the by-name leg needs" + // liveStateNameOfType returns the name of a workflow state of the given Linear -// type on the live team, skipping when the team has none — the by-name contracts -// are unobservable without one, and a skip is loud via CI's assert-ran guard. +// type on the live team. A team with no such state skips: the contract is +// unobservable without one, and the guard turns that skip into a red. func liveStateNameOfType(t *testing.T, ctx context.Context, ln *Linear, team, stateType string) string { t.Helper() states, _, err := ln.workflowStatesFor(ctx, team) @@ -970,7 +977,7 @@ func liveStateNameOfType(t *testing.T, ctx context.Context, ln *Linear, team, st return s.Name } } - t.Skipf("live linear oracle: team %q has no %s-type workflow state; the by-name leg needs one", team, stateType) + t.Skipf("%s (team %q, type %q)", liveBoardStateSkipPrefix, team, stateType) return "" } @@ -1400,9 +1407,10 @@ func TestLiveUpdateFixtures(t *testing.T) { } } -// updateCaptureSpecs is the capture table: one spec per committed fixture across -// both providers, split by provider so each half documents its own prelude-count -// grounding (githubUpdateSpecs / linearUpdateSpecs). +// updateCaptureSpecs is the capture table: one spec per REPRODUCIBLE committed +// fixture, split by provider so each half documents its own prelude-count +// grounding. Rejection (WantError) and golden-replay-only fixtures are +// hand-written and have no spec — see deriveFixtureHalves. func updateCaptureSpecs() []captureSpec { return append(githubUpdateSpecs(), linearUpdateSpecs()...) }