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.

861 lines
26 KiB

  1. // Package github implements a command service and a webhook service for interacting with Github.
  2. //
  3. // The command service is a service which adds !commands and issue expansions for Github. The
  4. // webhook service adds Github webhook support.
  5. package github
  6. import (
  7. "context"
  8. "database/sql"
  9. "errors"
  10. "fmt"
  11. "regexp"
  12. "strconv"
  13. "strings"
  14. "bytes"
  15. "html"
  16. gogithub "github.com/google/go-github/github"
  17. "github.com/matrix-org/go-neb/database"
  18. "github.com/matrix-org/go-neb/matrix"
  19. "github.com/matrix-org/go-neb/realms/github"
  20. "github.com/matrix-org/go-neb/services/github/client"
  21. "github.com/matrix-org/go-neb/types"
  22. log "github.com/sirupsen/logrus"
  23. mevt "maunium.net/go/mautrix/event"
  24. "maunium.net/go/mautrix/id"
  25. )
  26. // ServiceType of the Github service
  27. const ServiceType = "github"
  28. // Optionally matches alphanumeric then a /, then more alphanumeric
  29. // Used as a base for the expansion regexes below.
  30. var ownerRepoBaseRegex = `(?:(?:([A-z0-9-_.]+)/([A-z0-9-_.]+))|\B)`
  31. // Matches alphanumeric then a /, then more alphanumeric then a #, then a number.
  32. // E.g. owner/repo#11 (issue/PR numbers) - Captured groups for owner/repo/number
  33. // Does not match things like #3dprinting and testing#1234 (incomplete owner/repo format)
  34. var ownerRepoIssueRegex = regexp.MustCompile(ownerRepoBaseRegex + `#([0-9]+)\b`)
  35. // Matches alphanumeric then a /, then more alphanumeric then a @, then a hex string.
  36. // E.g. owner/repo@deadbeef1234 (commit hash) - Captured groups for owner/repo/hash
  37. var ownerRepoCommitRegex = regexp.MustCompile(ownerRepoBaseRegex + `@([0-9a-fA-F]+)\b`)
  38. // Matches like above, but anchored to start and end of the string respectively.
  39. var ownerRepoIssueRegexAnchored = regexp.MustCompile(`^(([A-z0-9-_.]+)/([A-z0-9-_.]+))?#([0-9]+)$`)
  40. var ownerRepoRegex = regexp.MustCompile(`^([A-z0-9-_.]+)/([A-z0-9-_.]+)$`)
  41. // Service contains the Config fields for the Github service.
  42. //
  43. // Before you can set up a Github Service, you need to set up a Github Realm.
  44. //
  45. // You can set optional config for a Matrix room by sending a `m.room.bot.options` state event
  46. // which has the following `content`:
  47. //
  48. // {
  49. // "github": {
  50. // // The default repository to use for this room; this allows "owner/repo" to be omitted
  51. // // when creating/expanding issues.
  52. // "default_repo": "owner/repo",
  53. //
  54. // // Array of Github labels to attach to any issue created by this bot in this room.
  55. // "new_issue_labels": ["bot-label-1", "bot-label-2"]
  56. // }
  57. // }
  58. //
  59. // Example request:
  60. // {
  61. // "RealmID": "github-realm-id"
  62. // }
  63. type Service struct {
  64. types.DefaultService
  65. // The ID of an existing "github" realm. This realm will be used to obtain
  66. // credentials of users when they create issues on Github.
  67. RealmID string
  68. }
  69. func (s *Service) requireGithubClientFor(userID id.UserID) (cli *gogithub.Client, resp interface{}, err error) {
  70. cli = s.githubClientFor(userID, false)
  71. if cli == nil {
  72. var r types.AuthRealm
  73. if r, err = database.GetServiceDB().LoadAuthRealm(s.RealmID); err != nil {
  74. return
  75. }
  76. if ghRealm, ok := r.(*github.Realm); ok {
  77. resp = matrix.StarterLinkMessage{
  78. Body: "You need to log into Github before you can create issues.",
  79. Link: ghRealm.StarterLink,
  80. }
  81. } else {
  82. err = fmt.Errorf("Failed to cast realm %s into a GithubRealm", s.RealmID)
  83. }
  84. }
  85. return
  86. }
  87. const numberGithubSearchSummaries = 3
  88. const cmdGithubSearchUsage = `!github search "search query"`
  89. func (s *Service) cmdGithubSearch(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  90. cli := s.githubClientFor(userID, true)
  91. if len(args) < 2 {
  92. return &mevt.MessageEventContent{
  93. MsgType: mevt.MsgNotice,
  94. Body: "Usage: " + cmdGithubSearchUsage,
  95. }, nil
  96. }
  97. query := strings.Join(args, " ")
  98. searchResult, res, err := cli.Search.Issues(context.Background(), query, nil)
  99. if err != nil {
  100. log.WithField("err", err).Print("Failed to search")
  101. if res == nil {
  102. return nil, fmt.Errorf("Failed to search. Failed to connect to Github")
  103. }
  104. return nil, fmt.Errorf("Failed to search. HTTP %d", res.StatusCode)
  105. }
  106. if searchResult.Total == nil || *searchResult.Total == 0 {
  107. return &mevt.MessageEventContent{
  108. MsgType: mevt.MsgNotice,
  109. Body: "No results found for your search query!",
  110. }, nil
  111. }
  112. numResults := *searchResult.Total
  113. var htmlBuffer bytes.Buffer
  114. var plainBuffer bytes.Buffer
  115. htmlBuffer.WriteString(fmt.Sprintf("Found %d results, here are the most relevant:<br><ol>", numResults))
  116. plainBuffer.WriteString(fmt.Sprintf("Found %d results, here are the most relevant:\n", numResults))
  117. for i, issue := range searchResult.Issues {
  118. if i >= numberGithubSearchSummaries {
  119. break
  120. }
  121. if issue.HTMLURL == nil || issue.User.Login == nil || issue.Title == nil {
  122. continue
  123. }
  124. escapedTitle, escapedUserLogin := html.EscapeString(*issue.Title), html.EscapeString(*issue.User.Login)
  125. htmlBuffer.WriteString(fmt.Sprintf(`<li><a href="%s" rel="noopener">%s: %s</a></li>`, *issue.HTMLURL, escapedUserLogin, escapedTitle))
  126. plainBuffer.WriteString(fmt.Sprintf("%d. %s\n", i+1, *issue.HTMLURL))
  127. }
  128. htmlBuffer.WriteString("</ol>")
  129. return &mevt.MessageEventContent{
  130. Body: plainBuffer.String(),
  131. MsgType: mevt.MsgNotice,
  132. Format: "org.matrix.custom.html",
  133. FormattedBody: htmlBuffer.String(),
  134. }, nil
  135. }
  136. const cmdGithubCreateUsage = `!github create [owner/repo] "issue title" "description"`
  137. func (s *Service) cmdGithubCreate(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  138. cli, resp, err := s.requireGithubClientFor(userID)
  139. if cli == nil {
  140. return resp, err
  141. }
  142. if len(args) == 0 {
  143. return &mevt.MessageEventContent{
  144. MsgType: mevt.MsgNotice,
  145. Body: "Usage: " + cmdGithubCreateUsage,
  146. }, nil
  147. }
  148. // We expect the args to look like:
  149. // [ "owner/repo", "title text", "desc text" ]
  150. // They can omit the owner/repo if there is a default one set.
  151. // Look for a default if the first arg doesn't look like an owner/repo
  152. ownerRepoGroups := ownerRepoRegex.FindStringSubmatch(args[0])
  153. if len(ownerRepoGroups) == 0 {
  154. // look for a default repo
  155. defaultRepo := s.defaultRepo(roomID)
  156. if defaultRepo == "" {
  157. return &mevt.MessageEventContent{
  158. MsgType: mevt.MsgNotice,
  159. Body: "Need to specify repo. Usage: " + cmdGithubCreateUsage,
  160. }, nil
  161. }
  162. // default repo should pass the regexp
  163. ownerRepoGroups = ownerRepoRegex.FindStringSubmatch(defaultRepo)
  164. if len(ownerRepoGroups) == 0 {
  165. return &mevt.MessageEventContent{
  166. MsgType: mevt.MsgNotice, Body: "Malformed default repo. Usage: " + cmdGithubCreateUsage}, nil
  167. }
  168. // insert the default as the first arg to reuse the same indices
  169. args = append([]string{defaultRepo}, args...)
  170. // continue through now that ownerRepoGroups has matching groups
  171. }
  172. var (
  173. title *string
  174. desc *string
  175. )
  176. if len(args) == 2 {
  177. title = &args[1]
  178. } else if len(args) == 3 {
  179. title = &args[1]
  180. desc = &args[2]
  181. } else { // > 3 args is probably a title without quote marks
  182. joinedTitle := strings.Join(args[1:], " ")
  183. title = &joinedTitle
  184. }
  185. labels := s.newIssueLabels(roomID)
  186. issue, res, err := cli.Issues.Create(context.Background(), ownerRepoGroups[1], ownerRepoGroups[2], &gogithub.IssueRequest{
  187. Title: title,
  188. Body: desc,
  189. Labels: &labels,
  190. })
  191. if err != nil {
  192. log.WithField("err", err).Print("Failed to create issue")
  193. if res == nil {
  194. return nil, fmt.Errorf("Failed to create issue. Failed to connect to Github")
  195. }
  196. return nil, fmt.Errorf("Failed to create issue. HTTP %d", res.StatusCode)
  197. }
  198. return mevt.MessageEventContent{
  199. MsgType: mevt.MsgNotice, Body: fmt.Sprintf("Created issue: %s", *issue.HTMLURL)}, nil
  200. }
  201. var cmdGithubReactAliases = map[string]string{
  202. "+1": "+1",
  203. ":+1:": "+1",
  204. "👍": "+1",
  205. "-1": "-1",
  206. ":-1:": "-1",
  207. "👎": "-1",
  208. "laugh": "laugh",
  209. "smile": "laugh",
  210. ":smile:": "laugh",
  211. "😄": "laugh",
  212. "grin": "laugh",
  213. "confused": "confused",
  214. ":confused:": "confused",
  215. "😕": "confused",
  216. "uncertain": "confused",
  217. "heart": "heart",
  218. ":heart:": "heart",
  219. "❤": "heart",
  220. "❤️": "heart",
  221. "hooray": "hooray",
  222. "tada": "hooray",
  223. ":tada:": "hooray",
  224. "🎉": "hooray",
  225. }
  226. const cmdGithubReactUsage = `!github react [owner/repo]#issue (+1|👍|-1|:-1:|laugh|:smile:|confused|uncertain|heart|❤|hooray|:tada:)`
  227. func (s *Service) cmdGithubReact(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  228. cli, resp, err := s.requireGithubClientFor(userID)
  229. if cli == nil {
  230. return resp, err
  231. }
  232. if len(args) < 2 {
  233. return &mevt.MessageEventContent{
  234. MsgType: mevt.MsgNotice, Body: "Usage: " + cmdGithubReactUsage,
  235. }, nil
  236. }
  237. reaction, ok := cmdGithubReactAliases[args[1]]
  238. if !ok {
  239. return &mevt.MessageEventContent{
  240. MsgType: mevt.MsgNotice,
  241. Body: "Invalid reaction. Usage: " + cmdGithubReactUsage,
  242. }, nil
  243. }
  244. // get owner,repo,issue,resp out of args[0]
  245. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubReactUsage)
  246. if resp != nil {
  247. return resp, nil
  248. }
  249. _, res, err := cli.Reactions.CreateIssueReaction(context.Background(), owner, repo, issueNum, reaction)
  250. if err != nil {
  251. log.WithField("err", err).Print("Failed to react to issue")
  252. if res == nil {
  253. return nil, fmt.Errorf("Failed to react to issue. Failed to connect to Github")
  254. }
  255. return nil, fmt.Errorf("Failed to react to issue. HTTP %d", res.StatusCode)
  256. }
  257. return mevt.MessageEventContent{
  258. MsgType: mevt.MsgNotice,
  259. Body: fmt.Sprintf("Reacted to issue with: %s", args[1]),
  260. }, nil
  261. }
  262. const cmdGithubCommentUsage = `!github comment [owner/repo]#issue "comment text"`
  263. func (s *Service) cmdGithubComment(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  264. cli, resp, err := s.requireGithubClientFor(userID)
  265. if cli == nil {
  266. return resp, err
  267. }
  268. if len(args) == 0 {
  269. return &mevt.MessageEventContent{
  270. MsgType: mevt.MsgNotice,
  271. Body: "Usage: " + cmdGithubCommentUsage,
  272. }, nil
  273. }
  274. // get owner,repo,issue,resp out of args[0]
  275. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubCommentUsage)
  276. if resp != nil {
  277. return resp, nil
  278. }
  279. var comment *string
  280. if len(args) == 2 {
  281. comment = &args[1]
  282. } else { // > 2 args is probably a comment without quote marks
  283. joinedComment := strings.Join(args[1:], " ")
  284. comment = &joinedComment
  285. }
  286. issueComment, res, err := cli.Issues.CreateComment(context.Background(), owner, repo, issueNum, &gogithub.IssueComment{
  287. Body: comment,
  288. })
  289. if err != nil {
  290. log.WithField("err", err).Print("Failed to create issue comment")
  291. if res == nil {
  292. return nil, fmt.Errorf("Failed to create issue comment. Failed to connect to Github")
  293. }
  294. return nil, fmt.Errorf("Failed to create issue comment. HTTP %d", res.StatusCode)
  295. }
  296. return mevt.MessageEventContent{
  297. MsgType: mevt.MsgNotice,
  298. Body: fmt.Sprintf("Commented on issue: %s", *issueComment.HTMLURL),
  299. }, nil
  300. }
  301. const cmdGithubAssignUsage = `!github assign [owner/repo]#issue username [username] [...]`
  302. func (s *Service) cmdGithubAssign(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  303. cli, resp, err := s.requireGithubClientFor(userID)
  304. if cli == nil {
  305. return resp, err
  306. }
  307. if len(args) < 1 {
  308. return &mevt.MessageEventContent{
  309. MsgType: mevt.MsgNotice,
  310. Body: "Usage: " + cmdGithubAssignUsage,
  311. }, nil
  312. } else if len(args) < 2 {
  313. return &mevt.MessageEventContent{
  314. MsgType: mevt.MsgNotice,
  315. Body: "Needs at least one username. Usage: " + cmdGithubAssignUsage,
  316. }, nil
  317. }
  318. // get owner,repo,issue,resp out of args[0]
  319. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubAssignUsage)
  320. if resp != nil {
  321. return resp, nil
  322. }
  323. issue, res, err := cli.Issues.AddAssignees(context.Background(), owner, repo, issueNum, args[1:])
  324. if err != nil {
  325. log.WithField("err", err).Print("Failed to add issue assignees")
  326. if res == nil {
  327. return nil, fmt.Errorf("Failed to add issue assignees. Failed to connect to Github")
  328. }
  329. return nil, fmt.Errorf("Failed to add issue assignees. HTTP %d", res.StatusCode)
  330. }
  331. return mevt.MessageEventContent{
  332. MsgType: mevt.MsgNotice,
  333. Body: fmt.Sprintf("Added assignees to issue: %s", *issue.HTMLURL),
  334. }, nil
  335. }
  336. func (s *Service) githubIssueCloseReopen(roomID id.RoomID, userID id.UserID, args []string, state, verb, help string) (interface{}, error) {
  337. cli, resp, err := s.requireGithubClientFor(userID)
  338. if cli == nil {
  339. return resp, err
  340. }
  341. if len(args) == 0 {
  342. return &mevt.MessageEventContent{
  343. MsgType: mevt.MsgNotice,
  344. Body: "Usage: " + help,
  345. }, nil
  346. }
  347. // get owner,repo,issue,resp out of args[0]
  348. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, help)
  349. if resp != nil {
  350. return resp, nil
  351. }
  352. issueComment, res, err := cli.Issues.Edit(context.Background(), owner, repo, issueNum, &gogithub.IssueRequest{
  353. State: &state,
  354. })
  355. if err != nil {
  356. log.WithField("err", err).Printf("Failed to %s issue", verb)
  357. if res == nil {
  358. return nil, fmt.Errorf("Failed to %s issue. Failed to connect to Github", verb)
  359. }
  360. return nil, fmt.Errorf("Failed to %s issue. HTTP %d", verb, res.StatusCode)
  361. }
  362. return mevt.MessageEventContent{
  363. MsgType: mevt.MsgNotice,
  364. Body: fmt.Sprintf("Closed issue: %s", *issueComment.HTMLURL),
  365. }, nil
  366. }
  367. const cmdGithubCloseUsage = `!github close [owner/repo]#issue`
  368. func (s *Service) cmdGithubClose(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  369. return s.githubIssueCloseReopen(roomID, userID, args, "closed", "close", cmdGithubCloseUsage)
  370. }
  371. const cmdGithubReopenUsage = `!github reopen [owner/repo]#issue`
  372. func (s *Service) cmdGithubReopen(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  373. return s.githubIssueCloseReopen(roomID, userID, args, "open", "open", cmdGithubCloseUsage)
  374. }
  375. func (s *Service) getIssueDetailsFor(input string, roomID id.RoomID, usage string) (owner, repo string, issueNum int, resp interface{}) {
  376. // We expect the input to look like:
  377. // "[owner/repo]#issue"
  378. // They can omit the owner/repo if there is a default one set.
  379. // Look for a default if the first arg is just an issue number
  380. ownerRepoIssueGroups := ownerRepoIssueRegexAnchored.FindStringSubmatch(input)
  381. if len(ownerRepoIssueGroups) != 5 {
  382. resp = &mevt.MessageEventContent{
  383. MsgType: mevt.MsgNotice,
  384. Body: "Usage: " + usage,
  385. }
  386. return
  387. }
  388. owner = ownerRepoIssueGroups[2]
  389. repo = ownerRepoIssueGroups[3]
  390. var err error
  391. if issueNum, err = strconv.Atoi(ownerRepoIssueGroups[4]); err != nil {
  392. resp = &mevt.MessageEventContent{
  393. MsgType: mevt.MsgNotice,
  394. Body: "Malformed issue number. Usage: " + usage,
  395. }
  396. return
  397. }
  398. if ownerRepoIssueGroups[1] == "" {
  399. // issue only match, this only works if there is a default repo
  400. defaultRepo := s.defaultRepo(roomID)
  401. if defaultRepo == "" {
  402. resp = &mevt.MessageEventContent{
  403. MsgType: mevt.MsgNotice,
  404. Body: "Need to specify repo. Usage: " + usage,
  405. }
  406. return
  407. }
  408. segs := strings.Split(defaultRepo, "/")
  409. if len(segs) != 2 {
  410. resp = &mevt.MessageEventContent{
  411. MsgType: mevt.MsgNotice,
  412. Body: "Malformed default repo. Usage: " + usage,
  413. }
  414. return
  415. }
  416. owner = segs[0]
  417. repo = segs[1]
  418. }
  419. return
  420. }
  421. func (s *Service) expandIssue(roomID id.RoomID, userID id.UserID, owner, repo string, issueNum int) interface{} {
  422. cli := s.githubClientFor(userID, true)
  423. i, _, err := cli.Issues.Get(context.Background(), owner, repo, issueNum)
  424. if err != nil {
  425. log.WithError(err).WithFields(log.Fields{
  426. "owner": owner,
  427. "repo": repo,
  428. "number": issueNum,
  429. }).Print("Failed to fetch issue")
  430. return nil
  431. }
  432. return &mevt.MessageEventContent{
  433. MsgType: mevt.MsgNotice,
  434. Body: fmt.Sprintf("%s : %s", *i.HTMLURL, *i.Title),
  435. }
  436. }
  437. func (s *Service) expandCommit(roomID id.RoomID, userID id.UserID, owner, repo, sha string) interface{} {
  438. cli := s.githubClientFor(userID, true)
  439. c, _, err := cli.Repositories.GetCommit(context.Background(), owner, repo, sha)
  440. if err != nil {
  441. log.WithError(err).WithFields(log.Fields{
  442. "owner": owner,
  443. "repo": repo,
  444. "sha": sha,
  445. }).Print("Failed to fetch commit")
  446. return nil
  447. }
  448. commit := c.Commit
  449. var htmlBuffer bytes.Buffer
  450. var plainBuffer bytes.Buffer
  451. shortURL := strings.TrimSuffix(*c.HTMLURL, *c.SHA) + sha
  452. htmlBuffer.WriteString(fmt.Sprintf("<a href=\"%s\">%s</a><br />", *c.HTMLURL, shortURL))
  453. plainBuffer.WriteString(fmt.Sprintf("%s\n", shortURL))
  454. if c.Stats != nil {
  455. htmlBuffer.WriteString(fmt.Sprintf("[<strong><font color='#1cc3ed'>~%d</font>, <font color='#30bf2b'>+%d</font>, <font color='#fc3a25'>-%d</font></strong>] ", len(c.Files), *c.Stats.Additions, *c.Stats.Deletions))
  456. plainBuffer.WriteString(fmt.Sprintf("[~%d, +%d, -%d] ", len(c.Files), *c.Stats.Additions, *c.Stats.Deletions))
  457. }
  458. if commit.Author != nil {
  459. authorName := ""
  460. if commit.Author.Name != nil {
  461. authorName = *commit.Author.Name
  462. } else if commit.Author.Login != nil {
  463. authorName = *commit.Author.Login
  464. }
  465. htmlBuffer.WriteString(fmt.Sprintf("%s: ", authorName))
  466. plainBuffer.WriteString(fmt.Sprintf("%s: ", authorName))
  467. }
  468. if commit.Message != nil {
  469. segs := strings.SplitN(*commit.Message, "\n", 2)
  470. htmlBuffer.WriteString(segs[0])
  471. plainBuffer.WriteString(segs[0])
  472. }
  473. return &mevt.MessageEventContent{
  474. Body: plainBuffer.String(),
  475. MsgType: mevt.MsgNotice,
  476. Format: mevt.FormatHTML,
  477. FormattedBody: htmlBuffer.String(),
  478. }
  479. }
  480. // Commands supported:
  481. // !github create owner/repo "issue title" "optional issue description"
  482. // Responds with the outcome of the issue creation request. This command requires
  483. // a Github account to be linked to the Matrix user ID issuing the command. If there
  484. // is no link, it will return a Starter Link instead.
  485. // !github comment [owner/repo]#issue "comment"
  486. // Responds with the outcome of the issue comment creation request. This command requires
  487. // a Github account to be linked to the Matrix user ID issuing the command. If there
  488. // is no link, it will return a Starter Link instead.
  489. func (s *Service) Commands(cli types.MatrixClient) []types.Command {
  490. return []types.Command{
  491. {
  492. Path: []string{"github", "search"},
  493. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  494. return s.cmdGithubSearch(roomID, userID, args)
  495. },
  496. },
  497. {
  498. Path: []string{"github", "create"},
  499. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  500. return s.cmdGithubCreate(roomID, userID, args)
  501. },
  502. },
  503. {
  504. Path: []string{"github", "react"},
  505. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  506. return s.cmdGithubReact(roomID, userID, args)
  507. },
  508. },
  509. {
  510. Path: []string{"github", "comment"},
  511. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  512. return s.cmdGithubComment(roomID, userID, args)
  513. },
  514. },
  515. {
  516. Path: []string{"github", "assign"},
  517. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  518. return s.cmdGithubAssign(roomID, userID, args)
  519. },
  520. },
  521. {
  522. Path: []string{"github", "close"},
  523. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  524. return s.cmdGithubClose(roomID, userID, args)
  525. },
  526. },
  527. {
  528. Path: []string{"github", "reopen"},
  529. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  530. return s.cmdGithubReopen(roomID, userID, args)
  531. },
  532. },
  533. {
  534. Path: []string{"github", "help"},
  535. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  536. return &mevt.MessageEventContent{
  537. MsgType: mevt.MsgNotice,
  538. Body: strings.Join([]string{
  539. cmdGithubCreateUsage,
  540. cmdGithubReactUsage,
  541. cmdGithubCommentUsage,
  542. cmdGithubAssignUsage,
  543. cmdGithubCloseUsage,
  544. cmdGithubReopenUsage,
  545. }, "\n"),
  546. }, nil
  547. },
  548. },
  549. }
  550. }
  551. // Expansions expands strings of the form:
  552. // owner/repo#12
  553. // Where #12 is an issue number or pull request. If there is a default repository set on the room,
  554. // it will also expand strings of the form:
  555. // #12
  556. // using the default repository.
  557. func (s *Service) Expansions(cli types.MatrixClient) []types.Expansion {
  558. return []types.Expansion{
  559. types.Expansion{
  560. Regexp: ownerRepoIssueRegex,
  561. Expand: func(roomID id.RoomID, userID id.UserID, matchingGroups []string) interface{} {
  562. // There's an optional group in the regex so matchingGroups can look like:
  563. // [foo/bar#55 foo bar 55]
  564. // [#55 55]
  565. if len(matchingGroups) != 4 {
  566. log.WithField("groups", matchingGroups).WithField("len", len(matchingGroups)).Print(
  567. "Unexpected number of groups",
  568. )
  569. return nil
  570. }
  571. if matchingGroups[1] == "" && matchingGroups[2] == "" {
  572. // issue only match, this only works if there is a default repo
  573. defaultRepo := s.defaultRepo(roomID)
  574. if defaultRepo == "" {
  575. return nil
  576. }
  577. segs := strings.Split(defaultRepo, "/")
  578. if len(segs) != 2 {
  579. log.WithFields(log.Fields{
  580. "room_id": roomID,
  581. "default_repo": defaultRepo,
  582. }).Error("Default repo is malformed")
  583. return nil
  584. }
  585. // Fill in the missing fields in matching groups and fall through into ["foo/bar#11", "foo", "bar", "11"]
  586. matchingGroups = []string{
  587. defaultRepo + matchingGroups[0],
  588. segs[0],
  589. segs[1],
  590. matchingGroups[3],
  591. }
  592. }
  593. num, err := strconv.Atoi(matchingGroups[3])
  594. if err != nil {
  595. log.WithField("issue_number", matchingGroups[3]).Print("Bad issue number")
  596. return nil
  597. }
  598. return s.expandIssue(roomID, userID, matchingGroups[1], matchingGroups[2], num)
  599. },
  600. },
  601. types.Expansion{
  602. Regexp: ownerRepoCommitRegex,
  603. Expand: func(roomID id.RoomID, userID id.UserID, matchingGroups []string) interface{} {
  604. // There's an optional group in the regex so matchingGroups can look like:
  605. // [foo/bar@a123 foo bar a123]
  606. // [@a123 a123]
  607. if len(matchingGroups) != 4 {
  608. log.WithField("groups", matchingGroups).WithField("len", len(matchingGroups)).Print(
  609. "Unexpected number of groups",
  610. )
  611. return nil
  612. }
  613. if matchingGroups[1] == "" && matchingGroups[2] == "" {
  614. // issue only match, this only works if there is a default repo
  615. defaultRepo := s.defaultRepo(roomID)
  616. if defaultRepo == "" {
  617. return nil
  618. }
  619. segs := strings.Split(defaultRepo, "/")
  620. if len(segs) != 2 {
  621. log.WithFields(log.Fields{
  622. "room_id": roomID,
  623. "default_repo": defaultRepo,
  624. }).Error("Default repo is malformed")
  625. return nil
  626. }
  627. // Fill in the missing fields in matching groups and fall through into ["foo/bar@a123", "foo", "bar", "a123"]
  628. matchingGroups = []string{
  629. defaultRepo + matchingGroups[0],
  630. segs[0],
  631. segs[1],
  632. matchingGroups[3],
  633. }
  634. }
  635. return s.expandCommit(roomID, userID, matchingGroups[1], matchingGroups[2], matchingGroups[3])
  636. },
  637. },
  638. }
  639. }
  640. // Register makes sure that the given realm ID maps to a github realm.
  641. func (s *Service) Register(oldService types.Service, client types.MatrixClient) error {
  642. if s.RealmID == "" {
  643. return fmt.Errorf("RealmID is required")
  644. }
  645. // check realm exists
  646. realm, err := database.GetServiceDB().LoadAuthRealm(s.RealmID)
  647. if err != nil {
  648. return err
  649. }
  650. // make sure the realm is of the type we expect
  651. if realm.Type() != "github" {
  652. return fmt.Errorf("Realm is of type '%s', not 'github'", realm.Type())
  653. }
  654. log.Infof("%+v", s)
  655. return nil
  656. }
  657. func (s *Service) loadBotOptions(roomID id.RoomID, logger *log.Entry) (result map[string]interface{}, err error) {
  658. opts, err := database.GetServiceDB().LoadBotOptions(s.ServiceUserID(), roomID)
  659. if err != nil {
  660. if err == sql.ErrNoRows {
  661. logger.Info("no bot options")
  662. return make(map[string]interface{}), nil
  663. } else {
  664. err := errors.New("Failed to load bot options")
  665. logger.WithError(err).Error(err)
  666. return nil, err
  667. }
  668. }
  669. // Expect opts to look like:
  670. // {
  671. // github: {
  672. // default_repo: $OWNER_REPO,
  673. // new_issue_labels: [ "label1", .. ]
  674. // }
  675. // }
  676. ghOpts, ok := opts.Options["github"].(map[string]interface{})
  677. if !ok {
  678. err = fmt.Errorf("Failed to cast bot options as github options")
  679. logger.WithField("options", opts.Options).Error(err)
  680. return nil, err
  681. }
  682. return ghOpts, nil
  683. }
  684. // defaultRepo returns the default repo for the given room, or an empty string.
  685. func (s *Service) defaultRepo(roomID id.RoomID) string {
  686. logger := log.WithFields(log.Fields{
  687. "room_id": roomID,
  688. "bot_user_id": s.ServiceUserID(),
  689. })
  690. ghOpts, err := s.loadBotOptions(roomID, logger)
  691. if err != nil {
  692. return ""
  693. }
  694. defaultRepo, ok := ghOpts["default_repo"].(string)
  695. if !ok {
  696. logger.WithField("default_repo", ghOpts["default_repo"]).Error(
  697. "Failed to cast default repo as a string",
  698. )
  699. return ""
  700. }
  701. return defaultRepo
  702. }
  703. func (s *Service) newIssueLabels(roomID id.RoomID) []string {
  704. logger := log.WithFields(log.Fields{
  705. "room_id": roomID,
  706. "bot_user_id": s.ServiceUserID(),
  707. })
  708. ghOpts, err := s.loadBotOptions(roomID, logger)
  709. if err != nil {
  710. return make([]string, 0)
  711. }
  712. newIssueLabels, ok := ghOpts["new_issue_labels"].([]interface{})
  713. if !ok {
  714. return make([]string, 0)
  715. }
  716. newIssueLabelsUnboxed := make([]string, 0)
  717. for _, s := range newIssueLabels {
  718. newIssueLabelsUnboxed = append(newIssueLabelsUnboxed, s.(string))
  719. }
  720. return newIssueLabelsUnboxed
  721. }
  722. func (s *Service) githubClientFor(userID id.UserID, allowUnauth bool) *gogithub.Client {
  723. token, err := getTokenForUser(s.RealmID, userID)
  724. if err != nil {
  725. log.WithFields(log.Fields{
  726. log.ErrorKey: err,
  727. "user_id": userID,
  728. "realm_id": s.RealmID,
  729. }).Print("Failed to get token for user")
  730. }
  731. if token != "" {
  732. return client.New(token)
  733. } else if allowUnauth {
  734. return client.New("")
  735. } else {
  736. return nil
  737. }
  738. }
  739. func getTokenForUser(realmID string, userID id.UserID) (string, error) {
  740. realm, err := database.GetServiceDB().LoadAuthRealm(realmID)
  741. if err != nil {
  742. return "", err
  743. }
  744. if realm.Type() != "github" {
  745. return "", fmt.Errorf("Bad realm type: %s", realm.Type())
  746. }
  747. // pull out the token (TODO: should the service know how the realm stores this?)
  748. session, err := database.GetServiceDB().LoadAuthSessionByUser(realm.ID(), userID)
  749. if err != nil {
  750. return "", err
  751. }
  752. ghSession, ok := session.(*github.Session)
  753. if !ok {
  754. return "", fmt.Errorf("Session is not a github session: %s", session.ID())
  755. }
  756. if ghSession.AccessToken == "" {
  757. return "", fmt.Errorf("Github auth session for %s has not been completed", userID)
  758. }
  759. return ghSession.AccessToken, nil
  760. }
  761. func init() {
  762. types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service {
  763. return &Service{
  764. DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
  765. }
  766. })
  767. }