Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
hasDeletedRecordCandidates,
isDeletedRecordCandidate,
} from '../deletedRecords'
import {
dropConfirmedEditedRecords,
hasEditedRecordCandidates,
isEditedRecordCandidate,
} from '../editedRecords'
import {
dropConfirmedForcePushedCommits,
hasForcePushCandidates,
Expand Down Expand Up @@ -97,13 +102,14 @@ export async function listShadowDiffChannels(): Promise<IShadowDiffChannel[]> {
function dropUncheckedCandidates(
syncName: string,
result: IShadowDiffUnitResult,
): { result: IShadowDiffUnitResult; droppedCount: number } {
): { result: IShadowDiffUnitResult; droppedCount: number; keptEditedCount: number } {
const isCandidate = (m: IShadowDiffMismatch) =>
isForcePushCandidate(syncName, m) || isDeletedRecordCandidate(syncName, m)
const mismatches = result.mismatches.filter((m) => !isCandidate(m))
return {
result: { ...result, mismatches },
droppedCount: result.mismatches.length - mismatches.length,
keptEditedCount: mismatches.filter((m) => isEditedRecordCandidate(syncName, m)).length,
}
}

Expand Down Expand Up @@ -180,8 +186,9 @@ export async function runShadowDiffForChannel(

const needsForcePushCheck = hasForcePushCandidates(unit.syncName, result.mismatches)
const needsDeletedRecordCheck = hasDeletedRecordCandidates(unit.syncName, result.mismatches)
const needsEditedRecordCheck = hasEditedRecordCandidates(unit.syncName, result.mismatches)

if (needsForcePushCheck || needsDeletedRecordCheck) {
if (needsForcePushCheck || needsDeletedRecordCheck || needsEditedRecordCheck) {
Comment thread
mbani01 marked this conversation as resolved.
confirmationHttp ??= createGithubConfirmationHttp(qx, channel.integrationId)
let http: ConnectorHttp
try {
Expand All @@ -195,9 +202,10 @@ export async function runShadowDiffForChannel(
day,
channelName: channel.channelName,
syncName: unit.syncName,
checkFailedCount: dropped.droppedCount,
checkFailedCount: dropped.droppedCount + dropped.keptEditedCount,
uncheckedEditedCount: dropped.keptEditedCount,
},
'shadow diff check_failed: github client setup failed, candidates excluded from missing_in_shadow',
'shadow diff check_failed: github client setup failed, candidates excluded from missing_in_shadow and edited-record candidates kept as field_mismatch',
)
unitDiffs.push({ unit, result: dropped.result })
continue
Expand Down Expand Up @@ -256,6 +264,30 @@ export async function runShadowDiffForChannel(
}
result = { ...result, mismatches }
}

if (hasEditedRecordCandidates(unit.syncName, result.mismatches)) {
const { mismatches, confirmedEditedCount, unconfirmedCount } =
await dropConfirmedEditedRecords(unit.syncName, result.mismatches, http, svc.log)
if (confirmedEditedCount > 0) {
svc.log.info(
{ unitId: unit.id, day, channelName: channel.channelName, confirmedEditedCount },
'skipped body mismatches on records edited after capture',
)
}
if (unconfirmedCount > 0) {
svc.log.warn(
{
unitId: unit.id,
day,
channelName: channel.channelName,
syncName: unit.syncName,
checkFailedCount: unconfirmedCount,
},
'shadow diff check_failed: edited-record candidates not confirmed against github, kept as field_mismatch',
)
}
result = { ...result, mismatches }
}
}

unitDiffs.push({ unit, result })
Expand Down
175 changes: 175 additions & 0 deletions services/apps/connectors_worker/src/editedRecords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'

import type { ConnectorHttp } from '@crowd/connectors'
import type { Logger } from '@crowd/logging'

import { dropConfirmedEditedRecords, hasEditedRecordCandidates } from './editedRecords'
import { IShadowDiffMismatch } from './shadowDiff'

const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger

function bodyMismatch(sourceId: string, type = 'issue-comment'): IShadowDiffMismatch {
return {
sourceId,
type,
kind: 'field_mismatch',
severity: 'high',
fields: [{ field: 'body', shadowValue: 'old', nangoValue: 'new' }],
}
}

function httpWithHandler(handler: (ids: string[]) => Promise<unknown>): ConnectorHttp {
return {
request: async (config: { data: { variables: { ids: string[] } } }) =>
handler(config.data.variables.ids),
requestCount: () => 0,
} as unknown as ConnectorHttp
}

function editedResponse(editedIds: string[]) {
return async (ids: string[]) => ({
data: {
nodes: ids.map((id) => ({
id,
lastEditedAt: editedIds.includes(id) ? '2026-10-07T00:00:00Z' : null,
})),
},
})
}

describe('hasEditedRecordCandidates', () => {
it('is false for the pull-request-commits sync', () => {
expect(
hasEditedRecordCandidates('pull-request-commits', [
bodyMismatch('abc123', 'authored-commit'),
]),
).toBe(false)
})

it('is false when the mismatch involves fields other than body', () => {
const mismatch: IShadowDiffMismatch = {
...bodyMismatch('IC_abc'),
fields: [
{ field: 'body', shadowValue: 'old', nangoValue: 'new' },
{ field: 'url', shadowValue: 'a', nangoValue: 'b' },
],
}
expect(hasEditedRecordCandidates('issue-comments', [mismatch])).toBe(false)
})

it('is false for missing_in_shadow mismatches', () => {
expect(
hasEditedRecordCandidates('issue-comments', [
{ sourceId: 'IC_abc', type: 'issue-comment', kind: 'missing_in_shadow', severity: 'high' },
]),
).toBe(false)
})

it('is true for a body-only mismatch on a real node id', () => {
expect(hasEditedRecordCandidates('issue-comments', [bodyMismatch('IC_abc')])).toBe(true)
})

it('is true for a body-only mismatch on a synthetic id carrying a parent node', () => {
expect(
hasEditedRecordCandidates('pull-requests', [
bodyMismatch(
'gen-RRE_PR_kwDOI7xefs8AAAABGaViEg_alice_bob_2026-10-06T15:07:23.000Z',
'pull_request-review-requested',
),
]),
).toBe(true)
})

it('is false for a reviewed event whose body belongs to the review, not the parent PR', () => {
expect(
hasEditedRecordCandidates('pull-requests', [
bodyMismatch(
'gen-PRR_PR_kwDOI7xefs8AAAABGaViEg_alice_2026-10-06T15:07:23.000Z',
'pull_request-reviewed',
),
]),
).toBe(false)
})
})

describe('dropConfirmedEditedRecords', () => {
it('drops body mismatches whose node was edited and keeps the never-edited ones', async () => {
const mismatches = [bodyMismatch('IC_edited'), bodyMismatch('IC_pristine')]
const http = httpWithHandler(editedResponse(['IC_edited']))

const result = await dropConfirmedEditedRecords('issue-comments', mismatches, http, log)

expect(result.mismatches).toEqual([bodyMismatch('IC_pristine')])
expect(result.confirmedEditedCount).toBe(1)
expect(result.unconfirmedCount).toBe(0)
})

it('resolves synthetic timeline ids to their parent node and queries each parent once', async () => {
const prId = 'PR_kwDOI7xefs8AAAABGaViEg'
const mismatches = [
bodyMismatch(
`gen-RRE_${prId}_alice_bob_2026-10-06T15:07:23.000Z`,
'pull_request-review-requested',
),
bodyMismatch(
`gen-RRE_${prId}_alice_carol_2026-10-06T15:07:23.000Z`,
'pull_request-review-requested',
),
bodyMismatch(prId, 'pull_request-opened'),
]
const seen: string[][] = []
const http = httpWithHandler(async (ids) => {
seen.push(ids)
return editedResponse([prId])(ids)
})

const result = await dropConfirmedEditedRecords('pull-requests', mismatches, http, log)

expect(seen).toEqual([[prId]])
expect(result.mismatches).toEqual([])
expect(result.confirmedEditedCount).toBe(3)
})

it('keeps mismatches and counts them unconfirmed when the request fails', async () => {
const mismatches = [bodyMismatch('IC_unknown')]
const http = httpWithHandler(async () => {
throw new Error('network exploded')
})

const result = await dropConfirmedEditedRecords('issue-comments', mismatches, http, log)

expect(result.mismatches).toEqual(mismatches)
expect(result.confirmedEditedCount).toBe(0)
expect(result.unconfirmedCount).toBe(1)
})

it('keeps mismatches whose node resolved to null and counts them unconfirmed', async () => {
const mismatches = [bodyMismatch('IC_gone')]
const http = httpWithHandler(async (ids) => ({ data: { nodes: ids.map(() => null) } }))

const result = await dropConfirmedEditedRecords('issue-comments', mismatches, http, log)

expect(result.mismatches).toEqual(mismatches)
expect(result.confirmedEditedCount).toBe(0)
expect(result.unconfirmedCount).toBe(1)
})

it('leaves non-candidate mismatches untouched', async () => {
const other: IShadowDiffMismatch = {
sourceId: 'IC_other',
type: 'issue-comment',
kind: 'missing_in_nango',
severity: 'high',
}
const http = httpWithHandler(editedResponse(['IC_edited']))

const result = await dropConfirmedEditedRecords(
'issue-comments',
[other, bodyMismatch('IC_edited')],
http,
log,
)

expect(result.mismatches).toEqual([other])
})
})
Loading
Loading