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
62 changes: 59 additions & 3 deletions internal/linear/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions internal/linear/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading