gitea
Зеркало из https://github.com/go-gitea/gitea
1// Copyright 2014 The Gogs Authors. All rights reserved.
2// Copyright 2020 The Gitea Authors. All rights reserved.
3// SPDX-License-Identifier: MIT
4
5package issues
6
7import (
8"context"
9"fmt"
10"html/template"
11"regexp"
12"slices"
13
14"code.gitea.io/gitea/models/db"
15project_model "code.gitea.io/gitea/models/project"
16repo_model "code.gitea.io/gitea/models/repo"
17user_model "code.gitea.io/gitea/models/user"
18"code.gitea.io/gitea/modules/container"
19"code.gitea.io/gitea/modules/log"
20"code.gitea.io/gitea/modules/setting"
21api "code.gitea.io/gitea/modules/structs"
22"code.gitea.io/gitea/modules/timeutil"
23"code.gitea.io/gitea/modules/util"
24
25"xorm.io/builder"
26)
27
28// ErrIssueNotExist represents a "IssueNotExist" kind of error.
29type ErrIssueNotExist struct {
30ID int64
31RepoID int64
32Index int64
33}
34
35// IsErrIssueNotExist checks if an error is a ErrIssueNotExist.
36func IsErrIssueNotExist(err error) bool {
37_, ok := err.(ErrIssueNotExist)
38return ok
39}
40
41func (err ErrIssueNotExist) Error() string {
42return fmt.Sprintf("issue does not exist [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index)
43}
44
45func (err ErrIssueNotExist) Unwrap() error {
46return util.ErrNotExist
47}
48
49// ErrIssueIsClosed represents a "IssueIsClosed" kind of error.
50type ErrIssueIsClosed struct {
51ID int64
52RepoID int64
53Index int64
54}
55
56// IsErrIssueIsClosed checks if an error is a ErrIssueNotExist.
57func IsErrIssueIsClosed(err error) bool {
58_, ok := err.(ErrIssueIsClosed)
59return ok
60}
61
62func (err ErrIssueIsClosed) Error() string {
63return fmt.Sprintf("issue is closed [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index)
64}
65
66// ErrNewIssueInsert is used when the INSERT statement in newIssue fails
67type ErrNewIssueInsert struct {
68OriginalError error
69}
70
71// IsErrNewIssueInsert checks if an error is a ErrNewIssueInsert.
72func IsErrNewIssueInsert(err error) bool {
73_, ok := err.(ErrNewIssueInsert)
74return ok
75}
76
77func (err ErrNewIssueInsert) Error() string {
78return err.OriginalError.Error()
79}
80
81// ErrIssueWasClosed is used when close a closed issue
82type ErrIssueWasClosed struct {
83ID int64
84Index int64
85}
86
87// IsErrIssueWasClosed checks if an error is a ErrIssueWasClosed.
88func IsErrIssueWasClosed(err error) bool {
89_, ok := err.(ErrIssueWasClosed)
90return ok
91}
92
93func (err ErrIssueWasClosed) Error() string {
94return fmt.Sprintf("Issue [%d] %d was already closed", err.ID, err.Index)
95}
96
97var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already changed")
98
99// Issue represents an issue or pull request of repository.
100type Issue struct {
101ID int64 `xorm:"pk autoincr"`
102RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
103Repo *repo_model.Repository `xorm:"-"`
104Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
105PosterID int64 `xorm:"INDEX"`
106Poster *user_model.User `xorm:"-"`
107OriginalAuthor string
108OriginalAuthorID int64 `xorm:"index"`
109Title string `xorm:"name"`
110Content string `xorm:"LONGTEXT"`
111RenderedContent template.HTML `xorm:"-"`
112ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
113Labels []*Label `xorm:"-"`
114isLabelsLoaded bool `xorm:"-"`
115MilestoneID int64 `xorm:"INDEX"`
116Milestone *Milestone `xorm:"-"`
117isMilestoneLoaded bool `xorm:"-"`
118Project *project_model.Project `xorm:"-"`
119Priority int
120AssigneeID int64 `xorm:"-"`
121Assignee *user_model.User `xorm:"-"`
122isAssigneeLoaded bool `xorm:"-"`
123IsClosed bool `xorm:"INDEX"`
124IsRead bool `xorm:"-"`
125IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
126PullRequest *PullRequest `xorm:"-"`
127NumComments int
128Ref string
129PinOrder int `xorm:"DEFAULT 0"`
130
131DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
132
133CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
134UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
135ClosedUnix timeutil.TimeStamp `xorm:"INDEX"`
136
137Attachments []*repo_model.Attachment `xorm:"-"`
138isAttachmentsLoaded bool `xorm:"-"`
139Comments CommentList `xorm:"-"`
140Reactions ReactionList `xorm:"-"`
141TotalTrackedTime int64 `xorm:"-"`
142Assignees []*user_model.User `xorm:"-"`
143
144// IsLocked limits commenting abilities to users on an issue
145// with write access
146IsLocked bool `xorm:"NOT NULL DEFAULT false"`
147
148// For view issue page.
149ShowRole RoleDescriptor `xorm:"-"`
150}
151
152var (
153issueTasksPat = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`)
154issueTasksDonePat = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`)
155)
156
157// IssueIndex represents the issue index table
158type IssueIndex db.ResourceIndex
159
160func init() {
161db.RegisterModel(new(Issue))
162db.RegisterModel(new(IssueIndex))
163}
164
165// LoadTotalTimes load total tracked time
166func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
167opts := FindTrackedTimesOptions{IssueID: issue.ID}
168issue.TotalTrackedTime, err = opts.toSession(db.GetEngine(ctx)).SumInt(&TrackedTime{}, "time")
169if err != nil {
170return err
171}
172return nil
173}
174
175// IsOverdue checks if the issue is overdue
176func (issue *Issue) IsOverdue() bool {
177if issue.IsClosed {
178return issue.ClosedUnix >= issue.DeadlineUnix
179}
180return timeutil.TimeStampNow() >= issue.DeadlineUnix
181}
182
183// LoadRepo loads issue's repository
184func (issue *Issue) LoadRepo(ctx context.Context) (err error) {
185if issue.Repo == nil && issue.RepoID != 0 {
186issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID)
187if err != nil {
188return fmt.Errorf("getRepositoryByID [%d]: %w", issue.RepoID, err)
189}
190}
191return nil
192}
193
194func (issue *Issue) LoadAttachments(ctx context.Context) (err error) {
195if issue.isAttachmentsLoaded || issue.Attachments != nil {
196return nil
197}
198
199issue.Attachments, err = repo_model.GetAttachmentsByIssueID(ctx, issue.ID)
200if err != nil {
201return fmt.Errorf("getAttachmentsByIssueID [%d]: %w", issue.ID, err)
202}
203issue.isAttachmentsLoaded = true
204return nil
205}
206
207// IsTimetrackerEnabled returns true if the repo enables timetracking
208func (issue *Issue) IsTimetrackerEnabled(ctx context.Context) bool {
209if err := issue.LoadRepo(ctx); err != nil {
210log.Error(fmt.Sprintf("loadRepo: %v", err))
211return false
212}
213return issue.Repo.IsTimetrackerEnabled(ctx)
214}
215
216// LoadPoster loads poster
217func (issue *Issue) LoadPoster(ctx context.Context) (err error) {
218if issue.Poster == nil && issue.PosterID != 0 {
219issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID)
220if err != nil {
221issue.PosterID = user_model.GhostUserID
222issue.Poster = user_model.NewGhostUser()
223if !user_model.IsErrUserNotExist(err) {
224return fmt.Errorf("getUserByID.(poster) [%d]: %w", issue.PosterID, err)
225}
226return nil
227}
228}
229return err
230}
231
232// LoadPullRequest loads pull request info
233func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) {
234if issue.IsPull {
235if issue.PullRequest == nil && issue.ID != 0 {
236issue.PullRequest, err = GetPullRequestByIssueID(ctx, issue.ID)
237if err != nil {
238if IsErrPullRequestNotExist(err) {
239return err
240}
241return fmt.Errorf("getPullRequestByIssueID [%d]: %w", issue.ID, err)
242}
243}
244if issue.PullRequest != nil {
245issue.PullRequest.Issue = issue
246}
247}
248return nil
249}
250
251func (issue *Issue) loadComments(ctx context.Context) (err error) {
252return issue.loadCommentsByType(ctx, CommentTypeUndefined)
253}
254
255// LoadDiscussComments loads discuss comments
256func (issue *Issue) LoadDiscussComments(ctx context.Context) error {
257return issue.loadCommentsByType(ctx, CommentTypeComment)
258}
259
260func (issue *Issue) loadCommentsByType(ctx context.Context, tp CommentType) (err error) {
261if issue.Comments != nil {
262return nil
263}
264issue.Comments, err = FindComments(ctx, &FindCommentsOptions{
265IssueID: issue.ID,
266Type: tp,
267})
268return err
269}
270
271func (issue *Issue) loadReactions(ctx context.Context) (err error) {
272if issue.Reactions != nil {
273return nil
274}
275reactions, _, err := FindReactions(ctx, FindReactionsOptions{
276IssueID: issue.ID,
277})
278if err != nil {
279return err
280}
281if err = issue.LoadRepo(ctx); err != nil {
282return err
283}
284// Load reaction user data
285if _, err := reactions.LoadUsers(ctx, issue.Repo); err != nil {
286return err
287}
288
289// Cache comments to map
290comments := make(map[int64]*Comment)
291for _, comment := range issue.Comments {
292comments[comment.ID] = comment
293}
294// Add reactions either to issue or comment
295for _, react := range reactions {
296if react.CommentID == 0 {
297issue.Reactions = append(issue.Reactions, react)
298} else if comment, ok := comments[react.CommentID]; ok {
299comment.Reactions = append(comment.Reactions, react)
300}
301}
302return nil
303}
304
305// LoadMilestone load milestone of this issue.
306func (issue *Issue) LoadMilestone(ctx context.Context) (err error) {
307if !issue.isMilestoneLoaded && (issue.Milestone == nil || issue.Milestone.ID != issue.MilestoneID) && issue.MilestoneID > 0 {
308issue.Milestone, err = GetMilestoneByRepoID(ctx, issue.RepoID, issue.MilestoneID)
309if err != nil && !IsErrMilestoneNotExist(err) {
310return fmt.Errorf("getMilestoneByRepoID [repo_id: %d, milestone_id: %d]: %w", issue.RepoID, issue.MilestoneID, err)
311}
312issue.isMilestoneLoaded = true
313}
314return nil
315}
316
317// LoadAttributes loads the attribute of this issue.
318func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
319if err = issue.LoadRepo(ctx); err != nil {
320return err
321}
322
323if err = issue.LoadPoster(ctx); err != nil {
324return err
325}
326
327if err = issue.LoadLabels(ctx); err != nil {
328return err
329}
330
331if err = issue.LoadMilestone(ctx); err != nil {
332return err
333}
334
335if err = issue.LoadProject(ctx); err != nil {
336return err
337}
338
339if err = issue.LoadAssignees(ctx); err != nil {
340return err
341}
342
343if err = issue.LoadPullRequest(ctx); err != nil && !IsErrPullRequestNotExist(err) {
344// It is possible pull request is not yet created.
345return err
346}
347
348if err = issue.LoadAttachments(ctx); err != nil {
349return err
350}
351
352if err = issue.loadComments(ctx); err != nil {
353return err
354}
355
356if err = issue.Comments.LoadAttributes(ctx); err != nil {
357return err
358}
359if issue.IsTimetrackerEnabled(ctx) {
360if err = issue.LoadTotalTimes(ctx); err != nil {
361return err
362}
363}
364
365return issue.loadReactions(ctx)
366}
367
368func (issue *Issue) ResetAttributesLoaded() {
369issue.isLabelsLoaded = false
370issue.isMilestoneLoaded = false
371issue.isAttachmentsLoaded = false
372issue.isAssigneeLoaded = false
373}
374
375// GetIsRead load the `IsRead` field of the issue
376func (issue *Issue) GetIsRead(ctx context.Context, userID int64) error {
377issueUser := &IssueUser{IssueID: issue.ID, UID: userID}
378if has, err := db.GetEngine(ctx).Get(issueUser); err != nil {
379return err
380} else if !has {
381issue.IsRead = false
382return nil
383}
384issue.IsRead = issueUser.IsRead
385return nil
386}
387
388// APIURL returns the absolute APIURL to this issue.
389func (issue *Issue) APIURL(ctx context.Context) string {
390if issue.Repo == nil {
391err := issue.LoadRepo(ctx)
392if err != nil {
393log.Error("Issue[%d].APIURL(): %v", issue.ID, err)
394return ""
395}
396}
397return fmt.Sprintf("%s/issues/%d", issue.Repo.APIURL(), issue.Index)
398}
399
400// HTMLURL returns the absolute URL to this issue.
401func (issue *Issue) HTMLURL() string {
402var path string
403if issue.IsPull {
404path = "pulls"
405} else {
406path = "issues"
407}
408return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index)
409}
410
411// Link returns the issue's relative URL.
412func (issue *Issue) Link() string {
413var path string
414if issue.IsPull {
415path = "pulls"
416} else {
417path = "issues"
418}
419return fmt.Sprintf("%s/%s/%d", issue.Repo.Link(), path, issue.Index)
420}
421
422// DiffURL returns the absolute URL to this diff
423func (issue *Issue) DiffURL() string {
424if issue.IsPull {
425return fmt.Sprintf("%s/pulls/%d.diff", issue.Repo.HTMLURL(), issue.Index)
426}
427return ""
428}
429
430// PatchURL returns the absolute URL to this patch
431func (issue *Issue) PatchURL() string {
432if issue.IsPull {
433return fmt.Sprintf("%s/pulls/%d.patch", issue.Repo.HTMLURL(), issue.Index)
434}
435return ""
436}
437
438// State returns string representation of issue status.
439func (issue *Issue) State() api.StateType {
440if issue.IsClosed {
441return api.StateClosed
442}
443return api.StateOpen
444}
445
446// HashTag returns unique hash tag for issue.
447func (issue *Issue) HashTag() string {
448return fmt.Sprintf("issue-%d", issue.ID)
449}
450
451// IsPoster returns true if given user by ID is the poster.
452func (issue *Issue) IsPoster(uid int64) bool {
453return issue.OriginalAuthorID == 0 && issue.PosterID == uid
454}
455
456// GetTasks returns the amount of tasks in the issues content
457func (issue *Issue) GetTasks() int {
458return len(issueTasksPat.FindAllStringIndex(issue.Content, -1))
459}
460
461// GetTasksDone returns the amount of completed tasks in the issues content
462func (issue *Issue) GetTasksDone() int {
463return len(issueTasksDonePat.FindAllStringIndex(issue.Content, -1))
464}
465
466// GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close.
467func (issue *Issue) GetLastEventTimestamp() timeutil.TimeStamp {
468if issue.IsClosed {
469return issue.ClosedUnix
470}
471return issue.CreatedUnix
472}
473
474// GetLastEventLabel returns the localization label for the current issue.
475func (issue *Issue) GetLastEventLabel() string {
476if issue.IsClosed {
477if issue.IsPull && issue.PullRequest.HasMerged {
478return "repo.pulls.merged_by"
479}
480return "repo.issues.closed_by"
481}
482return "repo.issues.opened_by"
483}
484
485// GetLastComment return last comment for the current issue.
486func (issue *Issue) GetLastComment(ctx context.Context) (*Comment, error) {
487var c Comment
488exist, err := db.GetEngine(ctx).Where("type = ?", CommentTypeComment).
489And("issue_id = ?", issue.ID).Desc("created_unix").Get(&c)
490if err != nil {
491return nil, err
492}
493if !exist {
494return nil, nil
495}
496return &c, nil
497}
498
499// GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username.
500func (issue *Issue) GetLastEventLabelFake() string {
501if issue.IsClosed {
502if issue.IsPull && issue.PullRequest.HasMerged {
503return "repo.pulls.merged_by_fake"
504}
505return "repo.issues.closed_by_fake"
506}
507return "repo.issues.opened_by_fake"
508}
509
510// GetIssueByIndex returns raw issue without loading attributes by index in a repository.
511func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
512if index < 1 {
513return nil, ErrIssueNotExist{}
514}
515issue := &Issue{
516RepoID: repoID,
517Index: index,
518}
519has, err := db.GetEngine(ctx).Get(issue)
520if err != nil {
521return nil, err
522} else if !has {
523return nil, ErrIssueNotExist{0, repoID, index}
524}
525return issue, nil
526}
527
528// GetIssueWithAttrsByIndex returns issue by index in a repository.
529func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
530issue, err := GetIssueByIndex(ctx, repoID, index)
531if err != nil {
532return nil, err
533}
534return issue, issue.LoadAttributes(ctx)
535}
536
537// GetIssueByID returns an issue by given ID.
538func GetIssueByID(ctx context.Context, id int64) (*Issue, error) {
539issue := new(Issue)
540has, err := db.GetEngine(ctx).ID(id).Get(issue)
541if err != nil {
542return nil, err
543} else if !has {
544return nil, ErrIssueNotExist{id, 0, 0}
545}
546return issue, nil
547}
548
549// GetIssuesByIDs return issues with the given IDs.
550// If keepOrder is true, the order of the returned issues will be the same as the given IDs.
551func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) {
552issues := make([]*Issue, 0, len(issueIDs))
553
554if err := db.GetEngine(ctx).In("id", issueIDs).Find(&issues); err != nil {
555return nil, err
556}
557
558if len(keepOrder) > 0 && keepOrder[0] {
559m := make(map[int64]*Issue, len(issues))
560appended := container.Set[int64]{}
561for _, issue := range issues {
562m[issue.ID] = issue
563}
564issues = issues[:0]
565for _, id := range issueIDs {
566if issue, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended
567appended.Add(id)
568issues = append(issues, issue)
569}
570}
571}
572
573return issues, nil
574}
575
576// GetIssueIDsByRepoID returns all issue ids by repo id
577func GetIssueIDsByRepoID(ctx context.Context, repoID int64) ([]int64, error) {
578ids := make([]int64, 0, 10)
579err := db.GetEngine(ctx).Table("issue").Cols("id").Where("repo_id = ?", repoID).Find(&ids)
580return ids, err
581}
582
583// GetParticipantsIDsByIssueID returns the IDs of all users who participated in comments of an issue,
584// but skips joining with `user` for performance reasons.
585// User permissions must be verified elsewhere if required.
586func GetParticipantsIDsByIssueID(ctx context.Context, issueID int64) ([]int64, error) {
587userIDs := make([]int64, 0, 5)
588return userIDs, db.GetEngine(ctx).
589Table("comment").
590Cols("poster_id").
591Where("issue_id = ?", issueID).
592And("type in (?,?,?)", CommentTypeComment, CommentTypeCode, CommentTypeReview).
593Distinct("poster_id").
594Find(&userIDs)
595}
596
597// IsUserParticipantsOfIssue return true if user is participants of an issue
598func IsUserParticipantsOfIssue(ctx context.Context, user *user_model.User, issue *Issue) bool {
599userIDs, err := issue.GetParticipantIDsByIssue(ctx)
600if err != nil {
601log.Error(err.Error())
602return false
603}
604return slices.Contains(userIDs, user.ID)
605}
606
607// DependencyInfo represents high level information about an issue which is a dependency of another issue.
608type DependencyInfo struct {
609Issue `xorm:"extends"`
610repo_model.Repository `xorm:"extends"`
611}
612
613// GetParticipantIDsByIssue returns all userIDs who are participated in comments of an issue and issue author
614func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, error) {
615if issue == nil {
616return nil, nil
617}
618userIDs := make([]int64, 0, 5)
619if err := db.GetEngine(ctx).Table("comment").Cols("poster_id").
620Where("`comment`.issue_id = ?", issue.ID).
621And("`comment`.type in (?,?,?)", CommentTypeComment, CommentTypeCode, CommentTypeReview).
622And("`user`.is_active = ?", true).
623And("`user`.prohibit_login = ?", false).
624Join("INNER", "`user`", "`user`.id = `comment`.poster_id").
625Distinct("poster_id").
626Find(&userIDs); err != nil {
627return nil, fmt.Errorf("get poster IDs: %w", err)
628}
629if !slices.Contains(userIDs, issue.PosterID) {
630return append(userIDs, issue.PosterID), nil
631}
632return userIDs, nil
633}
634
635// BlockedByDependencies finds all Dependencies an issue is blocked by
636func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) {
637sess := db.GetEngine(ctx).
638Table("issue").
639Join("INNER", "repository", "repository.id = issue.repo_id").
640Join("INNER", "issue_dependency", "issue_dependency.dependency_id = issue.id").
641Where("issue_id = ?", issue.ID).
642// sort by repo id then created date, with the issues of the same repo at the beginning of the list
643OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID)
644if opts.Page != 0 {
645sess = db.SetSessionPagination(sess, &opts)
646}
647err = sess.Find(&issueDeps)
648
649for _, depInfo := range issueDeps {
650depInfo.Issue.Repo = &depInfo.Repository
651}
652
653return issueDeps, err
654}
655
656// BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks
657func (issue *Issue) BlockingDependencies(ctx context.Context) (issueDeps []*DependencyInfo, err error) {
658err = db.GetEngine(ctx).
659Table("issue").
660Join("INNER", "repository", "repository.id = issue.repo_id").
661Join("INNER", "issue_dependency", "issue_dependency.issue_id = issue.id").
662Where("dependency_id = ?", issue.ID).
663// sort by repo id then created date, with the issues of the same repo at the beginning of the list
664OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID).
665Find(&issueDeps)
666
667for _, depInfo := range issueDeps {
668depInfo.Issue.Repo = &depInfo.Repository
669}
670
671return issueDeps, err
672}
673
674func migratedIssueCond(tp api.GitServiceType) builder.Cond {
675return builder.In("issue_id",
676builder.Select("issue.id").
677From("issue").
678InnerJoin("repository", "issue.repo_id = repository.id").
679Where(builder.Eq{
680"repository.original_service_type": tp,
681}),
682)
683}
684
685// RemapExternalUser ExternalUserRemappable interface
686func (issue *Issue) RemapExternalUser(externalName string, externalID, userID int64) error {
687issue.OriginalAuthor = externalName
688issue.OriginalAuthorID = externalID
689issue.PosterID = userID
690return nil
691}
692
693// GetUserID ExternalUserRemappable interface
694func (issue *Issue) GetUserID() int64 { return issue.PosterID }
695
696// GetExternalName ExternalUserRemappable interface
697func (issue *Issue) GetExternalName() string { return issue.OriginalAuthor }
698
699// GetExternalID ExternalUserRemappable interface
700func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
701
702// HasOriginalAuthor returns if an issue was migrated and has an original author.
703func (issue *Issue) HasOriginalAuthor() bool {
704return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
705}
706
707var ErrIssueMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned issues has been readched")
708
709// IsPinned returns if a Issue is pinned
710func (issue *Issue) IsPinned() bool {
711return issue.PinOrder != 0
712}
713
714// Pin pins a Issue
715func (issue *Issue) Pin(ctx context.Context, user *user_model.User) error {
716// If the Issue is already pinned, we don't need to pin it twice
717if issue.IsPinned() {
718return nil
719}
720
721var maxPin int
722_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
723if err != nil {
724return err
725}
726
727// Check if the maximum allowed Pins reached
728if maxPin >= setting.Repository.Issue.MaxPinned {
729return ErrIssueMaxPinReached
730}
731
732_, err = db.GetEngine(ctx).Table("issue").
733Where("id = ?", issue.ID).
734Update(map[string]any{
735"pin_order": maxPin + 1,
736})
737if err != nil {
738return err
739}
740
741// Add the pin event to the history
742opts := &CreateCommentOptions{
743Type: CommentTypePin,
744Doer: user,
745Repo: issue.Repo,
746Issue: issue,
747}
748if _, err = CreateComment(ctx, opts); err != nil {
749return err
750}
751
752return nil
753}
754
755// UnpinIssue unpins a Issue
756func (issue *Issue) Unpin(ctx context.Context, user *user_model.User) error {
757// If the Issue is not pinned, we don't need to unpin it
758if !issue.IsPinned() {
759return nil
760}
761
762// This sets the Pin for all Issues that come after the unpined Issue to the correct value
763_, err := db.GetEngine(ctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
764if err != nil {
765return err
766}
767
768_, err = db.GetEngine(ctx).Table("issue").
769Where("id = ?", issue.ID).
770Update(map[string]any{
771"pin_order": 0,
772})
773if err != nil {
774return err
775}
776
777// Add the unpin event to the history
778opts := &CreateCommentOptions{
779Type: CommentTypeUnpin,
780Doer: user,
781Repo: issue.Repo,
782Issue: issue,
783}
784if _, err = CreateComment(ctx, opts); err != nil {
785return err
786}
787
788return nil
789}
790
791// PinOrUnpin pins or unpins a Issue
792func (issue *Issue) PinOrUnpin(ctx context.Context, user *user_model.User) error {
793if !issue.IsPinned() {
794return issue.Pin(ctx, user)
795}
796
797return issue.Unpin(ctx, user)
798}
799
800// MovePin moves a Pinned Issue to a new Position
801func (issue *Issue) MovePin(ctx context.Context, newPosition int) error {
802// If the Issue is not pinned, we can't move them
803if !issue.IsPinned() {
804return nil
805}
806
807if newPosition < 1 {
808return fmt.Errorf("The Position can't be lower than 1")
809}
810
811dbctx, committer, err := db.TxContext(ctx)
812if err != nil {
813return err
814}
815defer committer.Close()
816
817var maxPin int
818_, err = db.GetEngine(dbctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
819if err != nil {
820return err
821}
822
823// If the new Position bigger than the current Maximum, set it to the Maximum
824if newPosition > maxPin+1 {
825newPosition = maxPin + 1
826}
827
828// Lower the Position of all Pinned Issue that came after the current Position
829_, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
830if err != nil {
831return err
832}
833
834// Higher the Position of all Pinned Issues that comes after the new Position
835_, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order + 1 WHERE repo_id = ? AND is_pull = ? AND pin_order >= ?", issue.RepoID, issue.IsPull, newPosition)
836if err != nil {
837return err
838}
839
840_, err = db.GetEngine(dbctx).Table("issue").
841Where("id = ?", issue.ID).
842Update(map[string]any{
843"pin_order": newPosition,
844})
845if err != nil {
846return err
847}
848
849return committer.Commit()
850}
851
852// GetPinnedIssues returns the pinned Issues for the given Repo and type
853func GetPinnedIssues(ctx context.Context, repoID int64, isPull bool) (IssueList, error) {
854issues := make(IssueList, 0)
855
856err := db.GetEngine(ctx).
857Table("issue").
858Where("repo_id = ?", repoID).
859And("is_pull = ?", isPull).
860And("pin_order > 0").
861OrderBy("pin_order").
862Find(&issues)
863if err != nil {
864return nil, err
865}
866
867err = issues.LoadAttributes(ctx)
868if err != nil {
869return nil, err
870}
871
872return issues, nil
873}
874
875// IsNewPinnedAllowed returns if a new Issue or Pull request can be pinned
876func IsNewPinAllowed(ctx context.Context, repoID int64, isPull bool) (bool, error) {
877var maxPin int
878_, err := db.GetEngine(ctx).SQL("SELECT COUNT(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ? AND pin_order > 0", repoID, isPull).Get(&maxPin)
879if err != nil {
880return false, err
881}
882
883return maxPin < setting.Repository.Issue.MaxPinned, nil
884}
885
886// IsErrIssueMaxPinReached returns if the error is, that the User can't pin more Issues
887func IsErrIssueMaxPinReached(err error) bool {
888return err == ErrIssueMaxPinReached
889}
890
891// InsertIssues insert issues to database
892func InsertIssues(ctx context.Context, issues ...*Issue) error {
893ctx, committer, err := db.TxContext(ctx)
894if err != nil {
895return err
896}
897defer committer.Close()
898
899for _, issue := range issues {
900if err := insertIssue(ctx, issue); err != nil {
901return err
902}
903}
904return committer.Commit()
905}
906
907func insertIssue(ctx context.Context, issue *Issue) error {
908sess := db.GetEngine(ctx)
909if _, err := sess.NoAutoTime().Insert(issue); err != nil {
910return err
911}
912issueLabels := make([]IssueLabel, 0, len(issue.Labels))
913for _, label := range issue.Labels {
914issueLabels = append(issueLabels, IssueLabel{
915IssueID: issue.ID,
916LabelID: label.ID,
917})
918}
919if len(issueLabels) > 0 {
920if _, err := sess.Insert(issueLabels); err != nil {
921return err
922}
923}
924
925for _, reaction := range issue.Reactions {
926reaction.IssueID = issue.ID
927}
928
929if len(issue.Reactions) > 0 {
930if _, err := sess.Insert(issue.Reactions); err != nil {
931return err
932}
933}
934
935return nil
936}
937