Skip to content

Commit bdced94

Browse files
author
hack-cli-tests
committed
fix(tickets): finalize normalization conflict semantics
1 parent 957c2ad commit bdced94

5 files changed

Lines changed: 555 additions & 45 deletions

File tree

src/control-plane/extensions/linear/commands.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { openUrl } from "../../../lib/os.ts";
1313
import { display } from "../../../ui/display.ts";
1414
import {
1515
findTicketRemoteLink,
16+
normalizeTicketFieldName,
1617
projectRemoteLinkToCompatibilityFields,
1718
} from "../tickets/provenance.ts";
1819
import {
@@ -2984,7 +2985,7 @@ function detectAuthoritativeFieldConflicts(input: {
29842985
});
29852986
if (localBody !== remoteBody) {
29862987
conflicts.push({
2987-
field: "body",
2988+
field: normalizeTicketFieldName("body"),
29882989
authority: input.authority,
29892990
summary: `Authoritative ${authorityLabel} body diverged from the other side.`,
29902991
localValue: localBody,
@@ -3188,7 +3189,7 @@ function buildConflictDedupKey(input: {
31883189
readonly remoteValue?: TicketMetadataValue;
31893190
}): string {
31903191
return [
3191-
input.field,
3192+
normalizeTicketFieldName(input.field),
31923193
JSON.stringify(input.localValue ?? null),
31933194
JSON.stringify(input.remoteValue ?? null),
31943195
].join("|");
@@ -3227,7 +3228,7 @@ function buildLinearConflictIdempotencyKey(input: {
32273228
const fingerprint = createHash("sha256")
32283229
.update(
32293230
JSON.stringify({
3230-
field: input.conflict.field,
3231+
field: normalizeTicketFieldName(input.conflict.field),
32313232
authority: input.conflict.authority ?? null,
32323233
summary: input.conflict.summary ?? null,
32333234
localValue: input.conflict.localValue ?? null,
@@ -3812,25 +3813,25 @@ async function syncTicketToLinearIssue(input: {
38123813
return { ok: false, error: recordedConflicts.error };
38133814
}
38143815

3815-
const effectiveFields =
3816-
authority === "linear" && existingIssue.issue
3817-
? {
3818-
...fields.value,
3819-
title: existingIssue.issue.title,
3820-
description: existingIssue.issue.description ?? "",
3821-
...(existingIssue.issue.assigneeId
3822-
? { assigneeId: existingIssue.issue.assigneeId }
3823-
: {}),
3824-
...(input.syncToggles.statuses
3825-
? { stateId: existingIssue.issue.state.id }
3826-
: {}),
3827-
}
3828-
: fields.value;
3816+
const preserveRemoteIssueFields = authority !== "hack" && existingIssue.issue;
3817+
3818+
const effectiveFields = preserveRemoteIssueFields
3819+
? {
3820+
...fields.value,
3821+
title: existingIssue.issue.title,
3822+
description: existingIssue.issue.description ?? "",
3823+
...(existingIssue.issue.assigneeId
3824+
? { assigneeId: existingIssue.issue.assigneeId }
3825+
: {}),
3826+
...(input.syncToggles.statuses
3827+
? { stateId: existingIssue.issue.state.id }
3828+
: {}),
3829+
}
3830+
: fields.value;
38293831

3830-
const effectiveProjectId =
3831-
authority === "linear" && existingIssue.issue
3832-
? existingIssue.issue.projectId
3833-
: target.value.projectId;
3832+
const effectiveProjectId = preserveRemoteIssueFields
3833+
? existingIssue.issue.projectId
3834+
: target.value.projectId;
38343835

38353836
const syncedIssue = await upsertLinearIssueForTicketSync({
38363837
runtime: input.runtime,

src/control-plane/extensions/tickets/domain.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { buildLegacyDescriptionDocument } from "./documents.ts";
1+
import {
2+
buildLegacyDescriptionDocument,
3+
getActiveTicketDescription,
4+
} from "./documents.ts";
25
import {
36
buildTicketProvenance,
7+
normalizeTicketFieldName,
48
projectRemoteLinkToCompatibilityFields,
59
} from "./provenance.ts";
610

@@ -200,9 +204,9 @@ export function createNormalizedTicket(input: {
200204
export function projectNormalizedTicketSummary(input: {
201205
readonly ticket: NormalizedTicket;
202206
}): TicketSummaryCompatibility {
203-
const description = input.ticket.documents.find(
204-
(document) => document.role === "description"
205-
);
207+
const description = getActiveTicketDescription({
208+
documents: input.ticket.documents,
209+
});
206210
const primaryRemote = input.ticket.provenance.remotes[0];
207211

208212
return {
@@ -257,17 +261,31 @@ function buildFieldStates(input: {
257261
readonly fieldAuthorities: readonly TicketFieldAuthorityEntry[];
258262
readonly conflicts: readonly TicketSyncConflictCompatibility[];
259263
}): TicketFieldState[] {
260-
return input.fieldAuthorities
261-
.filter((fieldAuthority) =>
262-
["title", "status", "assignee", "description"].includes(
263-
fieldAuthority.field
264-
)
265-
)
266-
.map((fieldAuthority) => ({
267-
field: fieldAuthority.field,
264+
const trackedFields = new Map<string, TicketFieldState>();
265+
266+
for (const fieldAuthority of input.fieldAuthorities) {
267+
const field = normalizeTicketFieldName(fieldAuthority.field);
268+
if (!["title", "status", "assignee", "description"].includes(field)) {
269+
continue;
270+
}
271+
trackedFields.set(field, {
272+
field,
268273
authority: fieldAuthority.authority,
269-
conflictIds: input.conflicts
270-
.filter((conflict) => conflict.field === fieldAuthority.field)
271-
.map((conflict) => conflict.conflictId),
272-
}));
274+
conflictIds: [],
275+
});
276+
}
277+
278+
for (const conflict of input.conflicts) {
279+
const field = normalizeTicketFieldName(conflict.field);
280+
const current = trackedFields.get(field);
281+
if (!current) {
282+
continue;
283+
}
284+
trackedFields.set(field, {
285+
...current,
286+
conflictIds: [...current.conflictIds, conflict.conflictId],
287+
});
288+
}
289+
290+
return [...trackedFields.values()];
273291
}

src/control-plane/extensions/tickets/provenance.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export type TicketFieldVersion = {
9393
readonly value?: TicketMetadataValue;
9494
};
9595

96+
export function normalizeTicketFieldName(field: string): string {
97+
return field === "body" ? "description" : field;
98+
}
99+
96100
export function inferTicketSourceSystem(input: {
97101
readonly ticket: Pick<
98102
TicketProvenanceCompatibility,
@@ -300,9 +304,10 @@ export function buildTicketFieldAuthorities(input: {
300304
});
301305

302306
for (const conflict of input.conflicts ?? []) {
303-
const current = byField.get(conflict.field);
304-
byField.set(conflict.field, {
305-
field: conflict.field,
307+
const field = normalizeTicketFieldName(conflict.field);
308+
const current = byField.get(field);
309+
byField.set(field, {
310+
field,
306311
authority: conflict.authority ?? current?.authority ?? defaultAuthority,
307312
});
308313
}
@@ -345,17 +350,18 @@ export function buildTicketFieldVersions(input: {
345350
}
346351

347352
for (const conflict of input.conflicts ?? []) {
353+
const field = normalizeTicketFieldName(conflict.field);
348354
if (conflict.localValue !== undefined) {
349355
versions.push({
350-
field: conflict.field,
356+
field,
351357
source: "local",
352358
recordedAt: conflict.updatedAt,
353359
value: conflict.localValue,
354360
});
355361
}
356362
if (conflict.remoteValue !== undefined) {
357363
versions.push({
358-
field: conflict.field,
364+
field,
359365
source: "remote",
360366
provider: conflict.provider,
361367
recordedAt: conflict.updatedAt,

0 commit comments

Comments
 (0)