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.

gitlab.go 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. // Copyright 2019 The Gitea Authors. All rights reserved.
  2. // Use of this source code is governed by a MIT-style
  3. // license that can be found in the LICENSE file.
  4. package migrations
  5. import (
  6. "context"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "net/url"
  12. "path"
  13. "strings"
  14. "time"
  15. "code.gitea.io/gitea/modules/log"
  16. base "code.gitea.io/gitea/modules/migration"
  17. "code.gitea.io/gitea/modules/structs"
  18. "github.com/xanzy/go-gitlab"
  19. )
  20. var (
  21. _ base.Downloader = &GitlabDownloader{}
  22. _ base.DownloaderFactory = &GitlabDownloaderFactory{}
  23. )
  24. func init() {
  25. RegisterDownloaderFactory(&GitlabDownloaderFactory{})
  26. }
  27. // GitlabDownloaderFactory defines a gitlab downloader factory
  28. type GitlabDownloaderFactory struct{}
  29. // New returns a Downloader related to this factory according MigrateOptions
  30. func (f *GitlabDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
  31. u, err := url.Parse(opts.CloneAddr)
  32. if err != nil {
  33. return nil, err
  34. }
  35. baseURL := u.Scheme + "://" + u.Host
  36. repoNameSpace := strings.TrimPrefix(u.Path, "/")
  37. repoNameSpace = strings.TrimSuffix(repoNameSpace, ".git")
  38. log.Trace("Create gitlab downloader. BaseURL: %s RepoName: %s", baseURL, repoNameSpace)
  39. return NewGitlabDownloader(ctx, baseURL, repoNameSpace, opts.AuthUsername, opts.AuthPassword, opts.AuthToken)
  40. }
  41. // GitServiceType returns the type of git service
  42. func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType {
  43. return structs.GitlabService
  44. }
  45. // GitlabDownloader implements a Downloader interface to get repository information
  46. // from gitlab via go-gitlab
  47. // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap,
  48. // because Gitlab has individual Issue and Pull Request numbers.
  49. type GitlabDownloader struct {
  50. base.NullDownloader
  51. ctx context.Context
  52. client *gitlab.Client
  53. repoID int
  54. repoName string
  55. issueCount int64
  56. maxPerPage int
  57. }
  58. // NewGitlabDownloader creates a gitlab Downloader via gitlab API
  59. // Use either a username/password, personal token entered into the username field, or anonymous/public access
  60. // Note: Public access only allows very basic access
  61. func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, username, password, token string) (*GitlabDownloader, error) {
  62. gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
  63. // Only use basic auth if token is blank and password is NOT
  64. // Basic auth will fail with empty strings, but empty token will allow anonymous public API usage
  65. if token == "" && password != "" {
  66. gitlabClient, err = gitlab.NewBasicAuthClient(username, password, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
  67. }
  68. if err != nil {
  69. log.Trace("Error logging into gitlab: %v", err)
  70. return nil, err
  71. }
  72. // split namespace and subdirectory
  73. pathParts := strings.Split(strings.Trim(repoPath, "/"), "/")
  74. var resp *gitlab.Response
  75. u, _ := url.Parse(baseURL)
  76. for len(pathParts) >= 2 {
  77. _, resp, err = gitlabClient.Version.GetVersion()
  78. if err == nil || resp != nil && resp.StatusCode == http.StatusUnauthorized {
  79. err = nil // if no authentication given, this still should work
  80. break
  81. }
  82. u.Path = path.Join(u.Path, pathParts[0])
  83. baseURL = u.String()
  84. pathParts = pathParts[1:]
  85. _ = gitlab.WithBaseURL(baseURL)(gitlabClient)
  86. repoPath = strings.Join(pathParts, "/")
  87. }
  88. if err != nil {
  89. log.Trace("Error could not get gitlab version: %v", err)
  90. return nil, err
  91. }
  92. log.Trace("gitlab downloader: use BaseURL: '%s' and RepoPath: '%s'", baseURL, repoPath)
  93. // Grab and store project/repo ID here, due to issues using the URL escaped path
  94. gr, _, err := gitlabClient.Projects.GetProject(repoPath, nil, nil, gitlab.WithContext(ctx))
  95. if err != nil {
  96. log.Trace("Error retrieving project: %v", err)
  97. return nil, err
  98. }
  99. if gr == nil {
  100. log.Trace("Error getting project, project is nil")
  101. return nil, errors.New("Error getting project, project is nil")
  102. }
  103. return &GitlabDownloader{
  104. ctx: ctx,
  105. client: gitlabClient,
  106. repoID: gr.ID,
  107. repoName: gr.Name,
  108. maxPerPage: 100,
  109. }, nil
  110. }
  111. // SetContext set context
  112. func (g *GitlabDownloader) SetContext(ctx context.Context) {
  113. g.ctx = ctx
  114. }
  115. // GetRepoInfo returns a repository information
  116. func (g *GitlabDownloader) GetRepoInfo() (*base.Repository, error) {
  117. gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
  118. if err != nil {
  119. return nil, err
  120. }
  121. var private bool
  122. switch gr.Visibility {
  123. case gitlab.InternalVisibility:
  124. private = true
  125. case gitlab.PrivateVisibility:
  126. private = true
  127. }
  128. var owner string
  129. if gr.Owner == nil {
  130. log.Trace("gr.Owner is nil, trying to get owner from Namespace")
  131. if gr.Namespace != nil && gr.Namespace.Kind == "user" {
  132. owner = gr.Namespace.Path
  133. }
  134. } else {
  135. owner = gr.Owner.Username
  136. }
  137. // convert gitlab repo to stand Repo
  138. return &base.Repository{
  139. Owner: owner,
  140. Name: gr.Name,
  141. IsPrivate: private,
  142. Description: gr.Description,
  143. OriginalURL: gr.WebURL,
  144. CloneURL: gr.HTTPURLToRepo,
  145. DefaultBranch: gr.DefaultBranch,
  146. }, nil
  147. }
  148. // GetTopics return gitlab topics
  149. func (g *GitlabDownloader) GetTopics() ([]string, error) {
  150. gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
  151. if err != nil {
  152. return nil, err
  153. }
  154. return gr.TagList, err
  155. }
  156. // GetMilestones returns milestones
  157. func (g *GitlabDownloader) GetMilestones() ([]*base.Milestone, error) {
  158. perPage := g.maxPerPage
  159. state := "all"
  160. milestones := make([]*base.Milestone, 0, perPage)
  161. for i := 1; ; i++ {
  162. ms, _, err := g.client.Milestones.ListMilestones(g.repoID, &gitlab.ListMilestonesOptions{
  163. State: &state,
  164. ListOptions: gitlab.ListOptions{
  165. Page: i,
  166. PerPage: perPage,
  167. },
  168. }, nil, gitlab.WithContext(g.ctx))
  169. if err != nil {
  170. return nil, err
  171. }
  172. for _, m := range ms {
  173. var desc string
  174. if m.Description != "" {
  175. desc = m.Description
  176. }
  177. state := "open"
  178. var closedAt *time.Time
  179. if m.State != "" {
  180. state = m.State
  181. if state == "closed" {
  182. closedAt = m.UpdatedAt
  183. }
  184. }
  185. var deadline *time.Time
  186. if m.DueDate != nil {
  187. deadlineParsed, err := time.Parse("2006-01-02", m.DueDate.String())
  188. if err != nil {
  189. log.Trace("Error parsing Milestone DueDate time")
  190. deadline = nil
  191. } else {
  192. deadline = &deadlineParsed
  193. }
  194. }
  195. milestones = append(milestones, &base.Milestone{
  196. Title: m.Title,
  197. Description: desc,
  198. Deadline: deadline,
  199. State: state,
  200. Created: *m.CreatedAt,
  201. Updated: m.UpdatedAt,
  202. Closed: closedAt,
  203. })
  204. }
  205. if len(ms) < perPage {
  206. break
  207. }
  208. }
  209. return milestones, nil
  210. }
  211. func (g *GitlabDownloader) normalizeColor(val string) string {
  212. val = strings.TrimLeft(val, "#")
  213. val = strings.ToLower(val)
  214. if len(val) == 3 {
  215. c := []rune(val)
  216. val = fmt.Sprintf("%c%c%c%c%c%c", c[0], c[0], c[1], c[1], c[2], c[2])
  217. }
  218. if len(val) != 6 {
  219. return ""
  220. }
  221. return val
  222. }
  223. // GetLabels returns labels
  224. func (g *GitlabDownloader) GetLabels() ([]*base.Label, error) {
  225. perPage := g.maxPerPage
  226. labels := make([]*base.Label, 0, perPage)
  227. for i := 1; ; i++ {
  228. ls, _, err := g.client.Labels.ListLabels(g.repoID, &gitlab.ListLabelsOptions{ListOptions: gitlab.ListOptions{
  229. Page: i,
  230. PerPage: perPage,
  231. }}, nil, gitlab.WithContext(g.ctx))
  232. if err != nil {
  233. return nil, err
  234. }
  235. for _, label := range ls {
  236. baseLabel := &base.Label{
  237. Name: label.Name,
  238. Color: g.normalizeColor(label.Color),
  239. Description: label.Description,
  240. }
  241. labels = append(labels, baseLabel)
  242. }
  243. if len(ls) < perPage {
  244. break
  245. }
  246. }
  247. return labels, nil
  248. }
  249. func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Release {
  250. var zero int
  251. r := &base.Release{
  252. TagName: rel.TagName,
  253. TargetCommitish: rel.Commit.ID,
  254. Name: rel.Name,
  255. Body: rel.Description,
  256. Created: *rel.CreatedAt,
  257. PublisherID: int64(rel.Author.ID),
  258. PublisherName: rel.Author.Username,
  259. }
  260. httpClient := NewMigrationHTTPClient()
  261. for k, asset := range rel.Assets.Links {
  262. r.Assets = append(r.Assets, &base.ReleaseAsset{
  263. ID: int64(asset.ID),
  264. Name: asset.Name,
  265. ContentType: &rel.Assets.Sources[k].Format,
  266. Size: &zero,
  267. DownloadCount: &zero,
  268. DownloadFunc: func() (io.ReadCloser, error) {
  269. link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, asset.ID, gitlab.WithContext(g.ctx))
  270. if err != nil {
  271. return nil, err
  272. }
  273. req, err := http.NewRequest("GET", link.URL, nil)
  274. if err != nil {
  275. return nil, err
  276. }
  277. req = req.WithContext(g.ctx)
  278. resp, err := httpClient.Do(req)
  279. if err != nil {
  280. return nil, err
  281. }
  282. // resp.Body is closed by the uploader
  283. return resp.Body, nil
  284. },
  285. })
  286. }
  287. return r
  288. }
  289. // GetReleases returns releases
  290. func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
  291. perPage := g.maxPerPage
  292. releases := make([]*base.Release, 0, perPage)
  293. for i := 1; ; i++ {
  294. ls, _, err := g.client.Releases.ListReleases(g.repoID, &gitlab.ListReleasesOptions{
  295. Page: i,
  296. PerPage: perPage,
  297. }, nil, gitlab.WithContext(g.ctx))
  298. if err != nil {
  299. return nil, err
  300. }
  301. for _, release := range ls {
  302. releases = append(releases, g.convertGitlabRelease(release))
  303. }
  304. if len(ls) < perPage {
  305. break
  306. }
  307. }
  308. return releases, nil
  309. }
  310. type gitlabIssueContext struct {
  311. IsMergeRequest bool
  312. }
  313. // GetIssues returns issues according start and limit
  314. // Note: issue label description and colors are not supported by the go-gitlab library at this time
  315. func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
  316. state := "all"
  317. sort := "asc"
  318. if perPage > g.maxPerPage {
  319. perPage = g.maxPerPage
  320. }
  321. opt := &gitlab.ListProjectIssuesOptions{
  322. State: &state,
  323. Sort: &sort,
  324. ListOptions: gitlab.ListOptions{
  325. PerPage: perPage,
  326. Page: page,
  327. },
  328. }
  329. allIssues := make([]*base.Issue, 0, perPage)
  330. issues, _, err := g.client.Issues.ListProjectIssues(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
  331. if err != nil {
  332. return nil, false, fmt.Errorf("error while listing issues: %v", err)
  333. }
  334. for _, issue := range issues {
  335. labels := make([]*base.Label, 0, len(issue.Labels))
  336. for _, l := range issue.Labels {
  337. labels = append(labels, &base.Label{
  338. Name: l,
  339. })
  340. }
  341. var milestone string
  342. if issue.Milestone != nil {
  343. milestone = issue.Milestone.Title
  344. }
  345. var reactions []*base.Reaction
  346. awardPage := 1
  347. for {
  348. awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
  349. if err != nil {
  350. return nil, false, fmt.Errorf("error while listing issue awards: %v", err)
  351. }
  352. for i := range awards {
  353. reactions = append(reactions, g.awardToReaction(awards[i]))
  354. }
  355. if len(awards) < perPage {
  356. break
  357. }
  358. awardPage++
  359. }
  360. allIssues = append(allIssues, &base.Issue{
  361. Title: issue.Title,
  362. Number: int64(issue.IID),
  363. PosterID: int64(issue.Author.ID),
  364. PosterName: issue.Author.Username,
  365. Content: issue.Description,
  366. Milestone: milestone,
  367. State: issue.State,
  368. Created: *issue.CreatedAt,
  369. Labels: labels,
  370. Reactions: reactions,
  371. Closed: issue.ClosedAt,
  372. IsLocked: issue.DiscussionLocked,
  373. Updated: *issue.UpdatedAt,
  374. ForeignIndex: int64(issue.IID),
  375. Context: gitlabIssueContext{IsMergeRequest: false},
  376. })
  377. // increment issueCount, to be used in GetPullRequests()
  378. g.issueCount++
  379. }
  380. return allIssues, len(issues) < perPage, nil
  381. }
  382. // GetComments returns comments according issueNumber
  383. // TODO: figure out how to transfer comment reactions
  384. func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
  385. context, ok := commentable.GetContext().(gitlabIssueContext)
  386. if !ok {
  387. return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
  388. }
  389. allComments := make([]*base.Comment, 0, g.maxPerPage)
  390. page := 1
  391. for {
  392. var comments []*gitlab.Discussion
  393. var resp *gitlab.Response
  394. var err error
  395. if !context.IsMergeRequest {
  396. comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListIssueDiscussionsOptions{
  397. Page: page,
  398. PerPage: g.maxPerPage,
  399. }, nil, gitlab.WithContext(g.ctx))
  400. } else {
  401. comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListMergeRequestDiscussionsOptions{
  402. Page: page,
  403. PerPage: g.maxPerPage,
  404. }, nil, gitlab.WithContext(g.ctx))
  405. }
  406. if err != nil {
  407. return nil, false, fmt.Errorf("error while listing comments: %v %v", g.repoID, err)
  408. }
  409. for _, comment := range comments {
  410. // Flatten comment threads
  411. if !comment.IndividualNote {
  412. for _, note := range comment.Notes {
  413. allComments = append(allComments, &base.Comment{
  414. IssueIndex: commentable.GetLocalIndex(),
  415. Index: int64(note.ID),
  416. PosterID: int64(note.Author.ID),
  417. PosterName: note.Author.Username,
  418. PosterEmail: note.Author.Email,
  419. Content: note.Body,
  420. Created: *note.CreatedAt,
  421. })
  422. }
  423. } else {
  424. c := comment.Notes[0]
  425. allComments = append(allComments, &base.Comment{
  426. IssueIndex: commentable.GetLocalIndex(),
  427. Index: int64(c.ID),
  428. PosterID: int64(c.Author.ID),
  429. PosterName: c.Author.Username,
  430. PosterEmail: c.Author.Email,
  431. Content: c.Body,
  432. Created: *c.CreatedAt,
  433. })
  434. }
  435. }
  436. if resp.NextPage == 0 {
  437. break
  438. }
  439. page = resp.NextPage
  440. }
  441. return allComments, true, nil
  442. }
  443. // GetPullRequests returns pull requests according page and perPage
  444. func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
  445. if perPage > g.maxPerPage {
  446. perPage = g.maxPerPage
  447. }
  448. opt := &gitlab.ListProjectMergeRequestsOptions{
  449. ListOptions: gitlab.ListOptions{
  450. PerPage: perPage,
  451. Page: page,
  452. },
  453. }
  454. allPRs := make([]*base.PullRequest, 0, perPage)
  455. prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
  456. if err != nil {
  457. return nil, false, fmt.Errorf("error while listing merge requests: %v", err)
  458. }
  459. for _, pr := range prs {
  460. labels := make([]*base.Label, 0, len(pr.Labels))
  461. for _, l := range pr.Labels {
  462. labels = append(labels, &base.Label{
  463. Name: l,
  464. })
  465. }
  466. var merged bool
  467. if pr.State == "merged" {
  468. merged = true
  469. pr.State = "closed"
  470. }
  471. mergeTime := pr.MergedAt
  472. if merged && pr.MergedAt == nil {
  473. mergeTime = pr.UpdatedAt
  474. }
  475. closeTime := pr.ClosedAt
  476. if merged && pr.ClosedAt == nil {
  477. closeTime = pr.UpdatedAt
  478. }
  479. var locked bool
  480. if pr.State == "locked" {
  481. locked = true
  482. }
  483. var milestone string
  484. if pr.Milestone != nil {
  485. milestone = pr.Milestone.Title
  486. }
  487. var reactions []*base.Reaction
  488. awardPage := 1
  489. for {
  490. awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
  491. if err != nil {
  492. return nil, false, fmt.Errorf("error while listing merge requests awards: %v", err)
  493. }
  494. for i := range awards {
  495. reactions = append(reactions, g.awardToReaction(awards[i]))
  496. }
  497. if len(awards) < perPage {
  498. break
  499. }
  500. awardPage++
  501. }
  502. // Add the PR ID to the Issue Count because PR and Issues share ID space in Gitea
  503. newPRNumber := g.issueCount + int64(pr.IID)
  504. allPRs = append(allPRs, &base.PullRequest{
  505. Title: pr.Title,
  506. Number: newPRNumber,
  507. PosterName: pr.Author.Username,
  508. PosterID: int64(pr.Author.ID),
  509. Content: pr.Description,
  510. Milestone: milestone,
  511. State: pr.State,
  512. Created: *pr.CreatedAt,
  513. Closed: closeTime,
  514. Labels: labels,
  515. Merged: merged,
  516. MergeCommitSHA: pr.MergeCommitSHA,
  517. MergedTime: mergeTime,
  518. IsLocked: locked,
  519. Reactions: reactions,
  520. Head: base.PullRequestBranch{
  521. Ref: pr.SourceBranch,
  522. SHA: pr.SHA,
  523. RepoName: g.repoName,
  524. OwnerName: pr.Author.Username,
  525. CloneURL: pr.WebURL,
  526. },
  527. Base: base.PullRequestBranch{
  528. Ref: pr.TargetBranch,
  529. SHA: pr.DiffRefs.BaseSha,
  530. RepoName: g.repoName,
  531. OwnerName: pr.Author.Username,
  532. },
  533. PatchURL: pr.WebURL + ".patch",
  534. ForeignIndex: int64(pr.IID),
  535. Context: gitlabIssueContext{IsMergeRequest: true},
  536. })
  537. }
  538. return allPRs, len(prs) < perPage, nil
  539. }
  540. // GetReviews returns pull requests review
  541. func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
  542. approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(reviewable.GetForeignIndex()), gitlab.WithContext(g.ctx))
  543. if err != nil {
  544. if resp != nil && resp.StatusCode == http.StatusNotFound {
  545. log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error()))
  546. return []*base.Review{}, nil
  547. }
  548. return nil, err
  549. }
  550. var createdAt time.Time
  551. if approvals.CreatedAt != nil {
  552. createdAt = *approvals.CreatedAt
  553. } else if approvals.UpdatedAt != nil {
  554. createdAt = *approvals.UpdatedAt
  555. } else {
  556. createdAt = time.Now()
  557. }
  558. reviews := make([]*base.Review, 0, len(approvals.ApprovedBy))
  559. for _, user := range approvals.ApprovedBy {
  560. reviews = append(reviews, &base.Review{
  561. IssueIndex: reviewable.GetLocalIndex(),
  562. ReviewerID: int64(user.User.ID),
  563. ReviewerName: user.User.Username,
  564. CreatedAt: createdAt,
  565. // All we get are approvals
  566. State: base.ReviewStateApproved,
  567. })
  568. }
  569. return reviews, nil
  570. }
  571. func (g *GitlabDownloader) awardToReaction(award *gitlab.AwardEmoji) *base.Reaction {
  572. return &base.Reaction{
  573. UserID: int64(award.User.ID),
  574. UserName: award.User.Username,
  575. Content: award.Name,
  576. }
  577. }