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.

458 lines
14 KiB

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