From f792072c02d5f4ad742ec0f36b340fef5ece3ebd Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 10 Sep 2026 22:49:18 -0400 Subject: [PATCH 1/2] fix(forge): treat Linear's duplicate state as closed (RIG-3590) --- go/internal/forge/linear.go | 6 +- go/internal/forge/linear_test.go | 63 +++++++++++++++++++ .../forge/testdata/linear/list_issues.json | 2 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/go/internal/forge/linear.go b/go/internal/forge/linear.go index 5a3b22363..ab39b94b6 100644 --- a/go/internal/forge/linear.go +++ b/go/internal/forge/linear.go @@ -73,8 +73,10 @@ const ( // linearClosedStateTypes are the Linear workflow-state `type` values that map // to the forge's "closed" truth. Every other type maps to "open". Verified // against Linear SDL WorkflowState.type: "triage", "backlog", "unstarted", -// "started", "completed", "canceled", "duplicate". -var linearClosedStateTypes = []string{"completed", "canceled"} +// "started", "completed", "canceled", "duplicate". "duplicate" is closed +// because Linear's own UI files it under Done, so omitting it re-served a +// closed-as-duplicate issue as live work (RIG-3590). +var linearClosedStateTypes = []string{"completed", "canceled", "duplicate"} // LinearConfig configures a Linear client. type LinearConfig struct { diff --git a/go/internal/forge/linear_test.go b/go/internal/forge/linear_test.go index a464cd213..d79f67949 100644 --- a/go/internal/forge/linear_test.go +++ b/go/internal/forge/linear_test.go @@ -762,3 +762,66 @@ func TestLinearActorProbeTransientErrorReprobed(t *testing.T) { attributionUser, vars2["input"]) } } + +// TestMapLinearStateCoversEverySDLType pins the open/closed mapping for every +// workflow-state `type` Linear's SDL can return. A type absent from +// linearClosedStateTypes silently reads as open, so an issue closed as a +// duplicate would be re-served as live work. +func TestMapLinearStateCoversEverySDLType(t *testing.T) { + t.Parallel() + + // The full SDL enum, not just the ones we branch on: a new Linear type + // arriving is the failure mode this table is here to catch. + for _, tc := range []struct { + stateType string + want string + }{ + {"triage", stateOpen}, + {"backlog", stateOpen}, + {"unstarted", stateOpen}, + {"started", stateOpen}, + {"completed", stateClosed}, + {"canceled", stateClosed}, + {"duplicate", stateClosed}, + } { + if got := mapLinearState(tc.stateType); got != tc.want { + t.Errorf("mapLinearState(%q) = %q, want %q", tc.stateType, got, tc.want) + } + } +} + +// TestTeamIssueFilterExcludesDuplicateFromOpen proves the query filter and the +// read mapping agree. They share linearClosedStateTypes, so a state missing +// from it both mis-maps a fetched issue AND makes the server's open-issue query +// return it — the mapping test alone would not catch a divergence here. +func TestTeamIssueFilterExcludesDuplicateFromOpen(t *testing.T) { + t.Parallel() + + openTypes := extractStateTypes(t, teamIssueFilter("RIG", IssueFilter{State: stateOpen}), "nin") + if !slices.Contains(openTypes, "duplicate") { + t.Errorf("open-issue filter must exclude duplicate; nin = %v", openTypes) + } + closedTypes := extractStateTypes(t, teamIssueFilter("RIG", IssueFilter{State: stateClosed}), "in") + if !slices.Contains(closedTypes, "duplicate") { + t.Errorf("closed-issue filter must include duplicate; in = %v", closedTypes) + } +} + +// extractStateTypes digs the state-type list out of a teamIssueFilter result +// under the given set operator ("in" or "nin"). +func extractStateTypes(t *testing.T, filter map[string]any, op string) []string { + t.Helper() + state, ok := filter["state"].(map[string]any) + if !ok { + t.Fatalf("filter has no state clause: %#v", filter) + } + typ, ok := state["type"].(map[string]any) + if !ok { + t.Fatalf("state clause has no type clause: %#v", state) + } + got, ok := typ[op].([]string) + if !ok { + t.Fatalf("type clause has no %q list: %#v", op, typ) + } + return got +} diff --git a/go/internal/forge/testdata/linear/list_issues.json b/go/internal/forge/testdata/linear/list_issues.json index b08afca45..ddb8dffe3 100644 --- a/go/internal/forge/testdata/linear/list_issues.json +++ b/go/internal/forge/testdata/linear/list_issues.json @@ -13,7 +13,7 @@ "variables": { "filter": { "team": { "key": { "eq": "SEA" } }, - "state": { "type": { "nin": ["completed", "canceled"] } } + "state": { "type": { "nin": ["completed", "canceled", "duplicate"] } } } } } From 6ba75b22db53b5215676c3436f17ad79baf6e61b Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 10 Sep 2026 23:14:06 -0400 Subject: [PATCH 2/2] fix(forge): ground the duplicate-state comment in Linear's real category model (RIG-3590) Review found two comments asserting things I had not verified. The doc comment on linearClosedStateTypes claimed Linear files duplicate under Done. It does not: duplicate is its own system-managed terminal category, peer to completed, applied when an issue is marked a duplicate. The conclusion held but the reason was wrong, and it sat next to the frozen compass-forge-state-transition record whose default close rule targets completed only -- a reader believing the false premise had a reason to fold duplicate into that set, which that record rejects. The table test's comment promised enum-drift protection a hardcoded table cannot give. RIG-3590 was a KNOWN type left unhandled, so the promise installed false confidence exactly where the real failure mode lives. Also adds the missing fallback row: an unrecognised type maps to open. Mutation-proved unique -- a mutant that keeps all seven real verdicts and flips only the unknown case is caught by that row alone. Co-authored-by: Matt Wilkinson --- go/internal/forge/linear.go | 7 ++++--- go/internal/forge/linear_test.go | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/go/internal/forge/linear.go b/go/internal/forge/linear.go index ab39b94b6..9e2b4477a 100644 --- a/go/internal/forge/linear.go +++ b/go/internal/forge/linear.go @@ -73,9 +73,10 @@ const ( // linearClosedStateTypes are the Linear workflow-state `type` values that map // to the forge's "closed" truth. Every other type maps to "open". Verified // against Linear SDL WorkflowState.type: "triage", "backlog", "unstarted", -// "started", "completed", "canceled", "duplicate". "duplicate" is closed -// because Linear's own UI files it under Done, so omitting it re-served a -// closed-as-duplicate issue as live work (RIG-3590). +// "started", "completed", "canceled", "duplicate". "duplicate" is its own +// system-managed terminal category (not a member of "completed"), applied when +// an issue is marked a duplicate; Linear's own Active view is unstarted+started +// only, so a duplicate is never live work (RIG-3590). var linearClosedStateTypes = []string{"completed", "canceled", "duplicate"} // LinearConfig configures a Linear client. diff --git a/go/internal/forge/linear_test.go b/go/internal/forge/linear_test.go index d79f67949..ca0f42103 100644 --- a/go/internal/forge/linear_test.go +++ b/go/internal/forge/linear_test.go @@ -770,8 +770,10 @@ func TestLinearActorProbeTransientErrorReprobed(t *testing.T) { func TestMapLinearStateCoversEverySDLType(t *testing.T) { t.Parallel() - // The full SDL enum, not just the ones we branch on: a new Linear type - // arriving is the failure mode this table is here to catch. + // All seven SDL types, not just the ones in the closed set: this pins the + // verdict for each by name, so narrowing the set fails with the type named. + // It does NOT detect enum drift — an eighth type would take the open + // fallback and stay green. RIG-3590 was a KNOWN type left unhandled. for _, tc := range []struct { stateType string want string @@ -783,6 +785,8 @@ func TestMapLinearStateCoversEverySDLType(t *testing.T) { {"completed", stateClosed}, {"canceled", stateClosed}, {"duplicate", stateClosed}, + // The documented fallback: an unrecognised type maps to open. + {"no_such_type", stateOpen}, } { if got := mapLinearState(tc.stateType); got != tc.want { t.Errorf("mapLinearState(%q) = %q, want %q", tc.stateType, got, tc.want)