fix: filter epic sync issues by label (#3089)

github-coordination sync listed every repo issue and pushed the epic label onto all of them (#3084). Scope the listing to issues carrying the policy's epic label plus issues whose body still holds the coordination marker (label-drift recovery), deduped by number, and reject an empty labels.epic in loadPolicy. Tests cover the filtered path and the recovery path with exact gh argv. Independent exact-head review passed with no P0/P1; CI 44/44 at the head.
This commit is contained in:
Wu Shuwen
2026-09-12 01:46:04 +01:00
committed by GitHub
parent c9148d0bb2
commit 3033436dcc
5 changed files with 116 additions and 4 deletions
+18 -1
View File
@@ -84,7 +84,24 @@ function applySync(repo, options = {}, context = {}) {
assertValidRepo(repo);
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
const store = context.store || null;
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
const labelIssues = listIssues(repo, {
...options,
state: options.state || 'all',
limit: options.limit || 100,
label: policy.labels && policy.labels.epic,
});
const marker = policy.sectionMarker || 'ecc-coordination';
const coordinatedIssues = listIssues(repo, {
...options,
state: options.state || 'all',
limit: options.limit || 100,
search: `in:body "${marker}:start"`,
});
const issues = Array.from(
new Map(
[...labelIssues, ...coordinatedIssues].map(issue => [String(issue.number), issue])
).values()
);
const syncedAt = new Date().toISOString();
const results = [];
+5 -2
View File
@@ -102,7 +102,7 @@ function listIssues(repo, options = {}) {
const { owner, name } = normalizeRepo(repo);
const limit = Number.isFinite(options.limit) ? options.limit : 100;
const state = options.state || 'all';
return runGhJson([
const args = [
'issue',
'list',
'--repo',
@@ -111,9 +111,12 @@ function listIssues(repo, options = {}) {
state,
'--limit',
String(limit),
...(options.label ? ['--label', options.label] : []),
...(options.search ? ['--search', options.search] : []),
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options) || [];
];
return runGhJson(args, options) || [];
}
function editIssue(repo, issueNumber, options = {}) {
+5 -1
View File
@@ -74,10 +74,14 @@ function loadPolicy(rootDir = process.cwd(), configPath = null) {
const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {};
const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {};
const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {};
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
if (typeof mergedLabels.epic !== 'string' || !mergedLabels.epic.trim()) {
throw new Error(`Policy file ${resolvedPath} must define labels.epic as a non-empty string`);
}
return {
...DEFAULT_POLICY,
...parsed,
labels: { ...DEFAULT_LABELS, ...labels },
labels: mergedLabels,
review: { ...DEFAULT_POLICY.review, ...review },
validation: { ...DEFAULT_POLICY.validation, ...validation },
branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel },
@@ -128,6 +128,13 @@ if (test('merges labels when parsed.labels is a plain object', () => {
});
})) passed++; else failed++;
if (test('rejects an empty epic label', () => {
withTempDir(tmpDir => {
writeConfig(tmpDir, { labels: { epic: ' ' } });
assert.throws(() => loadPolicy(tmpDir), /labels\.epic.*non-empty string/);
});
})) passed++; else failed++;
if (test('falls back to empty labels when parsed.labels is null', () => {
withTempDir(tmpDir => {
writeConfig(tmpDir, { labels: null });
+81
View File
@@ -243,6 +243,87 @@ async function runTests() {
passed++;
else failed++;
if (
await test('sync filters the issue list to the configured epic label', async () => {
const rootDir = createTempDir('github-coordination-sync-');
const dbPath = path.join(rootDir, 'state.db');
try {
const epicIssue = {
number: 12,
title: 'Ship GitHub-native coordination',
body: '# Ship GitHub-native coordination',
url: 'https://github.com/affaan-m/ECC/issues/12',
state: 'OPEN',
labels: [{ name: 'epic' }],
author: { login: 'maintainer' },
updatedAt: '2026-06-01T12:00:00Z'
};
const shim = writeGhShim(rootDir, {
'issue list --repo affaan-m/ECC --state all --limit 100 --label epic --json number,title,body,url,state,labels,author,updatedAt,assignees': [epicIssue],
'issue list --repo affaan-m/ECC --state all --limit 100 --search in:body "ecc-coordination:start" --json number,title,body,url,state,labels,author,updatedAt,assignees': []
});
const result = run(['sync', '--repo', 'affaan-m/ECC', '--db', dbPath, '--dry-run', '--json'], {
cwd: rootDir,
env: {
ECC_GH_SHIM: shim.shimPath,
ECC_GH_SHIM_LOG: shim.logPath
}
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.count, 1);
assert.strictEqual(payload.items[0].issueNumber, 12);
} finally {
cleanup(rootDir);
}
})
)
passed++;
else failed++;
if (
await test('sync recovers coordinated issues whose epic label drifted', async () => {
const rootDir = createTempDir('github-coordination-sync-drift-');
const dbPath = path.join(rootDir, 'state.db');
try {
const driftedIssue = {
number: 13,
title: 'Recover label drift',
body: '<!-- ecc-coordination:start -->\n```json\n{}\n```\n<!-- ecc-coordination:end -->',
url: 'https://github.com/affaan-m/ECC/issues/13',
state: 'OPEN',
labels: [{ name: 'coordination:synced' }],
author: { login: 'maintainer' },
updatedAt: '2026-06-01T12:00:00Z'
};
const shim = writeGhShim(rootDir, {
'issue list --repo affaan-m/ECC --state all --limit 100 --label epic --json number,title,body,url,state,labels,author,updatedAt,assignees': [],
'issue list --repo affaan-m/ECC --state all --limit 100 --search in:body "ecc-coordination:start" --json number,title,body,url,state,labels,author,updatedAt,assignees': [driftedIssue]
});
const result = run(['sync', '--repo', 'affaan-m/ECC', '--db', dbPath, '--dry-run', '--json'], {
cwd: rootDir,
env: {
ECC_GH_SHIM: shim.shimPath,
ECC_GH_SHIM_LOG: shim.logPath
}
});
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.count, 1);
assert.strictEqual(payload.items[0].issueNumber, 13);
assert.ok(payload.items[0].labels.includes('epic'));
} finally {
cleanup(rootDir);
}
})
)
passed++;
else failed++;
process.stdout.write(`\nResults: Passed: ${passed}, Failed: ${failed}\n`);
process.exit(failed > 0 ? 1 : 0);
}