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.

456 lines
14 KiB

8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
  1. // Package jira implements a command and webhook service for interacting with JIRA.
  2. //
  3. // The service adds !commands and issue expansions, in addition to JIRA webhook support.
  4. package jira
  5. import (
  6. "database/sql"
  7. "errors"
  8. "fmt"
  9. "html"
  10. "net/http"
  11. "regexp"
  12. "strings"
  13. gojira "github.com/andygrunwald/go-jira"
  14. "github.com/matrix-org/go-neb/database"
  15. "github.com/matrix-org/go-neb/matrix"
  16. "github.com/matrix-org/go-neb/realms/jira"
  17. "github.com/matrix-org/go-neb/realms/jira/urls"
  18. "github.com/matrix-org/go-neb/services/jira/webhook"
  19. "github.com/matrix-org/go-neb/types"
  20. "github.com/matrix-org/gomatrix"
  21. log "github.com/sirupsen/logrus"
  22. )
  23. // ServiceType of the JIRA Service
  24. const ServiceType = "jira"
  25. // Matches alphas then a -, then a number. E.g "FOO-123"
  26. var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)")
  27. var projectKeyRegex = regexp.MustCompile("^[A-z]+$")
  28. // Service contains the Config fields for the JIRA service.
  29. //
  30. // Before you can set up a JIRA Service, you need to set up a JIRA Realm.
  31. //
  32. // Example request:
  33. // {
  34. // Rooms: {
  35. // "!qmElAGdFYCHoCJuaNt:localhost": {
  36. // Realms: {
  37. // "jira-realm-id": {
  38. // Projects: {
  39. // "SYN": { Expand: true },
  40. // "BOTS": { Expand: true, Track: true }
  41. // }
  42. // }
  43. // }
  44. // }
  45. // }
  46. // }
  47. type Service struct {
  48. types.DefaultService
  49. webhookEndpointURL string
  50. // The user ID to create issues as, or to create/delete webhooks as. This user
  51. // is also used to look up issues for expansions.
  52. ClientUserID string
  53. // A map from Matrix room ID to JIRA realms and project keys.
  54. Rooms map[string]struct {
  55. // A map of realm IDs to project keys. The realm IDs determine the JIRA
  56. // endpoint used.
  57. Realms map[string]struct {
  58. // A map of project keys e.g. "SYN" to config options.
  59. Projects map[string]struct {
  60. // True to expand issues with this key e.g "SYN-123" will be expanded.
  61. Expand bool
  62. // True to add a webhook to this project and send updates into the room.
  63. Track bool
  64. }
  65. }
  66. }
  67. }
  68. // Register ensures that the given realm IDs are valid JIRA realms and registers webhooks
  69. // with those JIRA endpoints.
  70. func (s *Service) Register(oldService types.Service, client *gomatrix.Client) error {
  71. // We only ever make 1 JIRA webhook which listens for all projects and then filter
  72. // on receive. So we simply need to know if we need to make a webhook or not. We
  73. // need to do this for each unique realm.
  74. for realmID, pkeys := range projectsAndRealmsToTrack(s) {
  75. realm, err := database.GetServiceDB().LoadAuthRealm(realmID)
  76. if err != nil {
  77. return err
  78. }
  79. jrealm, ok := realm.(*jira.Realm)
  80. if !ok {
  81. return errors.New("Realm ID doesn't map to a JIRA realm")
  82. }
  83. if err = webhook.RegisterHook(jrealm, pkeys, s.ClientUserID, s.webhookEndpointURL); err != nil {
  84. return err
  85. }
  86. }
  87. return nil
  88. }
  89. func (s *Service) cmdJiraCreate(roomID, userID string, args []string) (interface{}, error) {
  90. // E.g jira create PROJ "Issue title" "Issue desc"
  91. if len(args) <= 1 {
  92. return nil, errors.New("Missing project key (e.g 'ABC') and/or title")
  93. }
  94. if !projectKeyRegex.MatchString(args[0]) {
  95. return nil, errors.New("Project key must only contain A-Z")
  96. }
  97. pkey := strings.ToUpper(args[0]) // REST API complains if they are not ALL CAPS
  98. title := args[1]
  99. desc := ""
  100. if len(args) == 3 {
  101. desc = args[2]
  102. } else if len(args) > 3 { // > 3 args is probably a title without quote marks
  103. joinedTitle := strings.Join(args[1:], " ")
  104. title = joinedTitle
  105. }
  106. r, err := s.projectToRealm(userID, pkey)
  107. if err != nil {
  108. log.WithError(err).Print("Failed to map project key to realm")
  109. return nil, errors.New("Failed to map project key to a JIRA endpoint")
  110. }
  111. if r == nil {
  112. return nil, errors.New("No known project exists with that project key")
  113. }
  114. iss := gojira.Issue{
  115. Fields: &gojira.IssueFields{
  116. Summary: title,
  117. Description: desc,
  118. Project: gojira.Project{
  119. Key: pkey,
  120. },
  121. // FIXME: This may vary depending on the JIRA install!
  122. Type: gojira.IssueType{
  123. Name: "Bug",
  124. },
  125. },
  126. }
  127. cli, err := r.JIRAClient(userID, false)
  128. if err != nil {
  129. if err == sql.ErrNoRows { // no client found
  130. return matrix.StarterLinkMessage{
  131. Body: fmt.Sprintf(
  132. "You need to OAuth with JIRA on %s before you can create issues.",
  133. r.JIRAEndpoint,
  134. ),
  135. Link: r.StarterLink,
  136. }, nil
  137. }
  138. return nil, err
  139. }
  140. i, res, err := cli.Issue.Create(&iss)
  141. if err != nil {
  142. log.WithFields(log.Fields{
  143. log.ErrorKey: err,
  144. "user_id": userID,
  145. "project": pkey,
  146. "realm_id": r.ID(),
  147. }).Print("Failed to create issue")
  148. return nil, errors.New("Failed to create issue")
  149. }
  150. if res.StatusCode < 200 || res.StatusCode >= 300 {
  151. return nil, fmt.Errorf("Failed to create issue: JIRA returned %d", res.StatusCode)
  152. }
  153. return &gomatrix.TextMessage{
  154. "m.notice",
  155. fmt.Sprintf("Created issue: %sbrowse/%s", r.JIRAEndpoint, i.Key),
  156. }, nil
  157. }
  158. func (s *Service) expandIssue(roomID, userID string, issueKeyGroups []string) interface{} {
  159. // issueKeyGroups => ["SYN-123", "SYN", "123"]
  160. if len(issueKeyGroups) != 3 {
  161. log.WithField("groups", issueKeyGroups).Error("Bad number of groups")
  162. return nil
  163. }
  164. issueKey := strings.ToUpper(issueKeyGroups[0])
  165. logger := log.WithField("issue_key", issueKey)
  166. projectKey := strings.ToUpper(issueKeyGroups[1])
  167. realmID := s.realmIDForProject(roomID, projectKey)
  168. if realmID == "" {
  169. return nil
  170. }
  171. r, err := database.GetServiceDB().LoadAuthRealm(realmID)
  172. if err != nil {
  173. logger.WithFields(log.Fields{
  174. "realm_id": realmID,
  175. log.ErrorKey: err,
  176. }).Print("Failed to load realm")
  177. return nil
  178. }
  179. jrealm, ok := r.(*jira.Realm)
  180. if !ok {
  181. logger.WithField("realm_id", realmID).Print(
  182. "Realm cannot be typecast to jira.Realm",
  183. )
  184. }
  185. logger.WithFields(log.Fields{
  186. "room_id": roomID,
  187. "user_id": s.ClientUserID,
  188. }).Print("Expanding issue")
  189. // Use the person who *provisioned* the service to check for project keys
  190. // rather than the person who mentioned the issue key, as it is unlikely
  191. // some random who mentioned the issue will have the intended auth.
  192. cli, err := jrealm.JIRAClient(s.ClientUserID, false)
  193. if err != nil {
  194. logger.WithFields(log.Fields{
  195. log.ErrorKey: err,
  196. "user_id": s.ClientUserID,
  197. }).Print("Failed to retrieve client")
  198. return nil
  199. }
  200. issue, _, err := cli.Issue.Get(issueKey, nil)
  201. if err != nil {
  202. logger.WithError(err).Print("Failed to GET issue")
  203. return err
  204. }
  205. return gomatrix.GetHTMLMessage(
  206. "m.notice",
  207. fmt.Sprintf(
  208. "%sbrowse/%s : %s",
  209. jrealm.JIRAEndpoint, issueKey, htmlSummaryForIssue(issue),
  210. ),
  211. )
  212. }
  213. // Commands supported:
  214. // !jira create KEY "issue title" "optional issue description"
  215. // Responds with the outcome of the issue creation request. This command requires
  216. // a JIRA account to be linked to the Matrix user ID issuing the command. It also
  217. // requires there to be a project with the given project key (e.g. "KEY") to exist
  218. // on the linked JIRA account. If there are multiple JIRA accounts which contain the
  219. // same project key, which project is chosen is undefined. If there
  220. // is no JIRA account linked to the Matrix user ID, it will return a Starter Link
  221. // if there is a known public project with that project key.
  222. func (s *Service) Commands(cli *gomatrix.Client) []types.Command {
  223. return []types.Command{
  224. types.Command{
  225. Path: []string{"jira", "create"},
  226. Command: func(roomID, userID string, args []string) (interface{}, error) {
  227. return s.cmdJiraCreate(roomID, userID, args)
  228. },
  229. },
  230. }
  231. }
  232. // Expansions expands JIRA issues represented as:
  233. // KEY-12
  234. // Where "KEY" is the project key and 12" is an issue number. The Service Config will be used
  235. // to map the project key to a realm, and subsequently the JIRA endpoint to hit.
  236. // If there are multiple projects with the same project key in the Service Config, one will
  237. // be chosen arbitrarily.
  238. func (s *Service) Expansions(cli *gomatrix.Client) []types.Expansion {
  239. return []types.Expansion{
  240. types.Expansion{
  241. Regexp: issueKeyRegex,
  242. Expand: func(roomID, userID string, issueKeyGroups []string) interface{} {
  243. return s.expandIssue(roomID, userID, issueKeyGroups)
  244. },
  245. },
  246. }
  247. }
  248. // OnReceiveWebhook receives requests from JIRA and possibly sends requests to Matrix as a result.
  249. func (s *Service) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *gomatrix.Client) {
  250. eventProjectKey, event, httpErr := webhook.OnReceiveRequest(req)
  251. if httpErr != nil {
  252. log.Print("Failed to handle JIRA webhook")
  253. w.WriteHeader(httpErr.Code)
  254. return
  255. }
  256. // grab base jira url
  257. jurl, err := urls.ParseJIRAURL(event.Issue.Self)
  258. if err != nil {
  259. log.WithError(err).Print("Failed to parse base JIRA URL")
  260. w.WriteHeader(500)
  261. return
  262. }
  263. // work out the HTML to send
  264. htmlText := htmlForEvent(event, jurl.Base)
  265. if htmlText == "" {
  266. log.WithField("project", eventProjectKey).Print("Unable to process event for project")
  267. w.WriteHeader(200)
  268. return
  269. }
  270. // send message into each configured room
  271. for roomID, roomConfig := range s.Rooms {
  272. for _, realmConfig := range roomConfig.Realms {
  273. for pkey, projectConfig := range realmConfig.Projects {
  274. if pkey != eventProjectKey || !projectConfig.Track {
  275. continue
  276. }
  277. _, msgErr := cli.SendMessageEvent(
  278. roomID, "m.room.message", gomatrix.GetHTMLMessage("m.notice", htmlText),
  279. )
  280. if msgErr != nil {
  281. log.WithFields(log.Fields{
  282. log.ErrorKey: msgErr,
  283. "project": pkey,
  284. "room_id": roomID,
  285. }).Print("Failed to send notice into room")
  286. }
  287. }
  288. }
  289. }
  290. w.WriteHeader(200)
  291. }
  292. func (s *Service) realmIDForProject(roomID, projectKey string) string {
  293. // TODO: Multiple realms with the same pkey will be randomly chosen.
  294. for r, realmConfig := range s.Rooms[roomID].Realms {
  295. for pkey, projectConfig := range realmConfig.Projects {
  296. if pkey == projectKey && projectConfig.Expand {
  297. return r
  298. }
  299. }
  300. }
  301. return ""
  302. }
  303. func (s *Service) projectToRealm(userID, pkey string) (*jira.Realm, error) {
  304. // We don't know which JIRA installation this project maps to, so:
  305. // - Get all known JIRA realms and f.e query their endpoints with the
  306. // given user ID's credentials (so if it is a private project they
  307. // can see it will succeed.)
  308. // - If there is a matching project with that key, return that realm.
  309. // We search installations which the user has already OAuthed with first as most likely
  310. // the project key will be on a JIRA they have access to.
  311. logger := log.WithFields(log.Fields{
  312. "user_id": userID,
  313. "project": pkey,
  314. })
  315. knownRealms, err := database.GetServiceDB().LoadAuthRealmsByType("jira")
  316. if err != nil {
  317. logger.WithError(err).Print("Failed to load jira auth realms")
  318. return nil, err
  319. }
  320. // typecast and move ones which the user has authed with to the front of the queue
  321. var queue []*jira.Realm
  322. var unauthRealms []*jira.Realm
  323. for _, r := range knownRealms {
  324. jrealm, ok := r.(*jira.Realm)
  325. if !ok {
  326. logger.WithField("realm_id", r.ID()).Print(
  327. "Failed to type-cast 'jira' type realm into jira.Realm",
  328. )
  329. continue
  330. }
  331. _, err := database.GetServiceDB().LoadAuthSessionByUser(r.ID(), userID)
  332. if err != nil {
  333. if err == sql.ErrNoRows {
  334. unauthRealms = append(unauthRealms, jrealm)
  335. } else {
  336. logger.WithError(err).WithField("realm_id", r.ID()).Print(
  337. "Failed to load auth sessions for user",
  338. )
  339. }
  340. continue // this may not have been the match anyway so don't give up!
  341. }
  342. queue = append(queue, jrealm)
  343. }
  344. // push unauthed realms to the back
  345. queue = append(queue, unauthRealms...)
  346. for _, jr := range queue {
  347. exists, err := jr.ProjectKeyExists(userID, pkey)
  348. if err != nil {
  349. logger.WithError(err).WithField("realm_id", jr.ID()).Print(
  350. "Failed to check if project key exists on this realm.",
  351. )
  352. continue // may not have been found anyway so keep searching!
  353. }
  354. if exists {
  355. logger.Info("Project exists on ", jr.ID())
  356. return jr, nil
  357. }
  358. }
  359. return nil, nil
  360. }
  361. // Returns realm_id => [PROJ, ECT, KEYS]
  362. func projectsAndRealmsToTrack(s *Service) map[string][]string {
  363. ridsToProjects := make(map[string][]string)
  364. for _, roomConfig := range s.Rooms {
  365. for realmID, realmConfig := range roomConfig.Realms {
  366. for projectKey, projectConfig := range realmConfig.Projects {
  367. if projectConfig.Track {
  368. ridsToProjects[realmID] = append(
  369. ridsToProjects[realmID], projectKey,
  370. )
  371. }
  372. }
  373. }
  374. }
  375. return ridsToProjects
  376. }
  377. func htmlSummaryForIssue(issue *gojira.Issue) string {
  378. // form a summary of the issue being affected e.g:
  379. // "Flibble Wibble [P1, In Progress]"
  380. status := html.EscapeString(issue.Fields.Status.Name)
  381. if issue.Fields.Resolution != nil {
  382. status = fmt.Sprintf(
  383. "%s (%s)",
  384. status, html.EscapeString(issue.Fields.Resolution.Name),
  385. )
  386. }
  387. return fmt.Sprintf(
  388. "%s [%s, %s]",
  389. html.EscapeString(issue.Fields.Summary),
  390. html.EscapeString(issue.Fields.Priority.Name),
  391. status,
  392. )
  393. }
  394. // htmlForEvent formats a webhook event as HTML. Returns an empty string if there is nothing to send/cannot
  395. // be parsed.
  396. func htmlForEvent(whe *webhook.Event, jiraBaseURL string) string {
  397. action := ""
  398. if whe.WebhookEvent == "jira:issue_updated" {
  399. action = "updated"
  400. } else if whe.WebhookEvent == "jira:issue_deleted" {
  401. action = "deleted"
  402. } else if whe.WebhookEvent == "jira:issue_created" {
  403. action = "created"
  404. } else {
  405. return ""
  406. }
  407. summaryHTML := htmlSummaryForIssue(&whe.Issue)
  408. return fmt.Sprintf("%s %s <b>%s</b> - %s %s",
  409. html.EscapeString(whe.User.Name),
  410. html.EscapeString(action),
  411. html.EscapeString(whe.Issue.Key),
  412. summaryHTML,
  413. html.EscapeString(jiraBaseURL+"browse/"+whe.Issue.Key),
  414. )
  415. }
  416. func init() {
  417. types.RegisterService(func(serviceID, serviceUserID, webhookEndpointURL string) types.Service {
  418. return &Service{
  419. DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
  420. webhookEndpointURL: webhookEndpointURL,
  421. }
  422. })
  423. }