diff --git a/internal/linear/tracker.go b/internal/linear/tracker.go index 1c27e52af1..e8676dd283 100644 --- a/internal/linear/tracker.go +++ b/internal/linear/tracker.go @@ -255,6 +255,58 @@ func (t *Tracker) UpdateIssue(ctx context.Context, externalID string, issue *typ return &ti, nil } +func linearDescriptionForPush(issue *types.Issue) string { + if issue == nil { + return "" + } + + // engine.collectBatchPushIssues applies FormatDescription before calling + // BatchPush, but the structured fields remain populated on the issue copy. + // Detect "already formatted once" payloads so we don't append the same + // sections again during skip checks and update payload construction. + suffix := buildLinearStructuredDescriptionSuffix(issue) + if suffix != "" && strings.HasSuffix(issue.Description, suffix) { + return issue.Description + } + return BuildLinearDescription(issue) +} + +func buildLinearStructuredDescriptionSuffix(issue *types.Issue) string { + if issue == nil { + return "" + } + var b strings.Builder + if issue.AcceptanceCriteria != "" { + b.WriteString("\n\n## Acceptance Criteria\n") + b.WriteString(issue.AcceptanceCriteria) + } + if issue.Design != "" { + b.WriteString("\n\n## Design\n") + b.WriteString(issue.Design) + } + if issue.Notes != "" { + b.WriteString("\n\n## Notes\n") + b.WriteString(issue.Notes) + } + return b.String() +} + +func pushFieldsEqualForPreparedDescription(local *types.Issue, preparedDescription string, remote *Issue, config *MappingConfig) bool { + if local == nil || remote == nil { + return false + } + if local.Title != remote.Title { + return false + } + if preparedDescription != remote.Description { + return false + } + if PriorityToLinear(local.Priority, config) != remote.Priority { + return false + } + return StateToBeadsStatus(remote.State, config) == local.Status +} + // BatchPush implements tracker.BatchPushTracker. It partitions issues into // creates and updates, uses issueBatchCreate for new issues (chunked at 50), // and falls back to per-issue UpdateIssue for updates (since issueBatchUpdate @@ -345,7 +397,8 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs marker := GenerateIdempotencyMarker(issue.ID, issue.CreatedBy, issue.CreatedAt.UnixNano()) var labelIDs []string - created, _, createErr := client.CreateIssueIdempotent(ctx, issue.Title, issue.Description, priority, stateID, labelIDs, marker) + pushDescription := linearDescriptionForPush(issue) + created, _, createErr := client.CreateIssueIdempotent(ctx, issue.Title, pushDescription, priority, stateID, labelIDs, marker) if createErr != nil { result.Errors = append(result.Errors, tracker.BatchPushError{ LocalID: issue.ID, @@ -374,7 +427,8 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs } marker := GenerateIdempotencyMarker(issue.ID, issue.CreatedBy, issue.CreatedAt.UnixNano()) - desc := AppendIdempotencyMarker(issue.Description, marker) + pushDescription := linearDescriptionForPush(issue) + desc := AppendIdempotencyMarker(pushDescription, marker) input := IssueCreateInput{ TeamID: client.TeamID, @@ -446,12 +500,13 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs // Skip issues that haven't changed since the last push, unless forced. // This mirrors the ContentEqual / UpdatedAt skip logic in the single-issue // push path (engine.go doPush) to avoid redundant API writes. + pushDescription := linearDescriptionForPush(issue) var remoteIssue *Issue if !forceIDs[issue.ID] { fetched, lookupErr := routeClient.FetchIssueByIdentifier(ctx, externalID) if lookupErr == nil && fetched != nil { remoteIssue = fetched - if PushFieldsEqual(issue, remoteIssue, t.config) { + if pushFieldsEqualForPreparedDescription(issue, pushDescription, remoteIssue, t.config) { result.Skipped = append(result.Skipped, issue.ID) continue } @@ -460,6 +515,7 @@ func (t *Tracker) BatchPush(ctx context.Context, issues []*types.Issue, forceIDs mapper := t.FieldMapper() updates := mapper.IssueToTracker(issue) + updates["description"] = pushDescription stateID, stateErr := ResolveStateIDForBeadsStatus(teamCache, issue.Status, t.config) if stateErr != nil { diff --git a/internal/linear/tracker_test.go b/internal/linear/tracker_test.go index 1e97474db3..68414666f6 100644 --- a/internal/linear/tracker_test.go +++ b/internal/linear/tracker_test.go @@ -137,6 +137,78 @@ func TestBatchPush_SkipsUnchangedIssue(t *testing.T) { } } +// TestBatchPush_SkipsUnchangedIssue_WithStructuredFields verifies that BatchPush +// still skips unchanged issues when Description is already pre-formatted by the +// engine hook and structured fields are also present on the local issue. +func TestBatchPush_SkipsUnchangedIssue_WithStructuredFields(t *testing.T) { + var updateCalled bool + server := 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, "IssueByIdentifier"): + remoteDescription := "Base description\n\n## Acceptance Criteria\nMeets condition\n\n## Design\nImplementation notes" + json.NewEncoder(w).Encode(issueByIdentifierResp( + "remote-uuid", "TEAM-1", "My Issue", remoteDescription, 0, + "state-open", "Backlog", "backlog", + )) + case strings.Contains(req.Query, "issueUpdate"): + updateCalled = true + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "issueUpdate": map[string]interface{}{ + "success": true, + "issue": map[string]interface{}{"id": "remote-uuid", "url": "https://linear.app/team/issue/TEAM-1", "updatedAt": "2026-01-01T00:00:00Z"}, + }, + }, + }) + } + })) + defer server.Close() + + cfg := DefaultMappingConfig() + cfg.ExplicitStateMap = map[string]string{"backlog": "open"} + + extRef := "https://linear.app/team/issue/TEAM-1" + local := &types.Issue{ + ID: "local-structured", + Title: "My Issue", + Description: "Base description\n\n## Acceptance Criteria\nMeets condition\n\n## Design\nImplementation notes", + AcceptanceCriteria: "Meets condition", + Design: "Implementation notes", + Status: types.StatusOpen, + Priority: 4, + ExternalRef: &extRef, + } + + tr := &Tracker{ + teamIDs: []string{"team-1"}, + clients: map[string]*Client{ + "team-1": NewClient("key", "team-1").WithEndpoint(server.URL), + }, + config: cfg, + } + + result, err := tr.BatchPush(context.Background(), []*types.Issue{local}, nil) + if err != nil { + t.Fatalf("BatchPush: %v", err) + } + if updateCalled { + t.Error("UpdateIssue was called for an unchanged structured-field issue; expected it to be skipped") + } + if len(result.Skipped) != 1 || result.Skipped[0] != "local-structured" { + t.Errorf("Skipped = %v, want [local-structured]", result.Skipped) + } + if len(result.Updated) != 0 { + t.Errorf("Updated = %v, want []", result.Updated) + } +} + // TestBatchPush_ForceBypassesSkip verifies that an issue in forceIDs is // updated even when PushFieldsEqual would normally skip it. func TestBatchPush_ForceBypassesSkip(t *testing.T) {