Victor Casado d4486a7a29 refactor: apply code-review findings to github-native coordination
scripts/github-coordination.js:
- parseArgs: replace 13-entry if/else chain with BOOL_FLAGS/VALUE_FLAGS
  lookup maps; shrinks from 119 to ~45 lines
- Extract dispatchCommand(options, ctx) and formatOutput(payload, options)
  from main(); main() shrinks to ~20 lines

scripts/lib/github-coordination.js:
- Split 1041-line monolith into 6 focused sub-modules under
  scripts/lib/github-coordination/ (policy, parsing, gh-api, state,
  actions, store); index becomes a thin re-export (~55 lines)
- Document ECC_GH_SHIM trust boundary in runGh() (gh-api.js)
- Document applyClaim() read→check→write race condition (actions.js)

tests/lib/github-coordination.test.js:
- Refactor runTests() to data-driven DESCRIPTORS array + runGroup()
  helper; runTests() shrinks to ~10 lines
- Add 5 new edge-case tests: normalizeRepo('') and normalizeRepo('   ')
  throw, desiredLabelsForState for blocked/ready statuses, and
  buildIssueStateFromAction for validate action (15 → 20 tests)

tests/scripts/github-coordination.test.js:
- Replace console.log in test runner with process.stdout.write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:05:42 -04:00

176 lines
4.3 KiB
JavaScript

'use strict';
const { spawnSync } = require('child_process');
function normalizeRepo(repo) {
const parts = String(repo || '').split('/').filter(Boolean);
if (parts.length !== 2) {
throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`);
}
const [owner, name] = parts;
return { owner, name };
}
function normalizeIssueNumber(value) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid issue number: ${value}`);
}
return parsed;
}
function normalizeLabelValue(label) {
if (typeof label === 'string') {
return label.trim();
}
if (label && typeof label === 'object') {
return String(label.name || label.label || '').trim();
}
return '';
}
function normalizeLabels(labels) {
return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort();
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
}
return result.stdout || '';
}
// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real
// `gh` binary and command/commandArgs execute an arbitrary script via
// process.execPath. This variable MUST only be set in trusted, isolated test
// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in
// production — doing so allows arbitrary script execution under the caller's
// privileges.
function runGh(args, options = {}) {
const shimPath = process.env.ECC_GH_SHIM;
const command = shimPath ? process.execPath : 'gh';
const commandArgs = shimPath ? [shimPath, ...args] : args;
const env = { ...process.env };
if (!options.useEnvGithubToken) {
delete env.GITHUB_TOKEN;
}
return runCommand(command, commandArgs, { cwd: options.cwd, env });
}
function runGhJson(args, options = {}) {
try {
return JSON.parse(runGh(args, options) || 'null');
} catch (error) {
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
}
}
function getIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const json = runGhJson([
'issue',
'view',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options);
if (!json) {
throw new Error(`Unable to load issue #${issueNumber} from ${repo}`);
}
return json;
}
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([
'issue',
'list',
'--repo',
`${owner}/${name}`,
'--state',
state,
'--limit',
String(limit),
'--json',
'number,title,body,url,state,labels,author,updatedAt,assignees',
], options) || [];
}
function editIssue(repo, issueNumber, options = {}) {
const { owner, name } = normalizeRepo(repo);
const args = [
'issue',
'edit',
String(issueNumber),
'--repo',
`${owner}/${name}`,
];
if (options.body !== undefined) {
args.push('--body', options.body);
}
for (const label of options.addLabels || []) {
args.push('--add-label', label);
}
for (const label of options.removeLabels || []) {
args.push('--remove-label', label);
}
if (options.title) {
args.push('--title', options.title);
}
if (options.assignee) {
args.push('--add-assignee', options.assignee);
}
return runGh(args, options);
}
function commentIssue(repo, issueNumber, body, options = {}) {
const { owner, name } = normalizeRepo(repo);
return runGh([
'issue',
'comment',
String(issueNumber),
'--repo',
`${owner}/${name}`,
'--body',
body,
], options);
}
module.exports = {
commentIssue,
editIssue,
getIssue,
listIssues,
normalizeIssueNumber,
normalizeLabels,
normalizeRepo,
runGh,
runGhJson,
};