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:
Jakub Vrána
2026-10-17 10:58:33 +00:00
committed by GitHub
co-authored by wxiaoguang
parent 0081bd8430
commit 31626de2a6
7 changed files with 240 additions and 24 deletions
+5
View File
@@ -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)
}
+6 -3
View File
@@ -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
}
+14 -18
View File
@@ -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
}
+90
View File
@@ -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 {
+122
View File
@@ -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)
})
}
}
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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>