Merge pull request #828 from kaitranntt/kai/chore/ccs-backlog-project-automation

chore: automate CCS backlog project sync
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-28 15:35:20 -04:00
committed by GitHub
5 changed files with 789 additions and 0 deletions
@@ -0,0 +1,40 @@
name: Sync CCS Backlog Project
on:
issues:
types:
- opened
- reopened
- closed
- labeled
- unlabeled
workflow_dispatch:
schedule:
- cron: '17 3 * * *'
concurrency:
group: sync-ccs-backlog-project
cancel-in-progress: false
permissions:
contents: read
issues: read
jobs:
sync-project:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Sync CCS Backlog project
env:
GH_TOKEN: ${{ secrets.CCS_PROJECT_AUTOMATION_TOKEN }}
CCS_PROJECT_OWNER: kaitranntt
CCS_PROJECT_NUMBER: '3'
run: node scripts/github/ccs-backlog-sync.mjs
+104
View File
@@ -50,6 +50,110 @@ CLI wrapper for instant switching between multiple provider accounts and alterna
| Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` |
| Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule |
## GitHub Issue Operations (CCS-Specific)
These rules apply when the task is issue triage, backlog cleanup, labels, comments, Projects, or milestones for this repo.
### Scope Boundary
- Treat issue triage as a **GitHub-only workflow** unless the user explicitly asks for implementation.
- Do **NOT** create a worktree, branch, PR, or run `/fix`, `/cook`, or `kai:maintainer` just to tag issues, post follow-up comments, close duplicates, or clean up backlog state.
- Escalate into code workflow only when:
- the user explicitly asks to fix/implement an issue, or
- triage proves the same task now requires code changes.
### Read Before Mutating
- Always inspect live issue state first with `gh issue view <n> --json ...` or `gh api`.
- Never rely on stale memory, screenshots, or issue titles alone.
- Before closing as resolved, cross-check repo evidence in at least one of:
- `README.md`
- `docs/`
- `CHANGELOG.md`
- relevant source/help handlers
- If the `gh` query would touch Projects fields, verify token scope first. Missing `read:project` is a real blocker, not something to hand-wave around.
### Labeling Standard
- Every **open** issue should end triage with:
- one primary type label: `bug`, `enhancement`, `question`, `documentation`, `duplicate`, `invalid`, or `wontfix`
- one area label:
- `area:cli-runtime`
- `area:dashboard-ui`
- `area:config-auth`
- `area:provider-integration`
- `area:install-packaging`
- `area:documentation`
- `area:contributor-workflow`
- Add routing labels only when they materially change handling:
- `upstream-blocked`
- `needs-repro`
- `needs-split`
- `docs-gap`
- Use release-state labels for shipped work:
- `pending-release`
- `released-dev`
- `released`
- Do **NOT** create or use status labels like `todo`, `doing`, `blocked`, `done`.
- Do **NOT** create provider-name labels unless there is a proven long-term need. Provider names belong in titles/issues, not label spam.
### Commenting Rules
- Keep issue comments short, technical, and neutral.
- State the decision plainly: close, keep open, retag, needs repro, duplicate, blocked upstream.
- Include exact evidence when relevant: version, doc path, changelog release, canonical issue, upstream link.
- Do **NOT** reference internal plans, local report files, agent prompts, or private reasoning.
- Post **one** maintainer follow-up comment per triage pass. If accidental duplicates are created, delete them with `gh api repos/<owner>/<repo>/issues/comments/<id> -X DELETE`.
### Closure Rules
- Close immediately when:
- the issue is an obvious duplicate and you can point to the canonical issue
- the feature/fix is clearly shipped and documented
- a previously `pending-release` issue is now clearly past release and no longer needs tracking
- Keep open and retag when:
- upstream dependency still blocks CCS adoption -> `upstream-blocked`
- latest-release behavior is unclear -> `needs-repro`
- issue contains multiple independent asks -> `needs-split`
- feature likely exists but discoverability/docs are weak -> `docs-gap`
- Do **NOT** close just because an issue is old, vague, or inconvenient. Close only with evidence.
### Projects And Milestones
- Preferred project model for this repo: one project, `CCS Backlog`.
- Use Projects for workflow state and priority. Use labels for meaning and routing.
- Milestones are for real ship windows only, not generic categorization buckets.
- If `gh` token lacks `read:project`, say so explicitly and stop short of pretending Projects data is available.
- Active project:
- owner: `kaitranntt`
- number: `3`
- URL: `https://github.com/users/kaitranntt/projects/3`
- Active project fields:
- `Status` -> use for work state (`Todo`, `In Progress`, `Done`)
- `Priority` -> `P1` for bugs, `P2` default backlog, `P3` for broad `needs-split` buckets unless explicitly reprioritized
- `Follow-up` -> `Ready`, `Needs repro`, `Blocked upstream`, `Needs split`, `Docs follow-up`
- `Next review` -> date only for issues that need a follow-up checkpoint
- When triaging an open issue, make sure it exists in `CCS Backlog` and the project fields match the routing labels.
- Do **NOT** create a second backlog project unless the user explicitly wants a project split and gives a reason.
- Current automation path:
- workflow file: `.github/workflows/sync-ccs-backlog-project.yml`
- sync script: `scripts/github/ccs-backlog-sync.mjs`
- required Actions secret: `CCS_PROJECT_AUTOMATION_TOKEN`
- Automation mapping must stay aligned with labels:
- `upstream-blocked` -> `Follow-up=Blocked upstream`
- `needs-repro` -> `Follow-up=Needs repro`
- `needs-split` -> `Follow-up=Needs split`
- `docs-gap` -> `Follow-up=Docs follow-up`
- otherwise -> `Follow-up=Ready`
### New Or Updated Issue Creation
- When creating issues for this repo:
- assign `@kaitranntt`
- use conventional issue titles: `bug: ...`, `feat: ...`, `docs: ...`
- keep bodies factual and technical
- avoid personal info and internal-only context
## Quality Gates (MANDATORY)
Quality gates MUST pass before pushing. **Both projects have identical workflow.**
+379
View File
@@ -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() });
}
+6
View File
@@ -0,0 +1,6 @@
import { runSync } from './ccs-backlog-sync-lib.mjs';
runSync().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -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']);
});
});