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.

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