diff --git a/.env.example b/.env.example
index fb0c72f..04280f9 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,12 @@ JOKER_DYNDNS_USERNAME=
JOKER_DYNDNS_PASSWORD=
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
+REDMINE_URL=
+REDMINE_API_KEY=
+REDMINE_PROJECT_ID=
+REDMINE_TRACKER_BUG_ID=
+REDMINE_TRACKER_SECURITY_ID=
+REDMINE_ASSIGNEE_ID=
# Admin bootstrap (used by seed/reset scripts)
ADMIN_EMAIL=
diff --git a/docs/index.html b/docs/index.html
index 509e49b..965d19f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -19,6 +19,7 @@
Logical Architecture
Feature Sequences
Security Testing
+ Redmine Integration
diff --git a/docs/redmine.html b/docs/redmine.html
new file mode 100644
index 0000000..48b4836
--- /dev/null
+++ b/docs/redmine.html
@@ -0,0 +1,50 @@
+
+
+
+
+ Redmine Integration
+
+
+
+
+
+
+ Setup
+
+ Env vars (see .env.example): REDMINE_URL, REDMINE_API_KEY, REDMINE_PROJECT_ID, REDMINE_TRACKER_BUG_ID, REDMINE_TRACKER_SECURITY_ID (optional, falls back to bug), REDMINE_ASSIGNEE_ID (optional default owner).
+ Uses the Redmine REST API with the API key for authentication.
+ Ensure the API key is scoped to the project and can create issues.
+
+
+
+
+ Automatic tickets on failures
+
+ scripts/run-test-suite.sh now files a Redmine issue whenever any check fails. Security-related failures (npm audit, Trivy, ZAP) use the security tracker when configured.
+ Issues are de-duplicated by fingerprinting the suite/target + failure details; reruns of the same failing state will re-use the open ticket instead of creating a duplicate.
+ Manual wrapper for other test commands: ./scripts/run-tests-with-redmine.sh npm test (set TEST_NAME or TRACKER to override labels).
+
+
+
+
+ CLI tools
+
+ List open tickets grouped by tracker: REDMINE_URL=... REDMINE_API_KEY=... REDMINE_PROJECT_ID=... ./scripts/redmine-report.js list-open
+ Manual issue creation from a log file: ./scripts/redmine-report.js create-test-issue --suite my-tests --failures-file /path/to/log --tracker bug
+ Outputs go to stdout; non-zero exit code on API errors so CI can fail fast.
+
+
+
+
+ Notes
+
+ Tickets include a short fingerprint in the subject and description; keep it when editing so future runs keep de-duplicating.
+ Summary/log paths are included in the issue body to help locate artifacts from the run.
+
+
+
+
+
diff --git a/scripts/redmine-report.js b/scripts/redmine-report.js
new file mode 100755
index 0000000..cfdca10
--- /dev/null
+++ b/scripts/redmine-report.js
@@ -0,0 +1,297 @@
+#!/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();
diff --git a/scripts/run-test-suite.sh b/scripts/run-test-suite.sh
index feb7779..51db518 100755
--- a/scripts/run-test-suite.sh
+++ b/scripts/run-test-suite.sh
@@ -97,6 +97,49 @@ ${items:-" No runs found. "}
EOF
}
+notify_redmine() {
+ local fail_lines=()
+ local failures_file="$RUN_DIR/failures.txt"
+
+ for row in "${SUMMARY_TEXT_ROWS[@]}"; do
+ if [[ "$row" == *"FAIL"* ]]; then
+ fail_lines+=("$row")
+ fi
+ done
+
+ [ "${#fail_lines[@]}" -gt 0 ] || return
+
+ printf "%s\n" "${fail_lines[@]}" >"$failures_file"
+
+ local tracker="bug"
+ for row in "${fail_lines[@]}"; do
+ case "$row" in
+ npm\ audit:*) tracker="security" ;;
+ Trivy*) tracker="security" ;;
+ OWASP\ ZAP\ baseline*) tracker="security" ;;
+ esac
+ done
+
+ if command -v node >/dev/null 2>&1 && [ -x "${BASH_SOURCE%/*}/redmine-report.js" ]; then
+ log "Reporting failures to Redmine (${tracker})..."
+ if node "${BASH_SOURCE%/*}/redmine-report.js" create-test-issue \
+ --suite "run-test-suite" \
+ --run "$RUN_TS" \
+ --fail-count "$FAIL_COUNT" \
+ --failures-file "$failures_file" \
+ --summary-file "$SUMMARY_FILE" \
+ --target "$TARGET" \
+ --tracker "$tracker" \
+ --fingerprint-seed "${TARGET}|${fail_lines[*]}"; then
+ log "Redmine notification complete."
+ else
+ log "Redmine notification failed or skipped (see output above)."
+ fi
+ else
+ log "Redmine reporter not available; skipping Redmine notification."
+ fi
+}
+
# 1) npm audit
if command -v npm >/dev/null 2>&1; then
log "Running npm audit (high)..."
@@ -192,8 +235,8 @@ cat >"$SUMMARY_FILE" <Check Status Details
${SUMMARY_ROWS[*]}
-
-
+
+