import { describe, expect, it } from 'bun:test'; import { classify, getProjectContext, listIssuesForSync, parseRepoFullName, syncIssues, validateProjectFields, } from '../../../../scripts/github/ccs-backlog-sync-lib.mjs'; describe('ccs backlog sync helpers', () => { it('maps closed issues to Done and clears follow-up state', () => { const plan = classify( [{ name: 'bug' }, { name: 'upstream-blocked' }], 'closed', new Date('2026-03-28T00:00:00Z') ); expect(plan).toEqual({ priority: 'P1', followUp: 'Ready', nextReview: null, status: 'Done', }); }); it('rejects malformed repository identifiers with a clear error', () => { expect(() => parseRepoFullName('ccs')).toThrow( 'Invalid GITHUB_REPOSITORY value "ccs". Expected OWNER/REPO.' ); }); it('validates required project fields before syncing', () => { const fields = new Map([['Status', { id: 'status', options: new Map() }]]); expect(() => validateProjectFields(fields)).toThrow( 'Missing required project fields: "Priority", "Follow-up", "Next review"' ); }); it('paginates project items across multiple GraphQL pages', async () => { const graphqlRequest = async (_query: string, variables: { itemCursor?: string | null }) => { if (!variables.itemCursor) { return { user: { projectV2: { id: 'project-1', fields: { nodes: [ { id: 'status', name: 'Status', options: [ { id: 'todo', name: 'Todo' }, { id: 'done', name: 'Done' }, ], }, { id: 'priority', name: 'Priority', options: [ { id: 'p1', name: 'P1' }, { id: 'p2', name: 'P2' }, { id: 'p3', name: 'P3' }, ], }, { id: 'follow', name: 'Follow-up', options: [{ id: 'ready', name: 'Ready' }] }, { id: 'review', name: 'Next review', options: [] }, ], }, items: { pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, nodes: [ { id: 'item-1', content: { __typename: 'Issue', number: 1, repository: { nameWithOwner: 'kaitranntt/ccs' }, }, }, ], }, }, }, }; } return { user: { projectV2: { id: 'project-1', fields: { nodes: [] }, items: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [ { id: 'item-2', content: { __typename: 'Issue', number: 2, repository: { nameWithOwner: 'kaitranntt/ccs' }, }, }, ], }, }, }, }; }; const context = await getProjectContext({ owner: 'kaitranntt', projectNumber: 3, repoFullName: 'kaitranntt/ccs', graphqlRequest, }); expect(context.projectId).toBe('project-1'); expect(context.itemsByNumber.get(1)).toBe('item-1'); expect(context.itemsByNumber.get(2)).toBe('item-2'); expect(context.statusField.id).toBe('status'); }); it('includes recently closed issues during scheduled reconciliation while skipping stale closures', async () => { const headers = new Headers(); const githubRequest = async (path: string) => { if (path.includes('state=open')) { return { body: [{ number: 10, state: 'open', labels: [], node_id: 'node-10' }], headers }; } return { body: [ { number: 11, state: 'closed', closed_at: '2026-03-25T00:00:00Z', labels: [], node_id: 'node-11', }, { number: 12, state: 'closed', closed_at: '2026-02-01T00:00:00Z', labels: [], node_id: 'node-12', }, ], headers, }; }; const issues = await listIssuesForSync({ repoOwner: 'kaitranntt', repoName: 'ccs', githubRequest, now: new Date('2026-03-28T00:00:00Z'), closedLookbackDays: 14, }); expect(issues.map((issue) => issue.number)).toEqual([10, 11]); }); it('continues syncing remaining issues after an individual failure', async () => { const logs: string[] = []; const errors: string[] = []; const syncedItems: number[] = []; const context = { projectId: 'project-1', itemsByNumber: new Map(), statusField: { id: 'status', options: new Map([ ['Todo', 'todo'], ['Done', 'done'], ]), }, priorityField: { id: 'priority', options: new Map([ ['P1', 'p1'], ['P2', 'p2'], ['P3', 'p3'], ]), }, followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) }, nextReviewField: { id: 'review', options: new Map() }, }; const issues = [ { number: 1, state: 'open', labels: [], node_id: 'node-1' }, { number: 2, state: 'open', labels: [], node_id: 'node-2' }, { number: 3, state: 'open', labels: [], node_id: 'node-3' }, ]; const graphqlRequest = async (query: string, variables: Record) => { if (query.includes('addProjectV2ItemById')) return { addProjectV2ItemById: { item: { id: `item-${variables.contentId}` } } }; if (variables.itemId === 'item-node-2' && variables.fieldId === 'priority') throw new Error('priority write failed'); syncedItems.push(Number(variables.itemId.replace('item-node-', ''))); return {}; }; await expect( syncIssues({ issues, context, graphqlRequest, logger: { log: (message: string) => logs.push(message), error: (message: string) => errors.push(message), }, now: new Date('2026-03-28T00:00:00Z'), }) ).rejects.toThrow('Failed to sync 1 issue(s): #2 (priority write failed)'); expect(logs.some((message) => message.includes('synced #1'))).toBe(true); expect(logs.some((message) => message.includes('synced #3'))).toBe(true); expect(errors).toEqual(['[X] Failed to sync #2: priority write failed']); expect(syncedItems).toContain(3); }); it('skips untracked closed issues during scheduled reconciliation', async () => { const logs: string[] = []; const context = { projectId: 'project-1', itemsByNumber: new Map([[9, 'item-9']]), statusField: { id: 'status', options: new Map([ ['Todo', 'todo'], ['Done', 'done'], ]), }, priorityField: { id: 'priority', options: new Map([ ['P1', 'p1'], ['P2', 'p2'], ['P3', 'p3'], ]), }, followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) }, nextReviewField: { id: 'review', options: new Map() }, }; await syncIssues({ issues: [{ number: 10, state: 'closed', labels: [], node_id: 'node-10' }], context, graphqlRequest: async () => { throw new Error('should not attempt to mutate project state'); }, logger: { log: (message: string) => logs.push(message), error: () => {}, }, now: new Date('2026-03-28T00:00:00Z'), }); expect(logs).toEqual(['skipped #10: closed issue is not currently tracked in the project']); }); });