diff --git a/.env.example b/.env.example index 142c3a2..7a89d74 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ XRAY_REPORT_URL=https://xray.example.com/report/123 SONAR_REPORT_URL=https://sonar.example.com/dashboard?id=project JIRA_DRY_RUN=false +# Optional: Mehrfachangabe von Tickets, die geschlossen werden sollen (CSV) +JIRA_CLOSE_ISSUE_KEYS=SRC-101,SRC-102 +# Optional: Nur Tickets mit diesen aktuellen Status werden geschlossen (CSV) +JIRA_CLOSE_ALLOWED_STATUSES=Done,Resolved +# Optional: Name der Jira Transition in Richtung "geschlossen" +JIRA_CLOSE_TRANSITION_NAME=Geschlossen + # Atlassian Wiki / Confluence (optional) ATLASSIAN_WIKI_ENABLED=false # Optional, faellt auf JIRA_BASE_URL zurueck: diff --git a/README.md b/README.md index 297811b..e90acb8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ Empfohlen: - `SONAR_REPORT_URL` (optional) - `JIRA_DRY_RUN` (`true`/`false`) - `OUTPUT_HTML_PATH` (Default: `report-output/report.html`) +- `JIRA_CLOSE_ISSUE_KEYS` (CSV, optional; z. B. `ABC-1,ABC-2`) +- `JIRA_CLOSE_ALLOWED_STATUSES` (CSV, optional; z. B. `Done,Resolved`) +- `JIRA_CLOSE_TRANSITION_NAME` (optional; Default: `Geschlossen`) Atlassian Wiki / Confluence optional: @@ -103,6 +106,9 @@ Lege in TeamCity die Variablen als Parameter vom Typ `env.*` an, z. B.: - `env.SOLUTION_VERSION` - `env.XRAY_REPORT_URL` - `env.SONAR_REPORT_URL` +- `env.JIRA_CLOSE_ISSUE_KEYS` +- `env.JIRA_CLOSE_ALLOWED_STATUSES` +- `env.JIRA_CLOSE_TRANSITION_NAME` - `env.ATLASSIAN_WIKI_ENABLED` - `env.ATLASSIAN_WIKI_SPACE_KEY` - `env.ATLASSIAN_WIKI_PARENT_PAGE_ID` diff --git a/src/config.js b/src/config.js index 6bd9329..1170d20 100644 --- a/src/config.js +++ b/src/config.js @@ -76,6 +76,13 @@ function getConfig() { xrayReportUrl: (process.env.XRAY_REPORT_URL || "").trim(), sonarReportUrl: (process.env.SONAR_REPORT_URL || "").trim(), dryRun: parseBoolean(process.env.JIRA_DRY_RUN, false), + issueClose: { + issueKeys: parseList(process.env.JIRA_CLOSE_ISSUE_KEYS), + allowedStatuses: parseList(process.env.JIRA_CLOSE_ALLOWED_STATUSES), + transitionName: + (process.env.JIRA_CLOSE_TRANSITION_NAME || "Geschlossen").trim() || + "Geschlossen", + }, }, smtp: { enabled: parseBoolean(process.env.SMTP_ENABLED, false), @@ -140,6 +147,9 @@ Wichtige ENV Variablen: SOLUTION_VERSION XRAY_REPORT_URL SONAR_REPORT_URL + JIRA_CLOSE_ISSUE_KEYS + JIRA_CLOSE_ALLOWED_STATUSES + JIRA_CLOSE_TRANSITION_NAME ATLASSIAN_WIKI_ENABLED=true|false ATLASSIAN_WIKI_SPACE_KEY SMTP_ENABLED=true|false diff --git a/src/index.js b/src/index.js index 9676d54..848a79c 100644 --- a/src/index.js +++ b/src/index.js @@ -39,7 +39,7 @@ async function run() { }` ); logStep( - `Optionen: Wiki=${config.wiki.enabled}, SMTP=${config.smtp.enabled}, Output=${config.outputHtmlPath}` + `Optionen: Wiki=${config.wiki.enabled}, SMTP=${config.smtp.enabled}, Output=${config.outputHtmlPath}, CloseKeys=${config.jira.issueClose.issueKeys.length}` ); const jira = createJiraClient(config.jira); @@ -129,6 +129,54 @@ async function run() { logStep("Wiki-Seite deaktiviert (ATLASSIAN_WIKI_ENABLED=false)."); } + let issueCloseResult = null; + if (config.jira.issueClose.issueKeys.length > 0 && !dryRun) { + logStep( + `Starte Ticket-Schliessung fuer ${config.jira.issueClose.issueKeys.length} Tickets (Transition: ${config.jira.issueClose.transitionName})...` + ); + if (config.jira.issueClose.allowedStatuses.length > 0) { + logStep( + `Ticket-Schliessung nur aus Status: ${config.jira.issueClose.allowedStatuses.join( + ", " + )}` + ); + } + + issueCloseResult = await jira.closeIssues({ + issueKeys: config.jira.issueClose.issueKeys, + allowedStatuses: config.jira.issueClose.allowedStatuses, + transitionName: config.jira.issueClose.transitionName, + }); + + logStep( + `Ticket-Schliessung abgeschlossen: geschlossen=${issueCloseResult.closed.length}, uebersprungenStatus=${issueCloseResult.skippedByStatus.length}, uebersprungenTransition=${issueCloseResult.skippedByMissingTransition.length}, fehler=${issueCloseResult.errors.length}` + ); + + for (const item of issueCloseResult.closed) { + logStep( + `Ticket geschlossen: ${item.issueKey} (${item.fromStatus} -> ${item.transition})` + ); + } + for (const item of issueCloseResult.skippedByStatus) { + logStep( + `Ticket uebersprungen (Status nicht erlaubt): ${item.issueKey} (aktuell: ${item.currentStatus || "-"})` + ); + } + for (const item of issueCloseResult.skippedByMissingTransition) { + logStep( + `Ticket uebersprungen (Transition nicht gefunden): ${item.issueKey} (gewuenscht: ${item.requestedTransition})` + ); + } + for (const item of issueCloseResult.errors) { + logStep(`Ticket Fehler: ${item.issueKey}`); + console.error(item.message); + } + } else if (config.jira.issueClose.issueKeys.length > 0) { + logStep("Ticket-Schliessung uebersprungen (Dry-Run)."); + } else { + logStep("Ticket-Schliessung nicht konfiguriert."); + } + logStep("Erzeuge HTML-Zusammenfassung..."); const html = buildHtmlSummary(report, createdIssueUrl); const outputDir = path.dirname(config.outputHtmlPath); @@ -151,12 +199,24 @@ async function run() { createdIssueUrl || "nicht erstellt (Dry-Run)" }\nWiki Seite: ${wikiStatusText}`; + let issueCloseText = "\nTicket-Schliessung: nicht konfiguriert."; + if (config.jira.issueClose.issueKeys.length > 0) { + if (dryRun) { + issueCloseText = + "\nTicket-Schliessung: nicht ausgefuehrt (Dry-Run)."; + } else if (issueCloseResult) { + issueCloseText = `\nTicket-Schliessung: geschlossen=${issueCloseResult.closed.length}, uebersprungenStatus=${issueCloseResult.skippedByStatus.length}, uebersprungenTransition=${issueCloseResult.skippedByMissingTransition.length}, fehler=${issueCloseResult.errors.length}`; + } + } + + const mailTextWithIssueClose = `${mailText}${issueCloseText}`; + logStep("Starte E-Mail-Versand..."); const mailResult = await sendReportMail({ smtpConfig: config.smtp, subject: mailSubject, html, - text: mailText, + text: mailTextWithIssueClose, }); logStep("E-Mail-Versand abgeschlossen."); @@ -187,6 +247,12 @@ async function run() { } else { console.log(`E-Mail Versand: erfolgreich (Message-ID ${mailResult.messageId})`); } + + if (issueCloseResult) { + console.log( + `Ticket-Schliessung: geschlossen=${issueCloseResult.closed.length}, uebersprungenStatus=${issueCloseResult.skippedByStatus.length}, uebersprungenTransition=${issueCloseResult.skippedByMissingTransition.length}, fehler=${issueCloseResult.errors.length}` + ); + } } run().catch((error) => { diff --git a/src/jira.js b/src/jira.js index 7be8609..63b03a0 100644 --- a/src/jira.js +++ b/src/jira.js @@ -77,10 +77,98 @@ function createJiraClient(config) { return response.data; } + async function getIssueStatus(issueKey) { + const encodedIssueKey = encodeURIComponent(issueKey); + const response = await api.get(`/issue/${encodedIssueKey}`, { + params: { fields: "status" }, + }); + return response.data?.fields?.status?.name || ""; + } + + async function getTransitions(issueKey) { + const encodedIssueKey = encodeURIComponent(issueKey); + const response = await api.get(`/issue/${encodedIssueKey}/transitions`); + return response.data?.transitions || []; + } + + async function transitionIssueById(issueKey, transitionId) { + const encodedIssueKey = encodeURIComponent(issueKey); + await api.post(`/issue/${encodedIssueKey}/transitions`, { + transition: { id: String(transitionId) }, + }); + } + + async function closeIssues({ + issueKeys, + allowedStatuses = [], + transitionName = "Geschlossen", + }) { + const uniqueKeys = Array.from( + new Set((issueKeys || []).map((key) => String(key).trim()).filter(Boolean)) + ); + + const allowedSet = new Set( + (allowedStatuses || []).map((status) => status.toLowerCase()) + ); + + const transitionNameNormalized = transitionName.trim().toLowerCase(); + const result = { + closed: [], + skippedByStatus: [], + skippedByMissingTransition: [], + errors: [], + }; + + for (const issueKey of uniqueKeys) { + try { + const currentStatus = await getIssueStatus(issueKey); + + if ( + allowedSet.size > 0 && + !allowedSet.has(String(currentStatus).toLowerCase()) + ) { + result.skippedByStatus.push({ issueKey, currentStatus }); + continue; + } + + const transitions = await getTransitions(issueKey); + const closeTransition = transitions.find( + (transition) => + String(transition.name || "").trim().toLowerCase() === + transitionNameNormalized + ); + + if (!closeTransition) { + result.skippedByMissingTransition.push({ + issueKey, + currentStatus, + requestedTransition: transitionName, + }); + continue; + } + + await transitionIssueById(issueKey, closeTransition.id); + result.closed.push({ + issueKey, + fromStatus: currentStatus, + transition: closeTransition.name, + }); + } catch (error) { + result.errors.push({ + issueKey, + message: error?.response?.data || error.message || String(error), + }); + } + } + + return result; + } + return { getRecentIssues, resolveProjectKeyFromBoardId, createIssue, + closeIssues, }; } diff --git a/teamcity/run-workflow.ps1 b/teamcity/run-workflow.ps1 index 1b2a3db..39b266b 100644 --- a/teamcity/run-workflow.ps1 +++ b/teamcity/run-workflow.ps1 @@ -9,7 +9,7 @@ Write-Host "Builde Docker Image $imageName" docker build -t $imageName . Write-Host "Starte Workflow Container" -Write-Host "Kontext: IMAGE_TAG=$imageTag, JIRA_DRY_RUN=$($env:JIRA_DRY_RUN), ATLASSIAN_WIKI_ENABLED=$($env:ATLASSIAN_WIKI_ENABLED), SMTP_ENABLED=$($env:SMTP_ENABLED)" +Write-Host "Kontext: IMAGE_TAG=$imageTag, JIRA_DRY_RUN=$($env:JIRA_DRY_RUN), JIRA_CLOSE_ISSUE_KEYS=$($env:JIRA_CLOSE_ISSUE_KEYS), ATLASSIAN_WIKI_ENABLED=$($env:ATLASSIAN_WIKI_ENABLED), SMTP_ENABLED=$($env:SMTP_ENABLED)" Write-Host "Volume Mapping: $hostReportDir -> $containerReportDir" docker run --rm ` -e JIRA_BASE_URL ` @@ -25,6 +25,9 @@ docker run --rm ` -e SOLUTION_VERSION ` -e XRAY_REPORT_URL ` -e SONAR_REPORT_URL ` + -e JIRA_CLOSE_ISSUE_KEYS ` + -e JIRA_CLOSE_ALLOWED_STATUSES ` + -e JIRA_CLOSE_TRANSITION_NAME ` -e ATLASSIAN_WIKI_ENABLED ` -e ATLASSIAN_WIKI_BASE_URL ` -e ATLASSIAN_WIKI_USER_EMAIL ` diff --git a/teamcity/run-workflow.sh b/teamcity/run-workflow.sh index da96637..bd6968e 100644 --- a/teamcity/run-workflow.sh +++ b/teamcity/run-workflow.sh @@ -10,7 +10,7 @@ echo "Builde Docker Image ${IMAGE_NAME}" docker build -t "${IMAGE_NAME}" . echo "Starte Workflow Container" -echo "Kontext: IMAGE_TAG=${IMAGE_TAG}, JIRA_DRY_RUN=${JIRA_DRY_RUN:-false}, ATLASSIAN_WIKI_ENABLED=${ATLASSIAN_WIKI_ENABLED:-false}, SMTP_ENABLED=${SMTP_ENABLED:-false}" +echo "Kontext: IMAGE_TAG=${IMAGE_TAG}, JIRA_DRY_RUN=${JIRA_DRY_RUN:-false}, JIRA_CLOSE_ISSUE_KEYS=${JIRA_CLOSE_ISSUE_KEYS:-}, ATLASSIAN_WIKI_ENABLED=${ATLASSIAN_WIKI_ENABLED:-false}, SMTP_ENABLED=${SMTP_ENABLED:-false}" echo "Volume Mapping: ${HOST_REPORT_DIR} -> ${CONTAINER_REPORT_DIR}" docker run --rm \ -e JIRA_BASE_URL \ @@ -26,6 +26,9 @@ docker run --rm \ -e SOLUTION_VERSION \ -e XRAY_REPORT_URL \ -e SONAR_REPORT_URL \ + -e JIRA_CLOSE_ISSUE_KEYS \ + -e JIRA_CLOSE_ALLOWED_STATUSES \ + -e JIRA_CLOSE_TRANSITION_NAME \ -e ATLASSIAN_WIKI_ENABLED \ -e ATLASSIAN_WIKI_BASE_URL \ -e ATLASSIAN_WIKI_USER_EMAIL \