diff --git a/models/issues/issue_dev_link.go b/models/issues/issue_dev_link.go new file mode 100644 index 0000000000..f567d35673 --- /dev/null +++ b/models/issues/issue_dev_link.go @@ -0,0 +1,85 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "context" + "strconv" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/timeutil" +) + +type IssueDevLinkType int + +const ( + IssueDevLinkTypeBranch IssueDevLinkType = iota + 1 + IssueDevLinkTypePullRequest +) + +type IssueDevLink struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + LinkType IssueDevLinkType + LinkedRepoID int64 `xorm:"INDEX"` // it can link to self repo or other repo + LinkIndex string // branch name, pull request number or commit sha + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + Repo *repo_model.Repository `xorm:"-"` // current repo of issue + LinkedRepo *repo_model.Repository `xorm:"-"` + PullRequest *PullRequest `xorm:"-"` + Branch *git_model.Branch `xorm:"-"` + DisplayBranch bool `xorm:"-"` +} + +func init() { + db.RegisterModel(new(IssueDevLink)) +} + +func (i *IssueDevLink) BranchFullName() string { + if i.Repo.ID == i.LinkedRepo.ID { + return i.Branch.Name + } + return i.LinkedRepo.FullName() + ":" + i.Branch.Name +} + +// IssueDevLinks represents a list of issue development links +type IssueDevLinks []*IssueDevLink + +// FindIssueDevLinksByIssueID returns a list of issue development links by issue ID +func FindIssueDevLinksByIssueID(ctx context.Context, issueID int64) (IssueDevLinks, error) { + links := make(IssueDevLinks, 0, 5) + return links, db.GetEngine(ctx).Where("issue_id = ?", issueID).Find(&links) +} + +func FindDevLinksByBranch(ctx context.Context, repoID, linkedRepoID int64, branchName string) (IssueDevLinks, error) { + links := make(IssueDevLinks, 0, 5) + return links, db.GetEngine(ctx). + Join("INNER", "issue", "issue_dev_link.issue_id = issue.id"). + Where("link_type = ? AND link_index = ? AND linked_repo_id = ?", + IssueDevLinkTypeBranch, branchName, linkedRepoID). + And("issue.repo_id=?", repoID). + Find(&links) +} + +func CreateIssueDevLink(ctx context.Context, link *IssueDevLink) error { + _, err := db.GetEngine(ctx).Insert(link) + return err +} + +func DeleteIssueDevLinkByBranchName(ctx context.Context, repoID int64, branchName string) error { + _, err := db.GetEngine(ctx). + Where("linked_repo_id = ? AND link_type = ? AND link_index = ?", + repoID, IssueDevLinkTypeBranch, branchName). + Delete(new(IssueDevLink)) + return err +} + +func DeleteIssueDevLinkByPullRequestID(ctx context.Context, pullID int64) error { + pullIDStr := strconv.FormatInt(pullID, 10) + _, err := db.GetEngine(ctx).Where("link_type = ? AND link_index = ?", IssueDevLinkTypePullRequest, pullIDStr). + Delete(new(IssueDevLink)) + return err +} diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 03863fe968..473b0806b5 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -760,6 +760,10 @@ func DeleteIssuesByRepoID(ctx context.Context, repoID int64) (attachmentPaths [] return nil, err } + if _, err = sess.In("issue_id", issueIDs).Delete(&IssueDevLink{}); err != nil { + return nil, err + } + var attachments []*repo_model.Attachment err = sess.In("issue_id", issueIDs).Find(&attachments) if err != nil { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 52d10c4fe8..b80d945b40 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -369,6 +369,7 @@ func prepareMigrationTasks() []*migration { newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable), + newMigration(312, "Add table issue_dev_link", v1_23.CreateTableIssueDevLink), } return preparedMigrations } diff --git a/models/migrations/v1_23/v312.go b/models/migrations/v1_23/v312.go new file mode 100644 index 0000000000..c017890b50 --- /dev/null +++ b/models/migrations/v1_23/v312.go @@ -0,0 +1,22 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateTableIssueDevLink(x *xorm.Engine) error { + type IssueDevLink struct { + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + LinkType int + LinkedRepoID int64 `xorm:"INDEX"` // it can link to self repo or other repo + LinkIndex string // branch name, pull request number or commit sha + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + } + return x.Sync(new(IssueDevLink)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 2d9b9de88d..dd5636a7fa 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -597,7 +597,7 @@ func (repo *Repository) IsOwnedBy(userID int64) bool { // CanCreateBranch returns true if repository meets the requirements for creating new branches. func (repo *Repository) CanCreateBranch() bool { - return !repo.IsMirror + return !repo.IsMirror && !repo.IsArchived } // CanEnablePulls returns true if repository meets the requirements of accepting pulls. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 16d894aa26..baa45ea766 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1643,6 +1643,17 @@ issues.label.filter_sort.alphabetically = Alphabetically issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically issues.label.filter_sort.by_size = Smallest size issues.label.filter_sort.reverse_by_size = Largest size +issues.development = Development +issues.maybefixed = May be fixed by %s +issues.create_branch_from_issue_success = Create branch %s from issue successfully +issues.create_branch_from_repository = Repository the branch to be created +issues.base_branch = Base Branch in this repository +issues.pr.completed = Completed +issues.pr.conflicted = Merge conflicts +issues.pr.not_exist_issue = Reference issue does not exist. +issues.branch.latest = Latest commit %s +issues.link.created = Created %s +issues.create_branch_from_issue_error_is_pull = Issue links cannot be created with pull request issues.num_participants = %d Participants issues.attachment.open_tab = `Click to see "%s" in a new tab` issues.attachment.download = `Click to download "%s"` @@ -2688,6 +2699,7 @@ branch.create_from = from "%s" branch.create_success = Branch "%s" has been created. branch.branch_already_exists = Branch "%s" already exists in this repository. branch.branch_name_conflict = Branch name "%s" conflicts with the already existing branch "%s". +branch.branch_not_exist = Branch "%s" do not exists in this repository. branch.tag_collision = Branch "%s" cannot be created as a tag with same name already exists in the repository. branch.deleted_by = Deleted by %s branch.restore_success = Branch "%s" has been restored. diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index af40cb3988..63ea272819 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -114,6 +114,10 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { }) return } + + if err := issues_model.DeleteIssueDevLinkByBranchName(ctx, repo.ID, update.RefFullName.BranchName()); err != nil { + log.Error("Failed to DeleteIssueDevLinkByBranchName: %s/%s %s Error: %v", ownerName, repoName, update.RefFullName.BranchName(), err) + } } else { branchesToSync = append(branchesToSync, update) diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index b3c1eb7cb0..116b78b68c 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -848,6 +848,21 @@ func CompareDiff(ctx *context.Context) { ctx.Data["AllowMaintainerEdit"] = false } + refIssueIndex := ctx.FormInt64("ref_issue_index") + if refIssueIndex > 0 { + refIssue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, refIssueIndex) + if err != nil { + ctx.Flash.Warning(ctx.Tr("repo.issues.pr.not_exist_issue"), true) + } else { + keyword := "Resolve" + if len(setting.Repository.PullRequest.CloseKeywords) > 0 { + keyword = setting.Repository.PullRequest.CloseKeywords[0] + } + ctx.Data["TitleQuery"] = fmt.Sprintf("%s %s", keyword, refIssue.Title) + ctx.Data["BodyQuery"] = fmt.Sprintf("%s #%d", keyword, refIssueIndex) + } + } + ctx.HTML(http.StatusOK, tplCompare) } diff --git a/routers/web/repo/issue_dev.go b/routers/web/repo/issue_dev.go new file mode 100644 index 0000000000..e6f0486309 --- /dev/null +++ b/routers/web/repo/issue_dev.go @@ -0,0 +1,114 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +func CreateBranchFromIssue(ctx *context.Context) { + if ctx.HasError() { // form binding error check + ctx.JSONError(ctx.GetErrMsg()) + return + } + + issue := GetActionIssue(ctx) + if ctx.Written() { + return + } + + if issue.IsPull { + ctx.Flash.Error(ctx.Tr("repo.issues.create_branch_from_issue_error_is_pull")) + ctx.JSONRedirect(issue.Link()) + return + } + + form := web.GetForm(ctx).(*forms.NewBranchForm) + repo := ctx.Repo.Repository + gitRepo := ctx.Repo.GitRepo + // if create branch in a forked repository + if form.RepoID > 0 && form.RepoID != repo.ID { + var err error + repo, err = repo_model.GetRepositoryByID(ctx, form.RepoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + gitRepo, err = gitrepo.OpenRepository(ctx, repo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer gitRepo.Close() + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + canCreateBranch := perm.CanWrite(unit_model.TypeCode) && repo.CanCreateBranch() + if !canCreateBranch { + ctx.Error(http.StatusForbidden, "No permission to create branch in this repository") + return + } + + if err := repo_service.CreateNewBranch(ctx, ctx.Doer, repo, gitRepo, form.SourceBranchName, form.NewBranchName); err != nil { + switch { + case git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err): + ctx.JSONError(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) + case git_model.IsErrBranchNameConflict(err): + e := err.(git_model.ErrBranchNameConflict) + ctx.JSONError(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) + case git_model.IsErrBranchNotExist(err): + ctx.JSONError(ctx.Tr("repo.branch.branch_not_exist", form.SourceBranchName)) + case git.IsErrPushRejected(err): + e := err.(*git.ErrPushRejected) + if len(e.Message) == 0 { + ctx.Flash.Error(ctx.Tr("repo.editor.push_rejected_no_message")) + } else { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.editor.push_rejected"), + "Summary": ctx.Tr("repo.editor.push_rejected_summary"), + "Details": utils.SanitizeFlashErrorString(e.Message), + }) + if err != nil { + ctx.ServerError("UpdatePullRequest.HTMLString", err) + return + } + ctx.JSONError(flashError) + } + default: + ctx.ServerError("CreateNewBranch", err) + } + return + } + + if err := issues_model.CreateIssueDevLink(ctx, &issues_model.IssueDevLink{ + IssueID: issue.ID, + LinkType: issues_model.IssueDevLinkTypeBranch, + LinkedRepoID: repo.ID, + LinkIndex: form.NewBranchName, + }); err != nil { + ctx.ServerError("CreateIssueDevLink", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.issues.create_branch_from_issue_success", form.NewBranchName)) + ctx.JSONRedirect(issue.Link()) +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 61e75e211b..29c5e017e6 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -362,6 +362,7 @@ func ViewIssue(ctx *context.Context) { }, prepareIssueViewCommentsAndSidebarParticipants, preparePullViewReviewAndMerge, + prepareIssueViewSidebarDevLinks, prepareIssueViewSidebarWatch, prepareIssueViewSidebarTimeTracker, prepareIssueViewSidebarDependency, @@ -946,3 +947,50 @@ func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) { } ctx.Data["Issue"] = issue } + +func prepareIssueViewSidebarDevLinks(ctx *context.Context, issue *issues_model.Issue) { + if issue.IsPull { + return + } + + devLinks, err := issue_service.FindIssueDevLinksByIssue(ctx, issue) + if err != nil { + ctx.ServerError("FindIssueDevLinksByIssue", err) + return + } + ctx.Data["DevLinks"] = devLinks + for _, link := range devLinks { + if link.LinkType == issues_model.IssueDevLinkTypePullRequest && + !(link.PullRequest.Issue.IsClosed && !link.PullRequest.HasMerged) { + ctx.Data["MaybeFixed"] = link.PullRequest + break + } + } + + if !ctx.IsSigned { + return + } + + // Get all possible repositories for creating branch model dropdown list + forkedRepos, err := repo_model.GetForksByUserAndOrgs(ctx, ctx.Doer, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GetForksByUserAndOrgs", err) + return + } + allowedRepos := make([]*repo_model.Repository, 0, len(forkedRepos)+1) + for _, repo := range append(forkedRepos, ctx.Repo.Repository) { + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if perm.CanWrite(unit.TypeCode) { + allowedRepos = append(allowedRepos, repo) + } + } + + ctx.Data["AllowedRepos"] = allowedRepos + ctx.Data["ShowCreateBranchLink"] = !ctx.Repo.Repository.IsEmpty && + ctx.Repo.Repository.CanCreateBranch() && + len(allowedRepos) > 0 && !issue.IsClosed +} diff --git a/routers/web/web.go b/routers/web/web.go index 5e0995545e..9c3819ad95 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1240,6 +1240,7 @@ func registerRoutes(m *web.Router) { m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) m.Post("/delete", reqRepoAdmin, repo.DeleteIssue) + m.Post("/create_branch", web.Bind(forms.NewBranchForm{}), repo.CreateBranchFromIssue) }, context.RepoMustNotBeArchived()) m.Group("/{index}", func() { diff --git a/services/forms/repo_branch_form.go b/services/forms/repo_branch_form.go index 42e6c85c37..16ebddf07d 100644 --- a/services/forms/repo_branch_form.go +++ b/services/forms/repo_branch_form.go @@ -14,9 +14,11 @@ import ( // NewBranchForm form for creating a new branch type NewBranchForm struct { - NewBranchName string `binding:"Required;MaxSize(100);GitRefName"` - CurrentPath string - CreateTag bool + NewBranchName string `binding:"Required;MaxSize(100);GitRefName"` + RepoID int64 + SourceBranchName string + CurrentPath string + CreateTag bool } // Validate validates the fields diff --git a/services/issue/dev_link.go b/services/issue/dev_link.go new file mode 100644 index 0000000000..8b9633b14d --- /dev/null +++ b/services/issue/dev_link.go @@ -0,0 +1,85 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "fmt" + "sort" + "strconv" + + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/container" +) + +func FindIssueDevLinksByIssue(ctx context.Context, issue *issues_model.Issue) (issues_model.IssueDevLinks, error) { + devLinks, err := issues_model.FindIssueDevLinksByIssueID(ctx, issue.ID) + if err != nil { + return nil, err + } + + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + sort.Slice(devLinks, func(i, j int) bool { + switch { + case devLinks[j].LinkType == issues_model.IssueDevLinkTypePullRequest: + return false + default: + return true + } + }) + + branchPRExists := make(container.Set[string]) + + for _, link := range devLinks { + link.Repo = issue.Repo + if link.LinkedRepoID == 0 { + link.LinkedRepoID = issue.RepoID + } + isSameRepo := issue.RepoID == link.LinkedRepoID + if isSameRepo { + link.LinkedRepo = issue.Repo + } else if link.LinkedRepoID > 0 { + repo, err := repo_model.GetRepositoryByID(ctx, link.LinkedRepoID) + if err != nil { + return nil, err + } + link.LinkedRepo = repo + } + + switch link.LinkType { + case issues_model.IssueDevLinkTypePullRequest: + pullID, err := strconv.ParseInt(link.LinkIndex, 10, 64) + if err != nil { + return nil, err + } + pull, err := issues_model.GetPullRequestByID(ctx, pullID) + if err != nil { + return nil, err + } + pull.BaseRepo = issue.Repo + pull.HeadRepo = link.LinkedRepo + if err := pull.LoadIssue(ctx); err != nil { + return nil, err + } + pull.Issue.Repo = issue.Repo + link.PullRequest = pull + branchPRExists.Add(fmt.Sprintf("%d-%s", link.LinkedRepoID, pull.HeadBranch)) + case issues_model.IssueDevLinkTypeBranch: + branch, err := git_model.GetBranch(ctx, link.LinkedRepoID, link.LinkIndex) + if err != nil { + return nil, err + } + link.Branch = branch + link.Branch.Repo = link.LinkedRepo + link.DisplayBranch = !branchPRExists.Contains(fmt.Sprintf("%d-%s", link.LinkedRepoID, link.LinkIndex)) + } + } + + return devLinks, nil +} diff --git a/services/issue/issue.go b/services/issue/issue.go index c6a52cc0fe..8682d04e36 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -290,6 +290,12 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { return err } + if issue.IsPull { + if err := issues_model.DeleteIssueDevLinkByPullRequestID(ctx, issue.ID); err != nil { + return err + } + } + // find attachments related to this issue and remove them if err := issue.LoadAttributes(ctx); err != nil { return err @@ -318,6 +324,7 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { &issues_model.Comment{RefIssueID: issue.ID}, &issues_model.IssueDependency{DependencyID: issue.ID}, &issues_model.Comment{DependentIssueID: issue.ID}, + &issues_model.IssueDevLink{IssueID: issue.ID}, ); err != nil { return err } diff --git a/services/pull/pull.go b/services/pull/pull.go index 85c36bb16a..86a55c77cd 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -10,6 +10,7 @@ import ( "io" "os" "regexp" + "strconv" "strings" "time" @@ -141,6 +142,23 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { return err } + if pr.Flow == issues_model.PullRequestFlowGithub { + devLinks, err := issues_model.FindDevLinksByBranch(ctx, issue.RepoID, pr.HeadRepoID, pr.HeadBranch) + if err != nil { + return err + } + for _, link := range devLinks { + if err := issues_model.CreateIssueDevLink(ctx, &issues_model.IssueDevLink{ + IssueID: link.IssueID, + LinkType: issues_model.IssueDevLinkTypePullRequest, + LinkedRepoID: pr.HeadRepoID, + LinkIndex: strconv.FormatInt(pr.ID, 10), + }); err != nil { + return err + } + } + } + compareInfo, err := baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName(), false, false) if err != nil { diff --git a/services/repository/branch.go b/services/repository/branch.go index fc476298ca..4967e46a89 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -519,6 +519,10 @@ func DeleteBranch(ctx context.Context, doer *user_model.User, repo *repo_model.R } } + if err := issues_model.DeleteIssueDevLinkByBranchName(ctx, repo.ID, branchName); err != nil { + return err + } + return gitRepo.DeleteBranch(branchName, git.DeleteBranchOptions{ Force: true, }) diff --git a/templates/repo/issue/sidebar/development.tmpl b/templates/repo/issue/sidebar/development.tmpl new file mode 100644 index 0000000000..522fd99a32 --- /dev/null +++ b/templates/repo/issue/sidebar/development.tmpl @@ -0,0 +1,105 @@ +{{if not .Issue.IsPull}} +
+ + {{ctx.Locale.Tr "repo.issues.development"}} +