lomavuokraus/scripts/redmine-report.js
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

299 lines
7.9 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();