From 4cbfe1823525fd9f7f9d9882b7db1d482b02d8c4 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 26 Sep 2026 08:05:39 -0400 Subject: [PATCH] fix(server): register the Linear write coordinate with host linear.app (RIG-4038) The Linear write coordinate was registered with an empty host. The DL-055 row write rejected that, so a Linear create_issue never recorded its ownership row, and a ForgeRef naming linear.app resolved not_found. Production and the e2e wire now share one registration helper. A forge.LinearHost const is used by the webhook converter and by the notify and write lanes, so their coordinates cannot drift apart. Refs RIG-4038 Co-authored-by: Matt Wilkinson --- go/internal/forge/linear.go | 4 ++ go/internal/linearagent/data_event.go | 4 +- go/server/forge_e2e_pgtest_test.go | 67 ++++++++++++++++++++++++++- go/server/serve.go | 12 +++-- go/server/serve_forge_test.go | 3 ++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/go/internal/forge/linear.go b/go/internal/forge/linear.go index fcd796ade..e7839a2ec 100644 --- a/go/internal/forge/linear.go +++ b/go/internal/forge/linear.go @@ -21,6 +21,10 @@ import ( "time" ) +// LinearHost is the forge-coordinate host of every Linear artifact. Webhook +// events, the notify lane and the write lane must agree on it to join rows. +const LinearHost = "linear.app" + const ( // linearDefaultEndpoint is the public Linear GraphQL endpoint; LinearConfig.Host // overrides it (the whole endpoint URL, not just a hostname). diff --git a/go/internal/linearagent/data_event.go b/go/internal/linearagent/data_event.go index e9da9a069..3ab30d888 100644 --- a/go/internal/linearagent/data_event.go +++ b/go/internal/linearagent/data_event.go @@ -95,7 +95,7 @@ func ParseLinearDataEvent(raw []byte) (ev forge.ForgeEvent, ok bool, err error) func parseLinearIssue(de dataEvent) (forge.ForgeEvent, bool, error) { base := forge.ForgeEvent{ Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, - Host: "linear.app", + Host: forge.LinearHost, Repo: de.Data.Team.Key, Kind: compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE, Number: de.Data.Number, @@ -128,7 +128,7 @@ func parseLinearComment(de dataEvent) (forge.ForgeEvent, bool, error) { iss := de.Data.Issue base := forge.ForgeEvent{ Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, - Host: "linear.app", + Host: forge.LinearHost, Repo: iss.Team.Key, Kind: compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE, Number: iss.Number, diff --git a/go/server/forge_e2e_pgtest_test.go b/go/server/forge_e2e_pgtest_test.go index 57a1911b7..3f0d35b43 100644 --- a/go/server/forge_e2e_pgtest_test.go +++ b/go/server/forge_e2e_pgtest_test.go @@ -62,7 +62,7 @@ func newForgeE2EWire(t *testing.T) *forgeE2EWire { reg := newForgeProviderRegistry() reg.register(forgeCoordinate{provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, host: forgeE2EHost}, author, reviewer, true) - reg.register(forgeCoordinate{provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR}, linear, linear, false) + registerLinearForgeCoordinate(reg, linear) // The chokepoint reads tracked issues off a store-backed issue projection // (its own bus, distinct from the wire's). None of these writes are tracked, @@ -421,3 +421,68 @@ func TestForgeLinearUnimplementedOverTheWire(t *testing.T) { t.Fatalf("github author calls on a LINEAR-addressed call = %d, want 0 (ForgeRef must route to Linear)", len(w.author.Calls())) } } + +// TestForgeLinearCreateIssueOverTheWire pins the Linear create arm's DL-055 row: +// a Linear-addressed create_issue must record under the Linear host, whether the +// ForgeRef leaves the host unset (registry default) or names it explicitly. +func TestForgeLinearCreateIssueOverTheWire(t *testing.T) { + w := newForgeE2EWire(t) + ctx := w.ctx + + cases := []struct { + name string + ref *compassv1.ForgeRef + requestID string + number uint64 + }{ + { + name: "unset host resolves the linear.app default", + ref: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR}, + requestID: "req-linear-default-host", + number: 41, + }, + { + name: "explicit linear.app host resolves", + ref: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, Host: "linear.app"}, + requestID: "req-linear-explicit-host", + number: 42, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w.linear.CreateIssueResult = forge.Issue{Number: tc.number, URL: "https://linear.app/rigel/issue/RIG-1"} + + resp, err := w.supervisorClient.Forge(ctx, connect.NewRequest(&compassv1internal.ForgeCallRequest{ + CallId: "fc-" + tc.requestID, + ClientRequestId: tc.requestID, + Forge: tc.ref, + Call: &compassv1internal.ForgeCallRequest_CreateIssue{CreateIssue: &compassv1internal.CreateIssueRequest{ + Repo: "SEA", Title: "linear issue", Body: "b", + }}, + })) + if err != nil { + t.Fatalf("Forge(linear create_issue) over the socket = %v", err) + } + if e := resp.Msg.GetError(); e != nil { + t.Fatalf("linear create_issue returned in-band error {code=%q msg=%q}, want an Issue arm", e.GetCode(), e.GetMessage()) + } + if got := resp.Msg.GetIssue().GetForge().GetHost(); got != "linear.app" { + t.Fatalf("result forge host = %q, want linear.app", got) + } + + art, ok, aerr := w.store.AuthoredArtifactByRequestID(ctx, w.supervisor.ID, tc.requestID) + if aerr != nil { + t.Fatalf("AuthoredArtifactByRequestID = %v", aerr) + } + if !ok { + t.Fatal("no DL-055 ownership row for the linear create, want one") + } + if art.Provider != store.ForgeProviderLinear || art.Host != "linear.app" { + t.Fatalf("row coordinate = (%v, %q), want (linear, linear.app)", art.Provider, art.Host) + } + if art.Number != tc.number { + t.Fatalf("row number = %d, want %d", art.Number, tc.number) + } + }) + } +} diff --git a/go/server/serve.go b/go/server/serve.go index 0faa508f9..650693c55 100644 --- a/go/server/serve.go +++ b/go/server/serve.go @@ -1794,7 +1794,7 @@ func buildLinearNotifyLane( } const ( provider = store.ForgeProviderLinear - host = "linear.app" + host = forge.LinearHost ) client := forge.NewLinear(forge.LinearConfig{Token: tokens, Log: log}) @@ -2042,8 +2042,7 @@ func buildForgeWriteService( // One client serves both roles, riding the SAME linearTokens instance (DEC-4). // isDefault=false: GitHub is the default, Linear is selected explicitly. if linearTokens != nil { - linear := forge.NewLinear(forge.LinearConfig{Token: linearTokens, Log: log}) - registry.register(forgeCoordinate{provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR}, linear, linear, false) + registerLinearForgeCoordinate(registry, forge.NewLinear(forge.LinearConfig{Token: linearTokens, Log: log})) } return newForgeService(st, issueBrd, registry), nil @@ -2060,6 +2059,13 @@ func registerGitHubForgeCoordinate(reg *forgeProviderRegistry, fc ForgeConfig, a reg.register(forgeCoordinate{provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, host: fc.Host}, author, reviewer, true) } +// registerLinearForgeCoordinate registers the Linear write coordinate. It must +// carry forge.LinearHost: the DL-055 row rejects an empty host. One client +// serves both roles (Linear has no author/reviewer split). +func registerLinearForgeCoordinate(reg *forgeProviderRegistry, linear forge.Provider) { + reg.register(forgeCoordinate{provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, host: forge.LinearHost}, linear, linear, false) +} + // buildLinearTokenSource builds the ONE shared Linear OAuth client-credentials // token source (actor=app) from the declared client-id/secret pair, or returns // nil when Linear is not configured (neither name declared — the clean off-state diff --git a/go/server/serve_forge_test.go b/go/server/serve_forge_test.go index 82d34a660..0da4c5791 100644 --- a/go/server/serve_forge_test.go +++ b/go/server/serve_forge_test.go @@ -688,6 +688,9 @@ func TestForgeLinearLanesShareOneTokenSource(t *testing.T) { if !ok { t.Fatal("no Linear write coordinate registered with a configured token source") } + if resolved.host != forge.LinearHost { + t.Fatalf("Linear coordinate resolved host %q, want %q (the DL-055 row rejects an empty host)", resolved.host, forge.LinearHost) + } writeLinear, ok := resolved.author.(*forge.Linear) if !ok { t.Fatalf("Linear coordinate author is %T, want *forge.Linear", resolved.author)