From df4647489a575552a602192f45fd816053ccd91f Mon Sep 17 00:00:00 2001 From: Mouad BANI Date: Fri, 25 Sep 2026 16:31:52 +0000 Subject: [PATCH] test: cover ghost member commits and issue reclose mapping (CM-1473) Signed-off-by: Mouad BANI --- .../connectors/github/mappers/issue.test.ts | 67 +++++++++++++++++++ .../github/syncs/pullRequestCommits.test.ts | 34 ++++++++-- 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 services/libs/connectors/src/connectors/github/mappers/issue.test.ts diff --git a/services/libs/connectors/src/connectors/github/mappers/issue.test.ts b/services/libs/connectors/src/connectors/github/mappers/issue.test.ts new file mode 100644 index 0000000000..024a65e748 --- /dev/null +++ b/services/libs/connectors/src/connectors/github/mappers/issue.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { IssueNode } from '../graphql/issues' +import { toIssueActivities } from './issue' + +// @crowd/integrations eagerly scans and requires every integration folder on import, +// which fails outside its build environment and is unrelated to what's under test here. +vi.mock('@crowd/integrations', () => ({ + GithubActivityType: { ISSUE_OPENED: 'issue-opened', ISSUE_CLOSED: 'issue-closed' }, + GITHUB_GRID: { + 'issue-opened': { score: 1 }, + 'issue-closed': { score: 1 }, + }, +})) + +function baseIssue(overrides: Partial = {}): IssueNode { + return { + id: 'issue-1', + number: 62642, + title: 'some issue', + url: 'https://github.com/openclaw/openclaw/issues/62642', + state: 'OPEN', + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-01T00:00:00Z', + bodyText: 'body', + author: { login: 'author', databaseId: 1, __typename: 'User' }, + timelineItems: { nodes: [] }, + ...overrides, + } +} + +describe('toIssueActivities', () => { + it('emits the latest close event for a reopened and re-closed issue', () => { + const issue = baseIssue({ + state: 'CLOSED', + updatedAt: '2026-09-24T10:00:00Z', + timelineItems: { + nodes: [ + { + __typename: 'ClosedEvent', + createdAt: '2026-09-24T10:00:00Z', + actor: { login: 'closer', databaseId: 2, __typename: 'User' }, + }, + ], + }, + }) + + const activities = toIssueActivities(issue) + + const closeActivity = activities.find((a) => (a.type as string) === 'issue-closed') + expect(closeActivity).toBeDefined() + expect(closeActivity?.timestamp).toBe('2026-09-24T10:00:00Z') + expect(closeActivity?.attributes).toMatchObject({ closedBy: 'closer' }) + }) + + it('emits no close activity for a currently open issue', () => { + const issue = baseIssue({ + state: 'OPEN', + timelineItems: { nodes: [] }, + }) + + const activities = toIssueActivities(issue) + + expect(activities.some((a) => (a.type as string) === 'issue-closed')).toBe(false) + expect(activities).toHaveLength(1) + }) +}) diff --git a/services/libs/connectors/src/connectors/github/syncs/pullRequestCommits.test.ts b/services/libs/connectors/src/connectors/github/syncs/pullRequestCommits.test.ts index 24954a916f..dfc56c894e 100644 --- a/services/libs/connectors/src/connectors/github/syncs/pullRequestCommits.test.ts +++ b/services/libs/connectors/src/connectors/github/syncs/pullRequestCommits.test.ts @@ -6,6 +6,7 @@ import type { ConnectorHttp } from '../../../http/client' import { ProviderUnavailableError } from '../../../http/errors' import type { SyncContext } from '../../../types' import { PR_COMMITS_QUERY } from '../graphql/pullRequestChildren' +import type { GithubActivity } from '../schemas' import { pullRequestCommitsSync } from './pullRequestCommits' // @crowd/integrations eagerly scans and requires every integration folder on import, @@ -29,9 +30,11 @@ interface HarnessOptions { statsUnavailableForPrs?: number[] // number of commit pages to serve for a given PR (default 1) pagesPerPr?: Record + // PR numbers whose commits have no resolvable GitHub user (author.user = null) + ghostAuthorPrs?: number[] } -function commitNode(id: string) { +function commitNode(id: string, ghostAuthor = false) { return { commit: { parents: { totalCount: 1 }, @@ -40,13 +43,16 @@ function commitNode(id: string) { message: 'msg', authoredDate: '2026-09-20T00:00:00Z', url: `https://github.com/openclaw/openclaw/commit/${id}`, - author: { user: { login: 'someone' }, email: null, name: null }, + author: ghostAuthor + ? { user: null, email: 'someone@example.com', name: 'Someone' } + : { user: { login: 'someone' }, email: null, name: null }, }, } } function makeHarness(opts: HarnessOptions) { const requests: RequestLog[] = [] + const emitted: GithubActivity[] = [] let prListServed = false const commitPagesSeen: Record = {} @@ -101,7 +107,12 @@ function makeHarness(opts: HarnessOptions) { endCursor: hasNextPage ? `p${commitPagesSeen[prNumber] + 1}` : null, hasNextPage, }, - nodes: [commitNode(`${prNumber}-${commitPagesSeen[prNumber]}`)], + nodes: [ + commitNode( + `${prNumber}-${commitPagesSeen[prNumber]}`, + opts.ghostAuthorPrs?.includes(prNumber), + ), + ], }, }, }, @@ -113,7 +124,9 @@ function makeHarness(opts: HarnessOptions) { const ctx: SyncContext = { channel: CHANNEL, watermark: { phase: 'backfill', since: null, cursor: null }, - emit: async () => {}, + emit: async (records) => { + emitted.push(...(records as GithubActivity[])) + }, commitWatermark: async () => {}, hasRunBudget: () => true, http, @@ -125,7 +138,7 @@ function makeHarness(opts: HarnessOptions) { } as unknown as Logger, } - return { ctx, requests } + return { ctx, requests, emitted } } describe('pullRequestCommitsSync', () => { @@ -154,6 +167,17 @@ describe('pullRequestCommitsSync', () => { expect(requests).toHaveLength(3) }) + it('emits commits whose author has no github account as the ghost member', async () => { + const { ctx, emitted } = makeHarness({ prNumbers: [5], ghostAuthorPrs: [5] }) + + await pullRequestCommitsSync.run(ctx) + + expect(emitted).toHaveLength(1) + expect(emitted[0].sourceId).toBe('5-1') + expect(emitted[0].member.displayName).toBe('ghost') + expect(emitted[0].member.identities[0].value).toBe('ghost') + }) + it('does not carry the no-stats fallback over to the next PR', async () => { const { ctx, requests } = makeHarness({ prNumbers: [3, 4], statsUnavailableForPrs: [3] })