From e6617726cb9fcbeefd64d57d3f001fe381099607 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 09:51:53 -0400 Subject: [PATCH] fix: harden CCS backlog sync pagination and recovery --- .../workflows/sync-ccs-backlog-project.yml | 4 + scripts/github/ccs-backlog-sync-lib.mjs | 379 ++++++++++++++++++ scripts/github/ccs-backlog-sync.mjs | 190 +-------- .../scripts/github/ccs-backlog-sync.test.ts | 260 ++++++++++++ 4 files changed, 645 insertions(+), 188 deletions(-) create mode 100644 scripts/github/ccs-backlog-sync-lib.mjs create mode 100644 tests/unit/scripts/github/ccs-backlog-sync.test.ts diff --git a/.github/workflows/sync-ccs-backlog-project.yml b/.github/workflows/sync-ccs-backlog-project.yml index 14a57da6..cd48a014 100644 --- a/.github/workflows/sync-ccs-backlog-project.yml +++ b/.github/workflows/sync-ccs-backlog-project.yml @@ -12,6 +12,10 @@ on: schedule: - cron: '17 3 * * *' +concurrency: + group: sync-ccs-backlog-project + cancel-in-progress: false + permissions: contents: read issues: read diff --git a/scripts/github/ccs-backlog-sync-lib.mjs b/scripts/github/ccs-backlog-sync-lib.mjs new file mode 100644 index 00000000..e5783ae2 --- /dev/null +++ b/scripts/github/ccs-backlog-sync-lib.mjs @@ -0,0 +1,379 @@ +const REQUIRED_PROJECT_FIELDS = ['Status', 'Priority', 'Follow-up', 'Next review']; +const DEFAULT_REPO_FULL_NAME = 'kaitranntt/ccs'; +const DEFAULT_CLOSED_LOOKBACK_DAYS = 14; +const PRIORITY_FOR = { bug: 'P1', default: 'P2', split: 'P3' }; +const FOLLOW_UP_FOR = { + ready: 'Ready', + repro: 'Needs repro', + upstream: 'Blocked upstream', + split: 'Needs split', + docs: 'Docs follow-up', +}; + +const PROJECT_QUERY = `query($owner: String!, $number: Int!, $itemCursor: String) { + user(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { nodes { __typename ... on ProjectV2Field { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } } + items(first: 100, after: $itemCursor) { + pageInfo { hasNextPage endCursor } + nodes { id content { __typename ... on Issue { number id repository { nameWithOwner } } } } + } + } + } +}`; +const ADD_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } +}`; +const SET_SINGLE_SELECT_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } +}`; +const SET_DATE_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { date: $date } + }) { projectV2Item { id } } +}`; +const CLEAR_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { + clearProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId}) { projectV2Item { id } } +}`; + +export function isoDate(daysFromNow, now = new Date()) { + const date = new Date(now); + date.setUTCDate(date.getUTCDate() + daysFromNow); + return date.toISOString().slice(0, 10); +} + +export function classify(labels, state, now = new Date()) { + const names = new Set(labels.map((label) => label.name)); + const priority = names.has('bug') + ? PRIORITY_FOR.bug + : names.has('needs-split') + ? PRIORITY_FOR.split + : PRIORITY_FOR.default; + if (state === 'closed') + return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Done' }; + if (names.has('upstream-blocked')) + return { + priority, + followUp: FOLLOW_UP_FOR.upstream, + nextReview: isoDate(7, now), + status: 'Todo', + }; + if (names.has('needs-repro')) + return { + priority, + followUp: FOLLOW_UP_FOR.repro, + nextReview: isoDate(14, now), + status: 'Todo', + }; + if (names.has('needs-split')) + return { + priority, + followUp: FOLLOW_UP_FOR.split, + nextReview: isoDate(14, now), + status: 'Todo', + }; + if (names.has('docs-gap')) + return { priority, followUp: FOLLOW_UP_FOR.docs, nextReview: isoDate(7, now), status: 'Todo' }; + return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Todo' }; +} + +export function parseRepoFullName(repoFullName = DEFAULT_REPO_FULL_NAME) { + const [repoOwner, repoName, extra] = String(repoFullName).split('/'); + if (!repoOwner || !repoName || extra) { + throw new Error(`Invalid GITHUB_REPOSITORY value "${repoFullName}". Expected OWNER/REPO.`); + } + return { repoOwner, repoName, repoFullName: `${repoOwner}/${repoName}` }; +} + +export function parseNextLink(linkHeader) { + if (!linkHeader) return null; + for (const segment of linkHeader.split(',')) { + const match = segment.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/); + if (match?.[2] === 'next') return match[1]; + } + return null; +} + +function getHeader(headers, name) { + if (typeof headers?.get === 'function') return headers.get(name); + return headers?.[name] || headers?.[name.toLowerCase()] || null; +} + +function buildCutoffTimestamp(now, days) { + const cutoff = new Date(now); + cutoff.setUTCDate(cutoff.getUTCDate() - days); + return cutoff.toISOString(); +} + +function isRecentlyClosed(issue, now, days) { + if (issue.state !== 'closed' || !issue.closed_at) return false; + return Date.parse(issue.closed_at) >= Date.parse(buildCutoffTimestamp(now, days)); +} + +export function validateProjectFields(fields) { + const missing = REQUIRED_PROJECT_FIELDS.filter((name) => !fields.has(name)); + if (missing.length > 0) { + throw new Error( + `Missing required project field${missing.length > 1 ? 's' : ''}: ${missing.map((name) => `"${name}"`).join(', ')}` + ); + } + return { + statusField: fields.get('Status'), + priorityField: fields.get('Priority'), + followUpField: fields.get('Follow-up'), + nextReviewField: fields.get('Next review'), + }; +} + +export async function listGithubCollection(initialPath, githubRequest) { + const items = []; + let nextPath = initialPath; + while (nextPath) { + const { body, headers } = await githubRequest(nextPath); + if (!Array.isArray(body)) throw new Error(`Expected array response for ${nextPath}`); + items.push(...body); + nextPath = parseNextLink(getHeader(headers, 'link')); + } + return items; +} + +export async function getProjectContext({ owner, projectNumber, repoFullName, graphqlRequest }) { + const fields = new Map(); + const itemsByNumber = new Map(); + let projectId = null; + let itemCursor = null; + + do { + const data = await graphqlRequest(PROJECT_QUERY, { owner, number: projectNumber, itemCursor }); + const project = data.user?.projectV2; + if (!project) throw new Error(`Project ${owner}/${projectNumber} not found`); + projectId = projectId || project.id; + + if (fields.size === 0) { + for (const node of project.fields.nodes) { + if (!node?.name) continue; + fields.set(node.name, { + id: node.id, + options: new Map((node.options || []).map((opt) => [opt.name, opt.id])), + }); + } + } + + for (const node of project.items.nodes) { + if ( + node?.content?.__typename === 'Issue' && + node.content.repository.nameWithOwner === repoFullName + ) { + itemsByNumber.set(node.content.number, node.id); + } + } + + itemCursor = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor : null; + } while (itemCursor); + + return { projectId, itemsByNumber, ...validateProjectFields(fields) }; +} + +export async function listIssuesForSync({ + repoOwner, + repoName, + githubRequest, + eventPath, + now = new Date(), + closedLookbackDays = DEFAULT_CLOSED_LOOKBACK_DAYS, +}) { + if (eventPath) { + const event = JSON.parse( + await import('node:fs/promises').then((fs) => fs.readFile(eventPath, 'utf8')) + ); + if (event.issue && !event.issue.pull_request) return [event.issue]; + } + + const openIssues = await listGithubCollection( + `/repos/${repoOwner}/${repoName}/issues?state=open&per_page=100`, + githubRequest + ); + const recentlyClosedIssues = await listGithubCollection( + `/repos/${repoOwner}/${repoName}/issues?state=closed&per_page=100&since=${encodeURIComponent(buildCutoffTimestamp(now, closedLookbackDays))}`, + githubRequest + ); + + const byNumber = new Map(); + for (const issue of openIssues) { + if (!issue.pull_request) byNumber.set(issue.number, issue); + } + for (const issue of recentlyClosedIssues) { + if (!issue.pull_request && isRecentlyClosed(issue, now, closedLookbackDays)) + byNumber.set(issue.number, issue); + } + return [...byNumber.values()]; +} + +async function ensureProjectItem(projectId, itemsByNumber, issue, graphqlRequest) { + const existing = itemsByNumber.get(issue.number); + if (existing) return existing; + if (!issue.node_id) throw new Error(`Issue #${issue.number} is missing node_id`); + const data = await graphqlRequest(ADD_ITEM_MUTATION, { projectId, contentId: issue.node_id }); + const itemId = data.addProjectV2ItemById.item.id; + itemsByNumber.set(issue.number, itemId); + return itemId; +} + +async function setSingleSelect(projectId, itemId, field, optionName, graphqlRequest) { + const optionId = field.options.get(optionName); + if (!optionId) throw new Error(`Missing option "${optionName}" on field ${field.id}`); + await graphqlRequest(SET_SINGLE_SELECT_MUTATION, { + projectId, + itemId, + fieldId: field.id, + optionId, + }); +} + +async function setDate(projectId, itemId, fieldId, date, graphqlRequest) { + if (!date) { + await graphqlRequest(CLEAR_FIELD_MUTATION, { projectId, itemId, fieldId }); + return; + } + await graphqlRequest(SET_DATE_MUTATION, { projectId, itemId, fieldId, date }); +} + +export async function syncIssues({ + issues, + context, + graphqlRequest, + logger = console, + now = new Date(), +}) { + const failures = []; + for (const issue of issues) { + try { + if (issue.state === 'closed' && !context.itemsByNumber.has(issue.number)) { + logger.log( + `skipped #${issue.number}: closed issue is not currently tracked in the project` + ); + continue; + } + const itemId = await ensureProjectItem( + context.projectId, + context.itemsByNumber, + issue, + graphqlRequest + ); + const plan = classify(issue.labels || [], issue.state, now); + await setSingleSelect( + context.projectId, + itemId, + context.statusField, + plan.status, + graphqlRequest + ); + await setSingleSelect( + context.projectId, + itemId, + context.priorityField, + plan.priority, + graphqlRequest + ); + await setSingleSelect( + context.projectId, + itemId, + context.followUpField, + plan.followUp, + graphqlRequest + ); + await setDate( + context.projectId, + itemId, + context.nextReviewField.id, + plan.nextReview, + graphqlRequest + ); + logger.log( + `synced #${issue.number}: ${plan.status} / ${plan.priority} / ${plan.followUp}${plan.nextReview ? ` / ${plan.nextReview}` : ''}` + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + failures.push(`#${issue.number} (${detail})`); + logger.error(`[X] Failed to sync #${issue.number}: ${detail}`); + } + } + if (failures.length > 0) + throw new Error(`Failed to sync ${failures.length} issue(s): ${failures.join(', ')}`); +} + +function formatGraphqlError(errors) { + const raw = JSON.stringify(errors); + if (/resource not accessible|insufficient|forbidden|project/i.test(raw)) { + return `GitHub Project access failed. Ensure GH_TOKEN or GITHUB_TOKEN has project scope and access to the target project. Raw: ${raw}`; + } + return `GitHub GraphQL failed: ${raw}`; +} + +function buildRuntimeConfig(env = process.env) { + const token = env.GH_TOKEN || env.GITHUB_TOKEN; + if (!token) throw new Error('Missing GH_TOKEN or GITHUB_TOKEN'); + const projectNumber = Number(env.CCS_PROJECT_NUMBER || '3'); + if (!Number.isInteger(projectNumber) || projectNumber <= 0) + throw new Error('CCS_PROJECT_NUMBER must be a positive integer'); + return { + token, + owner: env.CCS_PROJECT_OWNER || 'kaitranntt', + projectNumber, + eventPath: env.GITHUB_EVENT_PATH, + closedLookbackDays: Number( + env.CCS_PROJECT_RECENTLY_CLOSED_DAYS || String(DEFAULT_CLOSED_LOOKBACK_DAYS) + ), + ...parseRepoFullName(env.GITHUB_REPOSITORY || DEFAULT_REPO_FULL_NAME), + }; +} + +export async function runSync({ env = process.env, logger = console, fetchImpl = fetch } = {}) { + const config = buildRuntimeConfig(env); + const githubRequest = async (path, init = {}) => { + const response = await fetchImpl( + path.startsWith('http') ? path : `https://api.github.com${path}`, + { + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${config.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(init.headers || {}), + }, + } + ); + const body = await response.json(); + if (!response.ok) throw new Error(`GitHub REST ${response.status}: ${JSON.stringify(body)}`); + return { body, headers: response.headers }; + }; + const graphqlRequest = async (query, variables = {}) => { + const response = await fetchImpl('https://api.github.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` }, + body: JSON.stringify({ query, variables }), + }); + const body = await response.json(); + if (!response.ok || body.errors) throw new Error(formatGraphqlError(body.errors || body)); + return body.data; + }; + + const issues = await listIssuesForSync({ + repoOwner: config.repoOwner, + repoName: config.repoName, + githubRequest, + eventPath: config.eventPath, + now: new Date(), + closedLookbackDays: config.closedLookbackDays, + }); + const context = await getProjectContext({ + owner: config.owner, + projectNumber: config.projectNumber, + repoFullName: config.repoFullName, + graphqlRequest, + }); + await syncIssues({ issues, context, graphqlRequest, logger, now: new Date() }); +} diff --git a/scripts/github/ccs-backlog-sync.mjs b/scripts/github/ccs-backlog-sync.mjs index 68cc6faf..33d69966 100644 --- a/scripts/github/ccs-backlog-sync.mjs +++ b/scripts/github/ccs-backlog-sync.mjs @@ -1,192 +1,6 @@ -const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; -if (!token) { - console.error('Missing GH_TOKEN or GITHUB_TOKEN'); - process.exit(1); -} -const owner = process.env.CCS_PROJECT_OWNER || 'kaitranntt'; -const projectNumber = Number(process.env.CCS_PROJECT_NUMBER || '3'); -const repoFullName = process.env.GITHUB_REPOSITORY || 'kaitranntt/ccs'; -const [repoOwner, repoName] = repoFullName.split('/'); -const PRIORITY_FOR = { bug: 'P1', default: 'P2', split: 'P3' }; -const FOLLOW_UP_FOR = { - ready: 'Ready', - repro: 'Needs repro', - upstream: 'Blocked upstream', - split: 'Needs split', - docs: 'Docs follow-up', -}; -const PROJECT_QUERY = `query($owner: String!, $number: Int!) { - user(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { nodes { __typename ... on ProjectV2Field { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } } - items(first: 100) { nodes { id content { __typename ... on Issue { number id repository { nameWithOwner } } } } } - } - } -}`; -const ADD_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } -}`; -const SET_SINGLE_SELECT_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } - }) { projectV2Item { id } } -}`; -const SET_DATE_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { date: $date } - }) { projectV2Item { id } } -}`; -const CLEAR_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { - clearProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId}) { projectV2Item { id } } -}`; +import { runSync } from './ccs-backlog-sync-lib.mjs'; -function isoDate(daysFromNow) { - const now = new Date(); - now.setUTCDate(now.getUTCDate() + daysFromNow); - return now.toISOString().slice(0, 10); -} - -function classify(labels, state) { - const names = new Set(labels.map((label) => label.name)); - const priority = names.has('bug') - ? PRIORITY_FOR.bug - : names.has('needs-split') - ? PRIORITY_FOR.split - : PRIORITY_FOR.default; - - let followUp = FOLLOW_UP_FOR.ready; - let nextReview = null; - if (state === 'closed') return { priority, followUp, nextReview, status: 'Done' }; - if (names.has('upstream-blocked')) { - followUp = FOLLOW_UP_FOR.upstream; - nextReview = isoDate(7); - } else if (names.has('needs-repro')) { - followUp = FOLLOW_UP_FOR.repro; - nextReview = isoDate(14); - } else if (names.has('needs-split')) { - followUp = FOLLOW_UP_FOR.split; - nextReview = isoDate(14); - } else if (names.has('docs-gap')) { - followUp = FOLLOW_UP_FOR.docs; - nextReview = isoDate(7); - } - return { priority, followUp, nextReview, status: 'Todo' }; -} - -async function github(path, init = {}) { - const response = await fetch(`https://api.github.com${path}`, { - ...init, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - ...(init.headers || {}), - }, - }); - if (!response.ok) { - throw new Error(`GitHub REST ${response.status}: ${await response.text()}`); - } - return response.json(); -} - -async function graphql(query, variables = {}) { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ query, variables }), - }); - const json = await response.json(); - if (!response.ok || json.errors) { - throw new Error(`GitHub GraphQL failed: ${JSON.stringify(json.errors || json)}`); - } - return json.data; -} - -async function getProjectContext() { - const data = await graphql(PROJECT_QUERY, { owner, number: projectNumber }); - const project = data.user?.projectV2; - if (!project) throw new Error(`Project ${owner}/${projectNumber} not found`); - const fields = new Map(); - for (const node of project.fields.nodes) { - if (!node?.name) continue; - const options = new Map((node.options || []).map((opt) => [opt.name, opt.id])); - fields.set(node.name, { id: node.id, options }); - } - const itemsByNumber = new Map(); - for (const node of project.items.nodes) { - if ( - node?.content?.__typename === 'Issue' && - node.content.repository.nameWithOwner === repoFullName - ) { - itemsByNumber.set(node.content.number, node.id); - } - } - return { projectId: project.id, fields, itemsByNumber }; -} - -async function ensureProjectItem(projectId, itemsByNumber, issue) { - const existing = itemsByNumber.get(issue.number); - if (existing) return existing; - - const data = await graphql(ADD_ITEM_MUTATION, { projectId, contentId: issue.node_id }); - const itemId = data.addProjectV2ItemById.item.id; - itemsByNumber.set(issue.number, itemId); - return itemId; -} - -async function setSingleSelect(projectId, itemId, field, optionName) { - const optionId = field.options.get(optionName); - if (!optionId) throw new Error(`Missing option "${optionName}" on field ${field.id}`); - await graphql(SET_SINGLE_SELECT_MUTATION, { projectId, itemId, fieldId: field.id, optionId }); -} - -async function setDate(projectId, itemId, fieldId, date) { - if (!date) { - await graphql(CLEAR_FIELD_MUTATION, { projectId, itemId, fieldId }); - return; - } - await graphql(SET_DATE_MUTATION, { projectId, itemId, fieldId, date }); -} - -async function getTargetIssues() { - if (process.env.GITHUB_EVENT_PATH) { - const event = JSON.parse( - await import('node:fs/promises').then((fs) => - fs.readFile(process.env.GITHUB_EVENT_PATH, 'utf8') - ) - ); - if (event.issue && !event.issue.pull_request) return [event.issue]; - } - const issues = await github(`/repos/${repoOwner}/${repoName}/issues?state=open&per_page=100`); - return issues.filter((issue) => !issue.pull_request); -} - -async function main() { - const issues = await getTargetIssues(); - const { projectId, fields, itemsByNumber } = await getProjectContext(); - const statusField = fields.get('Status'); - const priorityField = fields.get('Priority'); - const followUpField = fields.get('Follow-up'); - const nextReviewField = fields.get('Next review'); - - for (const issue of issues) { - const itemId = await ensureProjectItem(projectId, itemsByNumber, issue); - const plan = classify(issue.labels || [], issue.state); - await setSingleSelect(projectId, itemId, statusField, plan.status); - await setSingleSelect(projectId, itemId, priorityField, plan.priority); - await setSingleSelect(projectId, itemId, followUpField, plan.followUp); - await setDate(projectId, itemId, nextReviewField.id, plan.nextReview); - console.log( - `synced #${issue.number}: ${plan.status} / ${plan.priority} / ${plan.followUp}${plan.nextReview ? ` / ${plan.nextReview}` : ''}` - ); - } -} - -main().catch((error) => { +runSync().catch((error) => { console.error(error); process.exit(1); }); diff --git a/tests/unit/scripts/github/ccs-backlog-sync.test.ts b/tests/unit/scripts/github/ccs-backlog-sync.test.ts new file mode 100644 index 00000000..01c34beb --- /dev/null +++ b/tests/unit/scripts/github/ccs-backlog-sync.test.ts @@ -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) => { + 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']); + }); +});