Files
gitea/services/projects/issue.go
T
Oleks 3fd0aa751d feat(project): publish board events from service+model choke points
Wrap the model-layer column/project/issue mutation funcs in service-layer
helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns,
DeleteProject, AssignOrRemoveProjects) that publish the matching SSE
event after the underlying call succeeds.

Routers (web + REST) are migrated to call these service helpers so the
publish side-effects fire uniformly across repo, user, and org scopes.

DeleteColumn snapshots the column's issues before deletion and emits
one CardMoved per affected issue (alongside the ColumnDeleted event)
so the receiving tab can patch the DOM without a full reload.

Move-issue publishing fires after the txn commits so we never emit
events for moves that get rolled back.
2026-05-15 21:53:35 +03:00

353 lines
10 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"slices"
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
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
// issues that aren't yet attached to the column's project.
var ErrIssueNotInProject = errors.New("all issues have to be added to a project first")
// 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 {
// 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)
}
count, err := db.GetEngine(ctx).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).
Count(new(project_model.ProjectIssue))
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return ErrIssueNotInProject
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
return err
}
if _, err := issues.LoadRepositories(ctx); err != nil {
return err
}
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
if err != nil {
return err
}
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
for _, issue := range issues {
issuesMap[issue.ID] = issue
}
for sorting, issueID := range sortedIssueIDs {
curIssue := issuesMap[issueID]
if curIssue == nil {
continue
}
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
if err != nil {
return err
}
projectColumnID := projectColumnMap[column.ProjectID]
if projectColumnID != column.ID {
// add timeline to issue
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeProjectColumn,
Doer: doer,
Repo: curIssue.Repo,
Issue: curIssue,
ProjectID: column.ProjectID,
ProjectTitle: project.Title,
ProjectColumnID: column.ID,
ProjectColumnTitle: column.Title,
}); err != nil {
return err
}
}
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
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) {
var issueList issues_model.IssueList
for _, colIssues := range issuesMap {
issueList = append(issueList, colIssues...)
}
err := issueList.LoadAssignees(ctx)
if err != nil {
return nil, err
}
users := make([]*user_model.User, 0, len(issueList))
usersAdded := container.Set[int64]{}
for _, issue := range issueList {
for _, assignee := range issue.Assignees {
if !usersAdded.Contains(assignee.ID) {
usersAdded.Add(assignee.ID)
users = append(users, assignee)
}
}
}
slices.SortFunc(users, func(a, b *user_model.User) int {
return strings.Compare(a.Name, b.Name)
})
return users, nil
}
// LoadIssuesFromProject load issues assigned to each project column inside the given project
func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) {
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
o.ProjectIDs = []int64{project.ID}
o.SortType = issues_model.SortTypeProjectColumnSorting
}))
if err != nil {
return nil, err
}
if len(issueList) == 0 {
// if no issue, return directly, then no need to create a default column for an empty project
return results, nil
}
if err := issueList.LoadComments(ctx); err != nil {
return nil, err
}
defaultColumn, err := project.MustDefaultColumn(ctx)
if err != nil {
return nil, err
}
issueColumnMap, err := issues_model.LoadProjectIssueColumnMap(ctx, project.ID, defaultColumn.ID)
if err != nil {
return nil, err
}
results = make(map[int64]issues_model.IssueList)
for _, issue := range issueList {
projectColumnID, ok := issueColumnMap[issue.ID]
if !ok {
continue
}
if _, ok := results[projectColumnID]; !ok {
results[projectColumnID] = make(issues_model.IssueList, 0)
}
results[projectColumnID] = append(results[projectColumnID], issue)
}
return results, nil
}
// NumClosedIssues return counter of closed issues assigned to a project
func loadNumClosedIssues(ctx context.Context, p *project_model.Project) error {
cnt, err := db.GetEngine(ctx).Table("project_issue").
Join("INNER", "issue", "project_issue.issue_id=issue.id").
Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
Cols("issue_id").
Count()
if err != nil {
return err
}
p.NumClosedIssues = cnt
return nil
}
// NumOpenIssues return counter of open issues assigned to a project
func loadNumOpenIssues(ctx context.Context, p *project_model.Project) error {
cnt, err := db.GetEngine(ctx).Table("project_issue").
Join("INNER", "issue", "project_issue.issue_id=issue.id").
Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).
Cols("issue_id").
Count()
if err != nil {
return err
}
p.NumOpenIssues = cnt
return nil
}
func LoadIssueNumbersForProjects(ctx context.Context, projects []*project_model.Project, doer *user_model.User) error {
for _, project := range projects {
if err := LoadIssueNumbersForProject(ctx, project, doer); err != nil {
return err
}
}
return nil
}
func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Project, doer *user_model.User) error {
// for repository project, just get the numbers
if project.OwnerID == 0 {
if err := loadNumClosedIssues(ctx, project); err != nil {
return err
}
if err := loadNumOpenIssues(ctx, project); err != nil {
return err
}
project.NumIssues = project.NumClosedIssues + project.NumOpenIssues
return nil
}
if err := project.LoadOwner(ctx); err != nil {
return err
}
// for user or org projects, we need to check access permissions
opts := issues_model.IssuesOptions{
ProjectIDs: []int64{project.ID},
Doer: doer,
AllPublic: doer == nil,
Owner: project.Owner,
}
var err error
project.NumOpenIssues, err = issues_model.CountIssues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
o.IsClosed = optional.Some(false)
}))
if err != nil {
return err
}
project.NumClosedIssues, err = issues_model.CountIssues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
o.IsClosed = optional.Some(true)
}))
if err != nil {
return err
}
project.NumIssues = project.NumClosedIssues + project.NumOpenIssues
return nil
}