mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
normalizeIssueNumber parsed with Number.parseInt, which stops at the
first non-digit ("42oops" -> 42, "42.9" -> 42, "4e2" -> 4) and loses
precision past 2^53 (9007199254740993 became 9007199254740992). The CLI
shares this helper for --issue, --limit, and the positional issue number,
so malformed values silently targeted the wrong issue before any integer
guard downstream could notice.
Validate the entire trimmed decimal token and require
Number.isSafeInteger so malformed or out-of-range values throw
"Invalid issue number" during argument parsing, before the state store
is opened or GitHub is contacted. Whitespace, leading plus, and leading
zero tolerance is preserved. Regression coverage added at both the
library level (tests/lib/github-coordination-gh-api.test.js) and the CLI
argument parser (tests/scripts/github-coordination.test.js).
Fixes #3514
180 lines
4.5 KiB
JavaScript
180 lines
4.5 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 text = String(value).trim();
|
|
const parsed = Number(text);
|
|
if (!/^\+?\d+$/.test(text) || !Number.isSafeInteger(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.stripGithubToken) {
|
|
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';
|
|
const args = [
|
|
'issue',
|
|
'list',
|
|
'--repo',
|
|
`${owner}/${name}`,
|
|
'--state',
|
|
state,
|
|
'--limit',
|
|
String(limit),
|
|
...(options.label ? ['--label', options.label] : []),
|
|
...(options.search ? ['--search', options.search] : []),
|
|
'--json',
|
|
'number,title,body,url,state,labels,author,updatedAt,assignees',
|
|
];
|
|
return runGhJson(args, 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,
|
|
};
|