|
|
// 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" 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 types.MatrixClient) { 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), ¬if); 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 types.MatrixClient) 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 types.MatrixClient) { for roomID := range s.Rooms { if _, err := client.JoinRoom(roomID.String(), "", nil); err != nil { log.WithFields(log.Fields{ log.ErrorKey: err, "room_id": roomID, }).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, } }) }
|