Files
ECC/tests/scripts/work-items-identity.test.js
T

273 lines
12 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { test } = require('node:test');
const { createStateStore } = require('../../scripts/lib/state-store');
const CLI = path.join(__dirname, '..', '..', 'scripts', 'work-items.js');
const SYNC_MARKER = 'ecc-work-items-sync-github';
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-work-item-identity-'));
const dbPath = path.join(dir, 'state.db');
const responsePath = path.join(dir, 'response.json');
const shimPath = path.join(dir, 'gh-shim.cjs');
fs.writeFileSync(shimPath, [
"const fs = require('node:fs');",
'const response = JSON.parse(fs.readFileSync(process.env.ECC_IDENTITY_RESPONSE, "utf8"));',
'process.stdout.write(JSON.stringify(response[process.argv[2]]));'
].join('\n'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
function run(args) {
return spawnSync(process.execPath, [CLI, ...args, '--db', dbPath], {
encoding: 'utf8',
env: { ...process.env, ECC_GH_SHIM: shimPath, ECC_IDENTITY_RESPONSE: responsePath }
});
}
function sync(repo, response = { issue: [], pr: [] }, args = []) {
fs.writeFileSync(responsePath, JSON.stringify(response));
return run(['sync-github', '--repo', repo, '--json', ...args]);
}
function syncItems(repo, response, args) {
const result = sync(repo, response, args);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}
async function withStore(fn) {
const store = await createStateStore({ dbPath });
try {
return fn(store);
} finally {
store.close();
}
}
return { run, sync, syncItems, withStore };
}
function responseFor(repo, types = ['issue', 'pr']) {
const entry = type => ({
number: 1,
title: `${repo} ${type}`,
url: `https://github.com/${repo}/${type === 'pr' ? 'pull' : 'issues'}/1`,
author: { login: 'author' },
labels: [],
isDraft: false,
mergeStateStatus: 'CLEAN'
});
return {
issue: types.includes('issue') ? [entry('issue')] : [],
pr: types.includes('pr') ? [entry('pr')] : []
};
}
function legacyItem(repo, type, overrides = {}) {
return {
id: `github-${repo.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}-${type}-1`,
source: `github-${type}`,
sourceId: '1',
title: 'Previously imported item',
status: 'needs-review',
createdAt: '2020-01-02T03:04:05.000Z',
metadata: { repo, type: type === 'pr' ? 'pull_request' : 'issue', syncedBy: SYNC_MARKER },
...overrides
};
}
test('sync keeps punctuation and owner/repository boundaries distinct for issues and PRs', async t => {
const f = fixture(t);
const repos = ['acme/tool-kit', 'acme/tool_kit', 'acme/tool.kit', 'acme-tool/kit'];
const ids = [];
for (const repo of repos) {
const first = f.syncItems(repo, responseFor(repo));
const repeated = f.syncItems(repo, responseFor(repo));
assert.deepEqual(repeated.items.map(item => item.id), first.items.map(item => item.id));
ids.push(...first.items.map(item => item.id));
}
assert.equal(new Set(ids).size, repos.length * 2, 'every repository/type identity must have its own ID');
const rows = await f.withStore(store => store.listWorkItems({ limit: 100 }).items);
assert.equal(rows.length, repos.length * 2);
for (const repo of repos) {
for (const type of ['issue', 'pr']) {
const row = rows.find(item => item.metadata.repo === repo && item.source === `github-${type}`);
assert.ok(row, `${repo} ${type} remains stored`);
assert.equal(row.status, 'needs-review');
assert.equal(row.url, responseFor(repo)[type][0].url);
}
}
});
for (const type of ['issue', 'pr']) {
test(`${type}: reuse a matching legacy ID and preserve creation time and explicit linkage`, async t => {
const f = fixture(t);
const repo = 'Acme/Tool_Kit';
const legacy = legacyItem(repo, type, { sessionId: 'session-42', repoRoot: '/workspace/tool-kit' });
await f.withStore(store => {
store.upsertSession({ id: 'session-42', adapterId: 'manual', harness: 'codex', state: 'running' });
store.upsertWorkItem(legacy);
});
const args = ['--session', 'session-42', '--repo-root', '/workspace/tool-kit'];
const synced = f.syncItems(repo.toLowerCase(), responseFor(repo, [type]), args);
assert.equal(synced.items[0].id, legacy.id);
assert.equal(synced.items[0].createdAt, legacy.createdAt);
assert.equal(synced.items[0].sessionId, legacy.sessionId);
assert.equal(synced.items[0].repoRoot, legacy.repoRoot);
assert.equal(synced.items[0].title, `${type === 'pr' ? 'PR' : 'Issue'} #1: ${repo} ${type}`);
const repeated = f.syncItems(repo, responseFor(repo, [type]), args);
assert.equal(repeated.items[0].id, legacy.id);
assert.equal(repeated.closedCount, 0);
assert.equal(await f.withStore(store => store.listWorkItems({ limit: 100 }).items.length), 1);
});
test(`${type}: a colliding legacy repository is not overwritten`, async t => {
const f = fixture(t);
const original = await f.withStore(store => store.upsertWorkItem(legacyItem('acme/tool-kit', type)));
const incoming = f.syncItems('acme/tool_kit', responseFor('acme/tool_kit', [type])).items[0];
assert.notEqual(incoming.id, original.id);
assert.deepEqual(await f.withStore(store => store.getWorkItemById(original.id)), original);
assert.equal(await f.withStore(store => store.listWorkItems({ limit: 100 }).items.length), 2);
});
}
test('legacy reuse requires the matching source, number, sync marker, and repository', async t => {
const variants = [
{ source: 'manual' },
{ sourceId: '2' },
{ metadata: { repo: 'acme/tool-kit', type: 'issue' } },
...[null, 42, { toString: 5 }].map(repo => ({
metadata: { repo, type: 'issue', syncedBy: SYNC_MARKER }
}))
];
for (const overrides of variants) {
await t.test(JSON.stringify(overrides), async subtest => {
const f = fixture(subtest);
const original = await f.withStore(store => store.upsertWorkItem(legacyItem('acme/tool-kit', 'issue', {
...overrides,
status: 'done'
})));
const incoming = f.syncItems('acme/tool-kit', responseFor('acme/tool-kit', ['issue'])).items[0];
assert.notEqual(incoming.id, original.id);
assert.deepEqual(await f.withStore(store => store.getWorkItemById(original.id)), original);
});
}
});
test('canonical IDs stay stable across casing changes and when the legacy ID is vacant', async t => {
const f = fixture(t);
const repo = 'Acme/Tool_Kit';
const canonical = legacyItem(repo, 'issue', {
id: `github-${encodeURIComponent(repo.toLowerCase())}-issue-1`
});
await f.withStore(store => store.upsertWorkItem(canonical));
for (const spelling of [repo.toLowerCase(), repo.toUpperCase()]) {
const result = f.syncItems(spelling, responseFor(spelling, ['issue']));
assert.equal(result.items[0].id, canonical.id);
assert.equal(result.closedCount, 0);
assert.equal(result.items[0].createdAt, canonical.createdAt);
}
assert.equal(await f.withStore(store => store.listWorkItems({ limit: 100 }).items.length), 1);
});
test('canonical IDs take precedence when a matching legacy record also exists', async t => {
const f = fixture(t);
const repo = 'acme/tool-kit';
const legacy = await f.withStore(store => store.upsertWorkItem(legacyItem(repo, 'issue', { status: 'done' })));
const canonical = legacyItem(repo, 'issue', {
id: `github-${encodeURIComponent(repo)}-issue-1`,
createdAt: '2021-02-03T04:05:06.000Z'
});
await f.withStore(store => store.upsertWorkItem(canonical));
const result = f.syncItems(repo, responseFor(repo, ['issue']));
assert.equal(result.items[0].id, canonical.id);
assert.equal(result.items[0].createdAt, canonical.createdAt);
assert.equal(result.items[0].status, 'needs-review');
assert.deepEqual(await f.withStore(store => store.getWorkItemById(legacy.id)), legacy);
assert.equal(await f.withStore(store => store.listWorkItems({ limit: 100 }).items.length), 2);
});
test('a canonical ID occupied by a different identity fails without overwriting it', async t => {
const f = fixture(t);
const repo = 'acme/tool-kit';
const original = await f.withStore(store => store.upsertWorkItem({
id: `github-${encodeURIComponent(repo)}-issue-1`,
source: 'manual',
title: 'Unrelated local work',
status: 'open'
}));
const result = f.sync(repo, responseFor(repo, ['issue']));
assert.notEqual(result.status, 0);
assert.match(result.stderr, /collision|already|different|conflict/i);
assert.deepEqual(await f.withStore(store => store.getWorkItemById(original.id)), original);
assert.equal(await f.withStore(store => store.listWorkItems({ limit: 100 }).items.length), 1);
});
test('closing stale items isolates punctuation variants and recognizes repository casing', async t => {
const f = fixture(t);
const legacy = legacyItem('Acme/Tool-Kit', 'issue');
await f.withStore(store => store.upsertWorkItem(legacy));
const own = f.syncItems('Acme/Tool-Kit', responseFor('Acme/Tool-Kit')).items;
const neighbor = f.syncItems('acme/tool_kit', responseFor('acme/tool_kit')).items;
const closed = f.syncItems('ACME/TOOL-KIT', { issue: [], pr: [] });
// Legacy rows lack a source-state snapshot, so disappearing source data
// cannot silently close locally managed work. Fresh untouched imports follow it.
const sourceOwned = own.filter(item => item.id !== legacy.id);
assert.deepEqual(closed.closedItems.map(item => item.id).sort(), sourceOwned.map(item => item.id).sort());
assert.equal(closed.closedCount, 1);
for (const item of sourceOwned) {
assert.equal((await f.withStore(store => store.getWorkItemById(item.id))).status, 'closed');
}
const preserved = await f.withStore(store => store.getWorkItemById(legacy.id));
assert.equal(preserved.status, 'needs-review');
assert.equal(preserved.metadata.githubSync.managedLocally, true);
for (const item of neighbor) {
assert.equal((await f.withStore(store => store.getWorkItemById(item.id))).status, 'needs-review');
}
assert.equal(f.syncItems('acme/tool-kit', { issue: [], pr: [] }).closedCount, 0);
});
test('encoded IDs round-trip through display, claim, and close commands', async t => {
const f = fixture(t);
const repo = 'acme/tool_kit';
const response = responseFor(repo, ['issue']);
const imported = f.syncItems(repo, {
...response,
issue: response.issue.map(issue => ({ ...issue, author: null }))
}).items[0];
assert.match(imported.id, /%2F/);
await f.withStore(store => store.upsertWorkItem({ ...store.getWorkItemById(imported.id), status: 'ready' }));
for (const args of [
['sync-github', '--repo', repo],
['show', imported.id],
['list'],
['claim', imported.id, '--owner', 'maintainer', '--as', 'human'],
['close', imported.id]
]) {
const result = f.run(args);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(imported.id), `${args[0]} displays the exact ID`);
}
const shown = f.run(['show', imported.id, '--json']);
assert.equal(shown.status, 0, shown.stderr);
const item = JSON.parse(shown.stdout);
assert.equal(item.id, imported.id);
assert.equal(item.status, 'done');
assert.equal(item.owner, 'maintainer');
assert.equal(item.metadata.assigneeKind, 'human');
assert.equal(item.metadata.repo, repo);
assert.equal(item.source, imported.source);
assert.equal(item.sourceId, imported.sourceId);
assert.equal(item.createdAt, imported.createdAt);
assert.equal(item.url, imported.url);
assert.deepEqual(await f.withStore(store => store.listWorkItems({ limit: 100 }).items), [item]);
});