diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179e99..514a2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### ✨ Features +- **Webhooks can now have a name** – an optional label set when adding a webhook (or via the `webhook.name` DSL parameter) is shown instead of the masked `Slack Webhook (********)` placeholder on the project and build-configuration tabs and on the admin overview, so several webhooks of the same platform can be told apart. URLs stay masked; the name is display-only and does not affect webhook identity, inheritance, or delivery. + ## [1.4.0] - 2026-06-26 ### 🔧 Tooling diff --git a/DSL_USAGE.md b/DSL_USAGE.md index 7a83a65..a9ee1fa 100644 --- a/DSL_USAGE.md +++ b/DSL_USAGE.md @@ -19,6 +19,7 @@ buildFeature { type = "teamnotify.webhook" param("webhook.url", "%env.SLACK_WEBHOOK_URL%") // Reference parameter param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the UI param("webhook.enabled", "true") param("webhook.onFailure", "true") } @@ -201,6 +202,7 @@ All webhook parameters are configured using `param()` inside a `buildFeature` bl |-----------|------|----------|---------|-------------|---------| | `webhook.url` | String | **Yes** | - | Webhook URL | `https://discord.com/api/webhooks/...` | | `webhook.platform` | String | **Yes** | - | Platform: `SLACK`, `TEAMS`, or `DISCORD` | `DISCORD` | +| `webhook.name` | String | No | `null` | Display name shown in the TeamNotify UI lists (URLs stay masked) | `"#build-alerts"` | | `webhook.enabled` | String | No | `"true"` | Enable/disable webhook | `"true"` or `"false"` | | `webhook.onStart` | String | No | `"false"` | Trigger on build start | `"true"` or `"false"` | | `webhook.onSuccess` | String | No | `"false"` | Trigger on build success | `"true"` or `"false"` | diff --git a/README.md b/README.md index c17719d..c0a2d2b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ object MyBuild : BuildType({ type = "teamnotify.webhook" param("webhook.url", "%slack.webhook.url%") // Never hardcode the URL! param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") param("webhook.onFailure", "true") param("webhook.onSuccess", "true") diff --git a/build.number b/build.number index e0d1ea1..aebfaa1 100644 --- a/build.number +++ b/build.number @@ -1 +1 @@ -205 \ No newline at end of file +214 \ No newline at end of file diff --git a/example.teamcity.settings.kts b/example.teamcity.settings.kts index 4dcd7e9..f429b61 100644 --- a/example.teamcity.settings.kts +++ b/example.teamcity.settings.kts @@ -21,6 +21,7 @@ project { type = "teamnotify.webhook" param("webhook.url", "%env.SLACK_WEBHOOK_URL%") // Use parameter for security param("webhook.platform", "SLACK") + param("webhook.name", "#build-alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") // Trigger configuration diff --git a/examples/settings.kts.example b/examples/settings.kts.example index 0b5757b..a1ad4f9 100644 --- a/examples/settings.kts.example +++ b/examples/settings.kts.example @@ -30,6 +30,7 @@ project { type = "teamnotify.webhook" param("webhook.url", "%slack.webhook.critical%") param("webhook.platform", "SLACK") + param("webhook.name", "Critical alerts") // Optional label shown in the TeamNotify UI param("webhook.enabled", "true") param("webhook.onFirstFailure", "true") param("webhook.onBuildFixed", "true") diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt index 3e1402c..db2da0c 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/controllers/NotifierSettingsController.kt @@ -28,6 +28,12 @@ class NotifierSettingsController( private val sBuildServer: SBuildServer ) : BaseController() { + companion object { + // Names are display-only labels; cap them so a pasted blob can't blow up the settings XML + // or the webhook list layout. + const val MAX_NAME_LENGTH = 100 + } + fun register(): Unit { webControllerManager.registerController("/notifier/settings.html", this) webControllerManager.registerController("/notifier/testWebhook.html", this) @@ -87,6 +93,7 @@ class NotifierSettingsController( val action = request.getParameter("action") if (action == "add") { val webhookUrl = request.getParameter("webhookUrl")?.trim() + val webhookName = request.getParameter("webhookName")?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_NAME_LENGTH) val platformRaw = request.getParameter("platform")?.trim()?.uppercase() val onSuccess = request.getParameter("onSuccess") != null val onFailure = request.getParameter("onFailure") != null @@ -134,6 +141,7 @@ class NotifierSettingsController( if (errors.isNotEmpty()) { mv.model["validationErrors"] = errors mv.model["formUrl"] = webhookUrl ?: "" + mv.model["formName"] = webhookName ?: "" mv.model["formPlatform"] = platformRaw ?: "SLACK" mv.model["formOnStart"] = onStart mv.model["formOnSuccess"] = onSuccess @@ -146,6 +154,7 @@ class NotifierSettingsController( val newWebhook = WebhookConfiguration( url = webhookUrl!!, platform = platform!!, + name = webhookName, onStart = onStart, onSuccess = onSuccess, onFailure = onFailure, @@ -184,6 +193,7 @@ class NotifierSettingsController( errors += "Failed to save webhook: ${e.message ?: "Unknown error"}" mv.model["validationErrors"] = errors mv.model["formUrl"] = webhookUrl ?: "" + mv.model["formName"] = webhookName ?: "" mv.model["formPlatform"] = platformRaw ?: "SLACK" mv.model["formOnStart"] = onStart mv.model["formOnSuccess"] = onSuccess @@ -382,6 +392,7 @@ class NotifierSettingsController( """{ "index": $index, "platform": "${webhook.platform}", + "name": ${if (webhook.name != null) "\"${jsonEscape(webhook.name)}\"" else "null"}, "onStart": ${webhook.onStart}, "onSuccess": ${webhook.onSuccess}, "onFailure": ${webhook.onFailure}, @@ -587,6 +598,7 @@ class NotifierSettingsController( } val webhookUrl = request.getParameter("webhookUrl")?.trim() + val webhookName = request.getParameter("webhookName")?.trim()?.takeIf { it.isNotEmpty() }?.take(MAX_NAME_LENGTH) val platformRaw = request.getParameter("platform")?.trim()?.uppercase() val onSuccess = request.getParameter("onSuccess")?.toBoolean() ?: false val onFailure = request.getParameter("onFailure")?.toBoolean() ?: false @@ -619,6 +631,7 @@ class NotifierSettingsController( val newWebhook = WebhookConfiguration( url = webhookUrl, platform = platform, + name = webhookName, onStart = onStart, onSuccess = onSuccess, onFailure = onFailure, diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt index 834db56..a0dd96a 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/features/TeamNotifyWebhookFeature.kt @@ -65,7 +65,10 @@ class TeamNotifyWebhookFeature( val triggerText = if (triggers.isNotEmpty()) triggers.joinToString(", ") else "No triggers" - return "$platform webhook: $url (Triggers: $triggerText)" + val name = params["webhook.name"]?.trim()?.takeIf { it.isNotEmpty() } + val prefix = if (name != null) "$name - " else "" + + return "$prefix$platform webhook: $url (Triggers: $triggerText)" } override fun getDefaultParameters(): Map { diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt index 4452ddd..8b87004 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettings.kt @@ -103,6 +103,7 @@ data class TeamNotifyProjectSettings( WebhookConfiguration( url = url, platform = platform, + name = webhookElement.getChild("name")?.textTrim?.takeIf { it.isNotEmpty() }, enabled = webhookElement.getChild("enabled")?.textTrim?.toBoolean() ?: true, onStart = webhookElement.getChild("onStart")?.textTrim?.toBoolean() ?: false, onSuccess = webhookElement.getChild("onSuccess")?.textTrim?.toBoolean() ?: false, @@ -131,6 +132,9 @@ data class TeamNotifyProjectSettings( val webhookElement = Element("webhook") webhookElement.addContent(Element("url").setText(webhook.url)) webhookElement.addContent(Element("platform").setText(webhook.platform.name)) + webhook.name?.let { + webhookElement.addContent(Element("name").setText(it)) + } webhookElement.addContent(Element("enabled").setText(webhook.enabled.toString())) webhookElement.addContent(Element("onStart").setText(webhook.onStart.toString())) webhookElement.addContent(Element("onSuccess").setText(webhook.onSuccess.toString())) diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt index 7c5334f..9e43d0f 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/model/WebhookConfiguration.kt @@ -9,6 +9,9 @@ enum class WebhookPlatform { data class WebhookConfiguration( val url: String, val platform: WebhookPlatform, + // Optional display name shown in the UI lists so several webhooks of the same platform can be + // told apart (URLs are always masked). Purely cosmetic: webhook identity is still the URL. + val name: String? = null, val onStart: Boolean = false, val onSuccess: Boolean = false, val onFailure: Boolean = false, diff --git a/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt b/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt index 7c35595..783de00 100644 --- a/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt +++ b/src/main/kotlin/sk/v2/plugins/teamnotify/services/WebhookManager.kt @@ -135,6 +135,7 @@ class WebhookManager( return WebhookConfiguration( url = url, platform = platform, + name = params["webhook.name"]?.trim()?.takeIf { it.isNotEmpty() }, enabled = params["webhook.enabled"]?.toBoolean() ?: true, onStart = params["webhook.onStart"]?.toBoolean() ?: false, onSuccess = params["webhook.onSuccess"]?.toBoolean() ?: false, diff --git a/src/main/resources/buildServerResources/editNotifierSettings.jsp b/src/main/resources/buildServerResources/editNotifierSettings.jsp index ecfc66f..c8b4cb2 100644 --- a/src/main/resources/buildServerResources/editNotifierSettings.jsp +++ b/src/main/resources/buildServerResources/editNotifierSettings.jsp @@ -132,6 +132,19 @@ + +
+ + + Shown in the list below so webhooks can be told apart (URLs stay masked). +
+
@@ -694,7 +707,7 @@ Teams Webhook (********) Discord Webhook (********) Webhook (********) - + - ${fn:escapeXml(webhook.name)} Inherited from parent project @@ -801,6 +814,7 @@ const platformRadios = document.querySelectorAll('input[name="platform-radio"]'); const platformSelect = document.getElementById('platform'); const urlEl = document.getElementById('webhookUrl'); + const nameEl = document.getElementById('webhookName'); const urlError = document.getElementById('urlError'); const useThreshold = document.getElementById('useBuildLongerThan'); const thresholdRow = document.getElementById('thresholdRow'); @@ -850,7 +864,7 @@ if (p === 'SLACK') { placeholder = 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX'; } else if (p === 'TEAMS') { - placeholder = 'https://{tenant}.webhook.office.com/webhookb2/...'; + placeholder = 'https://{tenant}.webhook.office.com/webhookb2/... or https://{env}.environment.api.powerplatform.com/...'; } else if (p === 'DISCORD') { placeholder = 'https://discord.com/api/webhooks/{id}/{token}'; } @@ -863,7 +877,10 @@ const p = platformSelect.value; let ok = false; if (p === 'SLACK') ok = /^https:\/\/hooks\.slack\.com\//.test(val); - else if (p === 'TEAMS') ok = /^https:\/\/.*webhook\.office\.com\//.test(val) || /^https:\/\/outlook\.office\.com\//.test(val); + else if (p === 'TEAMS') ok = /^https:\/\/[a-z0-9_-]+\.webhook\.office\.com\//i.test(val) + || /^https:\/\/outlook\.office\.com\/webhook\//i.test(val) + || /^https:\/\/[a-z0-9_-]+\.logic\.azure\.com\//i.test(val) + || /^https:\/\/[a-z0-9_.-]+\.environment\.api\.powerplatform\.com/i.test(val); else if (p === 'DISCORD') ok = /^https:\/\/discord(?:app)?\.com\/api\/webhooks\//.test(val); urlError.style.display = ok || !val ? 'none' : 'block'; return ok; @@ -1043,6 +1060,7 @@ const params = new URLSearchParams({ platform: platformSelect.value, webhookUrl: urlEl.value.trim(), + webhookName: nameEl ? nameEl.value.trim() : '', onStart: document.getElementById('onStart').checked, onSuccess: document.getElementById('onSuccess').checked, onFailure: document.getElementById('onFailure').checked, @@ -1307,7 +1325,9 @@ function renderWebhooks(webhooks) { const html = webhooks.map((webhook, index) => { const platformIcon = getPlatformIcon(webhook.platform); - const displayUrl = webhook.platform + ' Webhook (********)'; + // webhook.name is user-supplied, so it must be escaped before going into this HTML string. + const displayUrl = webhook.platform + ' Webhook (********)' + + (webhook.name ? ' - ' + escapeHtml(webhook.name) : ''); const triggers = []; if (webhook.onStart) triggers.push('On Start'); @@ -1337,6 +1357,15 @@ webhooksList.innerHTML = html; } + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + function getPlatformIcon(platform) { const icons = { SLACK: '', @@ -1348,6 +1377,7 @@ function resetForm() { urlEl.value = ''; + if (nameEl) nameEl.value = ''; document.getElementById('onStart').checked = false; document.getElementById('onSuccess').checked = false; document.getElementById('onFailure').checked = false; diff --git a/src/main/resources/buildServerResources/teamNotifyAdmin.jsp b/src/main/resources/buildServerResources/teamNotifyAdmin.jsp index c2992f9..08c13fb 100644 --- a/src/main/resources/buildServerResources/teamNotifyAdmin.jsp +++ b/src/main/resources/buildServerResources/teamNotifyAdmin.jsp @@ -108,6 +108,11 @@
+ + +
${fn:escapeXml(webhookInfo.webhook.name)}
+
+
@@ -704,6 +709,16 @@ async function toggleWebhook(webhookIndex, projectId, buildTypeId, currentStatus letter-spacing: 0.5px; } +/* Webhook Name */ +.tn-webhook-name { + font-size: 15px; + font-weight: 600; + color: #1f2937; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Project Info */ .tn-webhook-project { display: flex; diff --git a/src/test/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettingsTest.kt b/src/test/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettingsTest.kt new file mode 100644 index 0000000..720b0c9 --- /dev/null +++ b/src/test/kotlin/sk/v2/plugins/teamnotify/model/TeamNotifyProjectSettingsTest.kt @@ -0,0 +1,63 @@ +package sk.v2.plugins.teamnotify.model + +import org.jdom.Element +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class TeamNotifyProjectSettingsTest { + + @Test + fun `webhook name survives a write-read round trip`() { + val settings = TeamNotifyProjectSettings( + mutableListOf( + WebhookConfiguration("url1", WebhookPlatform.SLACK, name = "Release channel", onSuccess = true) + ) + ) + + val root = Element("settings") + settings.writeTo(root) + + val restored = TeamNotifyProjectSettings() + restored.readFrom(root) + + assertEquals(1, restored.webhooks.size) + assertEquals("Release channel", restored.webhooks[0].name) + assertEquals("url1", restored.webhooks[0].url) + } + + @Test + fun `webhook without a name reads back as null`() { + val settings = TeamNotifyProjectSettings( + mutableListOf(WebhookConfiguration("url1", WebhookPlatform.TEAMS, onFailure = true)) + ) + + val root = Element("settings") + settings.writeTo(root) + + val restored = TeamNotifyProjectSettings() + restored.readFrom(root) + + assertEquals(1, restored.webhooks.size) + assertNull(restored.webhooks[0].name) + } + + @Test + fun `build-config webhook names survive a write-read round trip`() { + val settings = TeamNotifyProjectSettings( + buildTypeWebhooks = mutableMapOf( + "bt11" to mutableListOf( + WebhookConfiguration("url-bt", WebhookPlatform.DISCORD, name = "QA bot", onFailure = true) + ) + ) + ) + + val root = Element("settings") + settings.writeTo(root) + + val restored = TeamNotifyProjectSettings() + restored.readFrom(root) + + assertEquals("QA bot", restored.buildTypeWebhooks["bt11"]?.get(0)?.name) + } +}