Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 30e1716215 fix(community): track transferred issues in github-connector webhook and backfill
https://sonarly.com/issue/35053?type=bug

The github-connector app's issue object has no `isTransferred` field, and the webhook handler does not branch on `action === "transferred"`, so transferred issues are never flagged — consumers relying on this signal for anti-gaming classification are bypassed.

Fix: Added `isTransferred` boolean field tracking to the github-connector's issue module. The fix addresses both producer paths:

**1. Live webhook path** (`handle-webhook.logic-function.ts`): When GitHub fires an `issues` webhook with `action === "transferred"`, the handler now sets `isTransferred: true` on the upsert payload.

**2. Backfill path** (`fetch-issues.ts` GraphQL query): The backfill query now requests `timelineItems(itemTypes: [TRANSFERRED_EVENT], first: 1)` for each issue. The `issueFromGraphql` normalizer checks whether any `TransferredEvent` node exists and sets `isTransferred` accordingly.

**Schema addition** (`issue.object.ts`): Added a `BOOLEAN` field with `defaultValue: false` — existing issues retain `false` until re-synced via backfill, while new transfers are caught in real-time by the webhook.

All downstream types (`IssueCanonical`, `IssueRow`, `batchUpsertIssues` parameter) and queries (`find-by-number-and-repo`) were updated to include the new field.
2026-05-05 20:47:42 +00:00
7 changed files with 31 additions and 1 deletions
@@ -19,6 +19,7 @@ query($owner: String!, $name: String!, $cursor: String) {
closedAt
author { login avatarUrl ... on User { databaseId } }
labels(first: 50) { nodes { name } }
timelineItems(itemTypes: [TRANSFERRED_EVENT], first: 1) { nodes { __typename } }
}
}
}
@@ -33,6 +34,7 @@ export type GqlIssue = {
closedAt: string | null;
author: { login: string; avatarUrl?: string | null; databaseId?: number } | null;
labels: { nodes: Array<{ name: string }> };
timelineItems?: { nodes: Array<{ __typename: string } | null> } | null;
};
type Response = {
@@ -14,6 +14,7 @@ export async function batchUpsertIssues(
closedAt: string | null;
repo: string;
authorId: string | null;
isTransferred: boolean;
}>,
): Promise<IssueRow[]> {
return chunkedBatchCreate('createIssues', items, {
@@ -23,5 +24,6 @@ export async function batchUpsertIssues(
title: true,
state: true,
repo: true,
isTransferred: true,
}) as Promise<IssueRow[]>;
}
@@ -30,6 +30,7 @@ export async function findIssueByNumberAndRepo(
closedAt: true,
repo: true,
authorId: true,
isTransferred: true,
},
},
},
@@ -15,6 +15,7 @@ export type IssueCanonical = {
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
isTransferred: boolean;
};
export function deriveIssueState(state: string): IssueState {
@@ -31,6 +32,7 @@ export function buildIssueUniqueIdentifier(
export function issueFromWebhook(
issue: GitHubIssue,
repoFullName: string,
options?: { isTransferred?: boolean },
): IssueCanonical {
return {
title: issue.title,
@@ -42,6 +44,7 @@ export function issueFromWebhook(
githubCreatedAt: issue.created_at,
closedAt: issue.closed_at,
repo: repoFullName,
isTransferred: options?.isTransferred ?? false,
};
}
@@ -49,6 +52,9 @@ export function issueFromGraphql(
issue: GqlIssue,
repoFullName: string,
): IssueCanonical {
const hasTransferEvent =
issue.timelineItems?.nodes?.some((n) => n?.__typename === 'TransferredEvent') ?? false;
return {
title: issue.title,
githubNumber: issue.number,
@@ -59,5 +65,6 @@ export function issueFromGraphql(
githubCreatedAt: issue.createdAt,
closedAt: issue.closedAt,
repo: repoFullName,
isTransferred: hasTransferEvent,
};
}
@@ -27,6 +27,9 @@ export const ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER =
export const ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER =
'0ca04207-4d74-4298-8238-59cdf685862b';
export const ISSUE_IS_TRANSFERRED_FIELD_UNIVERSAL_IDENTIFIER =
'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
enum IssueState {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
@@ -130,5 +133,15 @@ export default defineObject({
icon: 'IconFingerprint',
isUnique: true,
},
{
universalIdentifier: ISSUE_IS_TRANSFERRED_FIELD_UNIVERSAL_IDENTIFIER,
name: 'isTransferred',
type: FieldType.BOOLEAN,
label: 'Transferred',
description:
'True when the issue has been transferred to a different repository via GitHub.',
icon: 'IconArrowsExchange',
defaultValue: false,
},
],
});
@@ -12,4 +12,5 @@ export type IssueRow = {
closedAt?: string | null;
repo?: string | null;
authorId?: string | null;
isTransferred?: boolean | null;
};
@@ -150,8 +150,12 @@ async function handleIssueEvent(payload: GitHubWebhookPayload) {
return { skipped: true, reason: 'missing issue or repository' };
}
const isTransferred = payload.action === 'transferred';
const idByLogin = await upsertContributorsByLogin([issue.user]);
const canonical = issueFromWebhook(issue, repository.full_name);
const canonical = issueFromWebhook(issue, repository.full_name, {
isTransferred,
});
const [record] = await batchUpsertIssues([
{ ...canonical, authorId: idByLogin.get(issue.user.login) ?? null },