8 Commits

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

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

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

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

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

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

Each mutation request the page makes also carries a
crypto.randomUUID-generated X-Session-Tag header; the receiving
handler compares it against the incoming payload's session_tag to
suppress own-tab echoes (the optimistic local update is already
authoritative).
2026-05-15 22:02:19 +03:00
Oleks 3fd0aa751d feat(project): publish board events from service+model choke points
Wrap the model-layer column/project/issue mutation funcs in service-layer
helpers (CreateColumn, EditColumn, DeleteColumn, ReorderColumns,
DeleteProject, AssignOrRemoveProjects) that publish the matching SSE
event after the underlying call succeeds.

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

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

Move-issue publishing fires after the txn commits so we never emit
events for moves that get rolled back.
2026-05-15 21:53:35 +03:00
Oleks 3c831efc0c feat(project): add SSE event bus and publish helpers
New services/project_events package marshals typed payloads to JSON,
wraps them in SSE events named project-board.{project_id}, and fans
them out via the eventsource manager to every connected user that
has read access to the project. Each Publish* helper runs the
broadcast in a goroutine so request handlers stay responsive.

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

Unit tests cover event-name format, payload JSON shape, session-tag
propagation, the connected-uids access filter, and the broadcast
fan-out path.
2026-05-15 21:45:28 +03:00
Oleks a8d8d138cb feat(eventsource): add ConnectedUIDs accessor
Expose a snapshot of currently registered uids so fan-out broadcasters
can pre-filter recipients before calling SendMessage.
2026-05-15 21:45:19 +03:00
28 changed files with 48 additions and 1110 deletions
-15
View File
@@ -136,21 +136,6 @@ 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
+1 -3
View File
@@ -48,9 +48,7 @@ type Column struct {
ProjectID int64 `xorm:"INDEX NOT NULL"`
CreatorID int64 `xorm:"NOT NULL"`
NumIssues int64 `xorm:"-"`
NumOpenIssues int64 `xorm:"-"`
NumClosedIssues int64 `xorm:"-"`
NumIssues int64 `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
-55
View File
@@ -5,11 +5,8 @@ package project
import (
"context"
"strconv"
"code.gitea.io/gitea/models/db"
"xorm.io/builder"
)
// CountProjectColumns returns the total number of columns for a project
@@ -30,58 +27,6 @@ func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions
return columns, nil
}
// LoadIssueCounts populates NumIssues, NumOpenIssues, and NumClosedIssues on
// every column in the list using two grouped queries against project_issue
// joined with issue. Columns with no attached issues stay at zero counts —
// nothing else has to be wired up by the caller.
func (cl ColumnList) LoadIssueCounts(ctx context.Context) error {
if len(cl) == 0 {
return nil
}
columnIDs := make([]int64, 0, len(cl))
for _, c := range cl {
columnIDs = append(columnIDs, c.ID)
}
openCounts, err := countColumnIssuesByState(ctx, columnIDs, false)
if err != nil {
return err
}
closedCounts, err := countColumnIssuesByState(ctx, columnIDs, true)
if err != nil {
return err
}
for _, c := range cl {
c.NumOpenIssues = openCounts[c.ID]
c.NumClosedIssues = closedCounts[c.ID]
c.NumIssues = c.NumOpenIssues + c.NumClosedIssues
}
return nil
}
func countColumnIssuesByState(ctx context.Context, columnIDs []int64, isClosed bool) (map[int64]int64, error) {
out := make(map[int64]int64, len(columnIDs))
cond := builder.In("project_issue.project_board_id", columnIDs).
And(builder.Eq{"issue.is_closed": isClosed})
sub := builder.Select("project_issue.project_board_id AS project_board_id", "COUNT(*) AS cnt").
From("project_issue").
InnerJoin("issue", "issue.id = project_issue.issue_id").
Where(cond).
GroupBy("project_issue.project_board_id")
rows, err := db.GetEngine(ctx).Query(sub)
if err != nil {
return nil, err
}
for _, r := range rows {
columnID, _ := strconv.ParseInt(string(r["project_board_id"]), 10, 64)
cnt, _ := strconv.ParseInt(string(r["cnt"]), 10, 64)
out[columnID] = cnt
}
return out, nil
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, len(columnsIDs))
if len(columnsIDs) == 0 {
-9
View File
@@ -17,7 +17,6 @@ func TestProjectColumns(t *testing.T) {
t.Run("CountProjectColumns", testCountProjectColumns)
t.Run("GetProjectColumns", testGetProjectColumns)
t.Run("GetColumnsByIDs", testGetColumnsByIDs)
t.Run("LoadIssueCountsEmpty", testLoadIssueCountsEmpty)
}
func testCountProjectColumns(t *testing.T) {
@@ -52,14 +51,6 @@ func testGetProjectColumns(t *testing.T) {
assert.Len(t, allIDs, 3)
}
func testLoadIssueCountsEmpty(t *testing.T) {
// Empty input is a fast path — must not touch the database and must not error.
// (The full open/closed-count behavior is exercised by the integration tests
// in tests/integration/api_*_project_test.go, which can join against the issue
// table; the unit-test fixture set here intentionally excludes it.)
assert.NoError(t, ColumnList{}.LoadIssueCounts(t.Context()))
}
func testGetColumnsByIDs(t *testing.T) {
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
-38
View File
@@ -1,38 +0,0 @@
// 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 ""
}
+4 -6
View File
@@ -68,12 +68,10 @@ type ProjectColumn struct {
Title string `json:"title"`
Default bool `json:"default"`
Sorting int `json:"sorting"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumOpenIssues int64 `json:"num_open_issues"`
NumClosedIssues int64 `json:"num_closed_issues"`
NumIssues int64 `json:"num_issues"`
Color string `json:"color,omitempty"`
ProjectID int64 `json:"project_id"`
Creator *User `json:"creator,omitempty"`
NumIssues int64 `json:"num_issues,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
-10
View File
@@ -403,11 +403,6 @@ func ListOrgProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -626,10 +621,6 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -658,7 +649,6 @@ func ListOrgProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
-3
View File
@@ -17,7 +17,6 @@ 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
@@ -231,7 +230,6 @@ func EditMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneProgress(ctx, milestone.ID)
ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
}
@@ -271,7 +269,6 @@ func DeleteMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneDeleted(ctx, ctx.Repo.Repository.ID, m.ID)
ctx.Status(http.StatusNoContent)
}
-10
View File
@@ -435,11 +435,6 @@ func ListProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -678,10 +673,6 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -707,7 +698,6 @@ func ListProjectColumnIssues(ctx *context.APIContext) {
RepoIDs: []int64{ctx.Repo.Repository.ID},
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
-10
View File
@@ -425,11 +425,6 @@ func ListUserProjectColumns(ctx *context.APIContext) {
return
}
if err := columns.LoadIssueCounts(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.SetLinkHeader(total, listOptions.PageSize)
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, convert.ToProjectColumnList(ctx, columns, ctx.Doer))
@@ -660,10 +655,6 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
// type: integer
// format: int64
// required: true
// - name: state
// in: query
// description: filter issues by state. "open" (default), "closed", or "all".
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -690,7 +681,6 @@ func ListUserProjectColumnIssues(ctx *context.APIContext) {
Paginator: &listOptions,
ProjectIDs: []int64{column.ProjectID},
ProjectColumnID: column.ID,
IsClosed: common.ParseIssueFilterStateIsClosed(ctx.FormTrim("state")),
SortType: issues_model.SortTypeProjectColumnSorting,
}
+3 -3
View File
@@ -6,7 +6,7 @@ package common
import (
"net/http"
"code.gitea.io/gitea/modules/sessiontag"
"code.gitea.io/gitea/services/project_events"
)
// SessionTagHeader is the HTTP header browser tabs use to broadcast a
@@ -17,7 +17,7 @@ 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.
// layer publishers read the value via project_events.SessionTagFromContext.
//
// Empty / missing headers are a no-op.
func SessionTagMiddleware() func(http.Handler) http.Handler {
@@ -25,7 +25,7 @@ func SessionTagMiddleware() func(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)
ctx := project_events.WithSessionTag(r.Context(), tag)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
+1 -8
View File
@@ -19,7 +19,6 @@ 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"
)
@@ -196,8 +195,6 @@ 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")
}
@@ -224,18 +221,14 @@ 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) {
repoID := ctx.Repo.Repository.ID
milestoneID := ctx.FormInt64("id")
if err := issues_model.DeleteMilestoneByRepoID(ctx, repoID, milestoneID); err != nil {
if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error())
} else {
milestone_events.PublishMilestoneDeleted(ctx, repoID, milestoneID)
ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success"))
}
+9 -11
View File
@@ -147,17 +147,15 @@ func ToProjectColumn(ctx context.Context, column *project_model.Column, doer *us
func toProjectColumn(ctx context.Context, column *project_model.Column, doer *user_model.User, creators map[int64]*user_model.User) *api.ProjectColumn {
apiColumn := &api.ProjectColumn{
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
NumIssues: column.NumIssues,
NumOpenIssues: column.NumOpenIssues,
NumClosedIssues: column.NumClosedIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
ID: column.ID,
Title: column.Title,
Default: column.Default,
Sorting: int(column.Sorting),
Color: column.Color,
ProjectID: column.ProjectID,
NumIssues: column.NumIssues,
CreatedAt: column.CreatedUnix.AsTime(),
UpdatedAt: column.UpdatedUnix.AsTime(),
}
if creator, ok := creators[column.CreatorID]; ok {
apiColumn.Creator = ToUser(ctx, creator, doer)
-9
View File
@@ -19,7 +19,6 @@ 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"
)
@@ -58,10 +57,6 @@ 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)
@@ -165,10 +160,6 @@ 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,7 +11,6 @@ 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"
)
@@ -76,15 +75,6 @@ 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
}
-9
View File
@@ -10,7 +10,6 @@ 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"
)
@@ -35,10 +34,6 @@ 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)
return nil
@@ -52,10 +47,6 @@ 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)
return nil
-216
View File
@@ -1,216 +0,0 @@
// 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
@@ -1,335 +0,0 @@
// 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)
}
+19 -8
View File
@@ -25,20 +25,31 @@ import (
"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).
// sessionTagCtxKey is the context key under which the X-Session-Tag value
// from the originating HTTP request is stashed. Publishers read it via
// SessionTagFromContext to attach to outgoing events so the originating
// browser tab can suppress its own echo.
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 {
return sessiontag.WithSessionTag(ctx, tag)
if tag == "" {
return ctx
}
return context.WithValue(ctx, sessionTagCtxKey{}, tag)
}
// SessionTagFromContext re-exports modules/sessiontag.SessionTagFromContext.
// SessionTagFromContext returns the session tag previously stored via
// WithSessionTag, or "" when none was set.
func SessionTagFromContext(ctx context.Context) string {
return sessiontag.SessionTagFromContext(ctx)
if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok {
return v
}
return ""
}
// Event payload structs ------------------------------------------------------
+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" data-milestone-id="{{.Milestone.ID}}" data-repo-id="{{.Repository.ID}}"></progress>
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></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 class="milestone-completeness-pct">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
<div>{{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" data-repo-id="{{$.Repository.ID}}">
<div class="milestone-list">
{{range .Milestones}}
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{$.Repository.ID}}">
<li class="milestone-card">
<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"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<span class="tw-mr-2">{{.Completeness}}%</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}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&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" data-milestone-id="{{.ID}}" data-repo-id="{{.Repo.ID}}">
<li class="milestone-card">
<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"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<span class="tw-mr-2">{{.Completeness}}%</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}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
-46
View File
@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -433,51 +432,6 @@ func testAPIOrgListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, member, ""))
// default (state omitted) -> open only -> colA returns nothing
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=closed", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns/%d/issues?state=all", p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint populates num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/orgs/org3/projects/%d/columns", p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
func testAPIOrgMoveProjectIssue(t *testing.T) {
@@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -656,57 +655,6 @@ func testAPIListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &issuesB)
assert.Len(t, issuesB, 1)
assert.Equal(t, issueB.ID, issuesB[0].ID)
// Close issueA, then exercise the new state= query parameter.
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// Default (state omitted) -> open only -> columnA returns nothing.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotOpenOnly []api.Issue
DecodeJSON(t, resp, &gotOpenOnly)
assert.Empty(t, gotOpenOnly, "default state=open must hide the closed issueA")
// state=closed -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotClosed []api.Issue
DecodeJSON(t, resp, &gotClosed)
assert.Len(t, gotClosed, 1)
assert.Equal(t, issueA.ID, gotClosed[0].ID)
// state=all -> returns issueA.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns/%d/issues?state=all", owner.Name, repo.Name, project.ID, columnA.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var gotAll []api.Issue
DecodeJSON(t, resp, &gotAll)
assert.Len(t, gotAll, 1)
// And the columns endpoint must populate num_issues / num_open_issues /
// num_closed_issues — issue #5. columnA has 1 closed; columnB has 1 open.
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/projects/%d/columns", owner.Name, repo.Name, project.ID).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[columnA.ID]) {
assert.EqualValues(t, 1, byID[columnA.ID].NumIssues, "columnA total")
assert.EqualValues(t, 0, byID[columnA.ID].NumOpenIssues, "columnA open")
assert.EqualValues(t, 1, byID[columnA.ID].NumClosedIssues, "columnA closed")
}
if assert.NotNil(t, byID[columnB.ID]) {
assert.EqualValues(t, 1, byID[columnB.ID].NumIssues, "columnB total")
assert.EqualValues(t, 1, byID[columnB.ID].NumOpenIssues, "columnB open")
assert.EqualValues(t, 0, byID[columnB.ID].NumClosedIssues, "columnB closed")
}
}
func testAPIRemoveIssueFromProjectColumn(t *testing.T) {
@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
issue_service "code.gitea.io/gitea/services/issue"
projects_service "code.gitea.io/gitea/services/projects"
"code.gitea.io/gitea/tests"
@@ -414,51 +413,6 @@ func testAPIUserListProjectColumnIssues(t *testing.T) {
DecodeJSON(t, resp, &gotB)
assert.Len(t, gotB, 1)
assert.Equal(t, issueB.ID, gotB[0].ID)
// Close issueA, then exercise the state filter (issue #4).
assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, ""))
// default (state omitted) -> open only -> colA has nothing
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var openOnly []api.Issue
DecodeJSON(t, resp, &openOnly)
assert.Empty(t, openOnly)
// state=closed -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var closedOnly []api.Issue
DecodeJSON(t, resp, &closedOnly)
assert.Len(t, closedOnly, 1)
assert.Equal(t, issueA.ID, closedOnly[0].ID)
// state=all -> colA returns issueA
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var all []api.Issue
DecodeJSON(t, resp, &all)
assert.Len(t, all, 1)
// Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5).
req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var listed []*api.ProjectColumn
DecodeJSON(t, resp, &listed)
byID := map[int64]*api.ProjectColumn{}
for _, c := range listed {
byID[c.ID] = c
}
if assert.NotNil(t, byID[colA.ID]) {
assert.EqualValues(t, 1, byID[colA.ID].NumIssues)
assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues)
assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues)
}
if assert.NotNil(t, byID[colB.ID]) {
assert.EqualValues(t, 1, byID[colB.ID].NumIssues)
assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues)
assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues)
}
}
func testAPIUserMoveProjectIssue(t *testing.T) {
-7
View File
@@ -1,16 +1,9 @@
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,7 +14,6 @@ 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';
@@ -51,12 +50,6 @@ 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();
-173
View File
@@ -1,173 +0,0 @@
import {UserEventsSharedWorker} from '../modules/worker.ts';
// 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');
if (idx > 0) {
window.location.href = `${parts.slice(0, idx).join('/')}/milestones`;
} else {
window.location.href = '/';
}
}
}
function dispatchMilestoneEvent(payload: any): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
if (isProgressPayload(payload)) {
patchMilestoneCard(payload);
} else if ('milestone_id' in payload && 'repo_id' in payload) {
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
}
}
// 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]));
}