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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# 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
- Commit/build details
- Configurable fact sections
- Optional variables table
- Customizable action buttons
- Base64-encoded avatar images
- Optional custom icon URL (for forges behind firewalls)
- Debug mode

## Configuration
Expand All @@ -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]
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
17 changes: 9 additions & 8 deletions docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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_ |
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -56,16 +58,15 @@ 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
- Commit/build details
- Configurable fact sections
- Optional variables table
- Customizable action buttons
- Base64-encoded avatar images
- Optional custom icon URL (for forges behind firewalls)

## Notification Examples

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module ci-teams-notification

go 1.23.4
go 1.24
108 changes: 34 additions & 74 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ package main

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"path"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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{
Expand All @@ -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",
},
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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))
}

Expand All @@ -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)
}

Expand Down