8 Commits

Author SHA1 Message Date
Oleks d421025147 fix(project_events): use process-lifetime ctx for async publish
The publish goroutine inherited the request context via
context.WithoutCancel. That context carries a request-scoped DB
session returned to the pool when the HTTP handler completes, so
GetProjectByID + access checks in the goroutine raced with session
teardown and intermittently returned empty recipient sets (uids=[]),
silently dropping every SSE board event. Root the detached context in
graceful.ShutdownContext() (global engine, process lifetime).

(cherry picked from commit bfc10289e6)
2026-05-16 00:10:10 +03:00
Oleks a02c4fb2ad chore(project): satisfy eslint unicorn rules in board SSE handlers 2026-05-15 22:10:49 +03:00
Oleks 6d09f611ea chore(project): satisfy gci formatting and nilnil lint 2026-05-15 22:08:25 +03:00
Oleks 47f3e4137e feat(project): session-tag propagation for own-tab event suppression
Adds a router middleware that extracts the X-Session-Tag header from
each request and decorates the request context via
project_events.WithSessionTag. Service- and model-layer publishers
then read it back via project_events.SessionTagFromContext and
attach it to outgoing CardMoved / CardLinked / CardUnlinked events.

The originating browser tab compares the incoming session_tag to
its own and skips the echo, avoiding double-application of the
optimistic local update. Other tabs see no tag match and apply the
event normally.

Wired into both the web router chain (before Contexter so the base
context inherits the tag) and the API router chain (before
APIContexter for the same reason).
2026-05-15 22:02:28 +03:00
Oleks 3c094d66fa feat(project): SSE subscriber + DOM patches on board page
The project board view now opens a SharedWorker EventSource
subscription scoped to project-board.{id} and patches the DOM in
response to incoming events:

- card.moved: relocates the card to the destination column and
  refreshes both column count badges; falls back to a column refetch
  when the card isn't currently rendered (filtered out / new).
- card.linked: refetches the destination column's issue list and
  updates the count badge.
- card.unlinked: removes the card and updates the badge.
- column.created: page reload (rare event, simplest path).
- column.updated: in-place title + color/contrast updates.
- column.deleted: removes the column element.
- column.reordered: re-attaches columns in the new sort order.
- project.updated: updates the header title + description text.
- project.deleted: navigates up to the projects listing.

The board template now exposes data-project-id, data-project-scope
(repo/user/org), data-project-owner, and data-project-repo so the
subscriber can build the right column-issues refetch URL.

Each mutation request the page makes also carries a
crypto.randomUUID-generated X-Session-Tag header; the receiving
handler compares it against the incoming payload's session_tag to
suppress own-tab echoes (the optimistic local update is already
authoritative).
2026-05-15 22:02:19 +03:00
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
Oleks 3c831efc0c feat(project): add SSE event bus and publish helpers
New services/project_events package marshals typed payloads to JSON,
wraps them in SSE events named project-board.{project_id}, and fans
them out via the eventsource manager to every connected user that
has read access to the project. Each Publish* helper runs the
broadcast in a goroutine so request handlers stay responsive.

Includes WithSessionTag / SessionTagFromContext for propagating an
X-Session-Tag value down to the publisher (so the originating browser
tab can suppress its own echo).

Unit tests cover event-name format, payload JSON shape, session-tag
propagation, the connected-uids access filter, and the broadcast
fan-out path.
2026-05-15 21:45:28 +03:00
Oleks a8d8d138cb feat(eventsource): add ConnectedUIDs accessor
Expose a snapshot of currently registered uids so fan-out broadcasters
can pre-filter recipients before calling SendMessage.
2026-05-15 21:45:19 +03:00
11 changed files with 14 additions and 258 deletions
+1 -3
View File
@@ -48,9 +48,7 @@ type Column struct {
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
NumIssues int64 `xorm:"-"`
NumOpenIssues int64 `xorm:"-"`
NumClosedIssues int64 `xorm:"-"`
NumIssues int64 `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
-55
View File
@@ -5,11 +5,8 @@ package project
import (
"context"
"strconv"
"code.gitea.io/gitea/models/db"
"xorm.io/builder"
)
// CountProjectColumns returns the total number of columns for a project
@@ -30,58 +27,6 @@ func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions
return columns, nil
}
// LoadIssueCounts populates NumIssues, NumOpenIssues, and NumClosedIssues on
// every column in the list using two grouped queries against project_issue
// joined with issue. Columns with no attached issues stay at zero counts —
// nothing else has to be wired up by the caller.
func (cl ColumnList) LoadIssueCounts(ctx context.Context) error {
if len(cl) == 0 {
return nil
}
columnIDs := make([]int64, 0, len(cl))
for _, c := range cl {
columnIDs = append(columnIDs, c.ID)
}
openCounts, err := countColumnIssuesByState(ctx, columnIDs, false)
if err != nil {
return err
}
closedCounts, err := countColumnIssuesByState(ctx, columnIDs, true)
if err != nil {
return err
}
for _, c := range cl {
c.NumOpenIssues = openCounts[c.ID]
c.NumClosedIssues = closedCounts[c.ID]
c.NumIssues = c.NumOpenIssues + c.NumClosedIssues
}
return nil
}
func countColumnIssuesByState(ctx context.Context, columnIDs []int64, isClosed bool) (map[int64]int64, error) {
out := make(map[int64]int64, len(columnIDs))
cond := builder.In("project_issue.project_board_id", columnIDs).
And(builder.Eq{"issue.is_closed": isClosed})
sub := builder.Select("project_issue.project_board_id AS project_board_id", "COUNT(*) AS cnt").
From("project_issue").
InnerJoin("issue", "issue.id = project_issue.issue_id").
Where(cond).
GroupBy("project_issue.project_board_id")
rows, err := db.GetEngine(ctx).Query(sub)
if err != nil {
return nil, err
}
for _, r := range rows {
columnID, _ := strconv.ParseInt(string(r["project_board_id"]), 10, 64)
cnt, _ := strconv.ParseInt(string(r["cnt"]), 10, 64)
out[columnID] = cnt
}
return out, nil
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, len(columnsIDs))
if len(columnsIDs) == 0 {
-9
View File
@@ -17,7 +17,6 @@ func TestProjectColumns(t *testing.T) {
t.Run("CountProjectColumns", testCountProjectColumns)
t.Run("GetProjectColumns", testGetProjectColumns)
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
t.Run("LoadIssueCountsEmpty", testLoadIssueCountsEmpty)
}
func testCountProjectColumns(t *testing.T) {
@@ -52,14 +51,6 @@ func testGetProjectColumns(t *testing.T) {
assert.Len(t, allIDs, 3)
}
func testLoadIssueCountsEmpty(t *testing.T) {
// Empty input is a fast path — must not touch the database and must not error.
// (The full open/closed-count behavior is exercised by the integration tests
// in tests/integration/api_*_project_test.go, which can join against the issue
// table; the unit-test fixture set here intentionally excludes it.)
assert.NoError(t, ColumnList{}.LoadIssueCounts(t.Context()))
}
func testGetColumnsByIDs(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
+4 -6
View File
@@ -68,12 +68,10 @@ type ProjectColumn struct {
Title string `json:"title"`
Default bool `json:"default"`
Sorting int `json:"sorting"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumOpenIssues int64 `json:"num_open_issues"`
NumClosedIssues int64 `json:"num_closed_issues"`
NumIssues int64 `json:"num_issues"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
-10
View File
@@ -403,11 +403,6 @@ func ListOrgProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -626,10 +621,6 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -658,7 +649,6 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
-10
View File
@@ -435,11 +435,6 @@ func ListProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -678,10 +673,6 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -707,7 +698,6 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
RepoIDs: []int64{ctx.Repo.Repository.ID},
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
-10
View File
@@ -425,11 +425,6 @@ func ListUserProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -660,10 +655,6 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -690,7 +681,6 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
+9 -11
View File
@@ -147,17 +147,15 @@ func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *us
func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn {
apiColumn := &api.ProjectColumn{
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
NumIssues: column.NumIssues,
NumOpenIssues: column.NumOpenIssues,
NumClosedIssues: column.NumClosedIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
NumIssues: column.NumIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
}
if creator, ok := creators[column.CreatorID]; ok {
apiColumn.Creator = ToUser(ctx, creator, doer)
-46
View File
@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -433,51 +432,6 @@ func testAPIOrgListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, member, ""))
// default (state omitted) -> open only -> colA returns nothing
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=closed", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=all", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint populates num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
func testAPIOrgMoveProjectIssue(t *testing.T) {
@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -656,57 +655,6 @@ func testAPIListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &issuesB)
assert.Len(t, issuesB, 1)
assert.Equal(t, issueB.ID, issuesB[0].ID)
// Close issueA, then exercise the new state= query parameter.
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// Default (state omitted) -> open only -> columnA returns nothing.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotOpenOnly []api.Issue
DecodeJSON(t, resp, &gotOpenOnly)
assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA")
// state=closed -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotClosed []api.Issue
DecodeJSON(t, resp, &gotClosed)
assert.Len(t, gotClosed, 1)
assert.Equal(t, issueA.ID, gotClosed[0].ID)
// state=all -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotAll []api.Issue
DecodeJSON(t, resp, &gotAll)
assert.Len(t, gotAll, 1)
// And the columns endpoint must populate num_issues / num_open_issues /
// num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[columnA.ID]) {
assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total")
assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open")
assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed")
}
if assert.NotNil(t, byID[columnB.ID]) {
assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total")
assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open")
assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed")
}
}
func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -414,51 +413,6 @@ func testAPIUserListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// default (state omitted) -> open only -> colA has nothing
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
func testAPIUserMoveProjectIssue(t *testing.T) {