Repository navigation
feat(api): ticket merge — a moved external ref records its origin ticket (#10120) - #10287
lennonflima wants to merge 2 commits into
Conversation
…came off (LanternOps#10120) ticket_external_refs.origin_ticket_id: a plain FK to tickets ON DELETE SET NULL, the shape of ticket_comments.merged_from_ticket_id. Set only by the moved-ref UPDATE in ticketMerge/repoint.ts, COALESCEd so A→B→C keeps A; NULL for a ref written directly, no backfill. The Partner API upsert keeps it for the same external id and drops it when the external id changes. Export policy: included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8mQQSv7pUuBL2gR5wsd9M
…ze from its origin (LanternOps#10120) When the principal's ref names an origin_ticket_id that is this principal's imported tombstone, the source id is skipped as merged_in_breeze with that tombstone and its survivor, as for a ref still on the tombstone. A moved ref with no origin (moved before the column) keeps the conflict answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8mQQSv7pUuBL2gR5wsd9M
Strix Security Review✅ No open security findings. PR summary: Adds `ticket_external_refs.origin_ticket_id` so a ticket merge records which tombstone a moved external ref came from, and teaches the ticket importer to answer `merged_in_breeze` for a source id whose ref was moved off one of the same principal's imported tombstones. Review summaryThis PR adds a nullable Ruled out (3)
Review 1 on this PR · Reviewed by Strix |
Summary
ticket_external_refsrow onto the survivor, the row now records the ticket it came from in a neworigin_ticket_idcolumn. This is the design accepted on Ticket merge: record the origin ticket on a moved ticket_external_refs row #10120.merged_in_breeze, the same answer as a ref that is still on the tombstone. Before, it was refused aslive_ticket/other_principal_ticketon every pass. This is the follow-up the issue mentions. It is included here because it is about 10 lines and only needs code that is already on main (W0).Linked Issues
Closes #10120
Refs #8972, #8313, #10034
Merge Danger
Door: two-way. The migration only adds a nullable column and its FK. It writes no rows and does no backfill.
Blast radius: ticket merge (the moved-ref UPDATE), Partner API ref upsert, ticket import resume answer.
Type of Change
Changes
Migration
apps/api/migrations/2027-01-08-120000-ticket-external-refs-origin-ticket.sqlticket_external_refs.origin_ticket_id uuid, a nullable plain FK totickets(id)withON DELETE SET NULL. It has the same shape asticket_comments.merged_from_ticket_idin2027-01-04-110000-ticket-merge.sql.org_id, so it does not need to beDEFERRABLE.merged_from_ticket_id.ADD COLUMN IF NOT EXISTSandDROP CONSTRAINT IF EXISTS/ADD. It has no innerBEGIN/COMMITand writes no rows.Export policy classification:
ticket_external_refs.origin_ticket_idis classifiedincluded, as one ticket id. Theticket_external_refsline inCORE_TENANT_EXPORT_POLICYis updated.Other registrations:
SET NULLon a nullable column, soorgCascadeFkOnDeleteclassifies it as safe without needing a ledger entry.services/ticketMerge/repoint.tsUPDATEsetsorigin_ticket_id = COALESCE(origin_ticket_id, <source>). A ref moved A→B→C therefore keeps A, the way comments keepmerged_from_ticket_id.services/ticketExternalRefs.ts(upsertTicketExternalRef)(principal, ticket_id). It can therefore change theexternal_idof a moved ref that now sits on the survivor.external_idstays the same, the upsert keepsorigin_ticket_id. If it changes, the upsert setsorigin_ticket_idto NULL, because the new external id was written directly. Without this, the importer could answermerged_in_breezefor an id that never belonged to the tombstone.services/ticketImport/importWriter.tsorigin_ticket_id, the plan reads that origin ticket in the window's context.merged_in_breezewith that tombstone and its survivor. Nothing is written or charged.Testing
New tests:
ticketMerge.integration.test.ts(external refs case) checks that:origin_ticket_id = source;ticketMergeSchema.integration.test.ts: hard-deleting the origin ticket setsorigin_ticket_idto NULL.ticketImport.integration.test.ts(merged-in-Breeze case):merged_in_breezewith its origin;other_principal_ticket;importWriter.flow.test.ts:Commands (Node 22.23.2, local per-worktree test stack):
cd apps/api && NODE_OPTIONS=--max-old-space-size=11264 npx tsc --build tsconfig.src.json tsconfig.tests.json: clean. It hit an out-of-memory error at a 9216 MB heap on this machine, so I used 11264.npx vitest run src/services/ticketMerge src/services/ticketImport src/db/migration src/services/ticketExternalRefs src/services/tenantExport src/services/ticketChildWriters src/routes/partnerApi --maxWorkers 2: 64 files, 3476 tests passed.pnpm test-stack up, thennpx vitest run --config vitest.integration.config.ts ticketMerge ticketImport.integration partnerApiTicketMerge portalTicketMerge ticketExternalRefsOrgMove ticketChildLockOrder partnerApiTickets --maxWorkers 2: 13 files, 164 tests passed.npx vitest run --config vitest.integration.config.ts ascade rasure erge xport enant FkOnDelete rls --maxWorkers 2(the cascade / erasure / merge / export / tenant / FK-on-delete / RLS contract filters): 215 files, 2207 tests passed.DATABASE_URL=<test stack> pnpm db:check-drift: no drift, all 1258 migration files match the ledger. I also re-applied the migration a second time by hand to check idempotency: the column add is skipped with a NOTICE, and the constraint is dropped and re-added with no error.Checklist
pages/settings/**or a*Settings*component: n/a🤖 Generated with Claude Code
https://claude.ai/code/session_01F8mQQSv7pUuBL2gR5wsd9M