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
This commit is contained in:
2026-03-19 13:11:02 +01:00
commit fae1771c61
13 changed files with 1154 additions and 0 deletions
+35
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
report-output/
npm-debug.log*
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["npm", "start"]
+104
View File
@@ -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.
+325
View File
@@ -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"
}
}
}
+21
View File
@@ -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"
}
}
+115
View File
@@ -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,
};
+44
View File
@@ -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,
};
+108
View File
@@ -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);
});
+89
View File
@@ -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,
};
+226
View File
@@ -0,0 +1,226 @@
const dayjs = require("dayjs");
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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) =>
`<tr><td>${escapeHtml(item.status)}</td><td>${item.count}</td></tr>`
)
.join("");
const issueRows = report.issues
.map((issue) => {
return `<tr>
<td><a href="${escapeHtml(issue.url)}">${escapeHtml(issue.key)}</a></td>
<td>${escapeHtml(issue.summary)}</td>
<td>${escapeHtml(issue.status)}</td>
<td>${escapeHtml(issue.assignee)}</td>
<td>${escapeHtml(issue.updated)}</td>
</tr>`;
})
.join("");
const createdIssueBlock = createdIssueUrl
? `<p><strong>Neues Jira Ticket:</strong> <a href="${escapeHtml(createdIssueUrl)}">${escapeHtml(createdIssueUrl)}</a></p>`
: "<p><strong>Neues Jira Ticket:</strong> Nicht erstellt (Dry-Run).</p>";
const xrayBlock = report.xrayReportUrl
? `<p><strong>Xray Report:</strong> <a href="${escapeHtml(report.xrayReportUrl)}">${escapeHtml(report.xrayReportUrl)}</a></p>`
: "";
const sonarBlock = report.sonarReportUrl
? `<p><strong>Sonar Report:</strong> <a href="${escapeHtml(report.sonarReportUrl)}">${escapeHtml(report.sonarReportUrl)}</a></p>`
: "";
const solutionVersionBlock = report.solutionVersion
? `<p><strong>Loesungsversion:</strong> ${escapeHtml(report.solutionVersion)}</p>`
: "";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Jira Zusammenfassung</title>
<style>
body { font-family: Arial, sans-serif; color: #1f2937; margin: 24px; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin-top: 8px; margin-bottom: 20px; }
th, td { border: 1px solid #e5e7eb; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; }
</style>
</head>
<body>
<h1>Jira Zusammenfassung</h1>
<p><strong>Erstellt am:</strong> ${escapeHtml(report.generatedAt)}</p>
<p><strong>JQL:</strong> ${escapeHtml(report.sourceJql)}</p>
<p><strong>Gefundene Tickets:</strong> ${report.totalIssues}</p>
${solutionVersionBlock}
${xrayBlock}
${sonarBlock}
${createdIssueBlock}
<h2>Status-Verteilung</h2>
<table>
<thead><tr><th>Status</th><th>Anzahl</th></tr></thead>
<tbody>${statsRows}</tbody>
</table>
<h2>Tickets</h2>
<table>
<thead>
<tr><th>Key</th><th>Summary</th><th>Status</th><th>Assignee</th><th>Updated</th></tr>
</thead>
<tbody>${issueRows}</tbody>
</table>
</body>
</html>`;
}
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,
};
+36
View File
@@ -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
+37
View File
@@ -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}"