From 1cad1d76bb6aeaa0bf8e6505bf0dfe7bff8ca8c8 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 12 Jun 2026 11:21:06 -0700 Subject: [PATCH] feat: migrate to Power Automate webhooks, add icon_url setting Fixes https://github.com/mobydeck/ci-teams-notification/issues/1 - Remove contentUrl:nil from attachment (fixes Power Automate rejection) - Remove base64 avatar encoding; use direct URLs instead - Simplify card body (flat elements, no Container wrappers) - Accept 202 Accepted responses from workflow endpoints - Support CI_PIPELINE_STATUS with DRONE_BUILD_STATUS fallback - Add icon_url setting for forges behind firewalls - Bump go.mod to 1.24 - Remove unused imports (encoding/base64, mime, path) Co-authored-by: Antigravity AI --- README.md | 14 ++++--- docs.md | 17 +++++---- go.mod | 2 +- main.go | 108 +++++++++++++++++------------------------------------- 4 files changed, 52 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 41e8a04..bcf4acd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # Microsoft Teams Notification -Docker image / CI plugin to send pipeline notifications to Microsoft Teams using Adaptive Cards. Designed to work with Woodpecker CI, but can be used with any CI system that supports Docker images. +Docker image / CI plugin to send pipeline notifications to Microsoft Teams using Adaptive Cards via Power Automate Workflows. Designed to work with Woodpecker CI, but can be used with any CI system that supports Docker images. ## Features +- Compatible with Teams Workflows (Power Automate) webhooks - Customizable notification card with: - Pipeline status (success/failure) - Author information with avatar @@ -11,7 +12,7 @@ Docker image / CI plugin to send pipeline notifications to Microsoft Teams using - Configurable fact sections - Optional variables table - Customizable action buttons -- Base64-encoded avatar images +- Optional custom icon URL (for forges behind firewalls) - Debug mode ## Configuration @@ -23,7 +24,7 @@ steps: - name: notify-teams image: mobydeck/ci-teams-notification settings: - webhook_url: https://outlook.office.com/webhook/... + webhook_url: https://prod-XX.westus.logic.azure.com:443/workflows/... when: - status: [success, failure] event: [manual, push, tag] @@ -35,8 +36,7 @@ The plugin uses environment variables: - `CI_REPO` - Repository name - `CI_REPO_URL` - Repository URL -- ~~`CI_PIPELINE_STATUS`~~ `DRONE_BUILD_STATUS` - Pipeline status - * `CI_PIPELINE_STATUS` is missing in 3.1.0 :( +- `CI_PIPELINE_STATUS` - Pipeline status (falls back to `DRONE_BUILD_STATUS`) - `CI_PIPELINE_URL` - Pipeline URL - `CI_PIPELINE_FORGE_URL` - Forge commit URL - `CI_COMMIT_SHA` - Commit SHA (shortened to 7 characters) @@ -49,7 +49,8 @@ The plugin uses environment variables: ### Plugin Settings -- `webhook_url` (required) - Microsoft Teams webhook URL +- `webhook_url` (required) - Microsoft Teams Workflow webhook URL +- `icon_url` (optional) - Public URL for the card icon image. When set, this URL is used directly instead of the forge avatar. Useful when the forge is behind a firewall. - `debug` (optional) - Enable debug output of the card JSON - `facts` (optional) - Comma-separated list of facts to display: - `project` - Repository name @@ -72,6 +73,7 @@ steps: settings: webhook_url: from_secret: teams_webhook_url + icon_url: https://example.com/my-icon.png facts: project,version buttons: pipeline,commit variables: MY_VAR1,MY_VAR2 diff --git a/docs.md b/docs.md index 3886533..339da6e 100644 --- a/docs.md +++ b/docs.md @@ -2,7 +2,7 @@ name: Microsoft Teams Notification author: mobydeck icon: https://raw.githubusercontent.com/mobydeck/ci-teams-notification/refs/heads/main/assets/ms-teams-logo.png -description: Plugin to send pipeline notifications to Microsoft Teams using Adaptive Cards +description: Plugin to send pipeline notifications to Microsoft Teams via Power Automate Workflows using Adaptive Cards tags: [notifications, chat] containerImage: mobydeck/ci-teams-notification containerImageUrl: https://hub.docker.com/r/mobydeck/ci-teams-notification @@ -11,15 +11,16 @@ url: https://github.com/mobydeck/ci-teams-notification # Teams Notification Plugin -CI plugin to send pipeline notifications to Microsoft Teams using Adaptive Cards. +CI plugin to send pipeline notifications to Microsoft Teams using Adaptive Cards via Power Automate Workflows. -For creating a Teams Webhook, follow [this guide](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook). +For creating a Teams Workflow webhook, follow [this guide](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook). ## Settings | Setting Name | Description | Default | |---------------|--------------------------------------------------------------------|---------| -| `webhook_url` | Teams Webhook URL (required) | _none_ | +| `webhook_url` | Teams Workflow webhook URL (required) | _none_ | +| `icon_url` | Public URL for the card icon (bypasses forge avatar) | _none_ | | `facts` | Comma-separated list of facts to display (project,message,version) | _all_ | | `buttons` | Comma-separated list of buttons (pipeline,commit,release) | _all_ | | `variables` | Comma-separated list of environment variables to display | _none_ | @@ -32,7 +33,7 @@ steps: - name: notify-teams image: mobydeck/ci-teams-notification settings: - webhook_url: https://outlook.office.com/webhook/... + webhook_url: https://prod-XX.westus.logic.azure.com:443/workflows/... when: - status: [success, failure] event: [manual, push, tag] @@ -47,6 +48,7 @@ steps: settings: webhook_url: from_secret: teams_webhook_url + icon_url: https://example.com/my-icon.png facts: project,version buttons: pipeline,commit variables: MY_VAR1,MY_VAR2 @@ -56,8 +58,7 @@ steps: event: [manual, push, tag] ``` -## Features - +- Compatible with Teams Workflows (Power Automate) webhooks - Customizable notification card with: - Pipeline status (success/failure) - Author information with avatar @@ -65,7 +66,7 @@ steps: - Configurable fact sections - Optional variables table - Customizable action buttons -- Base64-encoded avatar images +- Optional custom icon URL (for forges behind firewalls) ## Notification Examples diff --git a/go.mod b/go.mod index 00104e0..cf67f61 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module ci-teams-notification -go 1.23.4 +go 1.24 diff --git a/main.go b/main.go index 7d00c71..900802f 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,11 @@ package main import ( "bytes" - "encoding/base64" "encoding/json" "fmt" "io" - "mime" "net/http" "os" - "path" "sort" "strconv" "strings" @@ -50,13 +47,21 @@ func getProjectVersion() string { return "" } +// getPipelineStatus returns the pipeline status, checking CI_PIPELINE_STATUS +// first with a fallback to DRONE_BUILD_STATUS for older Woodpecker versions. +func getPipelineStatus() string { + if status := getEnvOrDefault("CI_PIPELINE_STATUS", ""); status != "" { + return status + } + return getEnvOrDefault("DRONE_BUILD_STATUS", "") +} + func createTeamsCard(projectVersion string) map[string]any { return map[string]any{ "type": "message", "attachments": []map[string]any{ { "contentType": "application/vnd.microsoft.card.adaptive", - "contentUrl": nil, "content": map[string]any{ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", @@ -69,57 +74,23 @@ func createTeamsCard(projectVersion string) map[string]any { } } -func getAvatarDataURI(avatarURL string) (string, error) { - resp, err := http.Get(avatarURL) - if err != nil { - return "", fmt.Errorf("failed to download avatar: %w", err) - } - defer resp.Body.Close() - - imageData, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read avatar data: %w", err) - } - - contentType := resp.Header.Get("Content-Type") - ext := path.Ext(avatarURL) - switch { - case contentType != "": - // Use content type from header - case ext != "": - contentType = mime.TypeByExtension(ext) - default: - contentType = http.DetectContentType(imageData) - } - - return fmt.Sprintf("data:%s;base64,%s", - contentType, - base64.StdEncoding.EncodeToString(imageData), - ), nil -} - func createCardBody(projectVersion string) []map[string]any { - status := getEnvOrDefault("DRONE_BUILD_STATUS", "") - color := "good" + status := getPipelineStatus() title := "✔ Pipeline succeeded" if status == "failure" { - color = "attention" title = "❌ Pipeline failed" } dateStr := time.Now().UTC().Format("2006-01-02T15:04:05Z") - avatarURL := getEnvOrDefault("CI_COMMIT_AUTHOR_AVATAR", "") - var avatarDataURI string - if avatarURL != "" { - if dataURI, err := getAvatarDataURI(avatarURL); err == nil { - avatarDataURI = dataURI - } else { - fmt.Printf("Warning: Failed to process avatar image: %v\n", err) - avatarDataURI = avatarURL - } + // PLUGIN_ICON_URL takes precedence - use it directly without fetching. + // This is useful when the forge is behind a firewall and Teams can't reach it. + // Falls back to CI_COMMIT_AUTHOR_AVATAR (used directly as a URL). + avatarURL := getEnvOrDefault("PLUGIN_ICON_URL", "") + if avatarURL == "" { + avatarURL = getEnvOrDefault("CI_COMMIT_AUTHOR_AVATAR", "") } - body := createBaseBody(color, title, avatarDataURI, dateStr, projectVersion) + body := createBaseBody(title, avatarURL, dateStr, projectVersion) if variables := getEnvOrDefault("PLUGIN_VARIABLES", ""); variables != "" { body = appendVariablesTable(body, variables) @@ -128,24 +99,16 @@ func createCardBody(projectVersion string) []map[string]any { return body } -func createBaseBody(color, title, avatarDataURI, dateStr, projectVersion string) []map[string]any { +func createBaseBody(title, avatarURL, dateStr, projectVersion string) []map[string]any { body := []map[string]any{ { - "type": "Container", - "bleed": true, - "spacing": "None", - "style": color, - "items": []map[string]any{ - { - "type": "TextBlock", - "text": title, - "weight": "bolder", - "size": "medium", - "color": color, - }, - createAuthorSection(avatarDataURI, dateStr), - }, + "type": "TextBlock", + "text": title, + "weight": "bolder", + "size": "medium", + "wrap": true, }, + createAuthorSection(avatarURL, dateStr), } if facts := createFactsSection(projectVersion); facts != nil { @@ -155,7 +118,7 @@ func createBaseBody(color, title, avatarDataURI, dateStr, projectVersion string) return body } -func createAuthorSection(avatarDataURI, dateStr string) map[string]any { +func createAuthorSection(avatarURL, dateStr string) map[string]any { return map[string]any{ "type": "ColumnSet", "columns": []map[string]any{ @@ -165,7 +128,7 @@ func createAuthorSection(avatarDataURI, dateStr string) map[string]any { "items": []map[string]any{ { "type": "Image", - "url": avatarDataURI, + "url": avatarURL, "size": "small", "style": "Person", }, @@ -235,13 +198,8 @@ func createFactsSection(projectVersion string) map[string]any { } return map[string]any{ - "type": "Container", - "items": []map[string]any{ - { - "type": "FactSet", - "facts": facts, - }, - }, + "type": "FactSet", + "facts": facts, } } @@ -359,7 +317,7 @@ func printBuildInfo(projectVersion string) { fmt.Println("\nBuild Info:") fmt.Printf(" PROJECT: %s\n", getEnvOrDefault("CI_REPO", "")) fmt.Printf(" VERSION: %s\n", projectVersion) - fmt.Printf(" STATUS: %s\n", getEnvOrDefault("DRONE_BUILD_STATUS", "")) + fmt.Printf(" STATUS: %s\n", getPipelineStatus()) fmt.Printf(" DATE: %s\n", time.Now().UTC().Format(time.RFC3339)) } @@ -373,9 +331,11 @@ func sendCard(webhookURL string, cardBytes []byte) { } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - fmt.Printf("Error response from Teams: %s\n", string(body)) + body, _ := io.ReadAll(resp.Body) + + // Power Automate workflow endpoints return 202 Accepted + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + fmt.Printf("Error response from Teams (HTTP %d): %s\n", resp.StatusCode, string(body)) os.Exit(1) }