diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 72a7597c69..c9d958fe08 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -344,7 +344,7 @@ func DeleteOrgProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -463,7 +463,7 @@ func CreateOrgProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -540,7 +540,7 @@ func EditOrgProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -587,7 +587,7 @@ func DeleteOrgProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -801,7 +801,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -811,7 +811,7 @@ func assignIssueToOrgProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 39ca7fb77e..1c462b0c00 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -31,6 +31,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" issue_service "code.gitea.io/gitea/services/issue" + project_service "code.gitea.io/gitea/services/projects" ) // buildSearchIssuesRepoIDs builds the list of repository IDs for issue search based on query parameters. @@ -915,7 +916,7 @@ func EditIssue(ctx *context.APIContext) { // Update projects if provided if canWrite && form.Projects != nil { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, *form.Projects); err != nil { if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusBadRequest, err) } else { diff --git a/routers/api/v1/repo/project.go b/routers/api/v1/repo/project.go index ec042f369e..eb91d4b43a 100644 --- a/routers/api/v1/repo/project.go +++ b/routers/api/v1/repo/project.go @@ -371,7 +371,7 @@ func DeleteProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -500,7 +500,7 @@ func CreateProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -582,7 +582,7 @@ func EditProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -634,7 +634,7 @@ func DeleteProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -866,7 +866,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -878,7 +878,7 @@ func assignIssueToProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/api/v1/user/project.go b/routers/api/v1/user/project.go index a47cc893bc..f1c4f40c1e 100644 --- a/routers/api/v1/user/project.go +++ b/routers/api/v1/user/project.go @@ -366,7 +366,7 @@ func DeleteUserProject(ctx *context.APIContext) { return } - if err := project_model.DeleteProjectByID(ctx, project.ID); err != nil { + if err := project_service.DeleteProject(ctx, project.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -489,7 +489,7 @@ func CreateUserProjectColumn(ctx *context.APIContext) { CreatorID: ctx.Doer.ID, } - if err := project_model.NewColumn(ctx, column); err != nil { + if err := project_service.CreateColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -570,7 +570,7 @@ func EditUserProjectColumn(ctx *context.APIContext) { column.Sorting = int8(*form.Sorting) } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.APIErrorInternal(err) return } @@ -621,7 +621,7 @@ func DeleteUserProjectColumn(ctx *context.APIContext) { return } - if err := project_model.DeleteColumnByID(ctx, column.ID); err != nil { + if err := project_service.DeleteColumn(ctx, column.ID); err != nil { ctx.APIErrorInternal(err) return } @@ -837,7 +837,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs = append(newProjectIDs, id) } } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } @@ -847,7 +847,7 @@ func assignIssueToUserProjectColumn(ctx *context.APIContext, add bool) { newProjectIDs := make([]int64, len(currentProjectIDs)+1) copy(newProjectIDs, currentProjectIDs) newProjectIDs[len(currentProjectIDs)] = column.ProjectID - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, newProjectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, newProjectIDs); err != nil { ctx.APIErrorInternal(err) return } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index f3cc970162..2222e88764 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -214,7 +214,7 @@ func DeleteProject(ctx *context.Context) { return } - if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + if err := project_service.DeleteProject(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) @@ -283,7 +283,7 @@ func EditProjectPost(ctx *context.Context) { p.Title = form.Title p.Description = form.Content p.CardType = form.CardType - if err = project_model.UpdateProject(ctx, p); err != nil { + if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil { ctx.ServerError("UpdateProjects", err) return } @@ -496,7 +496,7 @@ func DeleteProjectColumn(ctx *context.Context) { return } - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil { + if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil { ctx.ServerError("DeleteProjectColumnByID", err) return } @@ -514,7 +514,7 @@ func AddColumnToProjectPost(ctx *context.Context) { return } - if err := project_model.NewColumn(ctx, &project_model.Column{ + if err := project_service.CreateColumn(ctx, &project_model.Column{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -567,7 +567,7 @@ func EditProjectColumn(ctx *context.Context) { column.Sorting = form.Sorting } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.ServerError("UpdateProjectColumn", err) return } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a517498fbc..93ba7b3804 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -191,7 +191,7 @@ func DeleteProject(ctx *context.Context) { return } - if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + if err := project_service.DeleteProject(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) @@ -264,7 +264,7 @@ func EditProjectPost(ctx *context.Context) { p.Title = form.Title p.Description = form.Content p.CardType = form.CardType - if err = project_model.UpdateProject(ctx, p); err != nil { + if err = project_service.UpdateProject(ctx, p, project_service.UpdateProjectOptions{}); err != nil { ctx.ServerError("UpdateProjects", err) return } @@ -459,7 +459,7 @@ func UpdateIssueProject(ctx *context.Context) { projectIDs = filteredIDs var failedIssues []int64 for _, issue := range issues { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil { + if err := project_service.AssignOrRemoveProjects(ctx, issue, ctx.Doer, projectIDs); err != nil { if errors.Is(err, util.ErrPermissionDenied) { failedIssues = append(failedIssues, issue.ID) continue @@ -569,7 +569,7 @@ func DeleteProjectColumn(ctx *context.Context) { return } - if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64("columnID")); err != nil { + if err := project_service.DeleteColumn(ctx, ctx.PathParamInt64("columnID")); err != nil { ctx.ServerError("DeleteProjectColumnByID", err) return } @@ -597,7 +597,7 @@ func AddColumnToProjectPost(ctx *context.Context) { return } - if err := project_model.NewColumn(ctx, &project_model.Column{ + if err := project_service.CreateColumn(ctx, &project_model.Column{ ProjectID: project.ID, Title: form.Title, Color: form.Color, @@ -672,7 +672,7 @@ func EditProjectColumn(ctx *context.Context) { column.Sorting = form.Sorting } - if err := project_model.UpdateColumn(ctx, column); err != nil { + if err := project_service.EditColumn(ctx, column); err != nil { ctx.ServerError("UpdateProjectColumn", err) return } diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go index b6ffaea7f8..9cbfb73113 100644 --- a/routers/web/shared/project/column.go +++ b/routers/web/shared/project/column.go @@ -7,6 +7,7 @@ import ( project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/services/context" + project_service "code.gitea.io/gitea/services/projects" ) // MoveColumns moves or keeps columns in a project and sorts them inside that project @@ -39,7 +40,7 @@ func MoveColumns(ctx *context.Context) { sortedColumnIDs[column.Sorting] = column.ColumnID } - if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + if err = project_service.ReorderColumns(ctx, project, sortedColumnIDs); err != nil { ctx.ServerError("MoveColumnsOnProject", err) return } diff --git a/services/projects/column.go b/services/projects/column.go new file mode 100644 index 0000000000..128ea66870 --- /dev/null +++ b/services/projects/column.go @@ -0,0 +1,151 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/project_events" +) + +// CreateColumn inserts a new column into a project and publishes a +// ColumnCreated event. Routers should call this instead of +// project_model.NewColumn so the SSE side-effect fires uniformly across +// repo, user, and org scopes. +func CreateColumn(ctx context.Context, column *project_model.Column) error { + if err := project_model.NewColumn(ctx, column); err != nil { + return err + } + project_events.PublishColumnCreated(ctx, project_events.ColumnCreated{ + ProjectID: column.ProjectID, + ColumnID: column.ID, + Title: column.Title, + Color: column.Color, + Sorting: int64(column.Sorting), + IsDefault: column.Default, + }) + return nil +} + +// EditColumn updates a column and publishes a ColumnUpdated event. +func EditColumn(ctx context.Context, column *project_model.Column) error { + if err := project_model.UpdateColumn(ctx, column); err != nil { + return err + } + project_events.PublishColumnUpdated(ctx, project_events.ColumnUpdated{ + ProjectID: column.ProjectID, + ColumnID: column.ID, + Title: column.Title, + Color: column.Color, + Sorting: int64(column.Sorting), + }) + return nil +} + +// DeleteColumn removes a column from a project and publishes the +// matching ColumnDeleted event. The model layer also moves the +// column's issues to the project's default column; we publish those +// individual moves so receiving tabs can patch the DOM without a full +// reload. We snapshot affected issues *before* the delete so we have +// their ids; the destination column id is resolved after. +func DeleteColumn(ctx context.Context, columnID int64) error { + // Snapshot the column + its issues so we know what to publish + // after the delete commits. Errors here are non-fatal: we still + // run the delete, and just skip per-issue events. + col, snapErr := project_model.GetColumn(ctx, columnID) + var ( + projectID int64 + movedIssues []int64 + ) + if snapErr == nil { + projectID = col.ProjectID + issues, err := col.GetIssues(ctx) + if err == nil { + movedIssues = make([]int64, 0, len(issues)) + for _, pi := range issues { + movedIssues = append(movedIssues, pi.IssueID) + } + } + } + + if err := project_model.DeleteColumnByID(ctx, columnID); err != nil { + return err + } + + if snapErr != nil || projectID == 0 { + return nil + } + project_events.PublishColumnDeleted(ctx, project_events.ColumnDeleted{ + ProjectID: projectID, + ColumnID: columnID, + }) + + // Resolve the new (default) column to attach to the per-issue + // CardMoved events. Failures here are tolerated; the frontend + // already knows the column is gone and will simply render its + // next refresh as authoritative. + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + return nil + } + defaultCol, err := project.MustDefaultColumn(ctx) + if err != nil { + return nil + } + for _, issueID := range movedIssues { + project_events.PublishCardMoved(ctx, project_events.CardMoved{ + ProjectID: projectID, + IssueID: issueID, + FromColumnID: columnID, + ToColumnID: defaultCol.ID, + }) + } + return nil +} + +// ReorderColumns persists a new sort order for project columns and +// publishes a ColumnReordered batch event. +func ReorderColumns(ctx context.Context, project *project_model.Project, sortedColumnIDs map[int64]int64) error { + if err := project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + return err + } + cols := make([]project_events.ColumnSort, 0, len(sortedColumnIDs)) + for sorting, columnID := range sortedColumnIDs { + cols = append(cols, project_events.ColumnSort{ + ColumnID: columnID, + Sorting: sorting, + }) + } + project_events.PublishColumnReordered(ctx, project_events.ColumnReordered{ + ProjectID: project.ID, + Columns: cols, + }) + return nil +} + +// DeleteProject deletes a project and publishes a ProjectDeleted event. +func DeleteProject(ctx context.Context, projectID int64) error { + if err := project_model.DeleteProjectByID(ctx, projectID); err != nil { + return err + } + project_events.PublishProjectDeleted(ctx, project_events.ProjectDeleted{ + ProjectID: projectID, + }) + return nil +} + +// publishProjectUpdated emits a ProjectUpdated event for the current +// state of the given project. It is exported via UpdateProject in +// project.go after the txn commits. +func publishProjectUpdated(ctx context.Context, project *project_model.Project) { + project_events.PublishProjectUpdated(ctx, project_events.ProjectUpdated{ + ProjectID: project.ID, + Title: project.Title, + Description: project.Description, + CardType: convert.ProjectCardTypeToString(project.CardType), + IsClosed: project.IsClosed, + }) +} diff --git a/services/projects/issue.go b/services/projects/issue.go index 3655a000ce..95b8cd947b 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/services/project_events" ) // ErrIssueNotInProject is returned when MoveIssuesOnProjectColumn is asked to move @@ -23,7 +24,14 @@ var ErrIssueNotInProject = errors.New("all issues have to be added to a project // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { - return db.WithTx(ctx, func(ctx context.Context) error { + // movedEvents accumulates one CardMoved per issue we touch so they + // can be published after the transaction commits successfully. + // We capture the from-column inside the txn (cheap extra query) + // and emit *all* moves, including same-column reorders, so the + // frontend can update sorting without re-fetching the whole column. + var movedEvents []project_events.CardMoved + err := db.WithTx(ctx, func(ctx context.Context) error { + movedEvents = movedEvents[:0] issueIDs := make([]int64, 0, len(sortedIssueIDs)) for _, issueID := range sortedIssueIDs { issueIDs = append(issueIDs, issueID) @@ -89,9 +97,110 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum if err != nil { return err } + + movedEvents = append(movedEvents, project_events.CardMoved{ + ProjectID: column.ProjectID, + IssueID: issueID, + FromColumnID: projectColumnID, + ToColumnID: column.ID, + Sorting: sorting, + }) } return nil }) + if err != nil { + return err + } + for _, ev := range movedEvents { + project_events.PublishCardMoved(ctx, ev) + } + return nil +} + +// AssignOrRemoveProjects updates the projects associated with an issue +// (delegating to issues_model.IssueAssignOrRemoveProject) and publishes +// SSE events for each link/unlink so other tabs viewing the relevant +// project boards can update without a reload. +// +// Routers should prefer this helper over calling the model function +// directly so the publish side-effects fire at every call site. +func AssignOrRemoveProjects(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, newProjectIDs []int64) error { + // Snapshot the current project ids before the update so we can + // compute the link/unlink diff. If this read fails we just skip + // publishing — the user-visible operation still succeeds. + oldProjectIDs, snapErr := issueProjectIDs(ctx, issue.ID) + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, newProjectIDs); err != nil { + return err + } + if snapErr != nil { + return nil + } + + added, removed := diffInt64Slices(oldProjectIDs, newProjectIDs) + + for _, pid := range removed { + project_events.PublishCardUnlinked(ctx, project_events.CardUnlinked{ + ProjectID: pid, + IssueID: issue.ID, + }) + } + // For additions we want to surface the destination column so the + // receiving tab can refetch only that column's contents. The model + // function places newly added issues in each project's default + // column; re-derive that here. + for _, pid := range added { + project, err := project_model.GetProjectByID(ctx, pid) + if err != nil { + continue + } + col, err := project.MustDefaultColumn(ctx) + if err != nil { + continue + } + project_events.PublishCardLinked(ctx, project_events.CardLinked{ + ProjectID: pid, + IssueID: issue.ID, + ColumnID: col.ID, + }) + } + return nil +} + +// issueProjectIDs reads the set of project ids currently linked to issue. +// Mirrors models/issues/(*Issue).projectIDs but lives at the service layer +// so we can keep the model surface untouched. +func issueProjectIDs(ctx context.Context, issueID int64) ([]int64, error) { + var ids []int64 + err := db.GetEngine(ctx).Table("project_issue"). + Where("issue_id = ?", issueID). + Cols("project_id"). + Find(&ids) + return ids, err +} + +// diffInt64Slices returns the elements present in `b` but missing in `a` +// (added) and the elements present in `a` but missing in `b` (removed). +// Both inputs are treated as sets. +func diffInt64Slices(a, b []int64) (added, removed []int64) { + inA := make(map[int64]struct{}, len(a)) + for _, v := range a { + inA[v] = struct{}{} + } + inB := make(map[int64]struct{}, len(b)) + for _, v := range b { + inB[v] = struct{}{} + } + for _, v := range b { + if _, ok := inA[v]; !ok { + added = append(added, v) + } + } + for _, v := range a { + if _, ok := inB[v]; !ok { + removed = append(removed, v) + } + } + return added, removed } func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) { diff --git a/services/projects/project.go b/services/projects/project.go index 8d4296cfdd..213b5a005e 100644 --- a/services/projects/project.go +++ b/services/projects/project.go @@ -19,9 +19,10 @@ type UpdateProjectOptions struct { IsClosed optional.Option[bool] } -// UpdateProject applies the provided options to the project atomically. +// UpdateProject applies the provided options to the project atomically +// and emits a ProjectUpdated SSE event when the txn commits. func UpdateProject(ctx context.Context, project *project_model.Project, opts UpdateProjectOptions) error { - return db.WithTx(ctx, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { if opts.Title.Has() { project.Title = opts.Title.Value() } @@ -40,5 +41,9 @@ func UpdateProject(ctx context.Context, project *project_model.Project, opts Upd } } return nil - }) + }); err != nil { + return err + } + publishProjectUpdated(ctx, project) + return nil }