10 Commits

Author SHA1 Message Date
oleks c45ea82fd1 Merge pull request 'fix(project): push SSE update when an issue on a board is closed/reopened' (#20) from fix/issue-19-sse-state into main 2026-05-17 20:59:50 +03:00
oleks c19ecab35d fix(project): push SSE update when an issue on a board is closed/reopened
Closing or reopening an issue did not notify project boards that carry
it as a card, so board tabs showed stale state until a manual reload.
CloseIssue/ReopenIssue only published milestone events and the issue
timeline notification — nothing project-scoped.

Add a CardStateChanged project event, published per linked project from
CloseIssue/ReopenIssue (best-effort; never fails the state change). The
board frontend flips the issue-state octicon in place and refetches the
affected column so state-filtered boards and counts stay correct. The
dispatch check precedes the CardUnlinked branch so a close/reopen is
not mistaken for a card removal.

Also switch a pre-existing String#match to RegExp#exec in the same file
to keep it lint-clean.

Closes #19
2026-05-17 20:58:17 +03:00
oleks ad46f6cde8 Merge pull request 'fix(projects): scope project-issue move to its own project' (#18) from fix/user-project-move-multiproject-detach into main 2026-05-17 17:06:19 +03:00
Claude 078459c497 fix(projects): scope project-issue move to its own project
MoveIssuesOnProjectColumn updated `project_issue` with a WHERE clause on
issue_id only. An issue assigned to several projects has one project_issue
row per project, so moving it within one project rewrote project_board_id
for every project the issue belonged to, detaching it from all the others.

Scope the UPDATE to (issue_id, project_id) so only the target project's
row changes. Mirrors the fix already present in upstream/main.

Adds an integration regression test asserting an issue in two user
projects keeps its column in the other project after a move. Fixes #17.
2026-05-17 16:59:31 +03:00
oleks d4de99f96b feat(sse): toast notifications for project-board and milestone events (#15) 2026-05-16 14:32:25 +03:00
oleks 9f588d3dd3 feat(milestone): SSE live-updating progress bars (#14) 2026-05-16 10:21:38 +03:00
oleks 4676a3af93 fix(project_events): use process-lifetime ctx for async SSE publish (#8) 2026-05-16 00:10:51 +03:00
oleks 9c1699feb5 feat(project): SSE push updates for project board pages (#7) 2026-05-15 22:15:26 +03:00
oleks 15acfdb783 feat(api): state filter + populated num_issues on project columns (#6) 2026-05-15 22:00:51 +03:00
Oleks 1cd81ff925 feat(api): state filter + populated num_issues on project columns
Adds two improvements to the user/org/repo project-board REST API:

* state filter on column-issues endpoints (issue #4)
  GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues
  GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues
  Now accept ?state=open|closed|all (default open), matching the convention
  on the project-list endpoint and on /repos/.../issues. Applied at the
  IssuesOptions layer so all three scopes inherit the filter.

* populated num_issues / num_open_issues / num_closed_issues on column-list
  (issue #5)
  ColumnList.LoadIssueCounts runs two grouped queries against project_issue
  joined with issue (one open, one closed). All three List*Columns handlers
  call it before converting, so num_issues stops being null and consumers
  can render a kanban summary in a single round trip instead of N+1.

Tests:
* unit: empty-input fast path on ColumnList.LoadIssueCounts.
* integration: extended testAPIListProjectColumnIssues / -User / -Org to
  close an issue, then verify default=open hides it, state=closed and
  state=all return it, and the column-list response carries the correct
  open/closed/total split.

Closes #4, closes #5
2026-05-15 21:54:35 +03:00
41 changed files with 2571 additions and 66 deletions
+15
View File
@@ -136,6 +136,21 @@ func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, er
return m, nil
}
// GetMilestoneByID returns the milestone identified by id, regardless of
// which repository it belongs to. Used by the milestone_events SSE
// publisher, which only has the milestone id and re-reads the fresh
// counters from a detached, process-lifetime context.
func GetMilestoneByID(ctx context.Context, id int64) (*Milestone, error) {
m := new(Milestone)
has, err := db.GetEngine(ctx).ID(id).Get(m)
if err != nil {
return nil, err
} else if !has {
return nil, ErrMilestoneNotExist{ID: id}
}
return m, nil
}
// GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo
func GetMilestoneByRepoIDANDName(ctx context.Context, repoID int64, name string) (*Milestone, error) {
var mile Milestone
+3 -1
View File
@@ -48,7 +48,9 @@ type Column struct {
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
NumIssues int64 `xorm:"-"`
NumIssues int64 `xorm:"-"`
NumOpenIssues int64 `xorm:"-"`
NumClosedIssues int64 `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+55
View File
@@ -5,8 +5,11 @@ package project
import (
"context"
"strconv"
"code.gitea.io/gitea/models/db"
"xorm.io/builder"
)
// CountProjectColumns returns the total number of columns for a project
@@ -27,6 +30,58 @@ 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,6 +17,7 @@ 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) {
@@ -51,6 +52,14 @@ 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)
+13
View File
@@ -68,6 +68,19 @@ func (m *Manager) UnregisterAll() {
m.messengers = map[int64]*Messenger{}
}
// ConnectedUIDs returns a snapshot of all currently registered user IDs.
// Useful for fan-out broadcasters that need to filter recipients before
// calling SendMessage.
func (m *Manager) ConnectedUIDs() []int64 {
m.mutex.Lock()
defer m.mutex.Unlock()
uids := make([]int64, 0, len(m.messengers))
for uid := range m.messengers {
uids = append(uids, uid)
}
return uids
}
// SendMessage sends a message to a particular user
func (m *Manager) SendMessage(uid int64, message *Event) {
m.mutex.Lock()
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package sessiontag carries a per-page-load identifier from the
// originating HTTP request down to the service- and model-layer SSE
// publishers. The publishers echo the tag back inside event payloads so
// the originating browser tab can suppress its own event after it has
// already applied the optimistic update locally.
//
// It is deliberately tiny and dependency-free so any feature that emits
// Server-Sent Events (project boards, milestones, ...) can share one
// context key without importing one another.
package sessiontag
import "context"
// sessionTagCtxKey is the context key under which the X-Session-Tag
// value from the originating HTTP request is stashed.
type sessionTagCtxKey struct{}
// WithSessionTag returns ctx decorated with the provided session tag.
// Web/API middleware reads the X-Session-Tag header and calls this so
// service- and model-layer publishers can pull the tag back out.
func WithSessionTag(ctx context.Context, tag string) context.Context {
if tag == "" {
return ctx
}
return context.WithValue(ctx, sessionTagCtxKey{}, tag)
}
// SessionTagFromContext returns the session tag previously stored via
// WithSessionTag, or "" when none was set.
func SessionTagFromContext(ctx context.Context) string {
if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok {
return v
}
return ""
}
+6 -4
View File
@@ -68,10 +68,12 @@ 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"`
NumIssues int64 `json:"num_issues,omitempty"`
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"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
+1
View File
@@ -875,6 +875,7 @@ func Routes() *web.Router {
}))
}
m.AfterRouting(common.SessionTagMiddleware())
m.AfterRouting(context.APIContexter())
m.AfterRouting(checkDeprecatedAuthMethods)
+16 -6
View File
@@ -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
}
@@ -403,6 +403,11 @@ 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))
@@ -463,7 +468,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 +545,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 +592,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
}
@@ -621,6 +626,10 @@ 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)
@@ -649,6 +658,7 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
@@ -801,7 +811,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 +821,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
}
+2 -1
View File
@@ -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 {
+3
View File
@@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
milestone_events "code.gitea.io/gitea/services/milestone_events"
)
// ListMilestones list milestones for a repository
@@ -230,6 +231,7 @@ func EditMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneProgress(ctx, milestone.ID)
ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
}
@@ -269,6 +271,7 @@ func DeleteMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneDeleted(ctx, ctx.Repo.Repository.ID, m.ID)
ctx.Status(http.StatusNoContent)
}
+16 -6
View File
@@ -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
}
@@ -435,6 +435,11 @@ 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))
@@ -500,7 +505,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 +587,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 +639,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
}
@@ -673,6 +678,10 @@ 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)
@@ -698,6 +707,7 @@ 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,
}
@@ -866,7 +876,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 +888,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
}
+16 -6
View File
@@ -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
}
@@ -425,6 +425,11 @@ 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))
@@ -489,7 +494,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 +575,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 +626,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
}
@@ -655,6 +660,10 @@ 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)
@@ -681,6 +690,7 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
@@ -837,7 +847,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 +857,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
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net/http"
"code.gitea.io/gitea/modules/sessiontag"
)
// SessionTagHeader is the HTTP header browser tabs use to broadcast a
// per-page-load identifier with every mutation request. The server
// echoes it back inside SSE event payloads so the originating tab
// can suppress its own event after applying the optimistic update.
const SessionTagHeader = "X-Session-Tag"
// SessionTagMiddleware decorates each incoming request's context with
// the X-Session-Tag header value when present. Service- and model-
// layer publishers read the value via sessiontag.SessionTagFromContext.
//
// Empty / missing headers are a no-op.
func SessionTagMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tag := r.Header.Get(SessionTagHeader)
if tag != "" {
ctx := sessiontag.WithSessionTag(r.Context(), tag)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
+5 -5
View File
@@ -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
}
+8 -1
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/issue"
milestone_events "code.gitea.io/gitea/services/milestone_events"
"xorm.io/builder"
)
@@ -195,6 +196,8 @@ func EditMilestonePost(ctx *context.Context) {
return
}
milestone_events.PublishMilestoneProgress(ctx, m.ID)
ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name))
ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
}
@@ -221,14 +224,18 @@ func ChangeMilestoneStatus(ctx *context.Context) {
}
return
}
milestone_events.PublishMilestoneProgress(ctx, id)
ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.PathParam("action")))
}
// DeleteMilestone delete a milestone
func DeleteMilestone(ctx *context.Context) {
if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
repoID := ctx.Repo.Repository.ID
milestoneID := ctx.FormInt64("id")
if err := issues_model.DeleteMilestoneByRepoID(ctx, repoID, milestoneID); err != nil {
ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error())
} else {
milestone_events.PublishMilestoneDeleted(ctx, repoID, milestoneID)
ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success"))
}
+6 -6
View File
@@ -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
}
+2 -1
View File
@@ -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
}
+1 -1
View File
@@ -295,7 +295,7 @@ func Routes() *web.Router {
routes.Get("/ssh_info", misc.SSHInfo)
routes.Get("/api/healthz", healthcheck.Check)
mid = append(mid, common.MustInitSessioner(), context.Contexter())
mid = append(mid, common.SessionTagMiddleware(), common.MustInitSessioner(), context.Contexter())
// Get user from session if logged in.
webAuth := newWebAuthMiddleware()
+11 -9
View File
@@ -147,15 +147,17 @@ 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,
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,
NumOpenIssues: column.NumOpenIssues,
NumClosedIssues: column.NumClosedIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
}
if creator, ok := creators[column.CreatorID]; ok {
apiColumn.Creator = ToUser(ctx, creator, doer)
+9
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -57,6 +58,10 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.NewIssue(ctx, issue, mentions)
if len(issue.Labels) > 0 {
notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil)
@@ -160,6 +165,10 @@ func DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model
}
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.DeleteIssue(ctx, doer, issue)
return nil
+10
View File
@@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -75,6 +76,15 @@ func ChangeMilestoneAssign(ctx context.Context, issue *issues_model.Issue, doer
return err
}
// Both the previous and the new milestone may have had their issue
// counters move; publish progress for each affected milestone.
if oldMilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, oldMilestoneID)
}
if issue.MilestoneID > 0 && issue.MilestoneID != oldMilestoneID {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID)
return nil
}
+30
View File
@@ -10,9 +10,29 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
project_events "code.gitea.io/gitea/services/project_events"
)
// publishProjectCardStateChanged notifies every project board the issue is a
// card on that its open/closed state changed, so subscribed board tabs can
// re-render the card live instead of showing stale state until reload.
// Best-effort: a failure here must not fail the close/reopen operation.
func publishProjectCardStateChanged(ctx context.Context, issue *issues_model.Issue, isClosed bool) {
if err := issue.LoadProjects(ctx); err != nil {
log.Error("LoadProjects for issue[%d]: %v", issue.ID, err)
return
}
for _, p := range issue.Projects {
project_events.PublishCardStateChanged(ctx, project_events.CardStateChanged{
ProjectID: p.ID,
IssueID: issue.ID,
IsClosed: isClosed,
})
}
}
// CloseIssue close an issue.
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
var comment *issues_model.Comment
@@ -34,7 +54,12 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
publishProjectCardStateChanged(ctx, issue, true)
return nil
}
@@ -47,7 +72,12 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
publishProjectCardStateChanged(ctx, issue, false)
return nil
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package milestone_events publishes milestone progress changes as
// Server-Sent Events so other browser tabs viewing the same repository's
// milestone list (or a single milestone's issue list) can update their
// progress bars in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it
// in an *eventsource.Event whose Name is "repo-milestones.{repo_id}", and
// fans the event out to every currently connected user that has read
// access to the repository's issues unit. All publish helpers are
// non-blocking: they spawn a goroutine so request handlers do not stall
// on slow consumers.
package milestone_events
import (
"context"
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/sessiontag"
)
// Event payload structs ------------------------------------------------------
// MilestoneProgress is emitted whenever a milestone's issue counters
// (and therefore its completeness percentage) change. It funnels every
// mutation that can move the bar: issue close/reopen, milestone
// (re)assignment, issue creation/deletion, milestone status change and
// milestone edit.
type MilestoneProgress struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_id"`
OpenIssues int `json:"open_issues"`
ClosedIssues int `json:"closed_issues"`
Completeness int `json:"completeness"`
SessionTag string `json:"session_tag,omitempty"`
}
// MilestoneDeleted is emitted when a milestone is deleted so viewers can
// drop the card (or navigate away from a single-milestone view).
type MilestoneDeleted struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_id"`
}
// Broadcast plumbing ---------------------------------------------------------
// broadcastFn is the package-level seam used to send an event to a set of
// uids. Tests swap it out to capture calls without touching the real
// eventsource manager.
var broadcastFn = defaultBroadcast
func defaultBroadcast(uids []int64, event *eventsource.Event) {
mgr := eventsource.GetManager()
for _, uid := range uids {
mgr.SendMessage(uid, event)
}
}
// connectedUIDsLister returns the uid set the broadcast helpers should
// consider as candidate recipients. Tests override it to feed a
// deterministic list.
var connectedUIDsLister = func() []int64 {
return eventsource.GetManager().ConnectedUIDs()
}
// milestoneLookup re-reads a milestone by id from the detached context.
// Stubbable in tests so PublishMilestoneProgress can be exercised
// without a database.
var milestoneLookup = issues_model.GetMilestoneByID
// repoLookup loads a repository by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var repoLookup = repo_model.GetRepositoryByID
// repoAccessChecker decides whether the user identified by uid is allowed
// to read the given repository's issues. Tests stub this to bypass the
// real permission system.
var repoAccessChecker = canReadMilestones
// connectedUIDsWithRepoIssueAccess returns the subset of currently
// connected uids that the access checker confirms can read the issues
// unit of repoID.
func connectedUIDsWithRepoIssueAccess(ctx context.Context, repoID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
repo, err := repoLookup(ctx, repoID)
if err != nil {
log.Debug("milestone_events: GetRepositoryByID(%d) failed: %v", repoID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := repoAccessChecker(ctx, uid, repo)
if err != nil {
log.Debug("milestone_events: access check uid=%d repo=%d: %v", uid, repoID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadMilestones implements the real read-permission check used in
// production: a user may see milestone progress for a repo when they can
// read its issues unit.
func canReadMilestones(ctx context.Context, uid int64, repo *repo_model.Repository) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
// AccessModeRead == 1; the literal mirrors project_events, where the
// perm_model typed constant would force another import alias and the
// meaning is well established here.
return access_model.HasAccessUnit(ctx, user, repo, unit.TypeIssues, 1)
}
// publishEvent is the shared pipeline used by every Publish* helper.
// It marshals the payload, builds the SSE Event, looks up authorized
// recipients, and fans the event out via broadcastFn.
func publishEvent(ctx context.Context, repoID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("milestone_events: marshal payload for repo %d: %v", repoID, err)
return
}
event := &eventsource.Event{
Name: eventName(repoID),
Data: data,
}
uids := connectedUIDsWithRepoIssueAccess(ctx, repoID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given repo id.
func eventName(repoID int64) string {
return "repo-milestones." + strconv.FormatInt(repoID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishMilestoneProgress re-reads the milestone's fresh counters and
// fans a MilestoneProgress event out to everyone who can read the repo's
// issues. The session tag is resolved synchronously from the request
// context before the goroutine starts; the goroutine itself runs on a
// detached, process-lifetime context so the request-scoped DB session
// being returned to the pool cannot make the re-fetch/access checks fail.
func PublishMilestoneProgress(ctx context.Context, milestoneID int64) {
if milestoneID <= 0 {
return
}
tag := sessiontag.SessionTagFromContext(ctx)
go func() {
detachCtx := detach(ctx)
m, err := milestoneLookup(detachCtx, milestoneID)
if err != nil {
log.Debug("milestone_events: GetMilestoneByID(%d) failed: %v", milestoneID, err)
return
}
payload := MilestoneProgress{
RepoID: m.RepoID,
MilestoneID: m.ID,
OpenIssues: m.NumOpenIssues,
ClosedIssues: m.NumClosedIssues,
Completeness: m.Completeness,
SessionTag: tag,
}
publishEvent(detachCtx, m.RepoID, payload)
}()
}
// PublishMilestoneDeleted fans a MilestoneDeleted event out for the given
// repo/milestone. No re-fetch is needed since the milestone is gone.
func PublishMilestoneDeleted(ctx context.Context, repoID, milestoneID int64) {
if repoID <= 0 || milestoneID <= 0 {
return
}
go func() {
detachCtx := detach(ctx)
publishEvent(detachCtx, repoID, MilestoneDeleted{
RepoID: repoID,
MilestoneID: milestoneID,
})
}()
}
// detach returns a context safe for use in the fire-and-forget publish
// goroutine. The request's context carries a request-scoped DB session
// that is returned to the pool once the HTTP handler completes; reusing
// it from the goroutine races with that teardown and makes subsequent
// queries (GetMilestoneByID, GetRepositoryByID, access checks) fail
// intermittently. The session tag is already resolved synchronously
// before the goroutine starts, so the goroutine needs no request-scoped
// values — only a clean, process-lifetime DB context. ShutdownContext is
// backed by the global engine, outlives any single request, and is
// cancelled on app shutdown so we don't leak goroutines past teardown.
func detach(_ context.Context) context.Context {
return graceful.GetManager().ShutdownContext()
}
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestone_events
import (
"context"
"sync"
"testing"
"time"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/sessiontag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// capturedCall is one observed broadcast: the recipient uid set plus the
// constructed Event.
type capturedCall struct {
uids []int64
event *eventsource.Event
}
// installFakes swaps every package-level seam used by the publishers for
// test doubles: a fake uid lister, a stubbed milestone lookup returning a
// synthetic milestone (no DB hit), a stubbed repo lookup, an "everyone
// passes" access checker, and a broadcaster that pushes calls onto a
// buffered channel.
//
// The returned restore func reverts every seam; defer it in the test.
func installFakes(t *testing.T, uids []int64, milestone *issues_model.Milestone) (<-chan capturedCall, func()) {
t.Helper()
calls := make(chan capturedCall, 16)
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
origMsLookup := milestoneLookup
broadcastFn = func(uids []int64, event *eventsource.Event) {
calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
}
connectedUIDsLister = func() []int64 {
return append([]int64(nil), uids...)
}
milestoneLookup = func(_ context.Context, id int64) (*issues_model.Milestone, error) {
if milestone != nil {
return milestone, nil
}
return &issues_model.Milestone{ID: id, RepoID: 1}, nil
}
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, _ int64, _ *repo_model.Repository) (bool, error) {
return true, nil
}
return calls, func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
milestoneLookup = origMsLookup
}
}
// awaitCall blocks until one capturedCall arrives or the test deadline
// elapses. It fails the test on timeout.
func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall {
t.Helper()
select {
case c := <-ch:
return c
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for broadcast")
return capturedCall{}
}
}
func TestEventNameFormat(t *testing.T) {
assert.Equal(t, "repo-milestones.42", eventName(42))
assert.Equal(t, "repo-milestones.0", eventName(0))
}
func TestPublishMilestoneProgress_NameAndPayload(t *testing.T) {
ms := &issues_model.Milestone{
ID: 7,
RepoID: 10,
NumIssues: 8,
NumClosedIssues: 6,
NumOpenIssues: 2,
Completeness: 75,
}
ch, restore := installFakes(t, []int64{1}, ms)
defer restore()
PublishMilestoneProgress(context.Background(), 7)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.10", c.event.Name)
data, ok := c.event.Data.([]byte)
require.True(t, ok, "Event.Data should be []byte")
var got MilestoneProgress
require.NoError(t, json.Unmarshal(data, &got))
assert.Equal(t, MilestoneProgress{
RepoID: 10, MilestoneID: 7, OpenIssues: 2, ClosedIssues: 6, Completeness: 75,
}, got)
}
func TestPublishMilestoneProgress_IgnoresNonPositiveID(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneProgress(context.Background(), 0)
PublishMilestoneProgress(context.Background(), -3)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive milestone id")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneProgress_LookupErrorIsSilent(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
milestoneLookup = func(_ context.Context, _ int64) (*issues_model.Milestone, error) {
return nil, issues_model.ErrMilestoneNotExist{ID: 99}
}
PublishMilestoneProgress(context.Background(), 99)
select {
case <-ch:
t.Fatal("no broadcast expected when the milestone re-fetch fails")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneDeleted_NameAndPayload(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 12, 5)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.12", c.event.Name)
var got MilestoneDeleted
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &got))
assert.Equal(t, MilestoneDeleted{RepoID: 12, MilestoneID: 5}, got)
}
func TestPublishMilestoneDeleted_IgnoresNonPositiveIDs(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 0, 5)
PublishMilestoneDeleted(context.Background(), 12, 0)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive ids")
case <-time.After(200 * time.Millisecond):
}
}
// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by sessiontag.WithSessionTag, the emitted
// JSON payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "abc-123")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "abc-123", payload.SessionTag)
}
// TestSessionTagAbsentWhenUnset verifies the omitempty tag stays empty
// when no session tag is on the context.
func TestSessionTagAbsentWhenUnset(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Empty(t, payload.SessionTag)
}
// TestSessionTagResolvedSynchronously ensures the tag is read from the
// request context before the goroutine starts, not from the detached
// context (which never carries request-scoped values).
func TestSessionTagResolvedSynchronously(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "sync-tag")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "sync-tag", payload.SessionTag)
}
// TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission(t *testing.T) {
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
allowed := map[int64]bool{1: true, 3: true}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return allowed[uid], nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.ElementsMatch(t, []int64{1, 3}, got)
}
// TestConnectedUIDsWithRepoIssueAccess_NoConnections shortcuts when no
// users are connected; the repo lookup must not be called.
func TestConnectedUIDsWithRepoIssueAccess_NoConnections(t *testing.T) {
origLister := connectedUIDsLister
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return nil }
called := false
repoLookup = func(_ context.Context, _ int64) (*repo_model.Repository, error) {
called = true
return &repo_model.Repository{}, nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.Empty(t, got)
assert.False(t, called, "repo lookup should be skipped when no uids are connected")
}
// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent
// directly to verify the uid set computed by the access filter is what
// gets handed to broadcastFn.
func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) {
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
var mu sync.Mutex
var got []int64
broadcastFn = func(uids []int64, _ *eventsource.Event) {
mu.Lock()
got = append([]int64(nil), uids...)
mu.Unlock()
}
connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 20, nil
}
publishEvent(context.Background(), 1, MilestoneDeleted{RepoID: 1, MilestoneID: 5})
mu.Lock()
defer mu.Unlock()
assert.ElementsMatch(t, []int64{10, 30}, got)
}
// TestPublishMilestoneProgress_NoConnectionsNoBroadcast verifies the
// connected-uid shortcut: with nobody connected nothing is sent even
// though the milestone re-fetch succeeds.
func TestPublishMilestoneProgress_NoConnectionsNoBroadcast(t *testing.T) {
ch, restore := installFakes(t, nil, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
select {
case <-ch:
t.Fatal("no broadcast expected when no users are connected")
case <-time.After(200 * time.Millisecond):
}
}
// TestPublishMilestoneProgress_FanOutTargetList verifies the recipient
// list handed to broadcast is exactly the access-filtered set.
func TestPublishMilestoneProgress_FanOutTargetList(t *testing.T) {
ch, restore := installFakes(t, []int64{5, 6, 7}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 6, nil
}
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
assert.ElementsMatch(t, []int64{5, 7}, c.uids)
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package project_events publishes project board mutations as Server-Sent
// Events so other browser tabs viewing the same board can update their DOM
// in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it in
// an *eventsource.Event whose Name is "project-board.{project_id}", and
// fans the event out to every currently connected user that has read
// access to the project. All publish helpers are non-blocking: they spawn
// a goroutine so request handlers do not stall on slow consumers.
package project_events
import (
"context"
"strconv"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/sessiontag"
)
// WithSessionTag re-exports modules/sessiontag.WithSessionTag so existing
// callers of project_events keep working after the context-key helper was
// extracted into its own dependency-free package (shared with
// milestone_events and any future SSE feature).
func WithSessionTag(ctx context.Context, tag string) context.Context {
return sessiontag.WithSessionTag(ctx, tag)
}
// SessionTagFromContext re-exports modules/sessiontag.SessionTagFromContext.
func SessionTagFromContext(ctx context.Context) string {
return sessiontag.SessionTagFromContext(ctx)
}
// Event payload structs ------------------------------------------------------
// CardMoved is emitted when an issue is moved between columns or reordered
// within a column.
type CardMoved struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
FromColumnID int64 `json:"from_column_id"`
ToColumnID int64 `json:"to_column_id"`
Sorting int64 `json:"sorting"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardLinked is emitted when an issue is added to a project's default column.
type CardLinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
ColumnID int64 `json:"column_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardUnlinked is emitted when an issue is removed from a project.
type CardUnlinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardStateChanged is emitted when an issue's open/closed state changes
// while it is a card on the project. It lets boards re-render the card's
// state live (and is the hook state-filtered boards use to correct their
// column counts) without a full page reload.
type CardStateChanged struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
IsClosed bool `json:"is_closed"`
SessionTag string `json:"session_tag,omitempty"`
}
// ColumnCreated is emitted when a new column is added to a project.
type ColumnCreated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
IsDefault bool `json:"is_default"`
}
// ColumnUpdated is emitted when a column's title, color, or sorting changes.
type ColumnUpdated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
}
// ColumnDeleted is emitted when a column is removed from a project.
// Deletion implicitly relocates issues to the default column, so the
// publisher will also emit one CardMoved per affected issue; the frontend
// only needs to drop the column and react to the per-issue moves.
type ColumnDeleted struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
}
// ColumnSort is one entry in a ColumnReordered batch.
type ColumnSort struct {
ColumnID int64 `json:"column_id"`
Sorting int64 `json:"sorting"`
}
// ColumnReordered is emitted when columns within a project are dragged into
// a new order.
type ColumnReordered struct {
ProjectID int64 `json:"project_id"`
Columns []ColumnSort `json:"columns"`
}
// ProjectUpdated is emitted when project metadata (title, description,
// card type, open/closed state) changes.
type ProjectUpdated struct {
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
CardType string `json:"card_type"`
IsClosed bool `json:"is_closed"`
}
// ProjectDeleted is emitted when a project is deleted.
type ProjectDeleted struct {
ProjectID int64 `json:"project_id"`
}
// Broadcast plumbing ---------------------------------------------------------
// broadcastFn is the package-level seam used to send an event to a set of
// uids. Tests swap it out to capture calls without touching the real
// eventsource manager.
var broadcastFn = defaultBroadcast
func defaultBroadcast(uids []int64, event *eventsource.Event) {
mgr := eventsource.GetManager()
for _, uid := range uids {
mgr.SendMessage(uid, event)
}
}
// connectedUIDsLister returns the uid set the broadcast helpers should
// consider as candidate recipients. Tests override it to feed a
// deterministic list.
var connectedUIDsLister = func() []int64 {
return eventsource.GetManager().ConnectedUIDs()
}
// projectLookup loads a project by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var projectLookup = project_model.GetProjectByID
// projectAccessChecker decides whether the user identified by uid is
// allowed to read the given project. Tests stub this to bypass the real
// permission system.
var projectAccessChecker = canReadProject
// connectedUIDsWithProjectAccess returns the subset of currently connected
// uids that the access checker confirms can read projectID.
func connectedUIDsWithProjectAccess(ctx context.Context, projectID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
project, err := projectLookup(ctx, projectID)
if err != nil {
log.Debug("project_events: GetProjectByID(%d) failed: %v", projectID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := projectAccessChecker(ctx, uid, project)
if err != nil {
log.Debug("project_events: access check uid=%d project=%d: %v", uid, projectID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadProject implements the real read-permission check used in
// production: repo projects defer to the repo's TypeProjects unit access;
// user / org projects fall back to user visibility.
func canReadProject(ctx context.Context, uid int64, project *project_model.Project) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
if project.RepoID > 0 {
var repo *repo_model.Repository
if project.Repo != nil {
repo = project.Repo
} else {
repo, err = repo_model.GetRepositoryByID(ctx, project.RepoID)
if err != nil {
return false, err
}
}
// AccessModeRead == 1; we use the literal because the
// perm_model package's typed constant would force another
// import alias and the meaning is well established here.
ok, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeProjects, 1)
if err != nil {
return false, err
}
return ok, nil
}
if project.OwnerID > 0 {
owner := project.Owner
if owner == nil {
owner, err = user_model.GetUserByID(ctx, project.OwnerID)
if err != nil {
return false, err
}
}
return user_model.IsUserVisibleToViewer(ctx, owner, user), nil
}
return false, nil
}
// publishEvent is the shared pipeline used by every Publish* helper.
// It marshals the payload, builds the SSE Event, looks up authorized
// recipients, and fans the event out via broadcastFn. The whole thing
// runs inside the calling goroutine; callers should wrap it in `go` so
// request handlers stay responsive.
func publishEvent(ctx context.Context, projectID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("project_events: marshal payload for project %d: %v", projectID, err)
return
}
event := &eventsource.Event{
Name: eventName(projectID),
Data: data,
}
uids := connectedUIDsWithProjectAccess(ctx, projectID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given project id.
func eventName(projectID int64) string {
return "project-board." + strconv.FormatInt(projectID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishCardMoved fans out a CardMoved event for the given payload.
func PublishCardMoved(ctx context.Context, payload CardMoved) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardLinked fans out a CardLinked event for the given payload.
func PublishCardLinked(ctx context.Context, payload CardLinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardUnlinked fans out a CardUnlinked event for the given payload.
func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardStateChanged fans out a CardStateChanged event for the given payload.
func PublishCardStateChanged(ctx context.Context, payload CardStateChanged) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnUpdated fans out a ColumnUpdated event for the given payload.
func PublishColumnUpdated(ctx context.Context, payload ColumnUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnDeleted fans out a ColumnDeleted event for the given payload.
func PublishColumnDeleted(ctx context.Context, payload ColumnDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnReordered fans out a ColumnReordered event for the given payload.
func PublishColumnReordered(ctx context.Context, payload ColumnReordered) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectUpdated fans out a ProjectUpdated event for the given payload.
func PublishProjectUpdated(ctx context.Context, payload ProjectUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectDeleted fans out a ProjectDeleted event for the given payload.
func PublishProjectDeleted(ctx context.Context, payload ProjectDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// detach returns a context safe for use in the fire-and-forget publish
// goroutine. The request's context carries a request-scoped DB session
// that is returned to the pool once the HTTP handler completes; reusing
// it from the goroutine races with that teardown and makes subsequent
// queries (GetProjectByID, access checks) fail intermittently. The
// session tag is already resolved synchronously before the goroutine
// starts, so the goroutine needs no request-scoped values — only a
// clean, process-lifetime DB context. ShutdownContext is backed by the
// global engine, outlives any single request, and is cancelled on app
// shutdown so we don't leak goroutines past teardown.
func detach(_ context.Context) context.Context {
return graceful.GetManager().ShutdownContext()
}
+330
View File
@@ -0,0 +1,330 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project_events
import (
"context"
"sync"
"testing"
"time"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// capturedCall is one observed broadcast: the recipient uid set plus the
// constructed Event.
type capturedCall struct {
uids []int64
event *eventsource.Event
}
// installFakes swaps every package-level seam used by publishEvent for
// test doubles: a fake uid lister, a stubbed project lookup that
// returns a synthetic project (no DB hit), an "everyone passes" access
// checker, and a broadcaster that pushes calls onto a buffered channel.
//
// The returned restore func reverts every seam; defer it in the test.
func installFakes(t *testing.T, uids []int64) (<-chan capturedCall, func()) {
t.Helper()
calls := make(chan capturedCall, 16)
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
broadcastFn = func(uids []int64, event *eventsource.Event) {
calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
}
connectedUIDsLister = func() []int64 {
return append([]int64(nil), uids...)
}
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
projectAccessChecker = func(_ context.Context, _ int64, _ *project_model.Project) (bool, error) {
return true, nil
}
return calls, func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}
}
// awaitCall blocks until one capturedCall arrives or the test deadline
// elapses. It fails the test on timeout.
func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall {
t.Helper()
select {
case c := <-ch:
return c
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for broadcast")
return capturedCall{}
}
}
func TestEventNameFormat(t *testing.T) {
assert.Equal(t, "project-board.42", eventName(42))
assert.Equal(t, "project-board.0", eventName(0))
}
func TestPublishHelpers_NameAndPayload(t *testing.T) {
cases := []struct {
name string
invoke func(ctx context.Context)
wantName string
wantData any
}{
{
name: "card.moved",
wantName: "project-board.10",
invoke: func(ctx context.Context) {
PublishCardMoved(ctx, CardMoved{
ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3,
})
},
wantData: CardMoved{ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3},
},
{
name: "card.linked",
wantName: "project-board.11",
invoke: func(ctx context.Context) {
PublishCardLinked(ctx, CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9})
},
wantData: CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9},
},
{
name: "card.unlinked",
wantName: "project-board.12",
invoke: func(ctx context.Context) {
PublishCardUnlinked(ctx, CardUnlinked{ProjectID: 12, IssueID: 8})
},
wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
},
{
name: "card.state_changed",
wantName: "project-board.12",
invoke: func(ctx context.Context) {
PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true})
},
wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true},
},
{
name: "column.created",
wantName: "project-board.13",
invoke: func(ctx context.Context) {
PublishColumnCreated(ctx, ColumnCreated{
ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
})
},
wantData: ColumnCreated{
ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
},
},
{
name: "column.updated",
wantName: "project-board.14",
invoke: func(ctx context.Context) {
PublishColumnUpdated(ctx, ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"})
},
wantData: ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"},
},
{
name: "column.deleted",
wantName: "project-board.15",
invoke: func(ctx context.Context) {
PublishColumnDeleted(ctx, ColumnDeleted{ProjectID: 15, ColumnID: 5})
},
wantData: ColumnDeleted{ProjectID: 15, ColumnID: 5},
},
{
name: "column.reordered",
wantName: "project-board.16",
invoke: func(ctx context.Context) {
PublishColumnReordered(ctx, ColumnReordered{
ProjectID: 16,
Columns: []ColumnSort{
{ColumnID: 1, Sorting: 0},
{ColumnID: 2, Sorting: 1},
},
})
},
wantData: ColumnReordered{
ProjectID: 16,
Columns: []ColumnSort{
{ColumnID: 1, Sorting: 0},
{ColumnID: 2, Sorting: 1},
},
},
},
{
name: "project.updated",
wantName: "project-board.17",
invoke: func(ctx context.Context) {
PublishProjectUpdated(ctx, ProjectUpdated{
ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
})
},
wantData: ProjectUpdated{
ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
},
},
{
name: "project.deleted",
wantName: "project-board.18",
invoke: func(ctx context.Context) {
PublishProjectDeleted(ctx, ProjectDeleted{ProjectID: 18})
},
wantData: ProjectDeleted{ProjectID: 18},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
tc.invoke(context.Background())
c := awaitCall(t, ch)
assert.Equal(t, tc.wantName, c.event.Name)
gotJSON, ok := c.event.Data.([]byte)
require.True(t, ok, "Event.Data should be []byte")
wantJSON, err := json.Marshal(tc.wantData)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON))
})
}
}
// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by WithSessionTag, the emitted JSON
// payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
ctx := WithSessionTag(context.Background(), "abc-123")
PublishCardMoved(ctx, CardMoved{
ProjectID: 99, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
})
c := awaitCall(t, ch)
var payload CardMoved
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "abc-123", payload.SessionTag)
}
// TestSessionTagExplicitOverridesContext verifies that an explicit
// SessionTag set on the payload struct is preserved.
func TestSessionTagExplicitOverridesContext(t *testing.T) {
ch, restore := installFakes(t, []int64{1})
defer restore()
ctx := WithSessionTag(context.Background(), "from-ctx")
PublishCardMoved(ctx, CardMoved{
ProjectID: 1, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
SessionTag: "explicit",
})
c := awaitCall(t, ch)
var payload CardMoved
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "explicit", payload.SessionTag)
}
// TestConnectedUIDsWithProjectAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithProjectAccess_FiltersByPermission(t *testing.T) {
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
defer func() {
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}()
connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
allowed := map[int64]bool{1: true, 3: true}
projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
return allowed[uid], nil
}
got := connectedUIDsWithProjectAccess(context.Background(), 42)
assert.ElementsMatch(t, []int64{1, 3}, got)
}
// TestConnectedUIDsWithProjectAccess_NoConnections shortcuts when no
// users are connected; the project lookup must not be called.
func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) {
origLister := connectedUIDsLister
origLookup := projectLookup
defer func() {
connectedUIDsLister = origLister
projectLookup = origLookup
}()
connectedUIDsLister = func() []int64 { return nil }
called := false
projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) {
called = true
return &project_model.Project{}, nil
}
got := connectedUIDsWithProjectAccess(context.Background(), 42)
assert.Empty(t, got)
assert.False(t, called, "project lookup should be skipped when no uids are connected")
}
// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent
// directly to verify the uid set computed by the access filter is
// what gets handed to broadcastFn.
func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) {
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := projectAccessChecker
origLookup := projectLookup
defer func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
projectAccessChecker = origChecker
projectLookup = origLookup
}()
var mu sync.Mutex
var got []int64
broadcastFn = func(uids []int64, _ *eventsource.Event) {
mu.Lock()
got = append([]int64(nil), uids...)
mu.Unlock()
}
connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} }
projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
return &project_model.Project{ID: id}, nil
}
projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
return uid != 20, nil
}
publishEvent(context.Background(), 1, ColumnDeleted{ProjectID: 1, ColumnID: 5})
mu.Lock()
defer mu.Unlock()
assert.ElementsMatch(t, []int64{10, 30}, got)
}
+151
View File
@@ -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,
})
}
+120 -2
View File
@@ -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)
@@ -85,13 +93,123 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
}
}
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
// Scope the update to this issue *in this project*. Without the
// project_id predicate, an issue that belongs to several projects
// would have every project_issue row rewritten to the target
// column, detaching it from all other projects.
_, err = db.GetEngine(ctx).Table("project_issue").
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
Update(map[string]any{
"project_board_id": column.ID,
"sorting": sorting,
})
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) {
+8 -3
View File
@@ -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
}
+16 -1
View File
@@ -1,4 +1,13 @@
{{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}}
{{/* $projectScope is read by the SSE handler to build the right column-issues
refetch URL (repo / org / user). RepoID > 0 wins over Owner so a repo
project nested under an owner is still treated as repo-scoped. */}}
{{$projectScope := "user"}}
{{if .Repository}}{{$projectScope = "repo"}}{{else if and .ContextUser .ContextUser.IsOrganization}}{{$projectScope = "org"}}{{end}}
{{$projectOwnerName := ""}}
{{if and .Repository .Repository.Owner}}{{$projectOwnerName = .Repository.Owner.Name}}{{else if .ContextUser}}{{$projectOwnerName = .ContextUser.Name}}{{end}}
{{$projectRepoName := ""}}
{{if .Repository}}{{$projectRepoName = .Repository.Name}}{{end}}
<div class="ui container fluid padded projects-view" data-global-init="initRepoProjectsView">
<div class="ui container flex-text-block project-header">
@@ -77,7 +86,13 @@
<div class="divider"></div>
</div>
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}"
data-project-board-writable="{{$canWriteProject}}"
data-project-id="{{.Project.ID}}"
data-project-scope="{{$projectScope}}"
data-project-owner="{{$projectOwnerName}}"
data-project-repo="{{$projectRepoName}}"
{{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
{{range .Columns}}
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
+2 -2
View File
@@ -27,7 +27,7 @@
</div>
{{end}}
<div class="tw-flex tw-flex-col tw-gap-2">
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress>
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100" data-milestone-id="{{.Milestone.ID}}" data-repo-id="{{.Repository.ID}}"></progress>
<div class="flex-text-block tw-gap-4">
<div class="flex-text-inline">
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
@@ -46,7 +46,7 @@
{{end}}
{{end}}
</div>
<div>{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
<div class="milestone-completeness-pct">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
{{if .TotalTrackedTime}}
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
+5 -5
View File
@@ -15,16 +15,16 @@
{{template "repo/issue/filters" .}}
<!-- milestone list -->
<div class="milestone-list">
<div class="milestone-list" data-repo-id="{{$.Repository.ID}}">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{$.Repository.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
{{svg "octicon-milestone" 16}}
<a class="muted" href="{{$.RepoLink}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -32,11 +32,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+4 -4
View File
@@ -73,7 +73,7 @@
</div>
<div class="milestone-list">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{.Repo.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
<span class="ui large label">
@@ -83,7 +83,7 @@
<a class="muted" href="{{.Repo.Link}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -91,11 +91,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+46
View File
@@ -15,6 +15,7 @@ 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"
@@ -432,6 +433,51 @@ 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,6 +15,7 @@ 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"
@@ -655,6 +656,57 @@ 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,6 +14,7 @@ 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"
@@ -36,6 +37,7 @@ func TestAPIUserProjects(t *testing.T) {
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
t.Run("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation)
t.Run("Permissions", testAPIUserProjectPermissions)
}
@@ -413,6 +415,51 @@ 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) {
@@ -456,6 +503,44 @@ func testAPIUserMoveProjectIssue(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound)
}
// Regression for #17: moving an issue's column in one user project must not
// rewrite its column in other projects the issue also belongs to.
func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
p1 := makeUserProject(t, owner)
p2 := makeUserProject(t, owner)
p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA))
p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB))
p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID}
assert.NoError(t, project_model.NewColumn(t.Context(), p2Col))
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID}))
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID}))
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
// move the issue inside p1 only
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID),
&api.MoveProjectIssueOption{ColumnID: p1ColB.ID},
).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
// p1 updated as requested
pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID})
assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID)
// p2 must be untouched
pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID})
assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects")
}
func testAPIUserProjectPermissions(t *testing.T) {
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
+7
View File
@@ -1,9 +1,16 @@
import {createApp} from 'vue';
import DashboardRepoList from '../components/DashboardRepoList.vue';
import {initRepoMilestoneListSSE} from './repo-milestone-sse.ts';
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(DashboardRepoList).mount(el);
}
// The dashboard milestones page lists milestones across many repos;
// subscribe to live progress for each. subscribeRepos is guarded so
// this is a no-op if repo-legacy already wired it on the same page.
if (document.querySelector('.page-content.dashboard.milestones li.milestone-card[data-repo-id]')) {
initRepoMilestoneListSSE();
}
}
+7
View File
@@ -14,6 +14,7 @@ import {initRepoSettings} from './repo-settings.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoMilestoneListSSE, initRepoMilestoneSingleSSE} from './repo-milestone-sse.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
@@ -50,6 +51,12 @@ export function initRepository() {
// Labels
initCompLabelEdit('.page-content.repository.labels');
initRepoMilestone();
if (pageContent.matches('.page-content.repository.milestones')) {
initRepoMilestoneListSSE();
}
if (pageContent.matches('.page-content.repository.milestone-issue-list')) {
initRepoMilestoneSingleSSE();
}
initRepoNew();
initRepoCloneButtons();
+193
View File
@@ -0,0 +1,193 @@
import {UserEventsSharedWorker} from '../modules/worker.ts';
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
// milestoneTitle does a best-effort lookup of the milestone's display
// name for toast text. List cards expose it as a heading/link inside the
// card; the single-milestone view puts it in the page header. Falls back
// to a generic label so a toast still fires if the markup shifts.
function milestoneTitle(milestoneID: number): string {
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${milestoneID}"]`);
const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim();
if (fromCard) return fromCard;
const onSingle = document.querySelector<HTMLElement>(`progress[data-milestone-id="${milestoneID}"]`);
if (onSingle) {
const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim();
if (h) return h;
}
return 'Milestone';
}
// sessionTag is generated once per page load. The mutation requests on
// milestone pages (close/open/delete/edit) flow through the existing
// fetch helpers which attach the X-Session-Tag header; the backend
// echoes it back inside SSE payloads so the originating tab can suppress
// its own echo. We only need the read side here: skip any event whose
// session_tag matches ours.
let sessionTag = '';
function ensureSessionTag(): string {
if (sessionTag) return sessionTag;
if (globalThis.crypto?.randomUUID) {
sessionTag = globalThis.crypto.randomUUID();
} else {
sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
return sessionTag;
}
type MilestoneProgressPayload = {
repo_id: number;
milestone_id: number;
open_issues: number;
closed_issues: number;
completeness: number;
session_tag?: string;
};
type MilestoneDeletedPayload = {
repo_id: number;
milestone_id: number;
};
function isProgressPayload(p: any): p is MilestoneProgressPayload {
return p && typeof p.completeness === 'number' && 'open_issues' in p;
}
// patchMilestoneCard updates every progress-bar / counter site for a
// single milestone id, covering both the list-card layout (milestones
// list, dashboard) and the single-milestone big progress bar.
function patchMilestoneCard(payload: MilestoneProgressPayload): void {
const selector = `[data-milestone-id="${payload.milestone_id}"]`;
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
// The element itself may be a <progress> (single view) or a card
// <li> containing a <progress> (list views).
const progressEls = el instanceof HTMLProgressElement
? [el]
: Array.from(el.querySelectorAll<HTMLProgressElement>('progress'));
for (const pe of progressEls) {
pe.value = payload.completeness;
}
const scope: ParentNode = el instanceof HTMLProgressElement ? document : el;
for (const pct of scope.querySelectorAll<HTMLElement>('.milestone-completeness-pct')) {
// The list cards render just the number; the single-milestone
// view renders an i18n HTML fragment ("<strong>N%</strong>
// Completed"). Detect which by whether the node already holds a
// <strong> child.
const strong = pct.querySelector('strong');
if (strong) {
strong.textContent = `${payload.completeness}%`;
} else {
pct.textContent = String(payload.completeness);
}
}
for (const oc of scope.querySelectorAll<HTMLElement>('.milestone-open-count')) {
oc.textContent = String(payload.open_issues);
}
for (const cc of scope.querySelectorAll<HTMLElement>('.milestone-closed-count')) {
cc.textContent = String(payload.closed_issues);
}
}
}
function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`);
if (card) {
card.remove();
return;
}
// Single-milestone view: the milestone we are looking at is gone.
const single = document.querySelector<HTMLElement>(`progress[data-milestone-id="${payload.milestone_id}"]`);
if (single) {
const parts = window.location.pathname.split('/');
// .../milestone/{id} -> go up to the milestones listing.
const idx = parts.lastIndexOf('milestone');
const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/';
// Delay so the "milestone deleted" warning toast is visible before
// the page navigates out from under the viewer.
setTimeout(() => { window.location.href = dest }, 1500);
}
}
function dispatchMilestoneEvent(payload: any): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
if (isProgressPayload(payload)) {
patchMilestoneCard(payload);
const total = payload.open_issues + payload.closed_issues;
showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`);
} else if ('milestone_id' in payload && 'repo_id' in payload) {
const title = milestoneTitle(payload.milestone_id);
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`);
}
}
// subscribed guards against a double subscription if more than one init
// entry point matches the same page (e.g. the dashboard milestones page
// is wired both from repo-legacy and dashboard).
let subscribed = false;
// subscribeRepos opens one SharedWorker subscription per distinct repo
// id and dispatches every "repo-milestones.{repoID}" event by payload.
function subscribeRepos(repoIDs: Set<string>): void {
if (subscribed) return;
if (!repoIDs.size) return;
if (!window.EventSource || !window.SharedWorker) return;
subscribed = true;
ensureSessionTag();
let worker: UserEventsSharedWorker;
try {
worker = new UserEventsSharedWorker('repo-milestone-worker');
} catch (error) {
console.error('milestone SSE: failed to start worker', error);
return;
}
const eventNames = new Set<string>();
for (const repoID of repoIDs) {
eventNames.add(`repo-milestones.${repoID}`);
}
worker.addMessageEventListener((event: MessageEvent) => {
if (!event.data || !eventNames.has(event.data.type)) return;
let payload: any;
try {
payload = JSON.parse(event.data.data);
} catch (error) {
console.error('milestone SSE: malformed payload', error, event.data);
return;
}
dispatchMilestoneEvent(payload);
});
worker.startPort();
for (const name of eventNames) {
worker.sharedWorker.port.postMessage({type: 'listen', eventType: name});
}
}
// initRepoMilestoneListSSE wires the milestone list page and the
// dashboard milestones page: collect every distinct data-repo-id present
// on the cards (the dashboard mixes many repos) and subscribe to each.
export function initRepoMilestoneListSSE(): void {
const cards = document.querySelectorAll<HTMLElement>('li.milestone-card[data-repo-id]');
if (!cards.length) return;
const repoIDs = new Set<string>();
for (const card of cards) {
const id = card.getAttribute('data-repo-id');
if (id) repoIDs.add(id);
}
subscribeRepos(repoIDs);
}
// initRepoMilestoneSingleSSE wires the single-milestone issue list view.
export function initRepoMilestoneSingleSSE(): void {
const progress = document.querySelector<HTMLElement>('progress[data-milestone-id][data-repo-id]');
if (!progress) return;
const repoID = progress.getAttribute('data-repo-id');
if (!repoID) return;
subscribeRepos(new Set([repoID]));
}
+344 -2
View File
@@ -1,12 +1,51 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {GET, POST, request} from '../modules/fetch.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
// issueRef returns a short human label for a card, preferring the
// rendered "#index" anchor text and falling back to the internal id.
function issueRef(card: HTMLElement | null, issueID: number): string {
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
const m = /#\d+/.exec(idx ?? '');
return m ? m[0] : `#${issueID}`;
}
function columnName(board: HTMLElement, columnID: number): string {
const t = board.querySelector<HTMLElement>(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim();
return t || `column ${columnID}`;
}
const SESSION_TAG_HEADER = 'X-Session-Tag';
// sessionTag is generated once per page load. It is attached as the
// X-Session-Tag header on every mutation request and compared against
// incoming SSE payloads so the originating tab can suppress its own
// echo (the source-of-truth DOM update already happened locally).
let sessionTag = '';
function ensureSessionTag(): string {
if (sessionTag) return sessionTag;
if (globalThis.crypto?.randomUUID) {
sessionTag = globalThis.crypto.randomUUID();
} else {
sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
return sessionTag;
}
function withSessionTag(headers: HeadersInit | undefined): Headers {
const h = new Headers(headers ?? {});
h.set(SESSION_TAG_HEADER, ensureSessionTag());
return h;
}
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement!;
@@ -29,6 +68,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
try {
await POST(`${to.getAttribute('data-url')}/move`, {
data: columnSorting,
headers: withSessionTag(undefined),
});
} catch (error) {
console.error(error);
@@ -61,6 +101,7 @@ async function initRepoProjectSortable(): Promise<void> {
try {
await POST(mainBoard.getAttribute('data-url')!, {
data: columnSorting,
headers: withSessionTag(undefined),
});
} catch (error) {
console.error(error);
@@ -113,7 +154,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
try {
elForm.classList.add('is-loading');
await request(formLink, {method: formMethod, data: formData});
await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)});
if (!columnId) {
window.location.reload(); // newly added column, need to reload the page
return;
@@ -173,9 +214,310 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
}
}
// SSE handlers ---------------------------------------------------------------
type EventPayloadBase = {session_tag?: string};
type CardMovedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
from_column_id: number;
to_column_id: number;
sorting: number;
};
type CardLinkedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
column_id: number;
};
type CardUnlinkedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
};
type CardStateChangedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
is_closed: boolean;
};
type ColumnUpdatedPayload = {
project_id: number;
column_id: number;
title: string;
color: string;
sorting: number;
};
type ColumnDeletedPayload = {
project_id: number;
column_id: number;
};
type ColumnReorderedPayload = {
project_id: number;
columns: Array<{column_id: number; sorting: number}>;
};
type ProjectUpdatedPayload = {
project_id: number;
title: string;
description: string;
card_type: string;
is_closed: boolean;
};
// columnIssuesURL builds the appropriate "list issues for column" API
// path for the current page scope. Server-side these endpoints all
// return the same JSON shape; the frontend just needs the right base.
function columnIssuesURL(board: HTMLElement, columnID: number): string | null {
const projectID = board.getAttribute('data-project-id');
const scope = board.getAttribute('data-project-scope');
const owner = board.getAttribute('data-project-owner');
const repo = board.getAttribute('data-project-repo');
const {appSubUrl} = window.config;
if (!projectID || !owner) return null;
if (scope === 'repo' && repo) {
return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`;
}
if (scope === 'org') {
return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`;
}
return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`;
}
function updateColumnCount(columnEl: HTMLElement): void {
const cards = columnEl.querySelectorAll('.issue-card').length;
const badge = columnEl.querySelector('.project-column-issue-count');
if (badge) badge.textContent = String(cards);
}
function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
if (!card) {
// Card is not currently rendered (filtered out, or new since
// page load). A targeted column re-fetch is the safe fallback.
refetchColumn(board, payload.to_column_id);
showInfoToast(`#${payload.issue_id}${columnName(board, payload.to_column_id)}`);
return;
}
const ref = issueRef(card, payload.issue_id);
const target = board.querySelector<HTMLElement>(`#board_${payload.to_column_id}`);
if (!target) return;
const fromColumn = card.parentElement;
target.append(card);
if (fromColumn instanceof HTMLElement) {
const fromColumnEl = fromColumn.closest<HTMLElement>('.project-column');
if (fromColumnEl) updateColumnCount(fromColumnEl);
}
const toColumnEl = target.closest<HTMLElement>('.project-column');
if (toColumnEl) updateColumnCount(toColumnEl);
showInfoToast(`${ref}${columnName(board, payload.to_column_id)}`);
}
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
const url = columnIssuesURL(board, columnID);
if (!url) return;
try {
const resp = await GET(url);
if (!resp.ok) return;
// Response shape: list of API issues; we don't have a templated
// card render available client-side, so we just refresh the
// column count badge here. The DOM-level reorder/insert is
// delivered by the matching CardMoved/CardUnlinked events.
const issues = await resp.json();
const target = board.querySelector<HTMLElement>(`#board_${columnID}`);
if (!target) return;
const colEl = target.closest<HTMLElement>('.project-column');
if (colEl) {
const badge = colEl.querySelector('.project-column-issue-count');
if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0);
}
} catch (error) {
console.error(error);
}
}
function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
refetchColumn(board, payload.column_id); // no await
showInfoToast(`#${payload.issue_id} added to ${columnName(board, payload.column_id)}`);
}
function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
if (!card) return;
const ref = issueRef(card, payload.issue_id);
const colEl = card.closest<HTMLElement>('.project-column');
card.remove();
if (colEl) updateColumnCount(colEl);
showInfoToast(`${ref} removed from board`);
}
function handleCardStateChanged(board: HTMLElement, payload: CardStateChangedPayload): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
if (!card) return;
// Flip the issue state octicon in place (matches templates/shared/issueicon.tmpl).
// PR cards carry merged/draft variants we don't recompute here; the column
// refetch below keeps state-filtered boards and counts correct regardless.
const icon = card.querySelector<SVGElement>('.issue-card-icon svg');
if (icon && !icon.classList.contains('octicon-git-pull-request')) {
icon.classList.remove('octicon-issue-opened', 'octicon-issue-closed', 'tw-text-green', 'tw-text-red');
icon.classList.add(
payload.is_closed ? 'octicon-issue-closed' : 'octicon-issue-opened',
payload.is_closed ? 'tw-text-red' : 'tw-text-green',
);
}
const ref = issueRef(card, payload.issue_id);
// The card's containing column is `#board_{columnID}` (its direct parent).
const parent = card.parentElement;
if (parent instanceof HTMLElement && parent.id.startsWith('board_')) {
refetchColumn(board, Number(parent.id.slice('board_'.length)));
}
showInfoToast(`${ref} ${payload.is_closed ? 'closed' : 'reopened'}`);
}
function handleColumnCreated(): void {
// Rare event; reload is cheap and avoids client-side template duplication.
window.location.reload();
}
function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void {
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
if (!colEl) return;
const titleEl = colEl.querySelector<HTMLElement>('.project-column-title-text');
const oldTitle = titleEl?.textContent?.trim();
if (titleEl) titleEl.textContent = payload.title;
if (oldTitle && oldTitle !== payload.title) {
showInfoToast(`Column renamed to “${payload.title}`);
}
if (payload.color) {
const textColor = contrastColor(payload.color);
colEl.style.setProperty('background', payload.color, 'important');
colEl.style.setProperty('color', textColor, 'important');
queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
} else {
colEl.style.removeProperty('background');
colEl.style.removeProperty('color');
queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
}
function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
if (!colEl) return;
const name = colEl.querySelector<HTMLElement>('.project-column-title-text')?.textContent?.trim();
colEl.remove();
showInfoToast(name ? `Column “${name}” removed` : 'A column was removed');
}
function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void {
// Sort the columns array by the new sorting value, then re-attach
// each column element in that order. appendChild on an existing
// node moves it rather than cloning, so the result is an in-place
// reorder.
const order = Array.from(payload.columns).sort((a, b) => a.sorting - b.sorting);
for (const entry of order) {
const el = board.querySelector<HTMLElement>(`.project-column[data-id="${entry.column_id}"]`);
if (el) board.append(el);
}
}
function handleProjectUpdated(payload: ProjectUpdatedPayload): void {
const header = document.querySelector<HTMLElement>('.project-header h2');
if (header) header.textContent = payload.title;
const desc = document.querySelector<HTMLElement>('.project-description .render-content');
if (desc) desc.textContent = payload.description;
}
function handleProjectDeleted(): void {
// Best-effort: navigate up one path segment from the current URL.
// The board lives at .../projects/{id}; the listing page is the
// parent. Falling back to the homepage on any URL we don't
// recognise is acceptable since this is a destructive event.
// Show a sticky warning first and delay the redirect briefly so the
// user understands why the page is about to change under them.
showWarningToast('This project was deleted — returning to the project list');
const parts = window.location.pathname.split('/');
const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/';
setTimeout(() => { window.location.href = dest }, 1500);
}
// dispatchProjectEvent picks the right handler for an SSE payload.
// The backend uses one event name per project but disambiguates event
// types by payload shape; we sniff discriminating fields here. Order
// matters: the more specific shapes are checked first.
function dispatchProjectEvent(board: HTMLElement, payload: any): void {
if ('issue_id' in payload && 'is_closed' in payload) {
// CardStateChanged: must precede the CardUnlinked branch below, whose
// "issue_id and no column_id" shape would otherwise swallow it and
// wrongly remove the card on a close/reopen.
handleCardStateChanged(board, payload as CardStateChangedPayload);
} else if ('from_column_id' in payload && 'to_column_id' in payload) {
handleCardMoved(board, payload as CardMovedPayload);
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
handleCardLinked(board, payload as CardLinkedPayload);
} else if ('issue_id' in payload && !('column_id' in payload)) {
handleCardUnlinked(board, payload as CardUnlinkedPayload);
} else if ('columns' in payload) {
handleColumnReordered(board, payload as ColumnReorderedPayload);
} else if ('column_id' in payload && 'title' in payload) {
handleColumnUpdated(board, payload as ColumnUpdatedPayload);
if ('is_default' in payload) handleColumnCreated();
} else if ('column_id' in payload) {
handleColumnDeleted(board, payload as ColumnDeletedPayload);
} else if ('title' in payload && 'card_type' in payload) {
handleProjectUpdated(payload as ProjectUpdatedPayload);
} else if ('project_id' in payload && Object.keys(payload).length <= 2) {
handleProjectDeleted();
}
}
function initRepoProjectSSE(elProjectsView: HTMLElement): void {
const board = elProjectsView.querySelector<HTMLElement>('#project-board');
if (!board) return;
const projectID = board.getAttribute('data-project-id');
if (!projectID) return;
if (!window.EventSource || !window.SharedWorker) return;
ensureSessionTag();
const eventName = `project-board.${projectID}`;
let worker: UserEventsSharedWorker;
try {
worker = new UserEventsSharedWorker('project-board-worker');
} catch (error) {
console.error('project board SSE: failed to start worker', error);
return;
}
worker.addMessageEventListener((event: MessageEvent) => {
if (!event.data || event.data.type !== eventName) return;
let payload: any;
try {
payload = JSON.parse(event.data.data);
} catch (error) {
console.error('project board SSE: malformed payload', error, event.data);
return;
}
dispatchProjectEvent(board, payload);
});
worker.startPort();
// Subscribe to the per-project event name on top of the worker's
// default listener set so the SharedWorker forwards us the events.
worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName});
}
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectToggleFullScreen(elProjectsView);
initRepoProjectSSE(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;