#!/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();