Add a new section named development in issue view sidebar to interact with branch/pr

This commit is contained in:
Lunny Xiao 2024-08-21 15:34:03 -07:00
parent 40036b6102
commit 62fda252bd
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
14 changed files with 415 additions and 52 deletions

View File

@ -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
}

View File

@ -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

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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"`

View File

@ -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
}

View File

@ -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)
}

View File

@ -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())
}

View File

@ -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() {

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -255,6 +255,10 @@
<div class="divider"></div>
{{template "repo/issue/view_content/sidebar_development" .}}
<div class="divider"></div>
{{if .Participants}}
<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.num_participants" .NumParticipants}}</strong></span>
<div class="ui list tw-flex tw-flex-wrap">

View File

@ -0,0 +1,93 @@
<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.development"}}</strong></span>
<div class="ui devlinks list">
{{if not .DevLinks}}
<a class="tw-mt-1 fluid ui show-modal" data-modal="#create_branch">{{ctx.Locale.Tr "repo.branch.new_branch"}}</a>
{{end}}
{{range .DevLinks}}
{{if .PullRequest}}
<a href="{{.PullRequest.Issue.Link}}" class="item">
{{.PullRequest.Issue.Title}}
</a>
Created {{.PullRequest.Issue.CreatedAt}}
{{if .PullRequest.HasMerged}}
Completed
{{.PullRequest.MergedCommitID}}
Created {{.PullRequest.MergedUnix}}
{{else if .PullRequest.ChangedProtectedFiles}}
Merge conflicts
{{end}}
{{else if .Branch}}
<span>
{{svg "octicon-git-branch" 14}}
<a href="{{.Branch}}" class="item">
<span class="gt-ellipsis">{{.Branch.Name}}</span>
</a>
</span>
<div>Latest commit {{DateTime "short" .Branch.CommitTime}}</div>
<a href="{{$.Issue.Repo.Link}}/compare/main...{{.Branch.Name}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a>
{{end}}
{{end}}
</div>
<div class="ui tiny modal" id="create_branch">
<div class="header">
{{ctx.Locale.Tr "repo.branch.new_branch"}}
</div>
<div class="content">
<form class="ui form form-fetch-action" action="{{.Issue.Link}}/create_branch"
method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label for="new_branch_name">{{ctx.Locale.Tr "repo.branch.name"}}</label>
<input name="new_branch_name" type="text">
</div>
<div class="field">
<label for="source_repository">{{ctx.Locale.Tr "repository"}}</label>
<div class="ui fluid dropdown selection">
<select name="source_repository">
<option value=""> </option>
{{range .AllowedRepos}}
<option value="{{.ID}}">{{.FullName}}</option>
{{end}}
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text"> </div>
<div class="menu">
{{range .AllowedRepos}}
<div class="item" data-value="{{.ID}}">{{.FullName}}</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<label for="source_branch_name">{{ctx.Locale.Tr "repo.branches"}}</label>
<div class="ui fluid dropdown selection">
<select name="source_branch_name">
<option value=""> </option>
{{range .Branches}}
<option value="{{.}}"{{if eq . $.Issue.Ref}} checked{{end}}>{{.}}</option>
{{end}}
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text"> </div>
<div class="menu">
{{range .Branches}}
<div class="item" data-value="{{.}}">{{.}}</div>
{{end}}
</div>
</div>
</div>
<div class="text right actions">
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
<button class="ui red button">{{ctx.Locale.Tr "repo.branch.new_branch"}}</button>
</div>
</form>
</div>
</div>