1
0

comment.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. package database
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "time"
  7. "github.com/cockroachdb/errors"
  8. api "github.com/gogs/go-gogs-client"
  9. "github.com/unknwon/com"
  10. "gorm.io/gorm"
  11. log "unknwon.dev/clog/v2"
  12. "gogs.io/gogs/internal/errutil"
  13. "gogs.io/gogs/internal/markup"
  14. )
  15. // CommentType defines whether a comment is just a simple comment, an action (like close) or a reference.
  16. type CommentType int
  17. const (
  18. // Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0)
  19. CommentTypeComment CommentType = iota
  20. CommentTypeReopen
  21. CommentTypeClose
  22. // References.
  23. CommentTypeIssueRef
  24. // Reference from a commit (not part of a pull request)
  25. CommentTypeCommitRef
  26. // Reference from a comment
  27. CommentTypeCommentRef
  28. // Reference from a pull request
  29. CommentTypePullRef
  30. )
  31. type CommentTag int
  32. const (
  33. CommentTagNone CommentTag = iota
  34. CommentTagPoster
  35. CommentTagWriter
  36. CommentTagOwner
  37. )
  38. // Comment represents a comment in commit and issue page.
  39. type Comment struct {
  40. ID int64
  41. Type CommentType
  42. PosterID int64
  43. Poster *User `gorm:"-" json:"-"`
  44. IssueID int64 `gorm:"index"`
  45. Issue *Issue `gorm:"-" json:"-"`
  46. CommitID int64
  47. Line int64
  48. Content string `gorm:"type:text"`
  49. RenderedContent string `gorm:"-" json:"-"`
  50. Created time.Time `gorm:"-" json:"-"`
  51. CreatedUnix int64
  52. Updated time.Time `gorm:"-" json:"-"`
  53. UpdatedUnix int64
  54. // Reference issue in commit message
  55. CommitSHA string `gorm:"type:varchar(40)"`
  56. Attachments []*Attachment `gorm:"-" json:"-"`
  57. // For view issue page.
  58. ShowTag CommentTag `gorm:"-" json:"-"`
  59. }
  60. func (c *Comment) BeforeCreate(tx *gorm.DB) error {
  61. if c.CreatedUnix == 0 {
  62. c.CreatedUnix = tx.NowFunc().Unix()
  63. }
  64. if c.UpdatedUnix == 0 {
  65. c.UpdatedUnix = c.CreatedUnix
  66. }
  67. return nil
  68. }
  69. func (c *Comment) BeforeUpdate(tx *gorm.DB) error {
  70. c.UpdatedUnix = tx.NowFunc().Unix()
  71. return nil
  72. }
  73. func (c *Comment) AfterFind(tx *gorm.DB) error {
  74. c.Created = time.Unix(c.CreatedUnix, 0).Local()
  75. c.Updated = time.Unix(c.UpdatedUnix, 0).Local()
  76. return nil
  77. }
  78. func (c *Comment) loadAttributes(tx *gorm.DB) (err error) {
  79. if c.Poster == nil {
  80. c.Poster, err = Handle.Users().GetByID(context.TODO(), c.PosterID)
  81. if err != nil {
  82. if IsErrUserNotExist(err) {
  83. c.PosterID = -1
  84. c.Poster = NewGhostUser()
  85. } else {
  86. return errors.Newf("getUserByID.(Poster) [%d]: %v", c.PosterID, err)
  87. }
  88. }
  89. }
  90. if c.Issue == nil {
  91. c.Issue, err = getRawIssueByID(tx, c.IssueID)
  92. if err != nil {
  93. return errors.Newf("getIssueByID [%d]: %v", c.IssueID, err)
  94. }
  95. if c.Issue.Repo == nil {
  96. c.Issue.Repo, err = getRepositoryByID(tx, c.Issue.RepoID)
  97. if err != nil {
  98. return errors.Newf("getRepositoryByID [%d]: %v", c.Issue.RepoID, err)
  99. }
  100. }
  101. }
  102. if c.Attachments == nil {
  103. c.Attachments, err = getAttachmentsByCommentID(tx, c.ID)
  104. if err != nil {
  105. return errors.Newf("getAttachmentsByCommentID [%d]: %v", c.ID, err)
  106. }
  107. }
  108. return nil
  109. }
  110. func (c *Comment) LoadAttributes() error {
  111. return c.loadAttributes(db)
  112. }
  113. func (c *Comment) HTMLURL() string {
  114. return fmt.Sprintf("%s#issuecomment-%d", c.Issue.HTMLURL(), c.ID)
  115. }
  116. // This method assumes following fields have been assigned with valid values:
  117. // Required - Poster, Issue
  118. func (c *Comment) APIFormat() *api.Comment {
  119. return &api.Comment{
  120. ID: c.ID,
  121. HTMLURL: c.HTMLURL(),
  122. Poster: c.Poster.APIFormat(),
  123. Body: c.Content,
  124. Created: c.Created,
  125. Updated: c.Updated,
  126. }
  127. }
  128. func CommentHashTag(id int64) string {
  129. return "issuecomment-" + com.ToStr(id)
  130. }
  131. // HashTag returns unique hash tag for comment.
  132. func (c *Comment) HashTag() string {
  133. return CommentHashTag(c.ID)
  134. }
  135. // EventTag returns unique event hash tag for comment.
  136. func (c *Comment) EventTag() string {
  137. return "event-" + com.ToStr(c.ID)
  138. }
  139. // mailParticipants sends new comment emails to repository watchers
  140. // and mentioned people.
  141. func (c *Comment) mailParticipants(tx *gorm.DB, opType ActionType, issue *Issue) (err error) {
  142. mentions := markup.FindAllMentions(c.Content)
  143. if err = updateIssueMentions(tx, c.IssueID, mentions); err != nil {
  144. return errors.Newf("UpdateIssueMentions [%d]: %v", c.IssueID, err)
  145. }
  146. switch opType {
  147. case ActionCommentIssue:
  148. issue.Content = c.Content
  149. case ActionCloseIssue:
  150. issue.Content = fmt.Sprintf("Closed #%d", issue.Index)
  151. case ActionReopenIssue:
  152. issue.Content = fmt.Sprintf("Reopened #%d", issue.Index)
  153. }
  154. if err = mailIssueCommentToParticipants(issue, c.Poster, mentions); err != nil {
  155. log.Error("mailIssueCommentToParticipants: %v", err)
  156. }
  157. return nil
  158. }
  159. func createComment(tx *gorm.DB, opts *CreateCommentOptions) (_ *Comment, err error) {
  160. comment := &Comment{
  161. Type: opts.Type,
  162. PosterID: opts.Doer.ID,
  163. Poster: opts.Doer,
  164. IssueID: opts.Issue.ID,
  165. CommitID: opts.CommitID,
  166. CommitSHA: opts.CommitSHA,
  167. Line: opts.LineNum,
  168. Content: opts.Content,
  169. }
  170. if err = tx.Create(comment).Error; err != nil {
  171. return nil, err
  172. }
  173. // Compose comment action, could be plain comment, close or reopen issue/pull request.
  174. // This object will be used to notify watchers in the end of function.
  175. act := &Action{
  176. ActUserID: opts.Doer.ID,
  177. ActUserName: opts.Doer.Name,
  178. Content: fmt.Sprintf("%d|%s", opts.Issue.Index, strings.Split(opts.Content, "\n")[0]),
  179. RepoID: opts.Repo.ID,
  180. RepoUserName: opts.Repo.Owner.Name,
  181. RepoName: opts.Repo.Name,
  182. IsPrivate: opts.Repo.IsPrivate,
  183. }
  184. // Check comment type.
  185. switch opts.Type {
  186. case CommentTypeComment:
  187. act.OpType = ActionCommentIssue
  188. if err = tx.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID).Error; err != nil {
  189. return nil, err
  190. }
  191. // Check attachments
  192. attachments := make([]*Attachment, 0, len(opts.Attachments))
  193. for _, uuid := range opts.Attachments {
  194. attach, err := getAttachmentByUUID(tx, uuid)
  195. if err != nil {
  196. if IsErrAttachmentNotExist(err) {
  197. continue
  198. }
  199. return nil, errors.Newf("getAttachmentByUUID [%s]: %v", uuid, err)
  200. }
  201. attachments = append(attachments, attach)
  202. }
  203. for i := range attachments {
  204. attachments[i].IssueID = opts.Issue.ID
  205. attachments[i].CommentID = comment.ID
  206. if err = tx.Model(attachments[i]).Where("id = ?", attachments[i].ID).Updates(map[string]any{
  207. "issue_id": attachments[i].IssueID,
  208. "comment_id": attachments[i].CommentID,
  209. }).Error; err != nil {
  210. return nil, errors.Newf("update attachment [%d]: %v", attachments[i].ID, err)
  211. }
  212. }
  213. case CommentTypeReopen:
  214. act.OpType = ActionReopenIssue
  215. if opts.Issue.IsPull {
  216. act.OpType = ActionReopenPullRequest
  217. }
  218. if opts.Issue.IsPull {
  219. err = tx.Exec("UPDATE `repository` SET num_closed_pulls=num_closed_pulls-1 WHERE id=?", opts.Repo.ID).Error
  220. } else {
  221. err = tx.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues-1 WHERE id=?", opts.Repo.ID).Error
  222. }
  223. if err != nil {
  224. return nil, err
  225. }
  226. case CommentTypeClose:
  227. act.OpType = ActionCloseIssue
  228. if opts.Issue.IsPull {
  229. act.OpType = ActionClosePullRequest
  230. }
  231. if opts.Issue.IsPull {
  232. err = tx.Exec("UPDATE `repository` SET num_closed_pulls=num_closed_pulls+1 WHERE id=?", opts.Repo.ID).Error
  233. } else {
  234. err = tx.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues+1 WHERE id=?", opts.Repo.ID).Error
  235. }
  236. if err != nil {
  237. return nil, err
  238. }
  239. }
  240. if err = tx.Exec("UPDATE `issue` SET updated_unix = ? WHERE id = ?", tx.NowFunc().Unix(), opts.Issue.ID).Error; err != nil {
  241. return nil, errors.Newf("update issue 'updated_unix': %v", err)
  242. }
  243. // Notify watchers for whatever action comes in, ignore if no action type.
  244. if act.OpType > 0 {
  245. if err = notifyWatchers(tx, act); err != nil {
  246. log.Error("notifyWatchers: %v", err)
  247. }
  248. if err = comment.mailParticipants(tx, act.OpType, opts.Issue); err != nil {
  249. log.Error("MailParticipants: %v", err)
  250. }
  251. }
  252. return comment, comment.loadAttributes(tx)
  253. }
  254. func createStatusComment(tx *gorm.DB, doer *User, repo *Repository, issue *Issue) (*Comment, error) {
  255. cmtType := CommentTypeClose
  256. if !issue.IsClosed {
  257. cmtType = CommentTypeReopen
  258. }
  259. return createComment(tx, &CreateCommentOptions{
  260. Type: cmtType,
  261. Doer: doer,
  262. Repo: repo,
  263. Issue: issue,
  264. })
  265. }
  266. type CreateCommentOptions struct {
  267. Type CommentType
  268. Doer *User
  269. Repo *Repository
  270. Issue *Issue
  271. CommitID int64
  272. CommitSHA string
  273. LineNum int64
  274. Content string
  275. Attachments []string // UUIDs of attachments
  276. }
  277. // CreateComment creates comment of issue or commit.
  278. func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) {
  279. err = db.Transaction(func(tx *gorm.DB) error {
  280. var err error
  281. comment, err = createComment(tx, opts)
  282. return err
  283. })
  284. return comment, err
  285. }
  286. // CreateIssueComment creates a plain issue comment.
  287. func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content string, attachments []string) (*Comment, error) {
  288. comment, err := CreateComment(&CreateCommentOptions{
  289. Type: CommentTypeComment,
  290. Doer: doer,
  291. Repo: repo,
  292. Issue: issue,
  293. Content: content,
  294. Attachments: attachments,
  295. })
  296. if err != nil {
  297. return nil, errors.Newf("CreateComment: %v", err)
  298. }
  299. comment.Issue = issue
  300. if err = PrepareWebhooks(repo, HookEventTypeIssueComment, &api.IssueCommentPayload{
  301. Action: api.HOOK_ISSUE_COMMENT_CREATED,
  302. Issue: issue.APIFormat(),
  303. Comment: comment.APIFormat(),
  304. Repository: repo.APIFormatLegacy(nil),
  305. Sender: doer.APIFormat(),
  306. }); err != nil {
  307. log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)
  308. }
  309. return comment, nil
  310. }
  311. // CreateRefComment creates a commit reference comment to issue.
  312. func CreateRefComment(doer *User, repo *Repository, issue *Issue, content, commitSHA string) error {
  313. if commitSHA == "" {
  314. return errors.Newf("cannot create reference with empty commit SHA")
  315. }
  316. // Check if same reference from same commit has already existed.
  317. var count int64
  318. err := db.Model(new(Comment)).Where("type = ? AND issue_id = ? AND commit_sha = ?",
  319. CommentTypeCommitRef, issue.ID, commitSHA).Count(&count).Error
  320. if err != nil {
  321. return errors.Newf("check reference comment: %v", err)
  322. } else if count > 0 {
  323. return nil
  324. }
  325. _, err = CreateComment(&CreateCommentOptions{
  326. Type: CommentTypeCommitRef,
  327. Doer: doer,
  328. Repo: repo,
  329. Issue: issue,
  330. CommitSHA: commitSHA,
  331. Content: content,
  332. })
  333. return err
  334. }
  335. var _ errutil.NotFound = (*ErrCommentNotExist)(nil)
  336. type ErrCommentNotExist struct {
  337. args map[string]any
  338. }
  339. func IsErrCommentNotExist(err error) bool {
  340. _, ok := err.(ErrCommentNotExist)
  341. return ok
  342. }
  343. func (err ErrCommentNotExist) Error() string {
  344. return fmt.Sprintf("comment does not exist: %v", err.args)
  345. }
  346. func (ErrCommentNotExist) NotFound() bool {
  347. return true
  348. }
  349. // GetCommentByID returns the comment by given ID.
  350. func GetCommentByID(id int64) (*Comment, error) {
  351. c := new(Comment)
  352. err := db.Where("id = ?", id).First(c).Error
  353. if err != nil {
  354. if errors.Is(err, gorm.ErrRecordNotFound) {
  355. return nil, ErrCommentNotExist{args: map[string]any{"commentID": id}}
  356. }
  357. return nil, err
  358. }
  359. return c, c.LoadAttributes()
  360. }
  361. // FIXME: use CommentList to improve performance.
  362. func loadCommentsAttributes(tx *gorm.DB, comments []*Comment) (err error) {
  363. for i := range comments {
  364. if err = comments[i].loadAttributes(tx); err != nil {
  365. return errors.Newf("loadAttributes [%d]: %v", comments[i].ID, err)
  366. }
  367. }
  368. return nil
  369. }
  370. func getCommentsByIssueIDSince(tx *gorm.DB, issueID, since int64) ([]*Comment, error) {
  371. comments := make([]*Comment, 0, 10)
  372. query := tx.Where("issue_id = ?", issueID).Order("created_unix ASC")
  373. if since > 0 {
  374. query = query.Where("updated_unix >= ?", since)
  375. }
  376. if err := query.Find(&comments).Error; err != nil {
  377. return nil, err
  378. }
  379. return comments, loadCommentsAttributes(tx, comments)
  380. }
  381. func getCommentsByRepoIDSince(tx *gorm.DB, repoID, since int64) ([]*Comment, error) {
  382. comments := make([]*Comment, 0, 10)
  383. query := tx.Joins("INNER JOIN issue ON issue.id = comment.issue_id").
  384. Where("issue.repo_id = ?", repoID).
  385. Order("comment.created_unix ASC")
  386. if since > 0 {
  387. query = query.Where("comment.updated_unix >= ?", since)
  388. }
  389. if err := query.Find(&comments).Error; err != nil {
  390. return nil, err
  391. }
  392. return comments, loadCommentsAttributes(tx, comments)
  393. }
  394. func getCommentsByIssueID(tx *gorm.DB, issueID int64) ([]*Comment, error) {
  395. return getCommentsByIssueIDSince(tx, issueID, -1)
  396. }
  397. // GetCommentsByIssueID returns all comments of an issue.
  398. func GetCommentsByIssueID(issueID int64) ([]*Comment, error) {
  399. return getCommentsByIssueID(db, issueID)
  400. }
  401. // GetCommentsByIssueIDSince returns a list of comments of an issue since a given time point.
  402. func GetCommentsByIssueIDSince(issueID, since int64) ([]*Comment, error) {
  403. return getCommentsByIssueIDSince(db, issueID, since)
  404. }
  405. // GetCommentsByRepoIDSince returns a list of comments for all issues in a repo since a given time point.
  406. func GetCommentsByRepoIDSince(repoID, since int64) ([]*Comment, error) {
  407. return getCommentsByRepoIDSince(db, repoID, since)
  408. }
  409. // UpdateComment updates information of comment.
  410. func UpdateComment(doer *User, c *Comment, oldContent string) (err error) {
  411. if err = db.Model(c).Where("id = ?", c.ID).Updates(c).Error; err != nil {
  412. return err
  413. }
  414. if err = c.Issue.LoadAttributes(); err != nil {
  415. log.Error("Issue.LoadAttributes [issue_id: %d]: %v", c.IssueID, err)
  416. } else if err = PrepareWebhooks(c.Issue.Repo, HookEventTypeIssueComment, &api.IssueCommentPayload{
  417. Action: api.HOOK_ISSUE_COMMENT_EDITED,
  418. Issue: c.Issue.APIFormat(),
  419. Comment: c.APIFormat(),
  420. Changes: &api.ChangesPayload{
  421. Body: &api.ChangesFromPayload{
  422. From: oldContent,
  423. },
  424. },
  425. Repository: c.Issue.Repo.APIFormatLegacy(nil),
  426. Sender: doer.APIFormat(),
  427. }); err != nil {
  428. log.Error("PrepareWebhooks [comment_id: %d]: %v", c.ID, err)
  429. }
  430. return nil
  431. }
  432. // DeleteCommentByID deletes the comment by given ID.
  433. func DeleteCommentByID(doer *User, id int64) error {
  434. comment, err := GetCommentByID(id)
  435. if err != nil {
  436. if IsErrCommentNotExist(err) {
  437. return nil
  438. }
  439. return err
  440. }
  441. err = db.Transaction(func(tx *gorm.DB) error {
  442. if err := tx.Where("id = ?", comment.ID).Delete(new(Comment)).Error; err != nil {
  443. return err
  444. }
  445. if comment.Type == CommentTypeComment {
  446. if err := tx.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID).Error; err != nil {
  447. return err
  448. }
  449. }
  450. return nil
  451. })
  452. if err != nil {
  453. return errors.Newf("transaction: %v", err)
  454. }
  455. _, err = DeleteAttachmentsByComment(comment.ID, true)
  456. if err != nil {
  457. log.Error("Failed to delete attachments by comment[%d]: %v", comment.ID, err)
  458. }
  459. if err = comment.Issue.LoadAttributes(); err != nil {
  460. log.Error("Issue.LoadAttributes [issue_id: %d]: %v", comment.IssueID, err)
  461. } else if err = PrepareWebhooks(comment.Issue.Repo, HookEventTypeIssueComment, &api.IssueCommentPayload{
  462. Action: api.HOOK_ISSUE_COMMENT_DELETED,
  463. Issue: comment.Issue.APIFormat(),
  464. Comment: comment.APIFormat(),
  465. Repository: comment.Issue.Repo.APIFormatLegacy(nil),
  466. Sender: doer.APIFormat(),
  467. }); err != nil {
  468. log.Error("PrepareWebhooks [comment_id: %d]: %v", comment.ID, err)
  469. }
  470. return nil
  471. }