diff --git a/models/issues/issue_dev_link.go b/models/issues/issue_dev_link.go new file mode 100644 index 0000000000..cdb9e5d040 --- /dev/null +++ b/models/issues/issue_dev_link.go @@ -0,0 +1,77 @@ +// 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"` + + LinkedRepo *repo_model.Repository `xorm:"-"` + PullRequest *PullRequest `xorm:"-"` + Branch *git_model.Branch `xorm:"-"` +} + +func init() { + db.RegisterModel(new(IssueDevLink)) +} + +// 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("link_type = ? AND link_index = ? AND linked_repo_id = ?", + IssueDevLinkTypeBranch, branchName, repoID). + 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/migrations/migrations.go b/models/migrations/migrations.go index 13551423ce..6db078cd2c 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -601,6 +601,8 @@ var migrations = []Migration{ NewMigration("Add metadata column for comment table", v1_23.AddCommentMetaDataColumn), // v304 -> v305 NewMigration("Add index for release sha1", v1_23.AddIndexForReleaseSha1), + // v305 -> v306 + NewMigration("Add table issue_dev_link", v1_23.CreateTableIssueDevLink), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_23/v305.go b/models/migrations/v1_23/v305.go new file mode 100644 index 0000000000..fe696984a3 --- /dev/null +++ b/models/migrations/v1_23/v305.go @@ -0,0 +1,21 @@ +// 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 + 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/organization/org.go b/models/organization/org.go index b33d15d29c..16eb8f77b7 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -508,16 +508,20 @@ func HasOrgsVisible(ctx context.Context, orgs []*Organization, user *user_model. return false } +func orgAllowedCreatedRepoSubQuery(userID int64) *builder.Builder { + return builder.Select("`user`.id").From("`user`"). + Join("INNER", "`team_user`", "`team_user`.org_id = `user`.id"). + Join("INNER", "`team`", "`team`.id = `team_user`.team_id"). + Where(builder.Eq{"`team_user`.uid": userID}). + And(builder.Eq{"`team`.authorize": perm.AccessModeOwner}.Or(builder.Eq{"`team`.can_create_org_repo": true})) +} + // GetOrgsCanCreateRepoByUserID returns a list of organizations where given user ID // are allowed to create repos. func GetOrgsCanCreateRepoByUserID(ctx context.Context, userID int64) ([]*Organization, error) { orgs := make([]*Organization, 0, 10) - return orgs, db.GetEngine(ctx).Where(builder.In("id", builder.Select("`user`.id").From("`user`"). - Join("INNER", "`team_user`", "`team_user`.org_id = `user`.id"). - Join("INNER", "`team`", "`team`.id = `team_user`.team_id"). - Where(builder.Eq{"`team_user`.uid": userID}). - And(builder.Eq{"`team`.authorize": perm.AccessModeOwner}.Or(builder.Eq{"`team`.can_create_org_repo": true})))). + return orgs, db.GetEngine(ctx).Where(builder.In("id", orgAllowedCreatedRepoSubQuery(userID))). Asc("`user`.name"). Find(&orgs) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 042fd549a0..59ff860d1f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1622,6 +1622,7 @@ 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.num_participants = %d Participants issues.attachment.open_tab = `Click to see "%s" in a new tab` issues.attachment.download = `Click to download "%s"` diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index 4897a5f4fc..c5ac40226f 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -176,6 +176,54 @@ func redirect(ctx *context.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/branches?page=" + url.QueryEscape(ctx.FormString("page"))) } +func handleCreateBranchError(ctx *context.Context, err error, form *forms.NewBranchForm) { + if models.IsErrProtectedTagName(err) { + ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if models.IsErrTagAlreadyExists(err) { + e := err.(models.ErrTagAlreadyExists) + ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { + ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if git_model.IsErrBranchNameConflict(err) { + e := err.(git_model.ErrBranchNameConflict) + ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + if 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.Flash.Error(flashError) + } + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + ctx.ServerError("CreateNewBranch", err) + return +} + // CreateBranch creates new branch in repository func CreateBranch(ctx *context.Context) { form := web.GetForm(ctx).(*forms.NewBranchForm) @@ -204,50 +252,7 @@ func CreateBranch(ctx *context.Context) { err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.Repo.CommitID, form.NewBranchName) } if err != nil { - if models.IsErrProtectedTagName(err) { - ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) - ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) - return - } - - if models.IsErrTagAlreadyExists(err) { - e := err.(models.ErrTagAlreadyExists) - ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName)) - ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) - return - } - if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { - ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.NewBranchName)) - ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) - return - } - if git_model.IsErrBranchNameConflict(err) { - e := err.(git_model.ErrBranchNameConflict) - ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName)) - ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) - return - } - if 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.Flash.Error(flashError) - } - ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) - return - } - - ctx.ServerError("CreateNewBranch", err) + handleCreateBranchError(ctx, err, form) return } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 856e2f7392..dc8c706332 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2075,6 +2075,21 @@ func ViewIssue(ctx *context.Context) { return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) } + forkedRepos, err := repo_model.FindUserOrgForks(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID) + if err != nil { + ctx.ServerError("FindUserOrgForks", err) + return + } + + ctx.Data["AllowedRepos"] = append(forkedRepos, ctx.Repo.Repository) + + devLinks, err := issue_service.FindIssueDevLinksByIssue(ctx, issue) + if err != nil { + ctx.ServerError("FindIssueDevLinksByIssueID", err) + return + } + ctx.Data["DevLinks"] = devLinks + ctx.HTML(http.StatusOK, tplIssueView) } diff --git a/routers/web/repo/issue_dev.go b/routers/web/repo/issue_dev.go new file mode 100644 index 0000000000..fadf59cc0b --- /dev/null +++ b/routers/web/repo/issue_dev.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/web" + "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) { + 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.Redirect(issue.Link(), http.StatusSeeOther) + return + } + + form := web.GetForm(ctx).(*forms.NewBranchForm) + if !ctx.Repo.CanCreateBranch() { + ctx.NotFound("CreateBranch", nil) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL()) + return + } + + if err := repo_service.CreateNewBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, form.SourceBranchName, form.NewBranchName); err != nil { + handleCreateBranchError(ctx, err, form) + return + } + + if err := issues_model.CreateIssueDevLink(ctx, &issues_model.IssueDevLink{ + IssueID: issue.ID, + LinkType: issues_model.IssueDevLinkTypeBranch, + LinkIndex: form.NewBranchName, + }); err != nil { + ctx.ServerError("CreateIssueDevLink", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.issues.create_branch_from_issue_success", ctx.Repo.BranchName)) + ctx.Redirect(issue.Link()) +} diff --git a/routers/web/web.go b/routers/web/web.go index 4e917b5ede..79ececc613 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1216,6 +1216,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..32b17c7d54 100644 --- a/services/forms/repo_branch_form.go +++ b/services/forms/repo_branch_form.go @@ -14,9 +14,10 @@ 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"` + 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..37544444fa --- /dev/null +++ b/services/issue/dev_link.go @@ -0,0 +1,64 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issue + +import ( + "context" + "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" +) + +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 + } + + for _, link := range devLinks { + 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 + } + link.PullRequest = pull + link.PullRequest.Issue = issue + link.PullRequest.BaseRepo = issue.Repo + 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 + } + } + + return devLinks, nil +} diff --git a/services/pull/pull.go b/services/pull/pull.go index e69c842a2d..fd316fcb19 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -10,6 +10,7 @@ import ( "io" "os" "regexp" + "strconv" "strings" "time" @@ -166,6 +167,24 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss 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 + } + } + } + return nil }); err != nil { // cleanup: this will only remove the reference, the real commit will be clean up when next GC diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index ce34c5e939..a37b055501 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -255,6 +255,10 @@
+ {{template "repo/issue/view_content/sidebar_development" .}} + +
+ {{if .Participants}} {{ctx.Locale.Tr "repo.issues.num_participants" .NumParticipants}}
diff --git a/templates/repo/issue/view_content/sidebar_development.tmpl b/templates/repo/issue/view_content/sidebar_development.tmpl new file mode 100644 index 0000000000..ac4045fd3f --- /dev/null +++ b/templates/repo/issue/view_content/sidebar_development.tmpl @@ -0,0 +1,93 @@ +{{ctx.Locale.Tr "repo.issues.development"}} + + + \ No newline at end of file