diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index c9bdc5be76..b0116726b7 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -460,7 +460,39 @@ func UpdateIssueProject(ctx *context.Context) {
return
}
}
+ ctx.JSONOK()
+}
+// UpdateIssueProjectSetColumn moves an issue to a different column within its current project
+func UpdateIssueProjectSetColumn(ctx *context.Context) {
+ issues := getActionIssues(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if err := issues.LoadProjects(ctx); err != nil {
+ ctx.ServerError("LoadProjects", err)
+ return
+ }
+
+ column, err := project_model.GetColumn(ctx, ctx.FormInt64("id"))
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.NotFound(nil)
+ return
+ } else if err != nil {
+ ctx.ServerError("GetColumn", err)
+ return
+ }
+
+ for _, issue := range issues {
+ if column.ProjectID != issue.Project.ID {
+ continue
+ }
+ if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{0 /*sorting=0*/ : issue.ID}); err != nil {
+ ctx.ServerError("MoveIssuesOnProjectColumn", err)
+ return
+ }
+ }
ctx.JSONOK()
}
diff --git a/routers/web/web.go b/routers/web/web.go
index e3dcf27cc4..41a59e4f1a 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1350,6 +1350,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/set-column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectSetColumn)
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
diff --git a/templates/repo/issue/sidebar/project_column.tmpl b/templates/repo/issue/sidebar/project_column.tmpl
new file mode 100644
index 0000000000..5aca90c2e5
--- /dev/null
+++ b/templates/repo/issue/sidebar/project_column.tmpl
@@ -0,0 +1,25 @@
+{{$pageMeta := .}}
+{{$data := .ProjectsData}}
+{{$selectedColumn := $data.SelectedProjectColumn}}
+{{if and $pageMeta.Issue $pageMeta.Issue.Project $data.SelectedProjectColumns}}
+
+ {{svg "octicon-columns" 18}}
+ {{if $pageMeta.CanModifyIssueOrPull}}
+
+ {{else if $selectedColumn}}
+
{{$selectedColumn.Title}}
+ {{end}}
+
+{{end}}
diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl
index ca3b6bdd7a..8b09cf12bd 100644
--- a/templates/repo/issue/sidebar/project_list.tmpl
+++ b/templates/repo/issue/sidebar/project_list.tmpl
@@ -49,3 +49,7 @@
{{end}}
+{{if eq 1 (len $data.SelectedProjectIDs)}}
+ {{/* the project column selection is only supported when the issue is only in one project */}}
+ {{template "repo/issue/sidebar/project_column" $pageMeta}}
+{{end}}
diff --git a/tests/e2e/project-column-picker.test.ts b/tests/e2e/project-column-picker.test.ts
new file mode 100644
index 0000000000..e35ccbf9ee
--- /dev/null
+++ b/tests/e2e/project-column-picker.test.ts
@@ -0,0 +1,47 @@
+import {env} from 'node:process';
+import {test, expect} from '@playwright/test';
+import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo} from './utils.ts';
+
+test('project column picker', async ({page}) => {
+ const repoName = `e2e-colpicker-${Date.now()}`;
+ const owner = env.GITEA_TEST_E2E_USER;
+ await login(page);
+ await apiCreateRepo(page.request, {name: repoName});
+ await apiCreateIssue(page.request, owner, repoName, {title: 'Test Issue'});
+
+ // Create a project with board type
+ await page.goto(`/${owner}/${repoName}/projects/new`);
+ await page.getByPlaceholder('Title').fill('Test Board');
+ await page.locator('#project_template').selectOption('Basic Kanban');
+ await page.getByRole('button', {name: 'Create Project'}).click();
+ await expect(page.locator('.project-column')).toHaveCount(3); // Basic Kanban: To Do, In Progress, Done
+
+ // Assign the issue to the project via the issue sidebar
+ await page.goto(`/${owner}/${repoName}/issues/1`);
+ await page.locator('.issue-sidebar-combo[data-update-url*="/issues/projects?"] .ui.dropdown').click();
+ await page.locator('.issue-sidebar-combo[data-update-url*="/issues/projects?"] .menu .item').filter({hasText: 'Test Board'}).click();
+
+ // Wait for the column picker to appear
+ await expect(page.locator('#sidebar-project-column .ui.dropdown')).toBeVisible();
+
+ // Verify the column dropdown has items
+ await page.locator('#sidebar-project-column .ui.dropdown').click();
+ const columnItems = page.locator('#sidebar-project-column .menu .item');
+ await expect(columnItems).toHaveCount(3);
+
+ // Select a different column
+ await columnItems.filter({hasText: 'In Progress'}).click();
+
+ // Verify the dropdown closed and shows the new selection
+ await expect(page.locator('#sidebar-project-column .menu')).toBeHidden();
+ await expect(page.locator('#sidebar-project-column .default.text')).toHaveText('In Progress');
+
+ // Verify a timeline event appeared for the column move
+ await expect(page.locator('.timeline-item').last()).toContainText('moved this to In Progress');
+
+ // Reload and verify the column persisted
+ await page.reload();
+ await expect(page.locator('#sidebar-project-column .default.text')).toHaveText('In Progress');
+
+ await apiDeleteRepo(page.request, owner, repoName);
+});
diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go
index 1e38322dbf..bc97d7ef30 100644
--- a/tests/integration/project_test.go
+++ b/tests/integration/project_test.go
@@ -89,6 +89,56 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}
+func TestUpdateIssueProjectColumn(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Issue 1 is in project 1, column 1 (To Do) — see fixtures
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ sess := loginUser(t, "user2")
+
+ t.Run("MoveToColumn", func(t *testing.T) {
+ // Move issue 1 from To Do (column 1) to In Progress (column 2)
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
+ "issue_id": strconv.FormatInt(issue.ID, 10),
+ "id": "2",
+ })
+ sess.MakeRequest(t, req, http.StatusOK)
+
+ // Verify the column changed
+ columnID, err := issue.ProjectColumnID(t.Context())
+ require.NoError(t, err)
+ assert.EqualValues(t, 2, columnID)
+ })
+
+ t.Run("InvalidColumn", func(t *testing.T) {
+ // Column 4 belongs to project 4, not project 1
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
+ "issue_id": strconv.FormatInt(issue.ID, 10),
+ "id": "4",
+ })
+ sess.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("NonexistentColumn", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
+ "issue_id": strconv.FormatInt(issue.ID, 10),
+ "id": "99999",
+ })
+ sess.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("IssueFromOtherRepo", func(t *testing.T) {
+ // Issue 4 belongs to repo 2, not repo 1
+ otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
+ "issue_id": strconv.FormatInt(otherIssue.ID, 10),
+ "id": "2",
+ })
+ sess.MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
t.Helper()