4 Commits

17 changed files with 852 additions and 34 deletions
+15
View File
@@ -136,6 +136,21 @@ func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, er
return m, nil
}
// GetMilestoneByID returns the milestone identified by id, regardless of
// which repository it belongs to. Used by the milestone_events SSE
// publisher, which only has the milestone id and re-reads the fresh
// counters from a detached, process-lifetime context.
func GetMilestoneByID(ctx context.Context, id int64) (*Milestone, error) {
m := new(Milestone)
has, err := db.GetEngine(ctx).ID(id).Get(m)
if err != nil {
return nil, err
} else if !has {
return nil, ErrMilestoneNotExist{ID: id}
}
return m, nil
}
// GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo
func GetMilestoneByRepoIDANDName(ctx context.Context, repoID int64, name string) (*Milestone, error) {
var mile Milestone
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package sessiontag carries a per-page-load identifier from the
// originating HTTP request down to the service- and model-layer SSE
// publishers. The publishers echo the tag back inside event payloads so
// the originating browser tab can suppress its own event after it has
// already applied the optimistic update locally.
//
// It is deliberately tiny and dependency-free so any feature that emits
// Server-Sent Events (project boards, milestones, ...) can share one
// context key without importing one another.
package sessiontag
import "context"
// sessionTagCtxKey is the context key under which the X-Session-Tag
// value from the originating HTTP request is stashed.
type sessionTagCtxKey struct{}
// WithSessionTag returns ctx decorated with the provided session tag.
// Web/API middleware reads the X-Session-Tag header and calls this so
// service- and model-layer publishers can pull the tag back out.
func WithSessionTag(ctx context.Context, tag string) context.Context {
if tag == "" {
return ctx
}
return context.WithValue(ctx, sessionTagCtxKey{}, tag)
}
// SessionTagFromContext returns the session tag previously stored via
// WithSessionTag, or "" when none was set.
func SessionTagFromContext(ctx context.Context) string {
if v, ok := ctx.Value(sessionTagCtxKey{}).(string); ok {
return v
}
return ""
}
+3
View File
@@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
milestone_events "code.gitea.io/gitea/services/milestone_events"
)
// ListMilestones list milestones for a repository
@@ -230,6 +231,7 @@ func EditMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneProgress(ctx, milestone.ID)
ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone))
}
@@ -269,6 +271,7 @@ func DeleteMilestone(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
}
milestone_events.PublishMilestoneDeleted(ctx, ctx.Repo.Repository.ID, m.ID)
ctx.Status(http.StatusNoContent)
}
+3 -3
View File
@@ -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)
+8 -1
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/issue"
milestone_events "code.gitea.io/gitea/services/milestone_events"
"xorm.io/builder"
)
@@ -195,6 +196,8 @@ func EditMilestonePost(ctx *context.Context) {
return
}
milestone_events.PublishMilestoneProgress(ctx, m.ID)
ctx.Flash.Success(ctx.Tr("repo.milestones.edit_success", m.Name))
ctx.Redirect(ctx.Repo.RepoLink + "/milestones")
}
@@ -221,14 +224,18 @@ func ChangeMilestoneStatus(ctx *context.Context) {
}
return
}
milestone_events.PublishMilestoneProgress(ctx, id)
ctx.JSONRedirect(ctx.Repo.RepoLink + "/milestones?state=" + url.QueryEscape(ctx.PathParam("action")))
}
// DeleteMilestone delete a milestone
func DeleteMilestone(ctx *context.Context) {
if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
repoID := ctx.Repo.Repository.ID
milestoneID := ctx.FormInt64("id")
if err := issues_model.DeleteMilestoneByRepoID(ctx, repoID, milestoneID); err != nil {
ctx.Flash.Error("DeleteMilestoneByRepoID: " + err.Error())
} else {
milestone_events.PublishMilestoneDeleted(ctx, repoID, milestoneID)
ctx.Flash.Success(ctx.Tr("repo.milestones.deletion_success"))
}
+9
View File
@@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/storage"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -57,6 +58,10 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
return err
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.NewIssue(ctx, issue, mentions)
if len(issue.Labels) > 0 {
notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil)
@@ -160,6 +165,10 @@ func DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model
}
}
if issue.MilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.DeleteIssue(ctx, doer, issue)
return nil
+10
View File
@@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -75,6 +76,15 @@ func ChangeMilestoneAssign(ctx context.Context, issue *issues_model.Issue, doer
return err
}
// Both the previous and the new milestone may have had their issue
// counters move; publish progress for each affected milestone.
if oldMilestoneID > 0 {
milestone_events.PublishMilestoneProgress(ctx, oldMilestoneID)
}
if issue.MilestoneID > 0 && issue.MilestoneID != oldMilestoneID {
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
}
notify_service.IssueChangeMilestone(ctx, doer, issue, oldMilestoneID)
return nil
}
+9
View File
@@ -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
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package milestone_events publishes milestone progress changes as
// Server-Sent Events so other browser tabs viewing the same repository's
// milestone list (or a single milestone's issue list) can update their
// progress bars in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it
// in an *eventsource.Event whose Name is "repo-milestones.{repo_id}", and
// fans the event out to every currently connected user that has read
// access to the repository's issues unit. All publish helpers are
// non-blocking: they spawn a goroutine so request handlers do not stall
// on slow consumers.
package milestone_events
import (
"context"
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/sessiontag"
)
// Event payload structs ------------------------------------------------------
// MilestoneProgress is emitted whenever a milestone's issue counters
// (and therefore its completeness percentage) change. It funnels every
// mutation that can move the bar: issue close/reopen, milestone
// (re)assignment, issue creation/deletion, milestone status change and
// milestone edit.
type MilestoneProgress struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_id"`
OpenIssues int `json:"open_issues"`
ClosedIssues int `json:"closed_issues"`
Completeness int `json:"completeness"`
SessionTag string `json:"session_tag,omitempty"`
}
// MilestoneDeleted is emitted when a milestone is deleted so viewers can
// drop the card (or navigate away from a single-milestone view).
type MilestoneDeleted struct {
RepoID int64 `json:"repo_id"`
MilestoneID int64 `json:"milestone_id"`
}
// Broadcast plumbing ---------------------------------------------------------
// broadcastFn is the package-level seam used to send an event to a set of
// uids. Tests swap it out to capture calls without touching the real
// eventsource manager.
var broadcastFn = defaultBroadcast
func defaultBroadcast(uids []int64, event *eventsource.Event) {
mgr := eventsource.GetManager()
for _, uid := range uids {
mgr.SendMessage(uid, event)
}
}
// connectedUIDsLister returns the uid set the broadcast helpers should
// consider as candidate recipients. Tests override it to feed a
// deterministic list.
var connectedUIDsLister = func() []int64 {
return eventsource.GetManager().ConnectedUIDs()
}
// milestoneLookup re-reads a milestone by id from the detached context.
// Stubbable in tests so PublishMilestoneProgress can be exercised
// without a database.
var milestoneLookup = issues_model.GetMilestoneByID
// repoLookup loads a repository by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var repoLookup = repo_model.GetRepositoryByID
// repoAccessChecker decides whether the user identified by uid is allowed
// to read the given repository's issues. Tests stub this to bypass the
// real permission system.
var repoAccessChecker = canReadMilestones
// connectedUIDsWithRepoIssueAccess returns the subset of currently
// connected uids that the access checker confirms can read the issues
// unit of repoID.
func connectedUIDsWithRepoIssueAccess(ctx context.Context, repoID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
repo, err := repoLookup(ctx, repoID)
if err != nil {
log.Debug("milestone_events: GetRepositoryByID(%d) failed: %v", repoID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := repoAccessChecker(ctx, uid, repo)
if err != nil {
log.Debug("milestone_events: access check uid=%d repo=%d: %v", uid, repoID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadMilestones implements the real read-permission check used in
// production: a user may see milestone progress for a repo when they can
// read its issues unit.
func canReadMilestones(ctx context.Context, uid int64, repo *repo_model.Repository) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
// AccessModeRead == 1; the literal mirrors project_events, where the
// perm_model typed constant would force another import alias and the
// meaning is well established here.
return access_model.HasAccessUnit(ctx, user, repo, unit.TypeIssues, 1)
}
// publishEvent is the shared pipeline used by every Publish* helper.
// It marshals the payload, builds the SSE Event, looks up authorized
// recipients, and fans the event out via broadcastFn.
func publishEvent(ctx context.Context, repoID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("milestone_events: marshal payload for repo %d: %v", repoID, err)
return
}
event := &eventsource.Event{
Name: eventName(repoID),
Data: data,
}
uids := connectedUIDsWithRepoIssueAccess(ctx, repoID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given repo id.
func eventName(repoID int64) string {
return "repo-milestones." + strconv.FormatInt(repoID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishMilestoneProgress re-reads the milestone's fresh counters and
// fans a MilestoneProgress event out to everyone who can read the repo's
// issues. The session tag is resolved synchronously from the request
// context before the goroutine starts; the goroutine itself runs on a
// detached, process-lifetime context so the request-scoped DB session
// being returned to the pool cannot make the re-fetch/access checks fail.
func PublishMilestoneProgress(ctx context.Context, milestoneID int64) {
if milestoneID <= 0 {
return
}
tag := sessiontag.SessionTagFromContext(ctx)
go func() {
detachCtx := detach(ctx)
m, err := milestoneLookup(detachCtx, milestoneID)
if err != nil {
log.Debug("milestone_events: GetMilestoneByID(%d) failed: %v", milestoneID, err)
return
}
payload := MilestoneProgress{
RepoID: m.RepoID,
MilestoneID: m.ID,
OpenIssues: m.NumOpenIssues,
ClosedIssues: m.NumClosedIssues,
Completeness: m.Completeness,
SessionTag: tag,
}
publishEvent(detachCtx, m.RepoID, payload)
}()
}
// PublishMilestoneDeleted fans a MilestoneDeleted event out for the given
// repo/milestone. No re-fetch is needed since the milestone is gone.
func PublishMilestoneDeleted(ctx context.Context, repoID, milestoneID int64) {
if repoID <= 0 || milestoneID <= 0 {
return
}
go func() {
detachCtx := detach(ctx)
publishEvent(detachCtx, repoID, MilestoneDeleted{
RepoID: repoID,
MilestoneID: milestoneID,
})
}()
}
// detach returns a context safe for use in the fire-and-forget publish
// goroutine. The request's context carries a request-scoped DB session
// that is returned to the pool once the HTTP handler completes; reusing
// it from the goroutine races with that teardown and makes subsequent
// queries (GetMilestoneByID, GetRepositoryByID, access checks) fail
// intermittently. The session tag is already resolved synchronously
// before the goroutine starts, so the goroutine needs no request-scoped
// values — only a clean, process-lifetime DB context. ShutdownContext is
// backed by the global engine, outlives any single request, and is
// cancelled on app shutdown so we don't leak goroutines past teardown.
func detach(_ context.Context) context.Context {
return graceful.GetManager().ShutdownContext()
}
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestone_events
import (
"context"
"sync"
"testing"
"time"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/sessiontag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// capturedCall is one observed broadcast: the recipient uid set plus the
// constructed Event.
type capturedCall struct {
uids []int64
event *eventsource.Event
}
// installFakes swaps every package-level seam used by the publishers for
// test doubles: a fake uid lister, a stubbed milestone lookup returning a
// synthetic milestone (no DB hit), a stubbed repo lookup, an "everyone
// passes" access checker, and a broadcaster that pushes calls onto a
// buffered channel.
//
// The returned restore func reverts every seam; defer it in the test.
func installFakes(t *testing.T, uids []int64, milestone *issues_model.Milestone) (<-chan capturedCall, func()) {
t.Helper()
calls := make(chan capturedCall, 16)
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
origMsLookup := milestoneLookup
broadcastFn = func(uids []int64, event *eventsource.Event) {
calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
}
connectedUIDsLister = func() []int64 {
return append([]int64(nil), uids...)
}
milestoneLookup = func(_ context.Context, id int64) (*issues_model.Milestone, error) {
if milestone != nil {
return milestone, nil
}
return &issues_model.Milestone{ID: id, RepoID: 1}, nil
}
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, _ int64, _ *repo_model.Repository) (bool, error) {
return true, nil
}
return calls, func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
milestoneLookup = origMsLookup
}
}
// awaitCall blocks until one capturedCall arrives or the test deadline
// elapses. It fails the test on timeout.
func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall {
t.Helper()
select {
case c := <-ch:
return c
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for broadcast")
return capturedCall{}
}
}
func TestEventNameFormat(t *testing.T) {
assert.Equal(t, "repo-milestones.42", eventName(42))
assert.Equal(t, "repo-milestones.0", eventName(0))
}
func TestPublishMilestoneProgress_NameAndPayload(t *testing.T) {
ms := &issues_model.Milestone{
ID: 7,
RepoID: 10,
NumIssues: 8,
NumClosedIssues: 6,
NumOpenIssues: 2,
Completeness: 75,
}
ch, restore := installFakes(t, []int64{1}, ms)
defer restore()
PublishMilestoneProgress(context.Background(), 7)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.10", c.event.Name)
data, ok := c.event.Data.([]byte)
require.True(t, ok, "Event.Data should be []byte")
var got MilestoneProgress
require.NoError(t, json.Unmarshal(data, &got))
assert.Equal(t, MilestoneProgress{
RepoID: 10, MilestoneID: 7, OpenIssues: 2, ClosedIssues: 6, Completeness: 75,
}, got)
}
func TestPublishMilestoneProgress_IgnoresNonPositiveID(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneProgress(context.Background(), 0)
PublishMilestoneProgress(context.Background(), -3)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive milestone id")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneProgress_LookupErrorIsSilent(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
milestoneLookup = func(_ context.Context, _ int64) (*issues_model.Milestone, error) {
return nil, issues_model.ErrMilestoneNotExist{ID: 99}
}
PublishMilestoneProgress(context.Background(), 99)
select {
case <-ch:
t.Fatal("no broadcast expected when the milestone re-fetch fails")
case <-time.After(200 * time.Millisecond):
}
}
func TestPublishMilestoneDeleted_NameAndPayload(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 12, 5)
c := awaitCall(t, ch)
assert.Equal(t, "repo-milestones.12", c.event.Name)
var got MilestoneDeleted
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &got))
assert.Equal(t, MilestoneDeleted{RepoID: 12, MilestoneID: 5}, got)
}
func TestPublishMilestoneDeleted_IgnoresNonPositiveIDs(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, nil)
defer restore()
PublishMilestoneDeleted(context.Background(), 0, 5)
PublishMilestoneDeleted(context.Background(), 12, 0)
select {
case <-ch:
t.Fatal("no broadcast expected for non-positive ids")
case <-time.After(200 * time.Millisecond):
}
}
// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by sessiontag.WithSessionTag, the emitted
// JSON payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "abc-123")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "abc-123", payload.SessionTag)
}
// TestSessionTagAbsentWhenUnset verifies the omitempty tag stays empty
// when no session tag is on the context.
func TestSessionTagAbsentWhenUnset(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Empty(t, payload.SessionTag)
}
// TestSessionTagResolvedSynchronously ensures the tag is read from the
// request context before the goroutine starts, not from the detached
// context (which never carries request-scoped values).
func TestSessionTagResolvedSynchronously(t *testing.T) {
ch, restore := installFakes(t, []int64{1}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
ctx := sessiontag.WithSessionTag(context.Background(), "sync-tag")
PublishMilestoneProgress(ctx, 3)
c := awaitCall(t, ch)
var payload MilestoneProgress
require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
assert.Equal(t, "sync-tag", payload.SessionTag)
}
// TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithRepoIssueAccess_FiltersByPermission(t *testing.T) {
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
allowed := map[int64]bool{1: true, 3: true}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return allowed[uid], nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.ElementsMatch(t, []int64{1, 3}, got)
}
// TestConnectedUIDsWithRepoIssueAccess_NoConnections shortcuts when no
// users are connected; the repo lookup must not be called.
func TestConnectedUIDsWithRepoIssueAccess_NoConnections(t *testing.T) {
origLister := connectedUIDsLister
origRepoLookup := repoLookup
defer func() {
connectedUIDsLister = origLister
repoLookup = origRepoLookup
}()
connectedUIDsLister = func() []int64 { return nil }
called := false
repoLookup = func(_ context.Context, _ int64) (*repo_model.Repository, error) {
called = true
return &repo_model.Repository{}, nil
}
got := connectedUIDsWithRepoIssueAccess(context.Background(), 42)
assert.Empty(t, got)
assert.False(t, called, "repo lookup should be skipped when no uids are connected")
}
// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent
// directly to verify the uid set computed by the access filter is what
// gets handed to broadcastFn.
func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) {
origBroadcast := broadcastFn
origLister := connectedUIDsLister
origChecker := repoAccessChecker
origRepoLookup := repoLookup
defer func() {
broadcastFn = origBroadcast
connectedUIDsLister = origLister
repoAccessChecker = origChecker
repoLookup = origRepoLookup
}()
var mu sync.Mutex
var got []int64
broadcastFn = func(uids []int64, _ *eventsource.Event) {
mu.Lock()
got = append([]int64(nil), uids...)
mu.Unlock()
}
connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} }
repoLookup = func(_ context.Context, id int64) (*repo_model.Repository, error) {
return &repo_model.Repository{ID: id}, nil
}
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 20, nil
}
publishEvent(context.Background(), 1, MilestoneDeleted{RepoID: 1, MilestoneID: 5})
mu.Lock()
defer mu.Unlock()
assert.ElementsMatch(t, []int64{10, 30}, got)
}
// TestPublishMilestoneProgress_NoConnectionsNoBroadcast verifies the
// connected-uid shortcut: with nobody connected nothing is sent even
// though the milestone re-fetch succeeds.
func TestPublishMilestoneProgress_NoConnectionsNoBroadcast(t *testing.T) {
ch, restore := installFakes(t, nil, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
PublishMilestoneProgress(context.Background(), 3)
select {
case <-ch:
t.Fatal("no broadcast expected when no users are connected")
case <-time.After(200 * time.Millisecond):
}
}
// TestPublishMilestoneProgress_FanOutTargetList verifies the recipient
// list handed to broadcast is exactly the access-filtered set.
func TestPublishMilestoneProgress_FanOutTargetList(t *testing.T) {
ch, restore := installFakes(t, []int64{5, 6, 7}, &issues_model.Milestone{ID: 3, RepoID: 1})
defer restore()
repoAccessChecker = func(_ context.Context, uid int64, _ *repo_model.Repository) (bool, error) {
return uid != 6, nil
}
PublishMilestoneProgress(context.Background(), 3)
c := awaitCall(t, ch)
assert.ElementsMatch(t, []int64{5, 7}, c.uids)
}
+8 -19
View File
@@ -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 -2
View File
@@ -27,7 +27,7 @@
</div>
{{end}}
<div class="tw-flex tw-flex-col tw-gap-2">
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress>
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100" data-milestone-id="{{.Milestone.ID}}" data-repo-id="{{.Repository.ID}}"></progress>
<div class="flex-text-block tw-gap-4">
<div class="flex-text-inline">
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
@@ -46,7 +46,7 @@
{{end}}
{{end}}
</div>
<div>{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
<div class="milestone-completeness-pct">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
{{if .TotalTrackedTime}}
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
+5 -5
View File
@@ -15,16 +15,16 @@
{{template "repo/issue/filters" .}}
<!-- milestone list -->
<div class="milestone-list">
<div class="milestone-list" data-repo-id="{{$.Repository.ID}}">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{$.Repository.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
{{svg "octicon-milestone" 16}}
<a class="muted" href="{{$.RepoLink}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -32,11 +32,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+4 -4
View File
@@ -73,7 +73,7 @@
</div>
<div class="milestone-list">
{{range .Milestones}}
<li class="milestone-card">
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{.Repo.ID}}">
<div class="milestone-header">
<h3 class="flex-text-block tw-m-0">
<span class="ui large label">
@@ -83,7 +83,7 @@
<a class="muted" href="{{.Repo.Link}}/milestone/{{.ID}}">{{.Name}}</a>
</h3>
<div class="tw-flex tw-items-center">
<span class="tw-mr-2">{{.Completeness}}%</span>
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
<progress value="{{.Completeness}}" max="100"></progress>
</div>
</div>
@@ -91,11 +91,11 @@
<div class="group">
<div class="flex-text-block">
{{svg "octicon-issue-opened" 14}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span>&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</div>
{{if .TotalTrackedTime}}
<div class="flex-text-block">
+7
View File
@@ -1,9 +1,16 @@
import {createApp} from 'vue';
import DashboardRepoList from '../components/DashboardRepoList.vue';
import {initRepoMilestoneListSSE} from './repo-milestone-sse.ts';
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(DashboardRepoList).mount(el);
}
// The dashboard milestones page lists milestones across many repos;
// subscribe to live progress for each. subscribeRepos is guarded so
// this is a no-op if repo-legacy already wired it on the same page.
if (document.querySelector('.page-content.dashboard.milestones li.milestone-card[data-repo-id]')) {
initRepoMilestoneListSSE();
}
}
+7
View File
@@ -14,6 +14,7 @@ import {initRepoSettings} from './repo-settings.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoMilestoneListSSE, initRepoMilestoneSingleSSE} from './repo-milestone-sse.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
@@ -50,6 +51,12 @@ export function initRepository() {
// Labels
initCompLabelEdit('.page-content.repository.labels');
initRepoMilestone();
if (pageContent.matches('.page-content.repository.milestones')) {
initRepoMilestoneListSSE();
}
if (pageContent.matches('.page-content.repository.milestone-issue-list')) {
initRepoMilestoneSingleSSE();
}
initRepoNew();
initRepoCloneButtons();
+173
View File
@@ -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<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]));
}