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.

833 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. logger := log.WithFields(log.Fields{
  154. "room_id": roomID,
  155. "bot_user_id": s.ServiceUserID(),
  156. })
  157. options, err := s.loadBotOptions(roomID, logger)
  158. if err != nil {
  159. return nil, err
  160. }
  161. if len(ownerRepoGroups) == 0 {
  162. // look for a default repo
  163. defaultRepo := options.DefaultRepo
  164. if defaultRepo == "" {
  165. return &mevt.MessageEventContent{
  166. MsgType: mevt.MsgNotice,
  167. Body: "Need to specify repo. Usage: " + cmdGithubCreateUsage,
  168. }, nil
  169. }
  170. // default repo should pass the regexp
  171. ownerRepoGroups = ownerRepoRegex.FindStringSubmatch(defaultRepo)
  172. if len(ownerRepoGroups) == 0 {
  173. return &mevt.MessageEventContent{
  174. MsgType: mevt.MsgNotice, Body: "Malformed default repo. Usage: " + cmdGithubCreateUsage}, nil
  175. }
  176. // insert the default as the first arg to reuse the same indices
  177. args = append([]string{defaultRepo}, args...)
  178. // continue through now that ownerRepoGroups has matching groups
  179. }
  180. var (
  181. title *string
  182. desc *string
  183. )
  184. if len(args) == 2 {
  185. title = &args[1]
  186. } else if len(args) == 3 {
  187. title = &args[1]
  188. desc = &args[2]
  189. } else { // > 3 args is probably a title without quote marks
  190. joinedTitle := strings.Join(args[1:], " ")
  191. title = &joinedTitle
  192. }
  193. issue, res, err := cli.Issues.Create(context.Background(), ownerRepoGroups[1], ownerRepoGroups[2], &gogithub.IssueRequest{
  194. Title: title,
  195. Body: desc,
  196. Labels: &options.NewIssueLabels,
  197. })
  198. if err != nil {
  199. log.WithField("err", err).Print("Failed to create issue")
  200. if res == nil {
  201. return nil, fmt.Errorf("Failed to create issue. Failed to connect to Github")
  202. }
  203. return nil, fmt.Errorf("Failed to create issue. HTTP %d", res.StatusCode)
  204. }
  205. return mevt.MessageEventContent{
  206. MsgType: mevt.MsgNotice, Body: fmt.Sprintf("Created issue: %s", *issue.HTMLURL)}, nil
  207. }
  208. var cmdGithubReactAliases = map[string]string{
  209. "+1": "+1",
  210. ":+1:": "+1",
  211. "👍": "+1",
  212. "-1": "-1",
  213. ":-1:": "-1",
  214. "👎": "-1",
  215. "laugh": "laugh",
  216. "smile": "laugh",
  217. ":smile:": "laugh",
  218. "😄": "laugh",
  219. "grin": "laugh",
  220. "confused": "confused",
  221. ":confused:": "confused",
  222. "😕": "confused",
  223. "uncertain": "confused",
  224. "heart": "heart",
  225. ":heart:": "heart",
  226. "❤": "heart",
  227. "❤️": "heart",
  228. "hooray": "hooray",
  229. "tada": "hooray",
  230. ":tada:": "hooray",
  231. "🎉": "hooray",
  232. }
  233. const cmdGithubReactUsage = `!github react [owner/repo]#issue (+1|👍|-1|:-1:|laugh|:smile:|confused|uncertain|heart|❤|hooray|:tada:)`
  234. func (s *Service) cmdGithubReact(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  235. cli, resp, err := s.requireGithubClientFor(userID)
  236. if cli == nil {
  237. return resp, err
  238. }
  239. if len(args) < 2 {
  240. return &mevt.MessageEventContent{
  241. MsgType: mevt.MsgNotice, Body: "Usage: " + cmdGithubReactUsage,
  242. }, nil
  243. }
  244. reaction, ok := cmdGithubReactAliases[args[1]]
  245. if !ok {
  246. return &mevt.MessageEventContent{
  247. MsgType: mevt.MsgNotice,
  248. Body: "Invalid reaction. Usage: " + cmdGithubReactUsage,
  249. }, nil
  250. }
  251. // get owner,repo,issue,resp out of args[0]
  252. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubReactUsage)
  253. if resp != nil {
  254. return resp, nil
  255. }
  256. _, res, err := cli.Reactions.CreateIssueReaction(context.Background(), owner, repo, issueNum, reaction)
  257. if err != nil {
  258. log.WithField("err", err).Print("Failed to react to issue")
  259. if res == nil {
  260. return nil, fmt.Errorf("Failed to react to issue. Failed to connect to Github")
  261. }
  262. return nil, fmt.Errorf("Failed to react to issue. HTTP %d", res.StatusCode)
  263. }
  264. return mevt.MessageEventContent{
  265. MsgType: mevt.MsgNotice,
  266. Body: fmt.Sprintf("Reacted to issue with: %s", args[1]),
  267. }, nil
  268. }
  269. const cmdGithubCommentUsage = `!github comment [owner/repo]#issue "comment text"`
  270. func (s *Service) cmdGithubComment(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  271. cli, resp, err := s.requireGithubClientFor(userID)
  272. if cli == nil {
  273. return resp, err
  274. }
  275. if len(args) == 0 {
  276. return &mevt.MessageEventContent{
  277. MsgType: mevt.MsgNotice,
  278. Body: "Usage: " + cmdGithubCommentUsage,
  279. }, nil
  280. }
  281. // get owner,repo,issue,resp out of args[0]
  282. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubCommentUsage)
  283. if resp != nil {
  284. return resp, nil
  285. }
  286. var comment *string
  287. if len(args) == 2 {
  288. comment = &args[1]
  289. } else { // > 2 args is probably a comment without quote marks
  290. joinedComment := strings.Join(args[1:], " ")
  291. comment = &joinedComment
  292. }
  293. issueComment, res, err := cli.Issues.CreateComment(context.Background(), owner, repo, issueNum, &gogithub.IssueComment{
  294. Body: comment,
  295. })
  296. if err != nil {
  297. log.WithField("err", err).Print("Failed to create issue comment")
  298. if res == nil {
  299. return nil, fmt.Errorf("Failed to create issue comment. Failed to connect to Github")
  300. }
  301. return nil, fmt.Errorf("Failed to create issue comment. HTTP %d", res.StatusCode)
  302. }
  303. return mevt.MessageEventContent{
  304. MsgType: mevt.MsgNotice,
  305. Body: fmt.Sprintf("Commented on issue: %s", *issueComment.HTMLURL),
  306. }, nil
  307. }
  308. const cmdGithubAssignUsage = `!github assign [owner/repo]#issue username [username] [...]`
  309. func (s *Service) cmdGithubAssign(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  310. cli, resp, err := s.requireGithubClientFor(userID)
  311. if cli == nil {
  312. return resp, err
  313. }
  314. if len(args) < 1 {
  315. return &mevt.MessageEventContent{
  316. MsgType: mevt.MsgNotice,
  317. Body: "Usage: " + cmdGithubAssignUsage,
  318. }, nil
  319. } else if len(args) < 2 {
  320. return &mevt.MessageEventContent{
  321. MsgType: mevt.MsgNotice,
  322. Body: "Needs at least one username. Usage: " + cmdGithubAssignUsage,
  323. }, nil
  324. }
  325. // get owner,repo,issue,resp out of args[0]
  326. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, cmdGithubAssignUsage)
  327. if resp != nil {
  328. return resp, nil
  329. }
  330. issue, res, err := cli.Issues.AddAssignees(context.Background(), owner, repo, issueNum, args[1:])
  331. if err != nil {
  332. log.WithField("err", err).Print("Failed to add issue assignees")
  333. if res == nil {
  334. return nil, fmt.Errorf("Failed to add issue assignees. Failed to connect to Github")
  335. }
  336. return nil, fmt.Errorf("Failed to add issue assignees. HTTP %d", res.StatusCode)
  337. }
  338. return mevt.MessageEventContent{
  339. MsgType: mevt.MsgNotice,
  340. Body: fmt.Sprintf("Added assignees to issue: %s", *issue.HTMLURL),
  341. }, nil
  342. }
  343. func (s *Service) githubIssueCloseReopen(roomID id.RoomID, userID id.UserID, args []string, state, verb, help string) (interface{}, error) {
  344. cli, resp, err := s.requireGithubClientFor(userID)
  345. if cli == nil {
  346. return resp, err
  347. }
  348. if len(args) == 0 {
  349. return &mevt.MessageEventContent{
  350. MsgType: mevt.MsgNotice,
  351. Body: "Usage: " + help,
  352. }, nil
  353. }
  354. // get owner,repo,issue,resp out of args[0]
  355. owner, repo, issueNum, resp := s.getIssueDetailsFor(args[0], roomID, help)
  356. if resp != nil {
  357. return resp, nil
  358. }
  359. issueComment, res, err := cli.Issues.Edit(context.Background(), owner, repo, issueNum, &gogithub.IssueRequest{
  360. State: &state,
  361. })
  362. if err != nil {
  363. log.WithField("err", err).Printf("Failed to %s issue", verb)
  364. if res == nil {
  365. return nil, fmt.Errorf("Failed to %s issue. Failed to connect to Github", verb)
  366. }
  367. return nil, fmt.Errorf("Failed to %s issue. HTTP %d", verb, res.StatusCode)
  368. }
  369. return mevt.MessageEventContent{
  370. MsgType: mevt.MsgNotice,
  371. Body: fmt.Sprintf("Closed issue: %s", *issueComment.HTMLURL),
  372. }, nil
  373. }
  374. const cmdGithubCloseUsage = `!github close [owner/repo]#issue`
  375. func (s *Service) cmdGithubClose(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  376. return s.githubIssueCloseReopen(roomID, userID, args, "closed", "close", cmdGithubCloseUsage)
  377. }
  378. const cmdGithubReopenUsage = `!github reopen [owner/repo]#issue`
  379. func (s *Service) cmdGithubReopen(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  380. return s.githubIssueCloseReopen(roomID, userID, args, "open", "open", cmdGithubCloseUsage)
  381. }
  382. func (s *Service) getIssueDetailsFor(input string, roomID id.RoomID, usage string) (owner, repo string, issueNum int, resp interface{}) {
  383. // We expect the input to look like:
  384. // "[owner/repo]#issue"
  385. // They can omit the owner/repo if there is a default one set.
  386. // Look for a default if the first arg is just an issue number
  387. ownerRepoIssueGroups := ownerRepoIssueRegexAnchored.FindStringSubmatch(input)
  388. if len(ownerRepoIssueGroups) != 5 {
  389. resp = &mevt.MessageEventContent{
  390. MsgType: mevt.MsgNotice,
  391. Body: "Usage: " + usage,
  392. }
  393. return
  394. }
  395. owner = ownerRepoIssueGroups[2]
  396. repo = ownerRepoIssueGroups[3]
  397. var err error
  398. if issueNum, err = strconv.Atoi(ownerRepoIssueGroups[4]); err != nil {
  399. resp = &mevt.MessageEventContent{
  400. MsgType: mevt.MsgNotice,
  401. Body: "Malformed issue number. Usage: " + usage,
  402. }
  403. return
  404. }
  405. if ownerRepoIssueGroups[1] == "" {
  406. // issue only match, this only works if there is a default repo
  407. defaultRepo := s.defaultRepo(roomID)
  408. if defaultRepo == "" {
  409. resp = &mevt.MessageEventContent{
  410. MsgType: mevt.MsgNotice,
  411. Body: "Need to specify repo. Usage: " + usage,
  412. }
  413. return
  414. }
  415. segs := strings.Split(defaultRepo, "/")
  416. if len(segs) != 2 {
  417. resp = &mevt.MessageEventContent{
  418. MsgType: mevt.MsgNotice,
  419. Body: "Malformed default repo. Usage: " + usage,
  420. }
  421. return
  422. }
  423. owner = segs[0]
  424. repo = segs[1]
  425. }
  426. return
  427. }
  428. func (s *Service) expandIssue(roomID id.RoomID, userID id.UserID, owner, repo string, issueNum int) interface{} {
  429. cli := s.githubClientFor(userID, true)
  430. i, _, err := cli.Issues.Get(context.Background(), owner, repo, issueNum)
  431. if err != nil {
  432. log.WithError(err).WithFields(log.Fields{
  433. "owner": owner,
  434. "repo": repo,
  435. "number": issueNum,
  436. }).Print("Failed to fetch issue")
  437. return nil
  438. }
  439. return &mevt.MessageEventContent{
  440. MsgType: mevt.MsgNotice,
  441. Body: fmt.Sprintf("%s : %s", *i.HTMLURL, *i.Title),
  442. }
  443. }
  444. func (s *Service) expandCommit(roomID id.RoomID, userID id.UserID, owner, repo, sha string) interface{} {
  445. cli := s.githubClientFor(userID, true)
  446. c, _, err := cli.Repositories.GetCommit(context.Background(), owner, repo, sha)
  447. if err != nil {
  448. log.WithError(err).WithFields(log.Fields{
  449. "owner": owner,
  450. "repo": repo,
  451. "sha": sha,
  452. }).Print("Failed to fetch commit")
  453. return nil
  454. }
  455. commit := c.Commit
  456. var htmlBuffer bytes.Buffer
  457. var plainBuffer bytes.Buffer
  458. shortURL := strings.TrimSuffix(*c.HTMLURL, *c.SHA) + sha
  459. htmlBuffer.WriteString(fmt.Sprintf("<a href=\"%s\">%s</a><br />", *c.HTMLURL, shortURL))
  460. plainBuffer.WriteString(fmt.Sprintf("%s\n", shortURL))
  461. if c.Stats != nil {
  462. 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))
  463. plainBuffer.WriteString(fmt.Sprintf("[~%d, +%d, -%d] ", len(c.Files), *c.Stats.Additions, *c.Stats.Deletions))
  464. }
  465. if commit.Author != nil {
  466. authorName := ""
  467. if commit.Author.Name != nil {
  468. authorName = *commit.Author.Name
  469. } else if commit.Author.Login != nil {
  470. authorName = *commit.Author.Login
  471. }
  472. htmlBuffer.WriteString(fmt.Sprintf("%s: ", authorName))
  473. plainBuffer.WriteString(fmt.Sprintf("%s: ", authorName))
  474. }
  475. if commit.Message != nil {
  476. segs := strings.SplitN(*commit.Message, "\n", 2)
  477. htmlBuffer.WriteString(segs[0])
  478. plainBuffer.WriteString(segs[0])
  479. }
  480. return &mevt.MessageEventContent{
  481. Body: plainBuffer.String(),
  482. MsgType: mevt.MsgNotice,
  483. Format: mevt.FormatHTML,
  484. FormattedBody: htmlBuffer.String(),
  485. }
  486. }
  487. // Commands supported:
  488. // !github create owner/repo "issue title" "optional issue description"
  489. // Responds with the outcome of the issue creation request. This command requires
  490. // a Github account to be linked to the Matrix user ID issuing the command. If there
  491. // is no link, it will return a Starter Link instead.
  492. // !github comment [owner/repo]#issue "comment"
  493. // Responds with the outcome of the issue comment creation request. This command requires
  494. // a Github account to be linked to the Matrix user ID issuing the command. If there
  495. // is no link, it will return a Starter Link instead.
  496. func (s *Service) Commands(cli types.MatrixClient) []types.Command {
  497. return []types.Command{
  498. {
  499. Path: []string{"github", "search"},
  500. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  501. return s.cmdGithubSearch(roomID, userID, args)
  502. },
  503. },
  504. {
  505. Path: []string{"github", "create"},
  506. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  507. return s.cmdGithubCreate(roomID, userID, args)
  508. },
  509. },
  510. {
  511. Path: []string{"github", "react"},
  512. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  513. return s.cmdGithubReact(roomID, userID, args)
  514. },
  515. },
  516. {
  517. Path: []string{"github", "comment"},
  518. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  519. return s.cmdGithubComment(roomID, userID, args)
  520. },
  521. },
  522. {
  523. Path: []string{"github", "assign"},
  524. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  525. return s.cmdGithubAssign(roomID, userID, args)
  526. },
  527. },
  528. {
  529. Path: []string{"github", "close"},
  530. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  531. return s.cmdGithubClose(roomID, userID, args)
  532. },
  533. },
  534. {
  535. Path: []string{"github", "reopen"},
  536. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  537. return s.cmdGithubReopen(roomID, userID, args)
  538. },
  539. },
  540. {
  541. Path: []string{"github", "help"},
  542. Command: func(roomID id.RoomID, userID id.UserID, args []string) (interface{}, error) {
  543. return &mevt.MessageEventContent{
  544. MsgType: mevt.MsgNotice,
  545. Body: strings.Join([]string{
  546. cmdGithubCreateUsage,
  547. cmdGithubReactUsage,
  548. cmdGithubCommentUsage,
  549. cmdGithubAssignUsage,
  550. cmdGithubCloseUsage,
  551. cmdGithubReopenUsage,
  552. }, "\n"),
  553. }, nil
  554. },
  555. },
  556. }
  557. }
  558. // Expansions expands strings of the form:
  559. // owner/repo#12
  560. // Where #12 is an issue number or pull request. If there is a default repository set on the room,
  561. // it will also expand strings of the form:
  562. // #12
  563. // using the default repository.
  564. func (s *Service) Expansions(cli types.MatrixClient) []types.Expansion {
  565. return []types.Expansion{
  566. types.Expansion{
  567. Regexp: ownerRepoIssueRegex,
  568. Expand: func(roomID id.RoomID, userID id.UserID, matchingGroups []string) interface{} {
  569. // There's an optional group in the regex so matchingGroups can look like:
  570. // [foo/bar#55 foo bar 55]
  571. // [#55 55]
  572. if len(matchingGroups) != 4 {
  573. log.WithField("groups", matchingGroups).WithField("len", len(matchingGroups)).Print(
  574. "Unexpected number of groups",
  575. )
  576. return nil
  577. }
  578. if matchingGroups[1] == "" && matchingGroups[2] == "" {
  579. // issue only match, this only works if there is a default repo
  580. defaultRepo := s.defaultRepo(roomID)
  581. if defaultRepo == "" {
  582. return nil
  583. }
  584. segs := strings.Split(defaultRepo, "/")
  585. if len(segs) != 2 {
  586. log.WithFields(log.Fields{
  587. "room_id": roomID,
  588. "default_repo": defaultRepo,
  589. }).Error("Default repo is malformed")
  590. return nil
  591. }
  592. // Fill in the missing fields in matching groups and fall through into ["foo/bar#11", "foo", "bar", "11"]
  593. matchingGroups = []string{
  594. defaultRepo + matchingGroups[0],
  595. segs[0],
  596. segs[1],
  597. matchingGroups[3],
  598. }
  599. }
  600. num, err := strconv.Atoi(matchingGroups[3])
  601. if err != nil {
  602. log.WithField("issue_number", matchingGroups[3]).Print("Bad issue number")
  603. return nil
  604. }
  605. return s.expandIssue(roomID, userID, matchingGroups[1], matchingGroups[2], num)
  606. },
  607. },
  608. types.Expansion{
  609. Regexp: ownerRepoCommitRegex,
  610. Expand: func(roomID id.RoomID, userID id.UserID, matchingGroups []string) interface{} {
  611. // There's an optional group in the regex so matchingGroups can look like:
  612. // [foo/bar@a123 foo bar a123]
  613. // [@a123 a123]
  614. if len(matchingGroups) != 4 {
  615. log.WithField("groups", matchingGroups).WithField("len", len(matchingGroups)).Print(
  616. "Unexpected number of groups",
  617. )
  618. return nil
  619. }
  620. if matchingGroups[1] == "" && matchingGroups[2] == "" {
  621. // issue only match, this only works if there is a default repo
  622. defaultRepo := s.defaultRepo(roomID)
  623. if defaultRepo == "" {
  624. return nil
  625. }
  626. segs := strings.Split(defaultRepo, "/")
  627. if len(segs) != 2 {
  628. log.WithFields(log.Fields{
  629. "room_id": roomID,
  630. "default_repo": defaultRepo,
  631. }).Error("Default repo is malformed")
  632. return nil
  633. }
  634. // Fill in the missing fields in matching groups and fall through into ["foo/bar@a123", "foo", "bar", "a123"]
  635. matchingGroups = []string{
  636. defaultRepo + matchingGroups[0],
  637. segs[0],
  638. segs[1],
  639. matchingGroups[3],
  640. }
  641. }
  642. return s.expandCommit(roomID, userID, matchingGroups[1], matchingGroups[2], matchingGroups[3])
  643. },
  644. },
  645. }
  646. }
  647. // Register makes sure that the given realm ID maps to a github realm.
  648. func (s *Service) Register(oldService types.Service, client types.MatrixClient) error {
  649. if s.RealmID == "" {
  650. return fmt.Errorf("RealmID is required")
  651. }
  652. // check realm exists
  653. realm, err := database.GetServiceDB().LoadAuthRealm(s.RealmID)
  654. if err != nil {
  655. return err
  656. }
  657. // make sure the realm is of the type we expect
  658. if realm.Type() != "github" {
  659. return fmt.Errorf("Realm is of type '%s', not 'github'", realm.Type())
  660. }
  661. log.Infof("%+v", s)
  662. return nil
  663. }
  664. func (s *Service) loadBotOptions(roomID id.RoomID, logger *log.Entry) (result types.GithubOptions, err error) {
  665. opts, err := database.GetServiceDB().LoadBotOptions(s.ServiceUserID(), roomID)
  666. if err != nil {
  667. if err == sql.ErrNoRows {
  668. logger.Info("no bot options specified - using defaults")
  669. return types.GithubOptions{}, nil
  670. } else {
  671. err := errors.New("Failed to load bot options")
  672. logger.WithError(err).Error(err)
  673. return types.GithubOptions{}, err
  674. }
  675. }
  676. // Expect opts to look like:
  677. // {
  678. // github: {
  679. // default_repo: $OWNER_REPO,
  680. // new_issue_labels: [ "label1", .. ]
  681. // }
  682. // }
  683. return opts.Options.Github, nil
  684. }
  685. // defaultRepo returns the default repo for the given room, or an empty string.
  686. func (s *Service) defaultRepo(roomID id.RoomID) string {
  687. logger := log.WithFields(log.Fields{
  688. "room_id": roomID,
  689. "bot_user_id": s.ServiceUserID(),
  690. })
  691. // ignore any errors, we treat it the same as no options and log inside the method
  692. ghOpts, _ := s.loadBotOptions(roomID, logger)
  693. return ghOpts.DefaultRepo
  694. }
  695. func (s *Service) githubClientFor(userID id.UserID, allowUnauth bool) *gogithub.Client {
  696. token, err := getTokenForUser(s.RealmID, userID)
  697. if err != nil {
  698. log.WithFields(log.Fields{
  699. log.ErrorKey: err,
  700. "user_id": userID,
  701. "realm_id": s.RealmID,
  702. }).Print("Failed to get token for user")
  703. }
  704. if token != "" {
  705. return client.New(token)
  706. } else if allowUnauth {
  707. return client.New("")
  708. } else {
  709. return nil
  710. }
  711. }
  712. func getTokenForUser(realmID string, userID id.UserID) (string, error) {
  713. realm, err := database.GetServiceDB().LoadAuthRealm(realmID)
  714. if err != nil {
  715. return "", err
  716. }
  717. if realm.Type() != "github" {
  718. return "", fmt.Errorf("Bad realm type: %s", realm.Type())
  719. }
  720. // pull out the token (TODO: should the service know how the realm stores this?)
  721. session, err := database.GetServiceDB().LoadAuthSessionByUser(realm.ID(), userID)
  722. if err != nil {
  723. return "", err
  724. }
  725. ghSession, ok := session.(*github.Session)
  726. if !ok {
  727. return "", fmt.Errorf("Session is not a github session: %s", session.ID())
  728. }
  729. if ghSession.AccessToken == "" {
  730. return "", fmt.Errorf("Github auth session for %s has not been completed", userID)
  731. }
  732. return ghSession.AccessToken, nil
  733. }
  734. func init() {
  735. types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service {
  736. return &Service{
  737. DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType),
  738. }
  739. })
  740. }