Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions DSL_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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"` |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion build.number
Original file line number Diff line number Diff line change
@@ -1 +1 @@
205
214
1 change: 1 addition & 0 deletions example.teamcity.settings.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/settings.kts.example
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -146,6 +154,7 @@ class NotifierSettingsController(
val newWebhook = WebhookConfiguration(
url = webhookUrl!!,
platform = platform!!,
name = webhookName,
onStart = onStart,
onSuccess = onSuccess,
onFailure = onFailure,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -619,6 +631,7 @@ class NotifierSettingsController(
val newWebhook = WebhookConfiguration(
url = webhookUrl,
platform = platform,
name = webhookName,
onStart = onStart,
onSuccess = onSuccess,
onFailure = onFailure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 36 additions & 6 deletions src/main/resources/buildServerResources/editNotifierSettings.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@
</select>
</div>

<!-- Webhook Name -->
<div class="tn-form-group tn-form-group-full">
<label class="tn-label" for="webhookName">Name (Optional)</label>
<input type="text"
id="webhookName"
class="tn-input"
maxlength="100"
placeholder="e.g. #build-alerts (team channel)"
value="${fn:escapeXml(formName)}"
autocomplete="off">
<span class="tn-help-text">Shown in the list below so webhooks can be told apart (URLs stay masked).</span>
</div>

<!-- Webhook URL -->
<div class="tn-form-group tn-form-group-full">
<label class="tn-label" for="webhookUrl">
Expand Down Expand Up @@ -403,7 +416,7 @@
<c:otherwise>
Webhook (********)
</c:otherwise>
</c:choose>
</c:choose><c:if test="${not empty webhook.name}"> - ${fn:escapeXml(webhook.name)}</c:if>
<c:if test="${isInherited}">
<span class="tn-webhook-source-badge">
<c:choose>
Expand Down Expand Up @@ -568,7 +581,7 @@
<c:otherwise>
Webhook (********)
</c:otherwise>
</c:choose>
</c:choose><c:if test="${not empty webhook.name}"> - ${fn:escapeXml(webhook.name)}</c:if>
</div>

<div class="tn-webhook-triggers">
Expand Down Expand Up @@ -694,7 +707,7 @@
<c:when test="${webhook.platform == 'TEAMS'}">Teams Webhook (********)</c:when>
<c:when test="${webhook.platform == 'DISCORD'}">Discord Webhook (********)</c:when>
<c:otherwise>Webhook (********)</c:otherwise>
</c:choose>
</c:choose><c:if test="${not empty webhook.name}"> - ${fn:escapeXml(webhook.name)}</c:if>
<span class="tn-webhook-source-badge">
<c:choose>
<c:when test="${source == 'PROJECT'}">Inherited from parent project</c:when>
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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}';
}
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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('<span class="tn-trigger-tag">On Start</span>');
Expand Down Expand Up @@ -1337,6 +1357,15 @@
webhooksList.innerHTML = html;
}

function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function getPlatformIcon(platform) {
const icons = {
SLACK: '<svg viewBox="0 0 24 24" class="tn-platform-icon-small"><path fill="#4A154B" d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
Expand All @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/main/resources/buildServerResources/teamNotifyAdmin.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@
</c:if>
</div>

<!-- Webhook Name -->
<c:if test="${not empty webhookInfo.webhook.name}">
<div class="tn-webhook-name" title="${fn:escapeXml(webhookInfo.webhook.name)}">${fn:escapeXml(webhookInfo.webhook.name)}</div>
</c:if>

<!-- Project Info -->
<div class="tn-webhook-project">
<div class="tn-project-icon">
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}