You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

297 lines
10 KiB

// Package travisci implements a Service capable of processing webhooks from Travis-CI.
package travisci
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/matrix-org/go-neb/database"
"github.com/matrix-org/go-neb/types"
log "github.com/sirupsen/logrus"
"maunium.net/go/mautrix"
mevt "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// ServiceType of the Travis-CI service.
const ServiceType = "travis-ci"
// DefaultTemplate contains the template that will be used if none is supplied.
// This matches the default mentioned at: https://docs.travis-ci.com/user/notifications#Customizing-slack-notifications
const DefaultTemplate = (`%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}
Change view : %{compare_url}
Build details : %{build_url}`)
// Matches 'owner/repo'
var ownerRepoRegex = regexp.MustCompile(`^([A-z0-9-_.]+)/([A-z0-9-_.]+)$`)
var httpClient = &http.Client{}
// Service contains the Config fields for the Travis-CI service.
//
// This service will send notifications into a Matrix room when Travis-CI sends
// webhook events to it. It requires a public domain which Travis-CI can reach.
// Notices will be sent as the service user ID.
//
// Example JSON request:
// {
// rooms: {
// "!ewfug483gsfe:localhost": {
// repos: {
// "matrix-org/go-neb": {
// template: "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}\nBuild details : %{build_url}"
// }
// }
// }
// }
// }
type Service struct {
types.DefaultService
webhookEndpointURL string
// The URL which should be added to .travis.yml - Populated by Go-NEB after Service registration.
WebhookURL string `json:"webhook_url"`
// A map from Matrix room ID to Github-style owner/repo repositories.
Rooms map[id.RoomID]struct {
// A map of "owner/repo" to configuration information
Repos map[string]struct {
// The template string to use when creating notifications.
//
// This is identical to the format of Slack Notifications for Travis-CI:
// https://docs.travis-ci.com/user/notifications#Customizing-slack-notifications
//
// The following variables are available:
// repository_slug: your GitHub repo identifier (like svenfuchs/minimal)
// repository_name: the slug without the username
// build_number: build number
// build_id: build id
// branch: branch build name
// commit: shortened commit SHA
// author: commit author name
// commit_message: commit message of build
// commit_subject: first line of the commit message
// result: result of build
// message: Travis CI message to the build
// duration: total duration of all builds in the matrix
// elapsed_time: time between build start and finish
// compare_url: commit change view URL
// build_url: URL of the build detail
Template string `json:"template"`
} `json:"repos"`
} `json:"rooms"`
}
// The payload from Travis-CI
type webhookNotification struct {
ID int `json:"id"`
Number string `json:"number"`
Status *int `json:"status"` // 0 (success) or 1 (incomplete/fail).
StartedAt *string `json:"started_at"`
FinishedAt *string `json:"finished_at"`
StatusMessage string `json:"status_message"`
Commit string `json:"commit"`
Branch string `json:"branch"`
Message string `json:"message"`
CompareURL string `json:"compare_url"`
CommittedAt string `json:"committed_at"`
CommitterName string `json:"committer_name"`
CommitterEmail string `json:"committer_email"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
Type string `json:"type"`
BuildURL string `json:"build_url"`
Repository struct {
Name string `json:"name"`
OwnerName string `json:"owner_name"`
URL string `json:"url"`
} `json:"repository"`
}
// Converts a webhook notification into a map of template var name to value
func notifToTemplate(n webhookNotification) map[string]string {
t := make(map[string]string)
t["repository_slug"] = n.Repository.OwnerName + "/" + n.Repository.Name
t["repository"] = t["repository_slug"] // Deprecated form but still used everywhere in people's templates
t["repository_name"] = n.Repository.Name
t["build_number"] = n.Number
t["build_id"] = strconv.Itoa(n.ID)
t["branch"] = n.Branch
shaLength := len(n.Commit)
if shaLength > 10 {
shaLength = 10
}
t["commit"] = n.Commit[:shaLength] // shortened commit SHA
t["author"] = n.CommitterName // author: commit author name
// commit_message: commit message of build
// commit_subject: first line of the commit message
t["commit_message"] = n.Message
subjAndMsg := strings.SplitN(n.Message, "\n", 2)
t["commit_subject"] = subjAndMsg[0]
if n.Status != nil {
t["result"] = strconv.Itoa(*n.Status)
}
t["message"] = n.StatusMessage // message: Travis CI message to the build
if n.StartedAt != nil && n.FinishedAt != nil {
// duration: total duration of all builds in the matrix -- TODO
// elapsed_time: time between build start and finish
// Example from docs: "2011-11-11T11:11:11Z"
start, err := time.Parse("2006-01-02T15:04:05Z", *n.StartedAt)
finish, err2 := time.Parse("2006-01-02T15:04:05Z", *n.FinishedAt)
if err != nil || err2 != nil {
log.WithFields(log.Fields{
"started_at": *n.StartedAt,
"finished_at": *n.FinishedAt,
}).Warn("Failed to parse Travis-CI start/finish times.")
} else {
t["duration"] = finish.Sub(start).String()
t["elapsed_time"] = t["duration"]
}
}
t["compare_url"] = n.CompareURL
t["build_url"] = n.BuildURL
return t
}
func outputForTemplate(travisTmpl string, tmpl map[string]string) (out string) {
if travisTmpl == "" {
travisTmpl = DefaultTemplate
}
out = travisTmpl
for tmplVar, tmplValue := range tmpl {
out = strings.Replace(out, "%{"+tmplVar+"}", tmplValue, -1)
}
return out
}
// OnReceiveWebhook receives requests from Travis-CI and possibly sends requests to Matrix as a result.
//
// If the repository matches a known Github repository, a notification will be formed from the
// template for that repository and a notice will be sent to Matrix.
//
// Go-NEB cannot register with Travis-CI for webhooks automatically. The user must manually add the
// webhook endpoint URL to their .travis.yml file:
// notifications:
// webhooks: http://go-neb-endpoint.com/notifications
//
// See https://docs.travis-ci.com/user/notifications#Webhook-notifications for more information.
func (s *Service) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *mautrix.Client) {
if err := req.ParseForm(); err != nil {
log.WithError(err).Error("Failed to read incoming Travis-CI webhook form")
w.WriteHeader(400)
return
}
payload := req.PostFormValue("payload")
if payload == "" {
log.Error("Travis-CI webhook is missing payload= form value")
w.WriteHeader(400)
return
}
if err := verifyOrigin([]byte(payload), req.Header.Get("Signature")); err != nil {
log.WithFields(log.Fields{
"Signature": req.Header.Get("Signature"),
log.ErrorKey: err,
}).Warn("Received unauthorised Travis-CI webhook request.")
w.WriteHeader(403)
return
}
var notif webhookNotification
if err := json.Unmarshal([]byte(payload), &notif); err != nil {
log.WithError(err).Error("Travis-CI webhook received an invalid JSON payload=")
w.WriteHeader(400)
return
}
if notif.Repository.OwnerName == "" || notif.Repository.Name == "" {
log.WithField("repo", notif.Repository).Error("Travis-CI webhook missing repository fields")
w.WriteHeader(400)
return
}
whForRepo := notif.Repository.OwnerName + "/" + notif.Repository.Name
tmplData := notifToTemplate(notif)
logger := log.WithFields(log.Fields{
"repo": whForRepo,
})
for roomID, roomData := range s.Rooms {
for ownerRepo, repoData := range roomData.Repos {
if ownerRepo != whForRepo {
continue
}
msg := mevt.MessageEventContent{
Body: outputForTemplate(repoData.Template, tmplData),
MsgType: "m.notice",
}
logger.WithFields(log.Fields{
"message": msg,
"room_id": roomID,
}).Print("Sending Travis-CI notification to room")
if _, e := cli.SendMessageEvent(roomID, mevt.EventMessage, msg); e != nil {
logger.WithError(e).WithField("room_id", roomID).Print(
"Failed to send Travis-CI notification to room.")
}
}
}
w.WriteHeader(200)
}
// Register makes sure the Config information supplied is valid.
func (s *Service) Register(oldService types.Service, client *mautrix.Client) error {
s.WebhookURL = s.webhookEndpointURL
for _, roomData := range s.Rooms {
for repo := range roomData.Repos {
match := ownerRepoRegex.FindStringSubmatch(repo)
if len(match) == 0 {
return fmt.Errorf("Repository '%s' is not a valid repository name", repo)
}
}
}
s.joinRooms(client)
return nil
}
// PostRegister deletes this service if there are no registered repos.
func (s *Service) PostRegister(oldService types.Service) {
for _, roomData := range s.Rooms {
for range roomData.Repos {
return // at least 1 repo exists
}
}
// Delete this service since no repos are configured
logger := log.WithFields(log.Fields{
"service_type": s.ServiceType(),
"service_id": s.ServiceID(),
})
logger.Info("Removing service as no repositories are registered.")
if err := database.GetServiceDB().DeleteService(s.ServiceID()); err != nil {
logger.WithError(err).Error("Failed to delete service")
}
}
func (s *Service) joinRooms(client *mautrix.Client) {
for roomID := range s.Rooms {
if _, err := client.JoinRoom(roomID.String(), "", nil); err != nil {
log.WithFields(log.Fields{
log.ErrorKey: err,
"room_id": roomID,
"user_id": client.UserID,
}).Error("Failed to join room")
}
}
}
func init() {
types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service {
return &Service{
DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
webhookEndpointURL: webhookEndpointURL,
}
})
}