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.

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