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.

758 lines
24 KiB

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