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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  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. }
  30. // New returns a Downloader related to this factory according MigrateOptions
  31. func (f *GitlabDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
  32. u, err := url.Parse(opts.CloneAddr)
  33. if err != nil {
  34. return nil, err
  35. }
  36. baseURL := u.Scheme + "://" + u.Host
  37. repoNameSpace := strings.TrimPrefix(u.Path, "/")
  38. repoNameSpace = strings.TrimSuffix(repoNameSpace, ".git")
  39. log.Trace("Create gitlab downloader. BaseURL: %s RepoName: %s", baseURL, repoNameSpace)
  40. return NewGitlabDownloader(ctx, baseURL, repoNameSpace, opts.AuthUsername, opts.AuthPassword, opts.AuthToken)
  41. }
  42. // GitServiceType returns the type of git service
  43. func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType {
  44. return structs.GitlabService
  45. }
  46. // GitlabDownloader implements a Downloader interface to get repository information
  47. // from gitlab via go-gitlab
  48. // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap,
  49. // because Gitlab has individual Issue and Pull Request numbers.
  50. type GitlabDownloader struct {
  51. base.NullDownloader
  52. ctx context.Context
  53. client *gitlab.Client
  54. repoID int
  55. repoName string
  56. issueCount int64
  57. maxPerPage int
  58. }
  59. // NewGitlabDownloader creates a gitlab Downloader via gitlab API
  60. // Use either a username/password, personal token entered into the username field, or anonymous/public access
  61. // Note: Public access only allows very basic access
  62. func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, username, password, token string) (*GitlabDownloader, error) {
  63. gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
  64. // Only use basic auth if token is blank and password is NOT
  65. // Basic auth will fail with empty strings, but empty token will allow anonymous public API usage
  66. if token == "" && password != "" {
  67. gitlabClient, err = gitlab.NewBasicAuthClient(username, password, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
  68. }
  69. if err != nil {
  70. log.Trace("Error logging into gitlab: %v", err)
  71. return nil, err
  72. }
  73. // split namespace and subdirectory
  74. pathParts := strings.Split(strings.Trim(repoPath, "/"), "/")
  75. var resp *gitlab.Response
  76. u, _ := url.Parse(baseURL)
  77. for len(pathParts) >= 2 {
  78. _, resp, err = gitlabClient.Version.GetVersion()
  79. if err == nil || resp != nil && resp.StatusCode == 401 {
  80. err = nil // if no authentication given, this still should work
  81. break
  82. }
  83. u.Path = path.Join(u.Path, pathParts[0])
  84. baseURL = u.String()
  85. pathParts = pathParts[1:]
  86. _ = gitlab.WithBaseURL(baseURL)(gitlabClient)
  87. repoPath = strings.Join(pathParts, "/")
  88. }
  89. if err != nil {
  90. log.Trace("Error could not get gitlab version: %v", err)
  91. return nil, err
  92. }
  93. log.Trace("gitlab downloader: use BaseURL: '%s' and RepoPath: '%s'", baseURL, repoPath)
  94. // Grab and store project/repo ID here, due to issues using the URL escaped path
  95. gr, _, err := gitlabClient.Projects.GetProject(repoPath, nil, nil, gitlab.WithContext(ctx))
  96. if err != nil {
  97. log.Trace("Error retrieving project: %v", err)
  98. return nil, err
  99. }
  100. if gr == nil {
  101. log.Trace("Error getting project, project is nil")
  102. return nil, errors.New("Error getting project, project is nil")
  103. }
  104. return &GitlabDownloader{
  105. ctx: ctx,
  106. client: gitlabClient,
  107. repoID: gr.ID,
  108. repoName: gr.Name,
  109. maxPerPage: 100,
  110. }, nil
  111. }
  112. // SetContext set context
  113. func (g *GitlabDownloader) SetContext(ctx context.Context) {
  114. g.ctx = ctx
  115. }
  116. // GetRepoInfo returns a repository information
  117. func (g *GitlabDownloader) GetRepoInfo() (*base.Repository, error) {
  118. gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
  119. if err != nil {
  120. return nil, err
  121. }
  122. var private bool
  123. switch gr.Visibility {
  124. case gitlab.InternalVisibility:
  125. private = true
  126. case gitlab.PrivateVisibility:
  127. private = true
  128. }
  129. var owner string
  130. if gr.Owner == nil {
  131. log.Trace("gr.Owner is nil, trying to get owner from Namespace")
  132. if gr.Namespace != nil && gr.Namespace.Kind == "user" {
  133. owner = gr.Namespace.Path
  134. }
  135. } else {
  136. owner = gr.Owner.Username
  137. }
  138. // convert gitlab repo to stand Repo
  139. return &base.Repository{
  140. Owner: owner,
  141. Name: gr.Name,
  142. IsPrivate: private,
  143. Description: gr.Description,
  144. OriginalURL: gr.WebURL,
  145. CloneURL: gr.HTTPURLToRepo,
  146. DefaultBranch: gr.DefaultBranch,
  147. }, nil
  148. }
  149. // GetTopics return gitlab topics
  150. func (g *GitlabDownloader) GetTopics() ([]string, error) {
  151. gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
  152. if err != nil {
  153. return nil, err
  154. }
  155. return gr.TagList, err
  156. }
  157. // GetMilestones returns milestones
  158. func (g *GitlabDownloader) GetMilestones() ([]*base.Milestone, error) {
  159. var perPage = g.maxPerPage
  160. var state = "all"
  161. var milestones = make([]*base.Milestone, 0, perPage)
  162. for i := 1; ; i++ {
  163. ms, _, err := g.client.Milestones.ListMilestones(g.repoID, &gitlab.ListMilestonesOptions{
  164. State: &state,
  165. ListOptions: gitlab.ListOptions{
  166. Page: i,
  167. PerPage: perPage,
  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. var 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. var perPage = g.maxPerPage
  226. var 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. var perPage = g.maxPerPage
  292. var 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. foreignID int64
  312. localID int64
  313. IsMergeRequest bool
  314. }
  315. func (c gitlabIssueContext) LocalID() int64 {
  316. return c.localID
  317. }
  318. func (c gitlabIssueContext) ForeignID() int64 {
  319. return c.foreignID
  320. }
  321. // GetIssues returns issues according start and limit
  322. // Note: issue label description and colors are not supported by the go-gitlab library at this time
  323. func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
  324. state := "all"
  325. sort := "asc"
  326. if perPage > g.maxPerPage {
  327. perPage = g.maxPerPage
  328. }
  329. opt := &gitlab.ListProjectIssuesOptions{
  330. State: &state,
  331. Sort: &sort,
  332. ListOptions: gitlab.ListOptions{
  333. PerPage: perPage,
  334. Page: page,
  335. },
  336. }
  337. var allIssues = make([]*base.Issue, 0, perPage)
  338. issues, _, err := g.client.Issues.ListProjectIssues(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
  339. if err != nil {
  340. return nil, false, fmt.Errorf("error while listing issues: %v", err)
  341. }
  342. for _, issue := range issues {
  343. var labels = make([]*base.Label, 0, len(issue.Labels))
  344. for _, l := range issue.Labels {
  345. labels = append(labels, &base.Label{
  346. Name: l,
  347. })
  348. }
  349. var milestone string
  350. if issue.Milestone != nil {
  351. milestone = issue.Milestone.Title
  352. }
  353. var reactions []*base.Reaction
  354. var awardPage = 1
  355. for {
  356. awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
  357. if err != nil {
  358. return nil, false, fmt.Errorf("error while listing issue awards: %v", err)
  359. }
  360. for i := range awards {
  361. reactions = append(reactions, g.awardToReaction(awards[i]))
  362. }
  363. if len(awards) < perPage {
  364. break
  365. }
  366. awardPage++
  367. }
  368. allIssues = append(allIssues, &base.Issue{
  369. Title: issue.Title,
  370. Number: int64(issue.IID),
  371. PosterID: int64(issue.Author.ID),
  372. PosterName: issue.Author.Username,
  373. Content: issue.Description,
  374. Milestone: milestone,
  375. State: issue.State,
  376. Created: *issue.CreatedAt,
  377. Labels: labels,
  378. Reactions: reactions,
  379. Closed: issue.ClosedAt,
  380. IsLocked: issue.DiscussionLocked,
  381. Updated: *issue.UpdatedAt,
  382. Context: gitlabIssueContext{
  383. foreignID: int64(issue.IID),
  384. localID: int64(issue.IID),
  385. IsMergeRequest: false,
  386. },
  387. })
  388. // increment issueCount, to be used in GetPullRequests()
  389. g.issueCount++
  390. }
  391. return allIssues, len(issues) < perPage, nil
  392. }
  393. // GetComments returns comments according issueNumber
  394. // TODO: figure out how to transfer comment reactions
  395. func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
  396. context, ok := opts.Context.(gitlabIssueContext)
  397. if !ok {
  398. return nil, false, fmt.Errorf("unexpected context: %+v", opts.Context)
  399. }
  400. var allComments = make([]*base.Comment, 0, g.maxPerPage)
  401. var page = 1
  402. for {
  403. var comments []*gitlab.Discussion
  404. var resp *gitlab.Response
  405. var err error
  406. if !context.IsMergeRequest {
  407. comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListIssueDiscussionsOptions{
  408. Page: page,
  409. PerPage: g.maxPerPage,
  410. }, nil, gitlab.WithContext(g.ctx))
  411. } else {
  412. comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListMergeRequestDiscussionsOptions{
  413. Page: page,
  414. PerPage: g.maxPerPage,
  415. }, nil, gitlab.WithContext(g.ctx))
  416. }
  417. if err != nil {
  418. return nil, false, fmt.Errorf("error while listing comments: %v %v", g.repoID, err)
  419. }
  420. for _, comment := range comments {
  421. // Flatten comment threads
  422. if !comment.IndividualNote {
  423. for _, note := range comment.Notes {
  424. allComments = append(allComments, &base.Comment{
  425. IssueIndex: context.LocalID(),
  426. PosterID: int64(note.Author.ID),
  427. PosterName: note.Author.Username,
  428. PosterEmail: note.Author.Email,
  429. Content: note.Body,
  430. Created: *note.CreatedAt,
  431. })
  432. }
  433. } else {
  434. c := comment.Notes[0]
  435. allComments = append(allComments, &base.Comment{
  436. IssueIndex: context.LocalID(),
  437. PosterID: int64(c.Author.ID),
  438. PosterName: c.Author.Username,
  439. PosterEmail: c.Author.Email,
  440. Content: c.Body,
  441. Created: *c.CreatedAt,
  442. })
  443. }
  444. }
  445. if resp.NextPage == 0 {
  446. break
  447. }
  448. page = resp.NextPage
  449. }
  450. return allComments, true, nil
  451. }
  452. // GetPullRequests returns pull requests according page and perPage
  453. func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
  454. if perPage > g.maxPerPage {
  455. perPage = g.maxPerPage
  456. }
  457. opt := &gitlab.ListProjectMergeRequestsOptions{
  458. ListOptions: gitlab.ListOptions{
  459. PerPage: perPage,
  460. Page: page,
  461. },
  462. }
  463. var allPRs = make([]*base.PullRequest, 0, perPage)
  464. prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
  465. if err != nil {
  466. return nil, false, fmt.Errorf("error while listing merge requests: %v", err)
  467. }
  468. for _, pr := range prs {
  469. var labels = make([]*base.Label, 0, len(pr.Labels))
  470. for _, l := range pr.Labels {
  471. labels = append(labels, &base.Label{
  472. Name: l,
  473. })
  474. }
  475. var merged bool
  476. if pr.State == "merged" {
  477. merged = true
  478. pr.State = "closed"
  479. }
  480. var mergeTime = pr.MergedAt
  481. if merged && pr.MergedAt == nil {
  482. mergeTime = pr.UpdatedAt
  483. }
  484. var closeTime = pr.ClosedAt
  485. if merged && pr.ClosedAt == nil {
  486. closeTime = pr.UpdatedAt
  487. }
  488. var locked bool
  489. if pr.State == "locked" {
  490. locked = true
  491. }
  492. var milestone string
  493. if pr.Milestone != nil {
  494. milestone = pr.Milestone.Title
  495. }
  496. var reactions []*base.Reaction
  497. var awardPage = 1
  498. for {
  499. awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
  500. if err != nil {
  501. return nil, false, fmt.Errorf("error while listing merge requests awards: %v", err)
  502. }
  503. for i := range awards {
  504. reactions = append(reactions, g.awardToReaction(awards[i]))
  505. }
  506. if len(awards) < perPage {
  507. break
  508. }
  509. awardPage++
  510. }
  511. // Add the PR ID to the Issue Count because PR and Issues share ID space in Gitea
  512. newPRNumber := g.issueCount + int64(pr.IID)
  513. allPRs = append(allPRs, &base.PullRequest{
  514. Title: pr.Title,
  515. Number: newPRNumber,
  516. PosterName: pr.Author.Username,
  517. PosterID: int64(pr.Author.ID),
  518. Content: pr.Description,
  519. Milestone: milestone,
  520. State: pr.State,
  521. Created: *pr.CreatedAt,
  522. Closed: closeTime,
  523. Labels: labels,
  524. Merged: merged,
  525. MergeCommitSHA: pr.MergeCommitSHA,
  526. MergedTime: mergeTime,
  527. IsLocked: locked,
  528. Reactions: reactions,
  529. Head: base.PullRequestBranch{
  530. Ref: pr.SourceBranch,
  531. SHA: pr.SHA,
  532. RepoName: g.repoName,
  533. OwnerName: pr.Author.Username,
  534. CloneURL: pr.WebURL,
  535. },
  536. Base: base.PullRequestBranch{
  537. Ref: pr.TargetBranch,
  538. SHA: pr.DiffRefs.BaseSha,
  539. RepoName: g.repoName,
  540. OwnerName: pr.Author.Username,
  541. },
  542. PatchURL: pr.WebURL + ".patch",
  543. Context: gitlabIssueContext{
  544. foreignID: int64(pr.IID),
  545. localID: newPRNumber,
  546. IsMergeRequest: true,
  547. },
  548. })
  549. }
  550. return allPRs, len(prs) < perPage, nil
  551. }
  552. // GetReviews returns pull requests review
  553. func (g *GitlabDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) {
  554. approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(context.ForeignID()), gitlab.WithContext(g.ctx))
  555. if err != nil {
  556. if resp != nil && resp.StatusCode == 404 {
  557. log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error()))
  558. return []*base.Review{}, nil
  559. }
  560. return nil, err
  561. }
  562. var reviews = make([]*base.Review, 0, len(approvals.ApprovedBy))
  563. for _, user := range approvals.ApprovedBy {
  564. reviews = append(reviews, &base.Review{
  565. IssueIndex: context.LocalID(),
  566. ReviewerID: int64(user.User.ID),
  567. ReviewerName: user.User.Username,
  568. CreatedAt: *approvals.UpdatedAt,
  569. // All we get are approvals
  570. State: base.ReviewStateApproved,
  571. })
  572. }
  573. return reviews, nil
  574. }
  575. func (g *GitlabDownloader) awardToReaction(award *gitlab.AwardEmoji) *base.Reaction {
  576. return &base.Reaction{
  577. UserID: int64(award.User.ID),
  578. UserName: award.User.Username,
  579. Content: award.Name,
  580. }
  581. }