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