Skip to content
Draft
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
16 changes: 16 additions & 0 deletions cmd/bd/doctor/fix/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,22 @@ func reconcileAuthoritativeServerMetadata(cfg *configfile.Config, databases []se
strings.Join(names, ", "),
)
}
currentName := cfg.GetDoltDatabase()
current, ok := byName[currentName]
if len(matches) == 1 &&
ok &&
current.HasSchema &&
current.ProjectID != "" &&
current.ProjectID != cfg.ProjectID &&
matches[0].Name != currentName {
return false, "", fmt.Errorf(
"conflicting authoritative identity signals: metadata project_id %s maps to %q, but configured dolt_database %q has project_id %s",
cfg.ProjectID,
matches[0].Name,
currentName,
current.ProjectID,
)
}
if len(matches) == 1 && cfg.DoltDatabase != matches[0].Name {
from := cfg.GetDoltDatabase()
cfg.DoltDatabase = matches[0].Name
Expand Down
29 changes: 28 additions & 1 deletion cmd/bd/doctor/fix/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ func TestReconcileAuthoritativeServerMetadata_UsesProjectIDToRepairDatabaseName(
}

changed, msg, err := reconcileAuthoritativeServerMetadata(cfg, []serverDatabaseMetadata{
{Name: "wrong_db", HasSchema: true, ProjectID: "other-proj"},
// wrong_db exists but is not an authoritative beads database, so
// project_id matching can safely repair dolt_database.
{Name: "wrong_db", HasSchema: false, ProjectID: "other-proj"},
{Name: "canonical_db", HasSchema: true, ProjectID: "proj-123"},
})
if err != nil {
Expand All @@ -245,6 +247,31 @@ func TestReconcileAuthoritativeServerMetadata_UsesProjectIDToRepairDatabaseName(
}
}

func TestReconcileAuthoritativeServerMetadata_ErrorsOnConflictingAuthoritativeSignals(t *testing.T) {
cfg := &configfile.Config{
DoltMode: configfile.DoltModeServer,
DoltDatabase: "project_b_db",
ProjectID: "project-a-id",
}

changed, msg, err := reconcileAuthoritativeServerMetadata(cfg, []serverDatabaseMetadata{
{Name: "project_a_db", HasSchema: true, ProjectID: "project-a-id"},
{Name: "project_b_db", HasSchema: true, ProjectID: "project-b-id"},
})
if err == nil {
t.Fatal("expected conflict error, got nil")
}
if changed {
t.Fatal("changed = true, want false on conflict")
}
if msg != "" {
t.Fatalf("msg = %q, want empty", msg)
}
if !strings.Contains(err.Error(), "conflicting authoritative identity signals") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestReconcileAuthoritativeServerMetadata_AdoptsConfiguredDatabaseProjectID(t *testing.T) {
cfg := &configfile.Config{
DoltMode: configfile.DoltModeServer,
Expand Down
37 changes: 17 additions & 20 deletions internal/linear/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,26 +274,14 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs
return nil, fmt.Errorf("no Linear client available")
}

// Build per-team state caches so that updates to issues belonging to different
// teams resolve workflow state IDs against the correct team's state list.
teamCaches := make(map[string]*StateCache, len(t.teamIDs))
for _, teamID := range t.teamIDs {
teamClient := t.clients[teamID]
if teamClient == nil {
continue
}
cache, err := BuildStateCache(ctx, teamClient)
if err != nil {
return nil, fmt.Errorf("building state cache for team %s: %w", teamID, err)
}
teamCaches[teamID] = cache
}

// The primary team's cache is used for creates, which always target the primary team.
primaryCache := teamCaches[t.teamIDs[0]]
if primaryCache == nil {
return nil, fmt.Errorf("building state cache: no cache for primary team %s", t.teamIDs[0])
// Build the primary team's state cache up-front for creates.
primaryCache, err := BuildStateCache(ctx, client)
if err != nil {
return nil, fmt.Errorf("building state cache for primary team %s: %w", client.TeamID, err)
}
// Non-primary team caches are loaded lazily only when an issue routes there,
// so a broken/unused team does not block unrelated creates/updates.
teamCaches := map[string]*StateCache{client.TeamID: primaryCache}

result := &tracker.BatchPushResult{}

Expand Down Expand Up @@ -440,7 +428,16 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs
// against the correct team's workflow states, not the primary team's.
teamCache, ok := teamCaches[routeClient.TeamID]
if !ok || teamCache == nil {
teamCache = primaryCache // defensive fallback
cache, cacheErr := BuildStateCache(ctx, routeClient)
if cacheErr != nil {
result.Errors = append(result.Errors, tracker.BatchPushError{
LocalID: issue.ID,
Message: fmt.Sprintf("building state cache for team %s: %v", routeClient.TeamID, cacheErr),
})
continue
}
teamCache = cache
teamCaches[routeClient.TeamID] = teamCache
}

// Skip issues that haven't changed since the last push, unless forced.
Expand Down
85 changes: 85 additions & 0 deletions internal/linear/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,91 @@ func TestBatchPush_PerTeamStateCache(t *testing.T) {
}
}

// TestBatchPush_IgnoresUnusedBrokenTeamCache verifies that a broken secondary
// team does not abort a batch push when all pushed issues target the primary
// team. This guards against full-sync failure in multi-team configs when an
// unrelated team is temporarily unavailable.
func TestBatchPush_IgnoresUnusedBrokenTeamCache(t *testing.T) {
var team2StateCalls int

team1Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req GraphQLRequest
_ = json.Unmarshal(body, &req)
w.Header().Set("Content-Type", "application/json")

switch {
case strings.Contains(req.Query, "TeamStates"):
json.NewEncoder(w).Encode(teamStatesResp("team-1", "state-open", "Backlog", "backlog"))
case strings.Contains(req.Query, "issueBatchCreate"):
json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{
"issueBatchCreate": map[string]interface{}{
"success": true,
"issues": []interface{}{
map[string]interface{}{
"id": "uuid-1",
"identifier": "TEAM-1",
"title": "Primary Team Issue",
"url": "https://linear.app/team/issue/TEAM-1",
"priority": 0,
"state": map[string]interface{}{"id": "state-open", "name": "Backlog", "type": "backlog"},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
},
},
},
},
})
}
}))
defer team1Server.Close()

team2Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req GraphQLRequest
_ = json.Unmarshal(body, &req)
w.Header().Set("Content-Type", "application/json")
if strings.Contains(req.Query, "TeamStates") {
team2StateCalls++
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"errors":[{"message":"team-2 unavailable"}]}`))
return
}
}))
defer team2Server.Close()

cfg := DefaultMappingConfig()
cfg.ExplicitStateMap = map[string]string{"backlog": "open"}

tr := &Tracker{
teamIDs: []string{"team-1", "team-2"},
clients: map[string]*Client{
"team-1": NewClient("key", "team-1").WithEndpoint(team1Server.URL),
"team-2": NewClient("key", "team-2").WithEndpoint(team2Server.URL),
},
config: cfg,
}

local := &types.Issue{
ID: "local-create-1",
Title: "Primary Team Issue",
Status: types.StatusOpen,
Priority: 4,
}

result, err := tr.BatchPush(context.Background(), []*types.Issue{local}, nil)
if err != nil {
t.Fatalf("BatchPush returned unexpected error: %v", err)
}
if len(result.Created) != 1 {
t.Fatalf("Created = %d, want 1; errors: %v", len(result.Created), result.Errors)
}
if team2StateCalls != 0 {
t.Fatalf("unexpected TeamStates call for unused team-2: %d", team2StateCalls)
}
}

// TestBatchPush_DuplicateTitlesFallbackToSingleCreate verifies that issues with
// duplicate titles within a batch are routed through single-create with idempotency
// markers instead of being sent through the batch mutation, where title-based
Expand Down
Loading