fix: harden CCS backlog sync pagination and recovery

This commit is contained in:
Tam Nhu Tran
2026-03-28 09:51:53 -04:00
parent b3b3853db0
commit e6617726cb
4 changed files with 645 additions and 188 deletions
@@ -0,0 +1,260 @@
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<string, string>) => {
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']);
});
});