297 lines
7.8 KiB
JavaScript
Executable file
297 lines
7.8 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
|
|
const [command, ...argv] = process.argv.slice(2);
|
|
|
|
const parseArgs = (args) => {
|
|
const parsed = {};
|
|
let i = 0;
|
|
while (i < args.length) {
|
|
const arg = args[i];
|
|
if (arg.startsWith('--')) {
|
|
const key = arg.replace(/^--/, '');
|
|
const next = args[i + 1];
|
|
if (next && !next.startsWith('--')) {
|
|
parsed[key] = next;
|
|
i += 2;
|
|
} else {
|
|
parsed[key] = true;
|
|
i += 1;
|
|
}
|
|
} else {
|
|
parsed._ = parsed._ || [];
|
|
parsed._.push(arg);
|
|
i += 1;
|
|
}
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const args = parseArgs(argv);
|
|
|
|
const ensureEnv = (key, optional = false) => {
|
|
const value = process.env[key];
|
|
if (!value && !optional) {
|
|
throw new Error(`Missing required env: ${key}`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const readConfig = () => {
|
|
const baseUrl = ensureEnv('REDMINE_URL');
|
|
const url = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
return {
|
|
url,
|
|
apiKey: ensureEnv('REDMINE_API_KEY'),
|
|
projectId: ensureEnv('REDMINE_PROJECT_ID'),
|
|
trackerBugId: ensureEnv('REDMINE_TRACKER_BUG_ID'),
|
|
trackerSecurityId: process.env.REDMINE_TRACKER_SECURITY_ID,
|
|
assigneeId: process.env.REDMINE_ASSIGNEE_ID,
|
|
};
|
|
};
|
|
|
|
const redmineUrl = (config, path, params = {}) => {
|
|
const url = new URL(path.replace(/^\//, ''), config.url);
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
return url;
|
|
};
|
|
|
|
const fetchJson = async (config, path, options = {}, params = {}) => {
|
|
const url = redmineUrl(config, path, params);
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
'X-Redmine-API-Key': config.apiKey,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
...options,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Redmine request failed (${res.status}): ${text}`);
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
return {};
|
|
}
|
|
|
|
return res.json();
|
|
};
|
|
|
|
const fetchAllOpenIssues = async (config) => {
|
|
const issues = [];
|
|
const limit = 100;
|
|
let offset = 0;
|
|
let total = null;
|
|
|
|
while (total === null || offset < total) {
|
|
const data = await fetchJson(
|
|
config,
|
|
'/issues.json',
|
|
{},
|
|
{
|
|
project_id: config.projectId,
|
|
status_id: 'open',
|
|
limit,
|
|
offset,
|
|
sort: 'updated_on:desc',
|
|
},
|
|
);
|
|
if (Array.isArray(data.issues)) {
|
|
issues.push(...data.issues);
|
|
}
|
|
total = data.total_count ?? issues.length;
|
|
offset += limit;
|
|
if (!data.issues || data.issues.length === 0) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
};
|
|
|
|
const findExistingIssue = async (config, fingerprint) => {
|
|
if (!fingerprint) return null;
|
|
const issues = await fetchAllOpenIssues(config);
|
|
return issues.find(
|
|
(issue) =>
|
|
issue.subject?.includes(fingerprint) ||
|
|
issue.description?.includes(`Fingerprint: ${fingerprint}`),
|
|
);
|
|
};
|
|
|
|
const computeFingerprint = (seed) => {
|
|
const hash = crypto.createHash('sha1').update(seed).digest('hex');
|
|
return hash.slice(0, 12);
|
|
};
|
|
|
|
const readFailures = (opts) => {
|
|
const failures = [];
|
|
if (opts['failures-file']) {
|
|
try {
|
|
const text = fs.readFileSync(opts['failures-file'], 'utf8');
|
|
text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.forEach((line) => failures.push(line));
|
|
} catch (err) {
|
|
console.error(`Could not read failures file: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (opts.failures) {
|
|
failures.push(opts.failures);
|
|
}
|
|
|
|
if (failures.length === 0) {
|
|
failures.push('Test failure (no details provided)');
|
|
}
|
|
|
|
return failures;
|
|
};
|
|
|
|
const createIssue = async (config, payload) => {
|
|
const body = {
|
|
issue: {
|
|
project_id: config.projectId,
|
|
tracker_id: payload.trackerId,
|
|
subject: payload.subject,
|
|
description: payload.description,
|
|
},
|
|
};
|
|
|
|
if (config.assigneeId) {
|
|
body.issue.assigned_to_id = config.assigneeId;
|
|
}
|
|
|
|
const res = await fetchJson(config, '/issues.json', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
return res.issue || res;
|
|
};
|
|
|
|
const handleCreateTestIssue = async () => {
|
|
const config = readConfig();
|
|
const suite = args.suite || 'tests';
|
|
const tracker = (args.tracker || 'bug').toLowerCase();
|
|
const trackerId =
|
|
tracker === 'security'
|
|
? config.trackerSecurityId || config.trackerBugId
|
|
: config.trackerBugId;
|
|
|
|
if (!trackerId) {
|
|
throw new Error('Missing tracker id. Set REDMINE_TRACKER_BUG_ID (and optionally REDMINE_TRACKER_SECURITY_ID).');
|
|
}
|
|
|
|
const failCount = Number(args['fail-count'] || args.failcount || 0);
|
|
const failures = readFailures(args);
|
|
const fingerprintSeed =
|
|
args['fingerprint-seed'] ||
|
|
`${suite}|${args.target || ''}|${failCount}|${failures.join('|')}`;
|
|
const fingerprint = args.fingerprint || computeFingerprint(fingerprintSeed);
|
|
const subject = `[${suite}] ${failCount || failures.length} failure${failCount === 1 ? '' : 's'} (${tracker}) [${fingerprint}]`;
|
|
|
|
const descriptionLines = [
|
|
`Suite: ${suite}`,
|
|
args.run ? `Run: ${args.run}` : null,
|
|
args.target ? `Target: ${args.target}` : null,
|
|
`Tracker: ${tracker}`,
|
|
`Failures (${failures.length}):`,
|
|
...failures.map((line) => `- ${line}`),
|
|
args['summary-file'] ? `Summary: ${args['summary-file']}` : null,
|
|
args['failures-file'] ? `Log: ${args['failures-file']}` : null,
|
|
`Fingerprint: ${fingerprint}`,
|
|
].filter(Boolean);
|
|
|
|
const existing = await findExistingIssue(config, fingerprint);
|
|
if (existing) {
|
|
console.log(
|
|
`Open issue #${existing.id} already exists for fingerprint ${fingerprint}; not creating a duplicate.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const created = await createIssue(config, {
|
|
trackerId,
|
|
subject,
|
|
description: descriptionLines.join('\n'),
|
|
});
|
|
|
|
console.log(`Created Redmine issue #${created.id}: ${subject}`);
|
|
};
|
|
|
|
const handleListOpen = async () => {
|
|
const config = readConfig();
|
|
const issues = await fetchAllOpenIssues(config);
|
|
if (!issues.length) {
|
|
console.log('No open issues found for the configured project.');
|
|
return;
|
|
}
|
|
|
|
const groups = issues.reduce((acc, issue) => {
|
|
const tracker = issue.tracker?.name || 'Unknown';
|
|
acc[tracker] = acc[tracker] || [];
|
|
acc[tracker].push(issue);
|
|
return acc;
|
|
}, {});
|
|
|
|
Object.entries(groups).forEach(([trackerName, trackerIssues]) => {
|
|
console.log(`${trackerName} (${trackerIssues.length})`);
|
|
trackerIssues.forEach((issue) => {
|
|
const status = issue.status?.name ? ` [${issue.status.name}]` : '';
|
|
const priority = issue.priority?.name ? ` (${issue.priority.name})` : '';
|
|
console.log(`- #${issue.id}${status}${priority}: ${issue.subject}`);
|
|
});
|
|
console.log('');
|
|
});
|
|
};
|
|
|
|
const printHelp = () => {
|
|
console.log(`Usage:
|
|
redmine-report.js create-test-issue --suite <name> --run <id> --fail-count <n> --failures-file <path> [--target <url>] [--tracker bug|security] [--fingerprint <id>]
|
|
redmine-report.js list-open
|
|
|
|
Env:
|
|
REDMINE_URL Base URL, e.g. https://redmine.example.com
|
|
REDMINE_API_KEY API key
|
|
REDMINE_PROJECT_ID Project to file against
|
|
REDMINE_TRACKER_BUG_ID Tracker id for bugs
|
|
REDMINE_TRACKER_SECURITY_ID Tracker id for security issues (optional)
|
|
REDMINE_ASSIGNEE_ID Default assignee (optional)
|
|
`);
|
|
};
|
|
|
|
const main = async () => {
|
|
try {
|
|
switch (command) {
|
|
case 'create-test-issue':
|
|
await handleCreateTestIssue();
|
|
break;
|
|
case 'list-open':
|
|
await handleListOpen();
|
|
break;
|
|
case '-h':
|
|
case '--help':
|
|
case undefined:
|
|
printHelp();
|
|
process.exit(command ? 0 : 1);
|
|
break;
|
|
default:
|
|
console.error(`Unknown command: ${command}`);
|
|
printHelp();
|
|
process.exit(1);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Redmine command failed: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
main();
|