Add project column picker to issue and pull request sidebar (#37037)

Why? You are working on a ticket, it's ready to be moved to the QA
column in your project. Currently you have to go to the project, find
the issue card, then move it. With this change you can move the issue's
column on the issue page.

When an issue or pull request belongs to a project board, a dropdown
appears in the sidebar to move it between columns without opening the
board view. Read-only users see the current column name instead.

* Fix #13520
* Replace #30617

This was written using Claude Code and Opus. 

Closed:

<img width="1346" height="507" alt="image"
src="https://github.com/user-attachments/assets/7c1ea7ee-b71c-40af-bb14-aeb1d2beff73"
/>

Open:
<img width="1315" height="577" alt="image"
src="https://github.com/user-attachments/assets/4d64b065-44c2-42c7-8d20-84b5caea589a"
/>

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Cursor <cursor@cursor.com>
This commit is contained in:
Myers Carpenter
2026-04-19 08:53:02 -04:00
committed by GitHub
parent 6ed861589a
commit 2f5b5a9e9c
18 changed files with 380 additions and 60 deletions
+20 -10
View File
@@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct {
CandidateAssignees []*user_model.User
}
type issueSidebarProjectCardData struct {
Project *project_model.Project
Columns []*project_model.Column
SelectedColumn *project_model.Column
}
type issueSidebarProjectsData struct {
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
// the "selected" fields are only valid when len(SelectedProjectIDs)==1
SelectedProjectColumns []*project_model.Column
SelectedProjectColumn *project_model.Column
ProjectCards []*issueSidebarProjectCardData
OpenProjects []*project_model.Project
ClosedProjects []*project_model.Project
@@ -172,30 +175,37 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
if d.Issue == nil || d.Issue.Project == nil {
return
}
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
columns, err := d.Issue.Project.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
d.ProjectsData.SelectedProjectColumns = columns
columnID, err := d.Issue.ProjectColumnID(ctx)
if err != nil {
ctx.ServerError("ProjectColumnID", err)
return
}
var selectedColumn *project_model.Column
for _, col := range columns {
if col.ID == columnID {
d.ProjectsData.SelectedProjectColumn = col
selectedColumn = col
break
}
}
d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{
{
Project: d.Issue.Project,
Columns: columns,
SelectedColumn: selectedColumn,
},
}
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
for _, card := range d.ProjectsData.ProjectCards {
d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID)
}
}
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
if d.Issue != nil && d.Issue.Project != nil {
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
}
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
}
+48
View File
@@ -464,6 +464,54 @@ func UpdateIssueProject(ctx *context.Context) {
ctx.JSONOK()
}
// UpdateIssueProjectColumn moves an issue to a different column within its project
func UpdateIssueProjectColumn(ctx *context.Context) {
issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id"))
if err != nil {
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
return
}
column, err := project_model.GetColumn(ctx, ctx.FormInt64("id"))
if err != nil {
ctx.NotFoundOrServerError("GetColumn", project_model.IsErrProjectColumnNotExist, err)
return
}
if err := issue.LoadProject(ctx); err != nil {
ctx.ServerError("LoadProject", err)
return
}
issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future
// it must make sure the requested column is in this issue's projects
var columnProject *project_model.Project
for _, project := range issueProjects {
if column.ProjectID == project.ID {
columnProject = project
break
}
}
if columnProject == nil {
ctx.NotFound(nil)
return
}
// append to the end of the target column so we don't collide with existing sorting values
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID)
if err != nil {
ctx.ServerError("GetColumnIssueNextSorting", err)
return
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
ctx.JSONOK()
}
// DeleteProjectColumn allows for the deletion of a project column
func DeleteProjectColumn(ctx *context.Context) {
if ctx.Doer == nil {
+1
View File
@@ -1355,6 +1355,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject)
m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn)
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)