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

View File

@@ -592,6 +592,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) {
return issue, nil
}
func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) {
issue := new(Issue)
has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue)
if err != nil {
return nil, err
} else if !has {
return nil, ErrIssueNotExist{issueID, repoID, 0}
}
return issue, nil
}
// GetIssuesByIDs return issues with the given IDs.
// If keepOrder is true, the order of the returned issues will be the same as the given IDs.
func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) {

View File

@@ -115,17 +115,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
panic("newColumnID must not be zero") // shouldn't happen
}
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
Where("project_id=?", newProjectID).
And("project_board_id=?", newColumnID).
Get(&res); err != nil {
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID)
if err != nil {
return err
}
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.Insert(ctx, &project_model.ProjectIssue{
IssueID: issue.ID,
ProjectID: newProjectID,

View File

@@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
return err
}
if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil {
return err
}

View File

@@ -59,7 +59,7 @@ func Test_moveIssuesToAnotherColumn(t *testing.T) {
assert.Len(t, issues, 1)
assert.EqualValues(t, 3, issues[0].ID)
err = column1.moveIssuesToAnotherColumn(t.Context(), column2)
err = moveIssuesToAnotherColumn(t.Context(), column1, column2)
assert.NoError(t, err)
issues, err = column1.GetIssues(t.Context())

View File

@@ -33,38 +33,45 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}
func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if c.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")
}
if c.ID == newColumn.ID {
return nil
}
// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count").
Table("project_issue").
Where("project_id=?", newColumn.ProjectID).
And("project_board_id=?", newColumn.ID).
Where("project_id=?", projectID).
And("project_board_id=?", columnID).
Get(&res); err != nil {
return err
return 0, err
}
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
}
func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error {
if oldColumn.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")
}
issues, err := c.GetIssues(ctx)
if err != nil {
return err
}
if len(issues) == 0 {
if oldColumn.ID == newColumn.ID {
return nil
}
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
movedIssues, err := oldColumn.GetIssues(ctx)
if err != nil {
return err
}
if len(movedIssues) == 0 {
return nil
}
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID)
if err != nil {
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
for i, issue := range issues {
for i, issue := range movedIssues {
issue.ProjectColumnID = newColumn.ID
issue.Sorting = nextSorting + int64(i)
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {