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.

295 lines
10 KiB

8 years ago
8 years ago
8 years ago
8 years ago
  1. // Package travisci implements a Service capable of processing webhooks from Travis-CI.
  2. package travisci
  3. import (
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/matrix-org/go-neb/database"
  12. "github.com/matrix-org/go-neb/types"
  13. log "github.com/sirupsen/logrus"
  14. mevt "maunium.net/go/mautrix/event"
  15. "maunium.net/go/mautrix/id"
  16. )
  17. // ServiceType of the Travis-CI service.
  18. const ServiceType = "travis-ci"
  19. // DefaultTemplate contains the template that will be used if none is supplied.
  20. // This matches the default mentioned at: https://docs.travis-ci.com/user/notifications#Customizing-slack-notifications
  21. const DefaultTemplate = (`%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}
  22. Change view : %{compare_url}
  23. Build details : %{build_url}`)
  24. // Matches 'owner/repo'
  25. var ownerRepoRegex = regexp.MustCompile(`^([A-z0-9-_.]+)/([A-z0-9-_.]+)$`)
  26. var httpClient = &http.Client{}
  27. // Service contains the Config fields for the Travis-CI service.
  28. //
  29. // This service will send notifications into a Matrix room when Travis-CI sends
  30. // webhook events to it. It requires a public domain which Travis-CI can reach.
  31. // Notices will be sent as the service user ID.
  32. //
  33. // Example JSON request:
  34. // {
  35. // rooms: {
  36. // "!ewfug483gsfe:localhost": {
  37. // repos: {
  38. // "matrix-org/go-neb": {
  39. // template: "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}\nBuild details : %{build_url}"
  40. // }
  41. // }
  42. // }
  43. // }
  44. // }
  45. type Service struct {
  46. types.DefaultService
  47. webhookEndpointURL string
  48. // The URL which should be added to .travis.yml - Populated by Go-NEB after Service registration.
  49. WebhookURL string `json:"webhook_url"`
  50. // A map from Matrix room ID to Github-style owner/repo repositories.
  51. Rooms map[id.RoomID]struct {
  52. // A map of "owner/repo" to configuration information
  53. Repos map[string]struct {
  54. // The template string to use when creating notifications.
  55. //
  56. // This is identical to the format of Slack Notifications for Travis-CI:
  57. // https://docs.travis-ci.com/user/notifications#Customizing-slack-notifications
  58. //
  59. // The following variables are available:
  60. // repository_slug: your GitHub repo identifier (like svenfuchs/minimal)
  61. // repository_name: the slug without the username
  62. // build_number: build number
  63. // build_id: build id
  64. // branch: branch build name
  65. // commit: shortened commit SHA
  66. // author: commit author name
  67. // commit_message: commit message of build
  68. // commit_subject: first line of the commit message
  69. // result: result of build
  70. // message: Travis CI message to the build
  71. // duration: total duration of all builds in the matrix
  72. // elapsed_time: time between build start and finish
  73. // compare_url: commit change view URL
  74. // build_url: URL of the build detail
  75. Template string `json:"template"`
  76. } `json:"repos"`
  77. } `json:"rooms"`
  78. }
  79. // The payload from Travis-CI
  80. type webhookNotification struct {
  81. ID int `json:"id"`
  82. Number string `json:"number"`
  83. Status *int `json:"status"` // 0 (success) or 1 (incomplete/fail).
  84. StartedAt *string `json:"started_at"`
  85. FinishedAt *string `json:"finished_at"`
  86. StatusMessage string `json:"status_message"`
  87. Commit string `json:"commit"`
  88. Branch string `json:"branch"`
  89. Message string `json:"message"`
  90. CompareURL string `json:"compare_url"`
  91. CommittedAt string `json:"committed_at"`
  92. CommitterName string `json:"committer_name"`
  93. CommitterEmail string `json:"committer_email"`
  94. AuthorName string `json:"author_name"`
  95. AuthorEmail string `json:"author_email"`
  96. Type string `json:"type"`
  97. BuildURL string `json:"build_url"`
  98. Repository struct {
  99. Name string `json:"name"`
  100. OwnerName string `json:"owner_name"`
  101. URL string `json:"url"`
  102. } `json:"repository"`
  103. }
  104. // Converts a webhook notification into a map of template var name to value
  105. func notifToTemplate(n webhookNotification) map[string]string {
  106. t := make(map[string]string)
  107. t["repository_slug"] = n.Repository.OwnerName + "/" + n.Repository.Name
  108. t["repository"] = t["repository_slug"] // Deprecated form but still used everywhere in people's templates
  109. t["repository_name"] = n.Repository.Name
  110. t["build_number"] = n.Number
  111. t["build_id"] = strconv.Itoa(n.ID)
  112. t["branch"] = n.Branch
  113. shaLength := len(n.Commit)
  114. if shaLength > 10 {
  115. shaLength = 10
  116. }
  117. t["commit"] = n.Commit[:shaLength] // shortened commit SHA
  118. t["author"] = n.CommitterName // author: commit author name
  119. // commit_message: commit message of build
  120. // commit_subject: first line of the commit message
  121. t["commit_message"] = n.Message
  122. subjAndMsg := strings.SplitN(n.Message, "\n", 2)
  123. t["commit_subject"] = subjAndMsg[0]
  124. if n.Status != nil {
  125. t["result"] = strconv.Itoa(*n.Status)
  126. }
  127. t["message"] = n.StatusMessage // message: Travis CI message to the build
  128. if n.StartedAt != nil && n.FinishedAt != nil {
  129. // duration: total duration of all builds in the matrix -- TODO
  130. // elapsed_time: time between build start and finish
  131. // Example from docs: "2011-11-11T11:11:11Z"
  132. start, err := time.Parse("2006-01-02T15:04:05Z", *n.StartedAt)
  133. finish, err2 := time.Parse("2006-01-02T15:04:05Z", *n.FinishedAt)
  134. if err != nil || err2 != nil {
  135. log.WithFields(log.Fields{
  136. "started_at": *n.StartedAt,
  137. "finished_at": *n.FinishedAt,
  138. }).Warn("Failed to parse Travis-CI start/finish times.")
  139. } else {
  140. t["duration"] = finish.Sub(start).String()
  141. t["elapsed_time"] = t["duration"]
  142. }
  143. }
  144. t["compare_url"] = n.CompareURL
  145. t["build_url"] = n.BuildURL
  146. return t
  147. }
  148. func outputForTemplate(travisTmpl string, tmpl map[string]string) (out string) {
  149. if travisTmpl == "" {
  150. travisTmpl = DefaultTemplate
  151. }
  152. out = travisTmpl
  153. for tmplVar, tmplValue := range tmpl {
  154. out = strings.Replace(out, "%{"+tmplVar+"}", tmplValue, -1)
  155. }
  156. return out
  157. }
  158. // OnReceiveWebhook receives requests from Travis-CI and possibly sends requests to Matrix as a result.
  159. //
  160. // If the repository matches a known Github repository, a notification will be formed from the
  161. // template for that repository and a notice will be sent to Matrix.
  162. //
  163. // Go-NEB cannot register with Travis-CI for webhooks automatically. The user must manually add the
  164. // webhook endpoint URL to their .travis.yml file:
  165. // notifications:
  166. // webhooks: http://go-neb-endpoint.com/notifications
  167. //
  168. // See https://docs.travis-ci.com/user/notifications#Webhook-notifications for more information.
  169. func (s *Service) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli types.MatrixClient) {
  170. if err := req.ParseForm(); err != nil {
  171. log.WithError(err).Error("Failed to read incoming Travis-CI webhook form")
  172. w.WriteHeader(400)
  173. return
  174. }
  175. payload := req.PostFormValue("payload")
  176. if payload == "" {
  177. log.Error("Travis-CI webhook is missing payload= form value")
  178. w.WriteHeader(400)
  179. return
  180. }
  181. if err := verifyOrigin([]byte(payload), req.Header.Get("Signature")); err != nil {
  182. log.WithFields(log.Fields{
  183. "Signature": req.Header.Get("Signature"),
  184. log.ErrorKey: err,
  185. }).Warn("Received unauthorised Travis-CI webhook request.")
  186. w.WriteHeader(403)
  187. return
  188. }
  189. var notif webhookNotification
  190. if err := json.Unmarshal([]byte(payload), &notif); err != nil {
  191. log.WithError(err).Error("Travis-CI webhook received an invalid JSON payload=")
  192. w.WriteHeader(400)
  193. return
  194. }
  195. if notif.Repository.OwnerName == "" || notif.Repository.Name == "" {
  196. log.WithField("repo", notif.Repository).Error("Travis-CI webhook missing repository fields")
  197. w.WriteHeader(400)
  198. return
  199. }
  200. whForRepo := notif.Repository.OwnerName + "/" + notif.Repository.Name
  201. tmplData := notifToTemplate(notif)
  202. logger := log.WithFields(log.Fields{
  203. "repo": whForRepo,
  204. })
  205. for roomID, roomData := range s.Rooms {
  206. for ownerRepo, repoData := range roomData.Repos {
  207. if ownerRepo != whForRepo {
  208. continue
  209. }
  210. msg := mevt.MessageEventContent{
  211. Body: outputForTemplate(repoData.Template, tmplData),
  212. MsgType: "m.notice",
  213. }
  214. logger.WithFields(log.Fields{
  215. "message": msg,
  216. "room_id": roomID,
  217. }).Print("Sending Travis-CI notification to room")
  218. if _, e := cli.SendMessageEvent(roomID, mevt.EventMessage, msg); e != nil {
  219. logger.WithError(e).WithField("room_id", roomID).Print(
  220. "Failed to send Travis-CI notification to room.")
  221. }
  222. }
  223. }
  224. w.WriteHeader(200)
  225. }
  226. // Register makes sure the Config information supplied is valid.
  227. func (s *Service) Register(oldService types.Service, client types.MatrixClient) error {
  228. s.WebhookURL = s.webhookEndpointURL
  229. for _, roomData := range s.Rooms {
  230. for repo := range roomData.Repos {
  231. match := ownerRepoRegex.FindStringSubmatch(repo)
  232. if len(match) == 0 {
  233. return fmt.Errorf("Repository '%s' is not a valid repository name", repo)
  234. }
  235. }
  236. }
  237. s.joinRooms(client)
  238. return nil
  239. }
  240. // PostRegister deletes this service if there are no registered repos.
  241. func (s *Service) PostRegister(oldService types.Service) {
  242. for _, roomData := range s.Rooms {
  243. for range roomData.Repos {
  244. return // at least 1 repo exists
  245. }
  246. }
  247. // Delete this service since no repos are configured
  248. logger := log.WithFields(log.Fields{
  249. "service_type": s.ServiceType(),
  250. "service_id": s.ServiceID(),
  251. })
  252. logger.Info("Removing service as no repositories are registered.")
  253. if err := database.GetServiceDB().DeleteService(s.ServiceID()); err != nil {
  254. logger.WithError(err).Error("Failed to delete service")
  255. }
  256. }
  257. func (s *Service) joinRooms(client types.MatrixClient) {
  258. for roomID := range s.Rooms {
  259. if _, err := client.JoinRoom(roomID.String(), "", nil); err != nil {
  260. log.WithFields(log.Fields{
  261. log.ErrorKey: err,
  262. "room_id": roomID,
  263. }).Error("Failed to join room")
  264. }
  265. }
  266. }
  267. func init() {
  268. types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service {
  269. return &Service{
  270. DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
  271. webhookEndpointURL: webhookEndpointURL,
  272. }
  273. })
  274. }