mirror of https://github.com/matrix-org/go-neb.git
No known key found for this signature in database
GPG Key ID: E5B89311FAB91B9F
3 changed files with 433 additions and 0 deletions
-
248src/github.com/matrix-org/go-neb/services/circleci/circleci.go
-
141src/github.com/matrix-org/go-neb/services/circleci/circleci_test.go
-
44src/github.com/matrix-org/go-neb/services/circleci/types.go
@ -0,0 +1,248 @@ |
|||
// Package circleci implements a Service capable of processing webhooks from CircleCI.
|
|||
package circleci |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"net/http" |
|||
"regexp" |
|||
"strings" |
|||
|
|||
log "github.com/Sirupsen/logrus" |
|||
"github.com/matrix-org/go-neb/database" |
|||
"github.com/matrix-org/go-neb/types" |
|||
"github.com/matrix-org/gomatrix" |
|||
"io/ioutil" |
|||
) |
|||
|
|||
// ServiceType of the Travis-CI service.
|
|||
const ServiceType = "circleci" |
|||
|
|||
// 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} |
|||
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 CircleCI sends
|
|||
// webhook events to it. It requires a public domain which CircleCI 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 .circleci/config.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[string]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 Git* 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: CircleCI 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"` |
|||
} |
|||
|
|||
// 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) |
|||
//Get Payload to variable
|
|||
p := n.Payload |
|||
t["repository_slug"] = p.Username + "/" + p.Reponame |
|||
t["repository"] = t["repository_slug"] // Deprecated form but still used everywhere in people's templates
|
|||
t["repository_name"] = p.Reponame |
|||
t["build_number"] = string(p.BuildNum) |
|||
t["build_id"] = t["build_number"] // CircleCI doesn't have a difference between number and ID but to be consistent with TravisCI
|
|||
t["branch"] = p.Branch |
|||
shaLength := len(p.VcsRevision) |
|||
if shaLength > 10 { |
|||
shaLength = 10 |
|||
} |
|||
t["commit"] = p.VcsRevision[:shaLength] // shortened commit SHA
|
|||
t["author"] = p.CommitterName // author: commit author name
|
|||
// commit_message: commit message of build
|
|||
// commit_subject: first line of the commit message
|
|||
t["commit_message"] = p.Body |
|||
subjAndMsg := strings.SplitN(p.Body, "\n", 2) |
|||
t["commit_subject"] = subjAndMsg[0] |
|||
if p.Status != "" { |
|||
t["result"] = p.Status |
|||
} |
|||
t["message"] = p.Outcome // message: Travis CI message to the build
|
|||
|
|||
if !p.StartTime.IsZero() && !p.StopTime.IsZero() { |
|||
t["duration"] = p.StopTime.Sub(p.StartTime).String() |
|||
t["elapsed_time"] = t["duration"] |
|||
} |
|||
|
|||
t["build_url"] = p.BuildURL |
|||
return t |
|||
} |
|||
|
|||
func outputForTemplate(circleciTmpl string, tmpl map[string]string) (out string) { |
|||
if circleciTmpl == "" { |
|||
circleciTmpl = DefaultTemplate |
|||
} |
|||
out = circleciTmpl |
|||
for tmplVar, tmplValue := range tmpl { |
|||
out = strings.Replace(out, "%{"+tmplVar+"}", tmplValue, -1) |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// OnReceiveWebhook receives requests from CircleCI and possibly sends requests to Matrix as a result.
|
|||
//
|
|||
// If the repository matches a known Git* 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 CircleCI for webhooks automatically. The user must manually add the
|
|||
// webhook endpoint URL to their .circleci/config.yml file:
|
|||
// notify:
|
|||
// webhooks:
|
|||
// - url: https://example.com/hooks/circle
|
|||
//
|
|||
// See https://circleci.com/docs/1.0/configuration/#notify for more information.
|
|||
func (s *Service) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *gomatrix.Client) { |
|||
body, err := ioutil.ReadAll(req.Body) |
|||
if err != nil { |
|||
log.WithFields(log.Fields{ |
|||
"Body": req.Body, |
|||
log.ErrorKey: err, |
|||
}).Warn("Failed to Read Body") |
|||
w.WriteHeader(403) |
|||
return |
|||
} |
|||
//fmt.Printf("%s\n", body)
|
|||
|
|||
var notif WebhookNotification |
|||
if err := json.Unmarshal(body, ¬if); err != nil { |
|||
log.WithError(err).Error("CircleCI webhook received an invalid JSON body") |
|||
w.WriteHeader(400) |
|||
return |
|||
} |
|||
if notif.Payload.Username == "" || notif.Payload.Reponame == "" { |
|||
log.WithField("repo", notif.Payload).Error("CircleCI webhook missing repository fields") |
|||
w.WriteHeader(400) |
|||
return |
|||
} |
|||
whForRepo := notif.Payload.Username + "/" + notif.Payload.Reponame |
|||
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 := gomatrix.TextMessage{ |
|||
Body: outputForTemplate(repoData.Template, tmplData), |
|||
MsgType: "m.notice", |
|||
} |
|||
|
|||
logger.WithFields(log.Fields{ |
|||
"message": msg, |
|||
"room_id": roomID, |
|||
}).Print("Sending CircleCI notification to room") |
|||
if _, e := cli.SendMessageEvent(roomID, "m.room.message", msg); e != nil { |
|||
logger.WithError(e).WithField("room_id", roomID).Print( |
|||
"Failed to send CircleCI notification to room.") |
|||
} |
|||
} |
|||
} |
|||
w.WriteHeader(200) |
|||
} |
|||
|
|||
// Register makes sure the Config information supplied is valid.
|
|||
func (s *Service) Register(oldService types.Service, client *gomatrix.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 *gomatrix.Client) { |
|||
for roomID := range s.Rooms { |
|||
if _, err := client.JoinRoom(roomID, "", 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, serviceUserID, webhookEndpointURL string) types.Service { |
|||
return &Service{ |
|||
DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), |
|||
webhookEndpointURL: webhookEndpointURL, |
|||
} |
|||
}) |
|||
} |
|||
@ -0,0 +1,141 @@ |
|||
package circleci |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"github.com/matrix-org/go-neb/database" |
|||
"github.com/matrix-org/go-neb/testutils" |
|||
"github.com/matrix-org/go-neb/types" |
|||
"github.com/matrix-org/gomatrix" |
|||
"net/url" |
|||
) |
|||
|
|||
const exampleBody = ("%7B%22payload%22%3A%7B%22vcs_url%22%3A%22https%3A%2F%2Fgithub.com%2Fcircleci%2Fmongofinil%22%2C%22build_url%22%3A%22https%3A%2F%2Fcircleci.com%2Fgh%2Fcircleci%2Fmongofinil%2F22%22%2C%22build_num%22%3A22%2C%22branch%22%3A%22master%22%2C%22vcs_revision%22%3A%221d231626ba1d2838e599c5c598d28e2306ad4e48%22%2C%22committer_name%22%3A%22Allen%20Rohner%22%2C%22committer_email%22%3A%22arohner%40gmail.com%22%2C%22subject%22%3A%22Don%27t%20explode%20when%20the%20system%20clock%20shifts%20backwards%22%2C%22body%22%3A%22%22%2C%22why%22%3A%22github%22%2C%22dont_build%22%3Anull%2C%22queued_at%22%3A%222013-02-12T21%3A33%3A30Z%22%2C%22start_time%22%3A%222013-02-12T21%3A33%3A38Z%22%2C%22stop_time%22%3A%222013-02-12T21%3A34%3A01Z%22%2C%22build_time_millis%22%3A23505%2C%22username%22%3A%22circleci%22%2C%22reponame%22%3A%22mongofinil%22%2C%22lifecycle%22%3A%22finished%22%2C%22outcome%22%3A%22success%22%2C%22status%22%3A%22success%22%2C%22retry_of%22%3Anull%2C%22steps%22%3A%5B%7B%22name%22%3A%22configure%20the%20build%22%2C%22actions%22%3A%5B%7B%22bash_command%22%3Anull%2C%22run_time_millis%22%3A1646%2C%22start_time%22%3A%222013-02-12T21%3A33%3A38Z%22%2C%22end_time%22%3A%222013-02-12T21%3A33%3A39Z%22%2C%22name%22%3A%22configure%20the%20build%22%2C%22exit_code%22%3Anull%2C%22type%22%3A%22infrastructure%22%2C%22index%22%3A0%2C%22status%22%3A%22success%22%7D%5D%7D%2C%7B%22name%22%3A%22lein2%20deps%22%2C%22actions%22%3A%5B%7B%22bash_command%22%3A%22lein2%20deps%22%2C%22run_time_millis%22%3A7555%2C%22start_time%22%3A%222013-02-12T21%3A33%3A47Z%22%2C%22messages%22%3A%5B%5D%2C%22step%22%3A1%2C%22exit_code%22%3A0%2C%22end_time%22%3A%222013-02-12T21%3A33%3A54Z%22%2C%22index%22%3A0%2C%22status%22%3A%22success%22%2C%22type%22%3A%22dependencies%22%2C%22source%22%3A%22inference%22%2C%22failed%22%3Anull%7D%5D%7D%2C%7B%22name%22%3A%22lein2%20trampoline%20midje%22%2C%22actions%22%3A%5B%7B%22bash_command%22%3A%22lein2%20trampoline%20midje%22%2C%22run_time_millis%22%3A2310%2C%22continue%22%3Anull%2C%22parallel%22%3Atrue%2C%22start_time%22%3A%222013-02-12T21%3A33%3A59Z%22%2C%22name%22%3A%22lein2%20trampoline%20midje%22%2C%22messages%22%3A%5B%5D%2C%22step%22%3A6%2C%22exit_code%22%3A1%2C%22end_time%22%3A%222013-02-12T21%3A34%3A01Z%22%2C%22index%22%3A0%2C%22status%22%3A%22failed%22%2C%22timedout%22%3Anull%2C%22infrastructure_fail%22%3Anull%2C%22type%22%3A%22test%22%2C%22source%22%3A%22inference%22%2C%22failed%22%3Atrue%7D%5D%7D%5D%7D%7D") |
|||
var circleciTests = []struct { |
|||
Body string |
|||
Template string |
|||
ExpectedOutput string |
|||
}{ |
|||
{ |
|||
exampleBody, |
|||
"%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}", |
|||
"circleci/mongofinil#22 (master - 1d231626ba : Allen Rohner): success", |
|||
}, |
|||
{ |
|||
exampleBody, |
|||
"%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}", |
|||
"circleci/mongofinil#22 (master - 1d231626ba : Allen Rohner): success", |
|||
}, |
|||
{ |
|||
strings.TrimSuffix(exampleBody, "%7D") + "%2C%22EXTRA_KEY%22%3Anull%7D", |
|||
"%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}", |
|||
"circleci/mongofinil#22 (master - 1d231626ba : Allen Rohner): success", |
|||
}, |
|||
{ |
|||
exampleBody, |
|||
"%{repository}#%{build_number} %{duration}", |
|||
"circleci/mongofinil#22 23s", |
|||
}, |
|||
} |
|||
|
|||
func TestCircleCI(t *testing.T) { |
|||
database.SetServiceDB(&database.NopStorage{}) |
|||
|
|||
// Intercept message sending to Matrix and mock responses
|
|||
msgs := []gomatrix.TextMessage{} |
|||
matrixTrans := struct{ testutils.MockTransport }{} |
|||
matrixTrans.RT = func(req *http.Request) (*http.Response, error) { |
|||
if !strings.Contains(req.URL.String(), "/send/m.room.message") { |
|||
return nil, fmt.Errorf("Unhandled URL: %s", req.URL.String()) |
|||
} |
|||
var msg gomatrix.TextMessage |
|||
if err := json.NewDecoder(req.Body).Decode(&msg); err != nil { |
|||
return nil, fmt.Errorf("Failed to decode request JSON: %s", err) |
|||
} |
|||
msgs = append(msgs, msg) |
|||
return &http.Response{ |
|||
StatusCode: 200, |
|||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"event_id":"$yup:event"}`)), |
|||
}, nil |
|||
} |
|||
matrixCli, _ := gomatrix.NewClient("https://hyrule", "@travisci:hyrule", "its_a_secret") |
|||
matrixCli.Client = &http.Client{Transport: matrixTrans} |
|||
|
|||
// BEGIN running the CircleCI table tests
|
|||
// ---------------------------------------
|
|||
for _, test := range circleciTests { |
|||
msgs = []gomatrix.TextMessage{} // reset sent messages
|
|||
mockWriter := httptest.NewRecorder() |
|||
circleci := makeService(t, test.Template) |
|||
if circleci == nil { |
|||
t.Error("TestCircleCI Failed to create service") |
|||
continue |
|||
} |
|||
if err := circleci.Register(nil, matrixCli); err != nil { |
|||
t.Errorf("TestCircleCI Failed to Register(): %s", err) |
|||
continue |
|||
} |
|||
correctBody, convErr := url.QueryUnescape(test.Body) |
|||
if convErr != nil { |
|||
t.Errorf("TestCircleCI Failed to UnUrlEncode Test Body: %s", convErr) |
|||
continue |
|||
} |
|||
req, err := http.NewRequest( |
|||
"POST", "https://neb.endpoint/circleci-service", bytes.NewBufferString(correctBody), |
|||
) |
|||
if err != nil { |
|||
t.Errorf("TestCircleCI Failed to create webhook request: %s", err) |
|||
continue |
|||
} |
|||
req.Header.Set("Content-Type", "application/json") |
|||
circleci.OnReceiveWebhook(mockWriter, req, matrixCli) |
|||
|
|||
if !assertResponse(t, mockWriter, msgs, 200, 1) { |
|||
continue |
|||
} |
|||
|
|||
if msgs[0].Body != test.ExpectedOutput { |
|||
t.Errorf("TestCircleCI want matrix body '%s', got '%s'", test.ExpectedOutput, msgs[0].Body) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func assertResponse(t *testing.T, w *httptest.ResponseRecorder, msgs []gomatrix.TextMessage, expectCode int, expectMsgLength int) bool { |
|||
if w.Code != expectCode { |
|||
t.Errorf("TestCircleCI OnReceiveWebhook want HTTP code %d, got %d", expectCode, w.Code) |
|||
return false |
|||
} |
|||
if len(msgs) != expectMsgLength { |
|||
t.Errorf("TestCircleCI want %d sent messages, got %d ", expectMsgLength, len(msgs)) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
func makeService(t *testing.T, template string) *Service { |
|||
srv, err := types.CreateService("id", ServiceType, "@travisci:hyrule", []byte( |
|||
`{ |
|||
"rooms":{ |
|||
"!ewfug483gsfe:localhost": { |
|||
"repos": { |
|||
"circleci/mongofinil": { |
|||
"template": "`+template+`" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}`, |
|||
)) |
|||
if err != nil { |
|||
t.Error("Failed to create CircleCI service: ", err) |
|||
return nil |
|||
} |
|||
return srv.(*Service) |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
package circleci |
|||
|
|||
import "time" |
|||
|
|||
// WebhookNotification is the response of a webhook notification by circleCI
|
|||
type WebhookNotification struct { |
|||
Payload struct { |
|||
VcsURL string `json:"vcs_url,omitempty"` |
|||
BuildURL string `json:"build_url,omitempty"` |
|||
BuildNum int `json:"build_num,omitempty"` |
|||
Branch string `json:"branch,omitempty"` |
|||
VcsRevision string `json:"vcs_revision,omitempty"` |
|||
CommitterName string `json:"committer_name,omitempty"` |
|||
CommitterEmail string `json:"committer_email,omitempty"` |
|||
Subject string `json:"subject,omitempty"` |
|||
Body string `json:"body,omitempty"` |
|||
Why string `json:"why,omitempty"` |
|||
DontBuild interface{} `json:"dont_build,omitempty"` |
|||
QueuedAt time.Time `json:"queued_at,omitempty"` |
|||
StartTime time.Time `json:"start_time,omitempty"` |
|||
StopTime time.Time `json:"stop_time,omitempty"` |
|||
BuildTimeMillis int `json:"build_time_millis,omitempty"` |
|||
Username string `json:"username,omitempty"` |
|||
Reponame string `json:"reponame,omitempty"` |
|||
Lifecycle string `json:"lifecycle,omitempty"` |
|||
Outcome string `json:"outcome,omitempty"` |
|||
Status string `json:"status,omitempty"` |
|||
RetryOf interface{} `json:"retry_of,omitempty"` |
|||
Steps []struct { |
|||
Name string `json:"name,omitempty"` |
|||
Actions []struct { |
|||
BashCommand interface{} `json:"bash_command,omitempty"` |
|||
RunTimeMillis int `json:"run_time_millis,omitempty"` |
|||
StartTime time.Time `json:"start_time,omitempty"` |
|||
EndTime time.Time `json:"end_time,omitempty"` |
|||
Name string `json:"name,omitempty"` |
|||
ExitCode interface{} `json:"exit_code,omitempty"` |
|||
Type string `json:"type,omitempty"` |
|||
Index int `json:"index,omitempty"` |
|||
Status string `json:"status,omitempty"` |
|||
} `json:"actions,omitempty"` |
|||
} `json:"steps,omitempty"` |
|||
} `json:"payload"` |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue