commit fae1771c61ef297e8585484fadfe10744661800a Author: admrene Date: Thu Mar 19 13:11:02 2026 +0100 feat(workflow): automate Jira summary pipeline with optional Xray/Sonar links and solution version - add npm workflow to fetch recent Jira issues, summarize them, and create a target Jira ticket - generate HTML report and optional SMTP email output - add Docker runtime and TeamCity runner scripts (sh/ps1) - support optional SOLUTION_VERSION, XRAY_REPORT_URL, SONAR_REPORT_URL - map SOLUTION_VERSION to Jira fixVersions on ticket creation - update README and .env.example with new configuration diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b006145 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Jira Zugang +JIRA_BASE_URL=https://your-domain.atlassian.net +JIRA_USER_EMAIL=automation@company.com +JIRA_API_TOKEN=your-api-token + +# Quell-Tickets (letzte Tickets aus beliebigem Board/Projekt) +JIRA_SOURCE_JQL=project = SRC ORDER BY updated DESC +JIRA_SOURCE_MAX_RESULTS=10 + +# Ziel-Ticket (anderes Board/Projekt) +# Entweder direkt Project Key setzen: +JIRA_TARGET_PROJECT_KEY=DEST +# Oder alternativ ueber Board-ID aufloesen: +# JIRA_TARGET_BOARD_ID=123 +JIRA_TARGET_ISSUE_TYPE=Task +JIRA_TARGET_SUMMARY_PREFIX=Weekly Jira Report +JIRA_TARGET_LABELS=automation,teamcity +SOLUTION_VERSION=1.2.3 +XRAY_REPORT_URL=https://xray.example.com/report/123 +SONAR_REPORT_URL=https://sonar.example.com/dashboard?id=project +JIRA_DRY_RUN=false + +# HTML-Ausgabe +OUTPUT_HTML_PATH=report-output/report.html + +# SMTP (optional) +SMTP_ENABLED=false +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=smtp-user +SMTP_PASS=smtp-pass +SMTP_FROM=bot@example.com +SMTP_TO=pm@example.com,teamlead@example.com +SMTP_SUBJECT_PREFIX=Jira Ticket Zusammenfassung diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a36398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +report-output/ +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fea9e9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5c68b6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Jira Workflow Automation (NPM + Docker + TeamCity) + +Dieses Projekt automatisiert folgenden Ablauf: + +1. Letzte Jira Tickets anhand einer JQL abfragen. +2. Zusammenfassung erzeugen (Status-Verteilung + Ticketliste). +3. Neues Jira Ticket in einem anderen Projekt/Board erstellen. +4. Optional Xray/Sonar Links und Loesungsversion in den Report aufnehmen. +5. HTML-Report erzeugen und optional per E-Mail versenden. + +## Voraussetzungen + +- Node.js 20+ +- Docker +- Jira Cloud API Token +- Optional: SMTP Zugangsdaten fuer Mailversand + +## Lokaler Start + +```bash +npm install +cp .env.example .env +npm run dry-run +``` + +Wenn alles passt: + +```bash +npm start +``` + +## Wichtige Umgebungsvariablen + +Pflicht: + +- `JIRA_BASE_URL` +- `JIRA_USER_EMAIL` +- `JIRA_API_TOKEN` +- `JIRA_TARGET_PROJECT_KEY` oder `JIRA_TARGET_BOARD_ID` + +Empfohlen: + +- `JIRA_SOURCE_JQL` (z. B. `project = SRC ORDER BY updated DESC`) +- `JIRA_SOURCE_MAX_RESULTS` (Default: `10`) +- `JIRA_TARGET_ISSUE_TYPE` (Default: `Task`) +- `JIRA_TARGET_SUMMARY_PREFIX` (Default: `Automatischer Jira Report`) +- `JIRA_TARGET_LABELS` (CSV) +- `SOLUTION_VERSION` (setzt auch `fixVersions` im Jira Ticket) +- `XRAY_REPORT_URL` (optional) +- `SONAR_REPORT_URL` (optional) +- `JIRA_DRY_RUN` (`true`/`false`) +- `OUTPUT_HTML_PATH` (Default: `report-output/report.html`) + +SMTP optional: + +- `SMTP_ENABLED=true` +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` +- `SMTP_USER`, `SMTP_PASS` +- `SMTP_FROM`, `SMTP_TO` +- `SMTP_SUBJECT_PREFIX` + +## Docker Nutzung + +Image bauen: + +```bash +docker build -t its-workflow-npm:local . +``` + +Container starten: + +```bash +docker run --rm --env-file .env -v "$(pwd)/report-output:/app/report-output" its-workflow-npm:local +``` + +## TeamCity Integration + +Empfohlene Build Steps: + +1. `Command Line` (Linux Agent): + - Script: `sh teamcity/run-workflow.sh` +2. Alternativ `PowerShell` (Windows Agent): + - Script file: `teamcity/run-workflow.ps1` + +Lege in TeamCity die Variablen als Parameter vom Typ `env.*` an, z. B.: + +- `env.JIRA_BASE_URL` +- `env.JIRA_USER_EMAIL` +- `env.JIRA_API_TOKEN` (als Password Parameter) +- `env.JIRA_SOURCE_JQL` +- `env.JIRA_TARGET_PROJECT_KEY` +- `env.SOLUTION_VERSION` +- `env.XRAY_REPORT_URL` +- `env.SONAR_REPORT_URL` +- `env.SMTP_ENABLED` +- `env.SMTP_HOST` ... + +Der Report wird im Build-Workspace unter `report-output/report.html` abgelegt und kann als Build Artifact publiziert werden. + +## Ablauf im Fehlerfall + +- Jira API Fehler werden mit Antwortdetails ausgegeben. +- Bei `SMTP_ENABLED=true` ohne vollstaendige SMTP Daten wird der Lauf abgebrochen. +- Bei `JIRA_DRY_RUN=true` wird kein Ticket erstellt, aber Report + optional Mail erzeugt. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..63e31d1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,325 @@ +{ + "name": "its-workflow-npm", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "its-workflow-npm", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.6", + "dayjs": "^1.11.20", + "dotenv": "^17.3.1", + "nodemailer": "^8.0.3" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz", + "integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..953fd87 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "its-workflow-npm", + "version": "1.0.0", + "description": "Automatisierung fuer Jira-Zusammenfassungen inkl. Ticket-Erstellung und HTML-Mail Versand", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dry-run": "node src/index.js --dry-run", + "test": "node src/index.js --help" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "axios": "^1.13.6", + "dayjs": "^1.11.20", + "dotenv": "^17.3.1", + "nodemailer": "^8.0.3" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a239c9b --- /dev/null +++ b/src/config.js @@ -0,0 +1,115 @@ +const path = require("node:path"); +const dotenv = require("dotenv"); + +dotenv.config({ quiet: true }); + +function parseBoolean(value, defaultValue = false) { + if (value === undefined || value === null || value === "") { + return defaultValue; + } + + const normalized = String(value).trim().toLowerCase(); + return ["1", "true", "yes", "y", "on"].includes(normalized); +} + +function parseNumber(value, defaultValue) { + if (value === undefined || value === null || value === "") { + return defaultValue; + } + + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return defaultValue; + } + return parsed; +} + +function parseList(value) { + if (!value) { + return []; + } + + return String(value) + .split(/[,;]+/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function getRequiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Fehlende Pflichtvariable: ${name}`); + } + return value.trim(); +} + +function getConfig() { + const targetProjectKey = (process.env.JIRA_TARGET_PROJECT_KEY || "").trim(); + const targetBoardId = (process.env.JIRA_TARGET_BOARD_ID || "").trim(); + + if (!targetProjectKey && !targetBoardId) { + throw new Error( + "Bitte JIRA_TARGET_PROJECT_KEY oder JIRA_TARGET_BOARD_ID setzen." + ); + } + + return { + outputHtmlPath: + process.env.OUTPUT_HTML_PATH || path.join("report-output", "report.html"), + jira: { + baseUrl: getRequiredEnv("JIRA_BASE_URL").replace(/\/$/, ""), + userEmail: getRequiredEnv("JIRA_USER_EMAIL"), + apiToken: getRequiredEnv("JIRA_API_TOKEN"), + sourceJql: + process.env.JIRA_SOURCE_JQL || + "updated >= -14d ORDER BY updated DESC", + sourceMaxResults: parseNumber(process.env.JIRA_SOURCE_MAX_RESULTS, 10), + targetProjectKey, + targetBoardId, + targetIssueType: process.env.JIRA_TARGET_ISSUE_TYPE || "Task", + targetSummaryPrefix: + process.env.JIRA_TARGET_SUMMARY_PREFIX || "Automatischer Jira Report", + targetLabels: parseList(process.env.JIRA_TARGET_LABELS), + solutionVersion: (process.env.SOLUTION_VERSION || "").trim(), + xrayReportUrl: (process.env.XRAY_REPORT_URL || "").trim(), + sonarReportUrl: (process.env.SONAR_REPORT_URL || "").trim(), + dryRun: parseBoolean(process.env.JIRA_DRY_RUN, false), + }, + smtp: { + enabled: parseBoolean(process.env.SMTP_ENABLED, false), + host: process.env.SMTP_HOST || "", + port: parseNumber(process.env.SMTP_PORT, 587), + secure: parseBoolean(process.env.SMTP_SECURE, false), + user: process.env.SMTP_USER || "", + pass: process.env.SMTP_PASS || "", + from: process.env.SMTP_FROM || "", + to: parseList(process.env.SMTP_TO), + subjectPrefix: process.env.SMTP_SUBJECT_PREFIX || "Jira Zusammenfassung", + }, + }; +} + +function printHelp() { + console.log(` +Nutzung: + npm start + npm run dry-run + +Wichtige ENV Variablen: + JIRA_BASE_URL + JIRA_USER_EMAIL + JIRA_API_TOKEN + JIRA_SOURCE_JQL + JIRA_TARGET_PROJECT_KEY oder JIRA_TARGET_BOARD_ID + SOLUTION_VERSION + XRAY_REPORT_URL + SONAR_REPORT_URL + SMTP_ENABLED=true|false +`); +} + +module.exports = { + getConfig, + parseBoolean, + printHelp, +}; diff --git a/src/email.js b/src/email.js new file mode 100644 index 0000000..45676af --- /dev/null +++ b/src/email.js @@ -0,0 +1,44 @@ +const nodemailer = require("nodemailer"); + +async function sendReportMail({ smtpConfig, subject, html, text }) { + if (!smtpConfig.enabled) { + return { skipped: true, reason: "SMTP_ENABLED=false" }; + } + + if ( + !smtpConfig.host || + !smtpConfig.port || + !smtpConfig.user || + !smtpConfig.pass || + !smtpConfig.from || + smtpConfig.to.length === 0 + ) { + throw new Error( + "SMTP ist aktiviert, aber SMTP_HOST/PORT/USER/PASS/FROM/TO sind nicht vollstaendig gesetzt." + ); + } + + const transport = nodemailer.createTransport({ + host: smtpConfig.host, + port: smtpConfig.port, + secure: smtpConfig.secure, + auth: { + user: smtpConfig.user, + pass: smtpConfig.pass, + }, + }); + + const info = await transport.sendMail({ + from: smtpConfig.from, + to: smtpConfig.to.join(", "), + subject, + text, + html, + }); + + return { skipped: false, messageId: info.messageId }; +} + +module.exports = { + sendReportMail, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..1cfde63 --- /dev/null +++ b/src/index.js @@ -0,0 +1,108 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const dayjs = require("dayjs"); +const { getConfig, printHelp } = require("./config"); +const { createJiraClient } = require("./jira"); +const { buildReport, buildHtmlSummary } = require("./report"); +const { sendReportMail } = require("./email"); + +function hasFlag(flag) { + return process.argv.includes(flag); +} + +async function run() { + if (hasFlag("--help")) { + printHelp(); + return; + } + + const config = getConfig(); + const dryRun = config.jira.dryRun || hasFlag("--dry-run"); + + const jira = createJiraClient(config.jira); + const issues = await jira.getRecentIssues( + config.jira.sourceJql, + config.jira.sourceMaxResults + ); + + if (issues.length === 0) { + throw new Error("Keine Jira Tickets fuer die konfigurierte JQL gefunden."); + } + + const report = buildReport({ + issues, + jiraBaseUrl: config.jira.baseUrl, + sourceJql: config.jira.sourceJql, + solutionVersion: config.jira.solutionVersion, + xrayReportUrl: config.jira.xrayReportUrl, + sonarReportUrl: config.jira.sonarReportUrl, + }); + + let targetProjectKey = config.jira.targetProjectKey; + if (!targetProjectKey && config.jira.targetBoardId) { + targetProjectKey = await jira.resolveProjectKeyFromBoardId( + config.jira.targetBoardId + ); + } + + const ticketSummary = `${config.jira.targetSummaryPrefix} ${dayjs().format("YYYY-MM-DD")} (${report.totalIssues} Tickets)`; + + let createdIssue = null; + if (!dryRun) { + createdIssue = await jira.createIssue({ + projectKey: targetProjectKey, + issueType: config.jira.targetIssueType, + summary: ticketSummary, + descriptionAdf: report.adfDescription, + labels: config.jira.targetLabels, + solutionVersion: config.jira.solutionVersion, + }); + } + + const createdIssueKey = createdIssue?.key || null; + const createdIssueUrl = createdIssueKey + ? `${config.jira.baseUrl}/browse/${createdIssueKey}` + : null; + + const html = buildHtmlSummary(report, createdIssueUrl); + const outputDir = path.dirname(config.outputHtmlPath); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(config.outputHtmlPath, html, "utf8"); + + const mailSubject = `${config.smtp.subjectPrefix} - ${dayjs().format( + "YYYY-MM-DD" + )}`; + + const mailText = `${report.textSummary}\n\nNeues Jira Ticket: ${ + createdIssueUrl || "nicht erstellt (Dry-Run)" + }`; + + const mailResult = await sendReportMail({ + smtpConfig: config.smtp, + subject: mailSubject, + html, + text: mailText, + }); + + console.log("Workflow abgeschlossen."); + console.log(`Issues verarbeitet: ${report.totalIssues}`); + console.log(`HTML Report: ${config.outputHtmlPath}`); + + if (createdIssueUrl) { + console.log(`Neues Jira Ticket: ${createdIssueUrl}`); + } else { + console.log("Neues Jira Ticket: nicht erstellt (Dry-Run)"); + } + + if (mailResult.skipped) { + console.log(`E-Mail Versand: uebersprungen (${mailResult.reason})`); + } else { + console.log(`E-Mail Versand: erfolgreich (Message-ID ${mailResult.messageId})`); + } +} + +run().catch((error) => { + console.error("Workflow fehlgeschlagen."); + console.error(error?.response?.data || error.message || error); + process.exit(1); +}); diff --git a/src/jira.js b/src/jira.js new file mode 100644 index 0000000..7be8609 --- /dev/null +++ b/src/jira.js @@ -0,0 +1,89 @@ +const axios = require("axios"); + +function createJiraClient(config) { + const auth = Buffer.from( + `${config.userEmail}:${config.apiToken}`, + "utf8" + ).toString("base64"); + + const headers = { + Authorization: `Basic ${auth}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + + const api = axios.create({ + baseURL: `${config.baseUrl}/rest/api/3`, + headers, + timeout: 30000, + }); + + const agileApi = axios.create({ + baseURL: `${config.baseUrl}/rest/agile/1.0`, + headers, + timeout: 30000, + }); + + async function getRecentIssues(jql, maxResults) { + const payload = { + jql, + maxResults, + fields: ["summary", "status", "assignee", "updated", "labels"], + }; + + const response = await api.post("/search", payload); + return response.data.issues || []; + } + + async function resolveProjectKeyFromBoardId(boardId) { + const response = await agileApi.get(`/board/${boardId}/configuration`); + const board = response.data || {}; + const projectKey = board?.location?.projectKey; + + if (!projectKey) { + throw new Error( + `Konnte aus Board ${boardId} keinen Project Key ableiten.` + ); + } + + return projectKey; + } + + async function createIssue({ + projectKey, + issueType, + summary, + descriptionAdf, + labels, + solutionVersion, + }) { + const fields = { + project: { key: projectKey }, + issuetype: { name: issueType }, + summary, + description: descriptionAdf, + labels: labels || [], + }; + + if (solutionVersion) { + fields.fixVersions = [{ name: solutionVersion }]; + } + + const payload = { + fields, + }; + + const response = await api.post("/issue", payload); + return response.data; + } + + return { + getRecentIssues, + resolveProjectKeyFromBoardId, + createIssue, + }; +} + +module.exports = { + createJiraClient, +}; diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..64ca33a --- /dev/null +++ b/src/report.js @@ -0,0 +1,226 @@ +const dayjs = require("dayjs"); + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatIssue(issue, jiraBaseUrl) { + const assignee = + issue.fields?.assignee?.displayName || issue.fields?.assignee?.emailAddress; + + return { + key: issue.key, + summary: issue.fields?.summary || "(ohne Summary)", + status: issue.fields?.status?.name || "(ohne Status)", + assignee: assignee || "nicht zugewiesen", + updated: issue.fields?.updated + ? dayjs(issue.fields.updated).format("YYYY-MM-DD HH:mm") + : "-", + labels: issue.fields?.labels || [], + url: `${jiraBaseUrl}/browse/${issue.key}`, + }; +} + +function statusStats(issues) { + const countMap = new Map(); + + for (const issue of issues) { + countMap.set(issue.status, (countMap.get(issue.status) || 0) + 1); + } + + return Array.from(countMap.entries()) + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count); +} + +function buildTextSummary(report) { + const lines = []; + lines.push(`Jira Zusammenfassung (${report.generatedAt})`); + lines.push(""); + lines.push(`JQL: ${report.sourceJql}`); + lines.push(`Gefundene Tickets: ${report.totalIssues}`); + if (report.solutionVersion) { + lines.push(`Loesungsversion: ${report.solutionVersion}`); + } + if (report.xrayReportUrl) { + lines.push(`Xray Report: ${report.xrayReportUrl}`); + } + if (report.sonarReportUrl) { + lines.push(`Sonar Report: ${report.sonarReportUrl}`); + } + lines.push(""); + lines.push("Status-Verteilung:"); + + for (const item of report.statusCounts) { + lines.push(`- ${item.status}: ${item.count}`); + } + + lines.push(""); + lines.push("Tickets:"); + for (const issue of report.issues) { + lines.push( + `- ${issue.key}: ${issue.summary} | ${issue.status} | ${issue.assignee} | ${issue.updated}` + ); + } + + return lines.join("\n"); +} + +function buildHtmlSummary(report, createdIssueUrl) { + const statsRows = report.statusCounts + .map( + (item) => + `${escapeHtml(item.status)}${item.count}` + ) + .join(""); + + const issueRows = report.issues + .map((issue) => { + return ` +${escapeHtml(issue.key)} +${escapeHtml(issue.summary)} +${escapeHtml(issue.status)} +${escapeHtml(issue.assignee)} +${escapeHtml(issue.updated)} +`; + }) + .join(""); + + const createdIssueBlock = createdIssueUrl + ? `

Neues Jira Ticket: ${escapeHtml(createdIssueUrl)}

` + : "

Neues Jira Ticket: Nicht erstellt (Dry-Run).

"; + + const xrayBlock = report.xrayReportUrl + ? `

Xray Report: ${escapeHtml(report.xrayReportUrl)}

` + : ""; + + const sonarBlock = report.sonarReportUrl + ? `

Sonar Report: ${escapeHtml(report.sonarReportUrl)}

` + : ""; + + const solutionVersionBlock = report.solutionVersion + ? `

Loesungsversion: ${escapeHtml(report.solutionVersion)}

` + : ""; + + return ` + + + + Jira Zusammenfassung + + + +

Jira Zusammenfassung

+

Erstellt am: ${escapeHtml(report.generatedAt)}

+

JQL: ${escapeHtml(report.sourceJql)}

+

Gefundene Tickets: ${report.totalIssues}

+ ${solutionVersionBlock} + ${xrayBlock} + ${sonarBlock} + ${createdIssueBlock} + +

Status-Verteilung

+ + + ${statsRows} +
StatusAnzahl
+ +

Tickets

+ + + + + ${issueRows} +
KeySummaryStatusAssigneeUpdated
+ +`; +} + +function makeParagraph(text) { + return { + type: "paragraph", + content: [{ type: "text", text }], + }; +} + +function buildAdfDescription(report) { + const content = [ + makeParagraph(`Automatischer Jira Report vom ${report.generatedAt}`), + makeParagraph(`Quelle (JQL): ${report.sourceJql}`), + makeParagraph(`Gefundene Tickets: ${report.totalIssues}`), + ]; + + if (report.solutionVersion) { + content.push(makeParagraph(`Loesungsversion: ${report.solutionVersion}`)); + } + if (report.xrayReportUrl) { + content.push(makeParagraph(`Xray Report: ${report.xrayReportUrl}`)); + } + if (report.sonarReportUrl) { + content.push(makeParagraph(`Sonar Report: ${report.sonarReportUrl}`)); + } + + content.push( + makeParagraph("Status-Verteilung:"), + ); + + for (const status of report.statusCounts) { + content.push(makeParagraph(`- ${status.status}: ${status.count}`)); + } + + content.push(makeParagraph("Tickets:")); + + for (const issue of report.issues) { + const line = `${issue.key}: ${issue.summary} | ${issue.status} | ${issue.assignee} | ${issue.updated}`; + content.push(makeParagraph(line)); + } + + return { + type: "doc", + version: 1, + content, + }; +} + +function buildReport({ + issues, + jiraBaseUrl, + sourceJql, + solutionVersion = "", + xrayReportUrl = "", + sonarReportUrl = "", +}) { + const normalized = issues.map((issue) => formatIssue(issue, jiraBaseUrl)); + + const report = { + generatedAt: dayjs().format("YYYY-MM-DD HH:mm"), + sourceJql, + solutionVersion, + xrayReportUrl, + sonarReportUrl, + totalIssues: normalized.length, + statusCounts: statusStats(normalized), + issues: normalized, + }; + + report.textSummary = buildTextSummary(report); + report.adfDescription = buildAdfDescription(report); + return report; +} + +module.exports = { + buildReport, + buildHtmlSummary, +}; diff --git a/teamcity/run-workflow.ps1 b/teamcity/run-workflow.ps1 new file mode 100644 index 0000000..38668d3 --- /dev/null +++ b/teamcity/run-workflow.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = "Stop" + +$imageTag = if ($env:TEAMCITY_BUILD_NUMBER) { $env:TEAMCITY_BUILD_NUMBER } else { "local" } +$imageName = "its-workflow-npm:$imageTag" + +Write-Host "Builde Docker Image $imageName" +docker build -t $imageName . + +Write-Host "Starte Workflow Container" +docker run --rm ` + -e JIRA_BASE_URL ` + -e JIRA_USER_EMAIL ` + -e JIRA_API_TOKEN ` + -e JIRA_SOURCE_JQL ` + -e JIRA_SOURCE_MAX_RESULTS ` + -e JIRA_TARGET_PROJECT_KEY ` + -e JIRA_TARGET_BOARD_ID ` + -e JIRA_TARGET_ISSUE_TYPE ` + -e JIRA_TARGET_SUMMARY_PREFIX ` + -e JIRA_TARGET_LABELS ` + -e SOLUTION_VERSION ` + -e XRAY_REPORT_URL ` + -e SONAR_REPORT_URL ` + -e JIRA_DRY_RUN ` + -e OUTPUT_HTML_PATH ` + -e SMTP_ENABLED ` + -e SMTP_HOST ` + -e SMTP_PORT ` + -e SMTP_SECURE ` + -e SMTP_USER ` + -e SMTP_PASS ` + -e SMTP_FROM ` + -e SMTP_TO ` + -e SMTP_SUBJECT_PREFIX ` + -v "${PWD}\report-output:/app/report-output" ` + $imageName diff --git a/teamcity/run-workflow.sh b/teamcity/run-workflow.sh new file mode 100644 index 0000000..b0f4295 --- /dev/null +++ b/teamcity/run-workflow.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +set -eu + +IMAGE_TAG="${TEAMCITY_BUILD_NUMBER:-local}" +IMAGE_NAME="its-workflow-npm:${IMAGE_TAG}" + +echo "Builde Docker Image ${IMAGE_NAME}" +docker build -t "${IMAGE_NAME}" . + +echo "Starte Workflow Container" +docker run --rm \ + -e JIRA_BASE_URL \ + -e JIRA_USER_EMAIL \ + -e JIRA_API_TOKEN \ + -e JIRA_SOURCE_JQL \ + -e JIRA_SOURCE_MAX_RESULTS \ + -e JIRA_TARGET_PROJECT_KEY \ + -e JIRA_TARGET_BOARD_ID \ + -e JIRA_TARGET_ISSUE_TYPE \ + -e JIRA_TARGET_SUMMARY_PREFIX \ + -e JIRA_TARGET_LABELS \ + -e SOLUTION_VERSION \ + -e XRAY_REPORT_URL \ + -e SONAR_REPORT_URL \ + -e JIRA_DRY_RUN \ + -e OUTPUT_HTML_PATH \ + -e SMTP_ENABLED \ + -e SMTP_HOST \ + -e SMTP_PORT \ + -e SMTP_SECURE \ + -e SMTP_USER \ + -e SMTP_PASS \ + -e SMTP_FROM \ + -e SMTP_TO \ + -e SMTP_SUBJECT_PREFIX \ + -v "$(pwd)/report-output:/app/report-output" \ + "${IMAGE_NAME}"