#!/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 --run --fail-count --failures-file [--target ] [--tracker bug|security] [--fingerprint ] 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();