Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions go/internal/forge/linear.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions go/internal/linearagent/data_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 66 additions & 1 deletion go/server/forge_e2e_pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
})
}
}
12 changes: 9 additions & 3 deletions go/server/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/server/serve_forge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading