From ca06f6d5452f9fd5967c556675220a3eef040655 Mon Sep 17 00:00:00 2001 From: Oleks Date: Sat, 16 May 2026 00:33:30 +0300 Subject: [PATCH 1/4] refactor(sessiontag): extract WithSessionTag/SessionTagFromContext to modules/sessiontag --- modules/sessiontag/sessiontag.go | 38 +++++++++++++++++++++++++++++++ routers/common/session_tag.go | 6 ++--- services/project_events/events.go | 27 +++++++--------------- 3 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 modules/sessiontag/sessiontag.go diff --git a/modules/sessiontag/sessiontag.go b/modules/sessiontag/sessiontag.go new file mode 100644 index 0000000000..4aaa3262cb --- /dev/null +++ b/modules/sessiontag/sessiontag.go @@ -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 "" +} diff --git a/routers/common/session_tag.go b/routers/common/session_tag.go index bd91fc2421..fbbdde2065 100644 --- a/routers/common/session_tag.go +++ b/routers/common/session_tag.go @@ -6,7 +6,7 @@ package common import ( "net/http" - "code.gitea.io/gitea/services/project_events" + "code.gitea.io/gitea/modules/sessiontag" ) // 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 project_events.SessionTagFromContext. +// layer publishers read the value via sessiontag.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 := project_events.WithSessionTag(r.Context(), tag) + ctx := sessiontag.WithSessionTag(r.Context(), tag) r = r.WithContext(ctx) } next.ServeHTTP(w, r) diff --git a/services/project_events/events.go b/services/project_events/events.go index e168f6dcd1..936f32e47e 100644 --- a/services/project_events/events.go +++ b/services/project_events/events.go @@ -25,31 +25,20 @@ 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" ) -// 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. +// 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 { - if tag == "" { - return ctx - } - return context.WithValue(ctx, sessionTagCtxKey{}, tag) + return sessiontag.WithSessionTag(ctx, tag) } -// SessionTagFromContext returns the session tag previously stored via -// WithSessionTag, or "" when none was set. +// SessionTagFromContext re-exports modules/sessiontag.SessionTagFromContext. func SessionTagFromContext(ctx context.Context) string { - if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok { - return v - } - return "" + return sessiontag.SessionTagFromContext(ctx) } // Event payload structs ------------------------------------------------------ -- 2.53.0 From 0b5d7f5694e8547db8cc2578950eaacd6cfc6db0 Mon Sep 17 00:00:00 2001 From: Oleks Date: Sat, 16 May 2026 00:35:29 +0300 Subject: [PATCH 2/4] feat(milestone_events): SSE bus + publish helpers + unit tests --- models/issues/milestone.go | 15 + services/milestone_events/events.go | 216 +++++++++++++++ services/milestone_events/events_test.go | 335 +++++++++++++++++++++++ 3 files changed, 566 insertions(+) create mode 100644 services/milestone_events/events.go create mode 100644 services/milestone_events/events_test.go diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 1dd8630276..bedabd0e4d 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -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 diff --git a/services/milestone_events/events.go b/services/milestone_events/events.go new file mode 100644 index 0000000000..e92963f087 --- /dev/null +++ b/services/milestone_events/events.go @@ -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() +} diff --git a/services/milestone_events/events_test.go b/services/milestone_events/events_test.go new file mode 100644 index 0000000000..d000448bf6 --- /dev/null +++ b/services/milestone_events/events_test.go @@ -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) +} -- 2.53.0 From dfa34fc5d3e22fd4e119036def29da6eabd41cb3 Mon Sep 17 00:00:00 2001 From: Oleks Date: Sat, 16 May 2026 00:38:54 +0300 Subject: [PATCH 3/4] feat(milestone_events): publish from milestone-counter choke points (service layer to avoid models->services import cycle) --- routers/api/v1/repo/milestone.go | 3 +++ routers/web/repo/milestone.go | 9 ++++++++- services/issue/issue.go | 9 +++++++++ services/issue/milestone.go | 10 ++++++++++ services/issue/status.go | 9 +++++++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go index 2cd91b7caf..51bad106a3 100644 --- a/routers/api/v1/repo/milestone.go +++ b/routers/api/v1/repo/milestone.go @@ -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) } diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index 759b9910d8..32214a4e75 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -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")) } diff --git a/services/issue/issue.go b/services/issue/issue.go index 2bece1c7bb..d2613b5ff3 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -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 diff --git a/services/issue/milestone.go b/services/issue/milestone.go index 05aefad752..b12b83037c 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -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 } diff --git a/services/issue/status.go b/services/issue/status.go index fa59df93ba..e9325de110 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -10,6 +10,7 @@ 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" ) @@ -34,6 +35,10 @@ 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 @@ -47,6 +52,10 @@ 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 -- 2.53.0 From f376ec25fe653c1f145c563ea47dcf48b72b3938 Mon Sep 17 00:00:00 2001 From: Oleks Date: Sat, 16 May 2026 00:46:44 +0300 Subject: [PATCH 4/4] feat(milestone): frontend SSE subscribe + progress-bar DOM patch --- templates/repo/issue/milestone_issues.tmpl | 4 +- templates/repo/issue/milestones.tmpl | 10 +- templates/user/dashboard/milestones.tmpl | 8 +- web_src/js/features/dashboard.ts | 7 + web_src/js/features/repo-legacy.ts | 7 + web_src/js/features/repo-milestone-sse.ts | 173 +++++++++++++++++++++ 6 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 web_src/js/features/repo-milestone-sse.ts diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index 03cadfd295..4092a2196a 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -27,7 +27,7 @@ {{end}}
- +
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}} @@ -46,7 +46,7 @@ {{end}} {{end}}
-
{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}
+
{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}
{{if .TotalTrackedTime}}
{{svg "octicon-clock"}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 90e2a8ae3b..d427a8ffe1 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -15,16 +15,16 @@ {{template "repo/issue/filters" .}} -
+
{{range .Milestones}} -
  • +
  • {{svg "octicon-milestone" 16}} {{.Name}}

    - {{.Completeness}}% + {{.Completeness}}%
    @@ -32,11 +32,11 @@
    {{svg "octicon-issue-opened" 14}} - {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}} + {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
    {{svg "octicon-check" 14}} - {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}} + {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
    {{if .TotalTrackedTime}}
    diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl index d5dd64b1a3..95ea4e01ca 100644 --- a/templates/user/dashboard/milestones.tmpl +++ b/templates/user/dashboard/milestones.tmpl @@ -73,7 +73,7 @@
    {{range .Milestones}} -
  • +
  • @@ -83,7 +83,7 @@ {{.Name}}

    - {{.Completeness}}% + {{.Completeness}}%
    @@ -91,11 +91,11 @@
    {{svg "octicon-issue-opened" 14}} - {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}} + {{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
    {{svg "octicon-check" 14}} - {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}} + {{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
    {{if .TotalTrackedTime}}
    diff --git a/web_src/js/features/dashboard.ts b/web_src/js/features/dashboard.ts index 71a0df3a64..3c1e29519a 100644 --- a/web_src/js/features/dashboard.ts +++ b/web_src/js/features/dashboard.ts @@ -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(); + } } diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index db91529535..77e6552650 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -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(); diff --git a/web_src/js/features/repo-milestone-sse.ts b/web_src/js/features/repo-milestone-sse.ts new file mode 100644 index 0000000000..10e4379495 --- /dev/null +++ b/web_src/js/features/repo-milestone-sse.ts @@ -0,0 +1,173 @@ +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(selector)) { + // The element itself may be a (single view) or a card + //
  • containing a (list views). + const progressEls = el instanceof HTMLProgressElement + ? [el] + : Array.from(el.querySelectorAll('progress')); + for (const pe of progressEls) { + pe.value = payload.completeness; + } + + const scope: ParentNode = el instanceof HTMLProgressElement ? document : el; + + for (const pct of scope.querySelectorAll('.milestone-completeness-pct')) { + // The list cards render just the number; the single-milestone + // view renders an i18n HTML fragment ("N% + // Completed"). Detect which by whether the node already holds a + // child. + const strong = pct.querySelector('strong'); + if (strong) { + strong.textContent = `${payload.completeness}%`; + } else { + pct.textContent = String(payload.completeness); + } + } + for (const oc of scope.querySelectorAll('.milestone-open-count')) { + oc.textContent = String(payload.open_issues); + } + for (const cc of scope.querySelectorAll('.milestone-closed-count')) { + cc.textContent = String(payload.closed_issues); + } + } +} + +function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void { + const card = document.querySelector(`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(`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): 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(); + 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('li.milestone-card[data-repo-id]'); + if (!cards.length) return; + const repoIDs = new Set(); + 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('progress[data-milestone-id][data-repo-id]'); + if (!progress) return; + const repoID = progress.getAttribute('data-repo-id'); + if (!repoID) return; + subscribeRepos(new Set([repoID])); +} -- 2.53.0