mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-19 16:18:11 +00:00
enhance(repo): prefill new issue and pull request sidebars from query parameters (#39804)
Links to the new issue and new pull request pages can preselect sidebar values with the same query parameters as the issue list filters: `labels`, `milestone`, `project` and `assignee`, each taking comma-separated IDs, e.g. `compare/main...branch?labels=1,2&assignee=3`. Previously, pull requests supported none of them and issues only `milestone` and `project`. On the compare page they also expand the new pull request form, like `title` and `body`. Query values take precedence over issue template labels and assignees. * Fix https://github.com/go-gitea/gitea/issues/27341 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
wxiaoguang
parent
0081bd8430
commit
31626de2a6
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"hash"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -117,3 +118,7 @@ func Int64sToStrings(ints []int64) []string {
|
||||
}
|
||||
return strs
|
||||
}
|
||||
|
||||
func JoinInt64s(ints []int64, sep string) string {
|
||||
return strings.Join(Int64sToStrings(ints), sep)
|
||||
}
|
||||
|
||||
@@ -384,13 +384,16 @@ func (cpi *comparePageInfoType) prepareCompareDiff(ctx *context.Context, whitesp
|
||||
ctx.Data["BeforeCommitID"] = ci.CompareBase
|
||||
ctx.Data["AfterCommitID"] = headCommitID
|
||||
|
||||
// follow GitHub's behavior: autofill the form and expand
|
||||
newPrFormTitle := ctx.FormTrim("title")
|
||||
newPrFormBody := ctx.FormTrim("body")
|
||||
ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || newPrFormTitle != "" || newPrFormBody != ""
|
||||
ctx.Data["TitleQuery"] = newPrFormTitle
|
||||
ctx.Data["BodyQuery"] = newPrFormBody
|
||||
|
||||
// follow GitHub's behavior: autofill the form and expand
|
||||
prefillParams := issuePrefillQuery(ctx)
|
||||
ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") ||
|
||||
newPrFormTitle != "" || newPrFormBody != "" || prefillParams != ""
|
||||
|
||||
if headCommitID == ci.CompareBase {
|
||||
config := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
// if auto-detect manual merge, an empty PR will be closed immediately because it is already on base branch
|
||||
@@ -623,7 +626,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex
|
||||
|
||||
if !cpi.nothingToCompare {
|
||||
// Setup information for new form.
|
||||
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true)
|
||||
pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,14 +75,14 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
||||
ctx.Data["TemplateFile"] = template.FileName
|
||||
}
|
||||
|
||||
metaData.LabelsData.SetSelectedLabelNames(template.Labels)
|
||||
|
||||
selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
|
||||
selectedAssigneeIDs, _ := user_model.GetDoerUserIDsByNames(ctx, template.Assignees)
|
||||
for _, userID := range selectedAssigneeIDs {
|
||||
selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10))
|
||||
// the query parameters take precedence
|
||||
if metaData.LabelsData.SelectedLabelIDs == "" {
|
||||
metaData.LabelsData.SetSelectedLabelNames(template.Labels)
|
||||
}
|
||||
if metaData.AssigneesData.SelectedAssigneeIDs == "" {
|
||||
selectedAssigneeIDs, _ := user_model.GetDoerUserIDsByNames(ctx, template.Assignees)
|
||||
metaData.AssigneesData.SelectedAssigneeIDs = base.JoinInt64s(selectedAssigneeIDs, ",")
|
||||
}
|
||||
metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")
|
||||
|
||||
if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
|
||||
template.Ref = git.BranchPrefix + template.Ref
|
||||
@@ -114,14 +114,11 @@ func NewIssue(ctx *context.Context) {
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false)
|
||||
pageMetaData := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
||||
|
||||
pageMetaData.SetSelectedProjectIDs(parseProjectIDsFromQuery(ctx))
|
||||
if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 {
|
||||
ctx.Data["redirect_after_creation"] = "project"
|
||||
}
|
||||
@@ -148,7 +145,7 @@ func NewIssue(ctx *context.Context) {
|
||||
ctx.Data["IsIssuePoster"] = true // the current user will be the poster of the new issue
|
||||
|
||||
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
|
||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||
// The "issues/new" and "issues/new/choose" share the same prefill query parameters, if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -193,7 +190,7 @@ func NewIssueChooseTemplate(ctx *context.Context) {
|
||||
}
|
||||
|
||||
if !issue_service.HasTemplatesOrContactLinks(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo) {
|
||||
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters.
|
||||
// The "issues/new" and "issues/new/choose" share the same prefill query parameters, if no template here, just redirect to the "issues/new" page with these parameters.
|
||||
ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -202,8 +199,7 @@ func NewIssueChooseTemplate(ctx *context.Context) {
|
||||
ctx.Data["IssueConfig"] = issueConfig
|
||||
ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here
|
||||
|
||||
ctx.Data["milestone"] = ctx.FormInt64("milestone")
|
||||
ctx.Data["project"] = ctx.FormInt64("project")
|
||||
ctx.Data["PrefillQuery"] = issuePrefillQuery(ctx)
|
||||
|
||||
ctx.HTML(http.StatusOK, tplIssueChoose)
|
||||
}
|
||||
@@ -236,8 +232,8 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item
|
||||
return s
|
||||
}
|
||||
|
||||
// ValidateRepoMetasForNewIssue check and returns repository's meta information
|
||||
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||
// ValidateRepoMetasForCreateIssue check and returns repository's meta information
|
||||
func ValidateRepoMetasForCreateIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||
LabelIDs, AssigneeIDs []int64
|
||||
MilestoneID int64
|
||||
ProjectIDs []int64
|
||||
@@ -325,7 +321,7 @@ func NewIssuePost(ctx *context.Context) {
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
|
||||
validateRet := ValidateRepoMetasForCreateIssue(ctx, *form, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
@@ -129,6 +133,92 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository
|
||||
return data
|
||||
}
|
||||
|
||||
const (
|
||||
newIssuePrefillLabelFallback = "labels" // "labels" was introduced long time ago
|
||||
newIssuePrefillLabel = "label" // to be consistent with other prefill params, we use "label" instead of "labels"
|
||||
newIssuePrefillMilestone = "milestone"
|
||||
newIssuePrefillProject = "project"
|
||||
newIssuePrefillAssignee = "assignee"
|
||||
)
|
||||
|
||||
func issuePrefillQuery(ctx *context.Context) template.URL {
|
||||
keys := []string{newIssuePrefillLabel, newIssuePrefillLabelFallback, newIssuePrefillMilestone, newIssuePrefillProject, newIssuePrefillAssignee}
|
||||
query := url.Values{}
|
||||
for _, key := range keys {
|
||||
if value := ctx.FormString(key); value != "" {
|
||||
query.Set(key, value)
|
||||
}
|
||||
}
|
||||
return template.URL(query.Encode())
|
||||
}
|
||||
|
||||
func makeItemIdNameMap[T any](items []T, getKey func(item T) (int64, string)) map[string]T {
|
||||
m := make(map[string]T)
|
||||
for _, item := range items {
|
||||
id, name := getKey(item)
|
||||
m[strconv.FormatInt(id, 10)] = item
|
||||
lowerName := strings.ToLower(name)
|
||||
if _, exists := m[lowerName]; !exists {
|
||||
m[lowerName] = item
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func pickItemIDsFromQueryParam[T any](items []T, param string, getKey func(item T) (int64, string)) (ret []int64) {
|
||||
if param == "" {
|
||||
return nil
|
||||
}
|
||||
m := makeItemIdNameMap(items, getKey)
|
||||
for key := range strings.SplitSeq(param, ",") {
|
||||
if item, ok := m[strings.ToLower(key)]; ok {
|
||||
id, _ := getKey(item)
|
||||
ret = append(ret, id)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func retrieveRepoIssueMetaDataWithPrefill(ctx *context.Context, repo *repo_model.Repository, isPull bool) *IssuePageMetaData {
|
||||
data := retrieveRepoIssueMetaData(ctx, repo, nil, isPull)
|
||||
if ctx.Written() {
|
||||
return data
|
||||
}
|
||||
|
||||
// label
|
||||
paramLabel := ctx.FormString(newIssuePrefillLabel, ctx.FormString(newIssuePrefillLabelFallback))
|
||||
labelIDs := pickItemIDsFromQueryParam(data.LabelsData.AllLabels, paramLabel, func(label *issues_model.Label) (int64, string) {
|
||||
return label.ID, label.Name
|
||||
})
|
||||
data.LabelsData.SetSelectedLabelIDs(labelIDs)
|
||||
|
||||
// milestone
|
||||
paramMilestone := ctx.FormString(newIssuePrefillMilestone)
|
||||
allMilestones := slices.Concat(data.MilestonesData.OpenMilestones, data.MilestonesData.ClosedMilestones)
|
||||
milestoneIDs := pickItemIDsFromQueryParam(allMilestones, paramMilestone, func(milestone *issues_model.Milestone) (int64, string) {
|
||||
return milestone.ID, milestone.Name
|
||||
})
|
||||
if len(milestoneIDs) > 0 {
|
||||
data.MilestonesData.SelectedMilestoneID = milestoneIDs[0]
|
||||
}
|
||||
|
||||
// project
|
||||
paramProject := ctx.FormString(newIssuePrefillProject)
|
||||
allProjects := slices.Concat(data.ProjectsData.OpenProjects, data.ProjectsData.ClosedProjects)
|
||||
projectOpenIDs := pickItemIDsFromQueryParam(allProjects, paramProject, func(project *project_model.Project) (int64, string) {
|
||||
return project.ID, project.Title
|
||||
})
|
||||
data.SetSelectedProjectIDs(projectOpenIDs)
|
||||
|
||||
// assignee
|
||||
paramAssignee := ctx.FormString(newIssuePrefillAssignee)
|
||||
assigneeIDs := pickItemIDsFromQueryParam(data.AssigneesData.CandidateAssignees, paramAssignee, func(user *user_model.User) (int64, string) {
|
||||
return user.ID, user.Name
|
||||
})
|
||||
data.AssigneesData.SelectedAssigneeIDs = base.JoinInt64s(assigneeIDs, ",")
|
||||
return data
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Context) {
|
||||
var err error
|
||||
if d.Issue != nil {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
project_model "gitea.dev/models/project"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRetrieveRepoIssueMetaDataWithPrefill(t *testing.T) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
require.NoError(t, db.Insert(t.Context(),
|
||||
[]*issues_model.Label{
|
||||
{ID: 100, RepoID: 1, Name: "Bug"},
|
||||
{ID: 101, RepoID: 1, Name: "Feature Request"},
|
||||
{ID: 102, RepoID: 2, Name: "Other Label"},
|
||||
},
|
||||
[]*issues_model.Milestone{
|
||||
{ID: 100, RepoID: 1, Name: "Release One"},
|
||||
{ID: 101, RepoID: 1, Name: "Release Two"},
|
||||
{ID: 102, RepoID: 1, Name: "Closed Milestone", IsClosed: true},
|
||||
{ID: 103, RepoID: 2, Name: "Other Milestone"},
|
||||
},
|
||||
[]*project_model.Project{
|
||||
{ID: 100, RepoID: 1, Title: "Open Project", Type: project_model.TypeRepository},
|
||||
{ID: 101, RepoID: 1, Title: "Closed Project", Type: project_model.TypeRepository, IsClosed: true},
|
||||
{ID: 102, RepoID: 2, Title: "Other Project", Type: project_model.TypeRepository},
|
||||
},
|
||||
[]*user_model.User{
|
||||
{ID: 100, Name: "Alice", LowerName: "alice", IsActive: true},
|
||||
{ID: 101, Name: "Bob", LowerName: "bob", IsActive: true},
|
||||
{ID: 102, Name: "OtherUser", LowerName: "otheruser", IsActive: true},
|
||||
},
|
||||
[]*access_model.Access{
|
||||
{RepoID: 1, UserID: 100, Mode: perm.AccessModeWrite},
|
||||
{RepoID: 1, UserID: 101, Mode: perm.AccessModeWrite},
|
||||
},
|
||||
))
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
query url.Values
|
||||
labelIDs []int64
|
||||
milestoneID int64
|
||||
projectIDs []int64
|
||||
assigneeIDs []int64
|
||||
}
|
||||
for _, tc := range []testCase{
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive names",
|
||||
query: url.Values{
|
||||
"label": {"bUG,fEATURE rEQUEST"},
|
||||
"milestone": {"rELEASE oNE"},
|
||||
"project": {"cLOSED pROJECT,oPEN pROJECT"},
|
||||
"assignee": {"bOB,aLICE"},
|
||||
},
|
||||
labelIDs: []int64{100, 101},
|
||||
milestoneID: 100,
|
||||
projectIDs: []int64{101, 100},
|
||||
assigneeIDs: []int64{101, 100},
|
||||
},
|
||||
{
|
||||
name: "mixed IDs and names with invalid values",
|
||||
query: url.Values{
|
||||
"label": {"missing,102,Other Label,101,Bug,100,999"},
|
||||
"milestone": {"missing,102,Closed Milestone,103,Other Milestone,101,Release One,999"},
|
||||
"project": {"missing,102,Other Project,101,Open Project,999"},
|
||||
"assignee": {"missing,102,OtherUser,101,Alice,999"},
|
||||
},
|
||||
labelIDs: []int64{100, 101},
|
||||
milestoneID: 102,
|
||||
projectIDs: []int64{101, 100},
|
||||
assigneeIDs: []int64{101, 100},
|
||||
},
|
||||
{
|
||||
name: "labels fallback",
|
||||
query: url.Values{"labels": {"101,Bug"}},
|
||||
labelIDs: []int64{100, 101},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, _ := contexttest.MockContext(t, "/dummy?"+tc.query.Encode())
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
|
||||
data := retrieveRepoIssueMetaDataWithPrefill(ctx, ctx.Repo.Repository, false)
|
||||
require.False(t, ctx.Written())
|
||||
assert.Same(t, data, ctx.Data["IssuePageMetaData"])
|
||||
assert.True(t, data.CanModifyIssueOrPull)
|
||||
assert.Equal(t, base.JoinInt64s(tc.labelIDs, ","), data.LabelsData.SelectedLabelIDs)
|
||||
for _, label := range data.LabelsData.AllLabels {
|
||||
assert.Equal(t, slices.Contains(tc.labelIDs, label.ID), label.IsChecked, label.Name)
|
||||
}
|
||||
assert.Equal(t, tc.milestoneID, data.MilestonesData.SelectedMilestoneID)
|
||||
assert.Equal(t, tc.projectIDs, data.ProjectsData.SelectedProjectIDs)
|
||||
var projectIDs []int64
|
||||
for _, card := range data.ProjectsData.ProjectCards {
|
||||
projectIDs = append(projectIDs, card.Project.ID)
|
||||
}
|
||||
assert.Equal(t, tc.projectIDs, projectIDs)
|
||||
assert.Equal(t, base.JoinInt64s(tc.assigneeIDs, ","), data.AssigneesData.SelectedAssigneeIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1326,7 +1326,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.no_common_history"))
|
||||
return
|
||||
}
|
||||
validateRet := ValidateRepoMetasForNewIssue(ctx, *form, true)
|
||||
validateRet := ValidateRepoMetasForCreateIssue(ctx, *form, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<br>{{.About}}
|
||||
</div>
|
||||
<div class="column tw-text-right">
|
||||
<a href="{{$.RepoLink}}/issues/new?template={{.FileName}}{{if $.milestone}}&milestone={{$.milestone}}{{end}}{{if $.project}}&project={{$.project}}{{end}}" class="ui primary button">{{ctx.Locale.Tr "repo.issues.choose.get_started"}}</a>
|
||||
<a href="{{$.RepoLink}}/issues/new?template={{.FileName}}&{{$.PrefillQuery}}" class="ui primary button">{{ctx.Locale.Tr "repo.issues.choose.get_started"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<br/>{{ctx.Locale.Tr "repo.issues.choose.blank_about"}}
|
||||
</div>
|
||||
<div class="column tw-text-right">
|
||||
<a href="{{.RepoLink}}/issues/new?{{if .milestone}}&milestone={{.milestone}}{{end}}{{if $.project}}&project={{$.project}}{{end}}" class="ui primary button">{{ctx.Locale.Tr "repo.issues.choose.get_started"}}</a>
|
||||
<a href="{{.RepoLink}}/issues/new?{{$.PrefillQuery}}" class="ui primary button">{{ctx.Locale.Tr "repo.issues.choose.get_started"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user