Add Redmine automation for test failures
This commit is contained in:
parent
f91b62dc51
commit
c55013ee45
6 changed files with 462 additions and 2 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
<li><a href="./architecture.html">Logical Architecture</a></li>
|
||||
<li><a href="./sequences.html">Feature Sequences</a></li>
|
||||
<li><a href="./security.html">Security Testing</a></li>
|
||||
<li><a href="./redmine.html">Redmine Integration</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="card">
|
||||
|
|
|
|||
50
docs/redmine.html
Normal file
50
docs/redmine.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Redmine Integration</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Redmine Integration</h1>
|
||||
<div class="meta">File tickets automatically when tests fail and review open work by tracker.</div>
|
||||
</header>
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>Setup</h2>
|
||||
<ul>
|
||||
<li>Env vars (see <code>.env.example</code>): <code>REDMINE_URL</code>, <code>REDMINE_API_KEY</code>, <code>REDMINE_PROJECT_ID</code>, <code>REDMINE_TRACKER_BUG_ID</code>, <code>REDMINE_TRACKER_SECURITY_ID</code> (optional, falls back to bug), <code>REDMINE_ASSIGNEE_ID</code> (optional default owner).</li>
|
||||
<li>Uses the Redmine REST API with the API key for authentication.</li>
|
||||
<li>Ensure the API key is scoped to the project and can create issues.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Automatic tickets on failures</h2>
|
||||
<ul>
|
||||
<li><code>scripts/run-test-suite.sh</code> now files a Redmine issue whenever any check fails. Security-related failures (npm audit, Trivy, ZAP) use the security tracker when configured.</li>
|
||||
<li>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.</li>
|
||||
<li>Manual wrapper for other test commands: <code>./scripts/run-tests-with-redmine.sh npm test</code> (set <code>TEST_NAME</code> or <code>TRACKER</code> to override labels).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>CLI tools</h2>
|
||||
<ul>
|
||||
<li>List open tickets grouped by tracker: <code>REDMINE_URL=... REDMINE_API_KEY=... REDMINE_PROJECT_ID=... ./scripts/redmine-report.js list-open</code></li>
|
||||
<li>Manual issue creation from a log file: <code>./scripts/redmine-report.js create-test-issue --suite my-tests --failures-file /path/to/log --tracker bug</code></li>
|
||||
<li>Outputs go to stdout; non-zero exit code on API errors so CI can fail fast.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Notes</h2>
|
||||
<ul>
|
||||
<li>Tickets include a short fingerprint in the subject and description; keep it when editing so future runs keep de-duplicating.</li>
|
||||
<li>Summary/log paths are included in the issue body to help locate artifacts from the run.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
297
scripts/redmine-report.js
Executable file
297
scripts/redmine-report.js
Executable file
|
|
@ -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 <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();
|
||||
|
|
@ -97,6 +97,49 @@ ${items:-" <li>No runs found.</li>"}
|
|||
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)..."
|
||||
|
|
@ -193,7 +236,7 @@ cat >"$SUMMARY_FILE" <<EOF
|
|||
<tbody>
|
||||
${SUMMARY_ROWS[*]}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
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:"
|
||||
|
|
|
|||
59
scripts/run-tests-with-redmine.sh
Executable file
59
scripts/run-tests-with-redmine.sh
Executable file
|
|
@ -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 <command ...>
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <command ...>"
|
||||
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"
|
||||
Loading…
Add table
Reference in a new issue