From c55013ee459fe8dbea27fe354c997d364528196f Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Sun, 14 Dec 2025 01:24:44 +0200 Subject: [PATCH] Add Redmine automation for test failures --- .env.example | 6 + docs/index.html | 1 + docs/redmine.html | 50 +++++ scripts/redmine-report.js | 297 ++++++++++++++++++++++++++++++ scripts/run-test-suite.sh | 51 ++++- scripts/run-tests-with-redmine.sh | 59 ++++++ 6 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 docs/redmine.html create mode 100755 scripts/redmine-report.js create mode 100755 scripts/run-tests-with-redmine.sh 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 + + + +
    +

    Redmine Integration

    +
    File tickets automatically when tests fail and review open work by tracker.
    +
    +
    +
    +

    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" <CheckStatusDetails ${SUMMARY_ROWS[*]} - - + + EOF @@ -207,6 +250,10 @@ FAIL_COUNT=${FAIL_COUNT} SKIP_COUNT=${SKIP_COUNT} EOF +if [ "$FAIL_COUNT" -gt 0 ]; then + notify_redmine +fi + update_index log "Summary:" diff --git a/scripts/run-tests-with-redmine.sh b/scripts/run-tests-with-redmine.sh new file mode 100755 index 0000000..807e053 --- /dev/null +++ b/scripts/run-tests-with-redmine.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wraps any test command and files a Redmine ticket on failure. +# Usage: ./scripts/run-tests-with-redmine.sh + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +RUN_TS=$(date +"%Y%m%d-%H%M%S") +RUN_DIR="reports/runs/manual-${RUN_TS}" +mkdir -p "$RUN_DIR" + +CMD=("$@") +NAME="${TEST_NAME:-${CMD[*]}}" +LOG_FILE="$RUN_DIR/command.log" + +echo "[run-with-redmine] Running ${CMD[*]}..." +set +e +"${CMD[@]}" >"$LOG_FILE" 2>&1 +STATUS=$? +set -e + +if [ "$STATUS" -eq 0 ]; then + echo "[run-with-redmine] Command succeeded. Log: $LOG_FILE" + exit 0 +fi + +echo "[run-with-redmine] Command failed with status ${STATUS}. Log: $LOG_FILE" + +TAIL_SNIPPET=$(tail -n 40 "$LOG_FILE" 2>/dev/null || true) +FAILURE_FILE="$RUN_DIR/failures.txt" +{ + echo "Command: ${CMD[*]}" + echo "Exit status: ${STATUS}" + echo "--- tail ---" + echo "${TAIL_SNIPPET}" +} >"$FAILURE_FILE" + +if command -v node >/dev/null 2>&1 && [ -x "${BASH_SOURCE%/*}/redmine-report.js" ]; then + if node "${BASH_SOURCE%/*}/redmine-report.js" create-test-issue \ + --suite "${NAME}" \ + --run "$RUN_TS" \ + --fail-count 1 \ + --failures-file "$FAILURE_FILE" \ + --summary-file "$LOG_FILE" \ + --tracker "${TRACKER:-bug}" \ + --fingerprint-seed "${NAME}|${CMD[*]}|${STATUS}|${TAIL_SNIPPET}"; then + echo "[run-with-redmine] Redmine notification complete." + else + echo "[run-with-redmine] Redmine notification failed or skipped." + fi +else + echo "[run-with-redmine] Redmine reporter not available; skipping Redmine notification." +fi + +exit "$STATUS"