Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c45ea82fd1 | |||
| c19ecab35d | |||
| ad46f6cde8 | |||
| 078459c497 | |||
| d4de99f96b | |||
| 9f588d3dd3 | |||
| 4676a3af93 | |||
| 1cd81ff925 |
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,9 +10,29 @@ import (
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
milestone_events "code.gitea.io/gitea/services/milestone_events"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
project_events "code.gitea.io/gitea/services/project_events"
|
||||
)
|
||||
|
||||
// publishProjectCardStateChanged notifies every project board the issue is a
|
||||
// card on that its open/closed state changed, so subscribed board tabs can
|
||||
// re-render the card live instead of showing stale state until reload.
|
||||
// Best-effort: a failure here must not fail the close/reopen operation.
|
||||
func publishProjectCardStateChanged(ctx context.Context, issue *issues_model.Issue, isClosed bool) {
|
||||
if err := issue.LoadProjects(ctx); err != nil {
|
||||
log.Error("LoadProjects for issue[%d]: %v", issue.ID, err)
|
||||
return
|
||||
}
|
||||
for _, p := range issue.Projects {
|
||||
project_events.PublishCardStateChanged(ctx, project_events.CardStateChanged{
|
||||
ProjectID: p.ID,
|
||||
IssueID: issue.ID,
|
||||
IsClosed: isClosed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CloseIssue close an issue.
|
||||
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
||||
var comment *issues_model.Comment
|
||||
@@ -34,7 +54,12 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model
|
||||
return err
|
||||
}
|
||||
|
||||
if issue.MilestoneID > 0 {
|
||||
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
|
||||
}
|
||||
|
||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
|
||||
publishProjectCardStateChanged(ctx, issue, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -47,7 +72,12 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode
|
||||
return err
|
||||
}
|
||||
|
||||
if issue.MilestoneID > 0 {
|
||||
milestone_events.PublishMilestoneProgress(ctx, issue.MilestoneID)
|
||||
}
|
||||
|
||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
|
||||
publishProjectCardStateChanged(ctx, issue, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -22,33 +22,23 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 ------------------------------------------------------
|
||||
@@ -79,6 +69,17 @@ type CardUnlinked struct {
|
||||
SessionTag string `json:"session_tag,omitempty"`
|
||||
}
|
||||
|
||||
// CardStateChanged is emitted when an issue's open/closed state changes
|
||||
// while it is a card on the project. It lets boards re-render the card's
|
||||
// state live (and is the hook state-filtered boards use to correct their
|
||||
// column counts) without a full page reload.
|
||||
type CardStateChanged struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
IsClosed bool `json:"is_closed"`
|
||||
SessionTag string `json:"session_tag,omitempty"`
|
||||
}
|
||||
|
||||
// ColumnCreated is emitted when a new column is added to a project.
|
||||
type ColumnCreated struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
@@ -287,6 +288,14 @@ func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
|
||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||
}
|
||||
|
||||
// PublishCardStateChanged fans out a CardStateChanged event for the given payload.
|
||||
func PublishCardStateChanged(ctx context.Context, payload CardStateChanged) {
|
||||
if payload.SessionTag == "" {
|
||||
payload.SessionTag = SessionTagFromContext(ctx)
|
||||
}
|
||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||
}
|
||||
|
||||
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
|
||||
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
|
||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||
@@ -317,8 +326,16 @@ func PublishProjectDeleted(ctx context.Context, payload ProjectDeleted) {
|
||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||
}
|
||||
|
||||
// detach strips cancellation/deadline from ctx but preserves stored
|
||||
// values (notably the session tag) so the goroutine outlives the request.
|
||||
func detach(ctx context.Context) context.Context {
|
||||
return context.WithoutCancel(ctx)
|
||||
// detach returns a context safe for use in the fire-and-forget publish
|
||||
// goroutine. The request's context carries a request-scoped DB session
|
||||
// that is returned to the pool once the HTTP handler completes; reusing
|
||||
// it from the goroutine races with that teardown and makes subsequent
|
||||
// queries (GetProjectByID, access checks) fail intermittently. The
|
||||
// session tag is already resolved synchronously before the goroutine
|
||||
// starts, so the goroutine needs no request-scoped values — only a
|
||||
// clean, process-lifetime DB context. ShutdownContext is backed by the
|
||||
// global engine, outlives any single request, and is cancelled on app
|
||||
// shutdown so we don't leak goroutines past teardown.
|
||||
func detach(_ context.Context) context.Context {
|
||||
return graceful.GetManager().ShutdownContext()
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@ func TestPublishHelpers_NameAndPayload(t *testing.T) {
|
||||
},
|
||||
wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
|
||||
},
|
||||
{
|
||||
name: "card.state_changed",
|
||||
wantName: "project-board.12",
|
||||
invoke: func(ctx context.Context) {
|
||||
PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true})
|
||||
},
|
||||
wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true},
|
||||
},
|
||||
{
|
||||
name: "column.created",
|
||||
wantName: "project-board.13",
|
||||
|
||||
@@ -93,7 +93,16 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
|
||||
// Scope the update to this issue *in this project*. Without the
|
||||
// project_id predicate, an issue that belongs to several projects
|
||||
// would have every project_issue row rewritten to the target
|
||||
// column, detaching it from all other projects.
|
||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
||||
Update(map[string]any{
|
||||
"project_board_id": column.ID,
|
||||
"sorting": sorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-check" 14}}
|
||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
</div>
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
|
||||
@@ -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}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-check" 14}}
|
||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||
</div>
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestAPIUserProjects(t *testing.T) {
|
||||
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
|
||||
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
|
||||
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
|
||||
t.Run("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation)
|
||||
t.Run("Permissions", testAPIUserProjectPermissions)
|
||||
}
|
||||
|
||||
@@ -502,6 +503,44 @@ func testAPIUserMoveProjectIssue(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// Regression for #17: moving an issue's column in one user project must not
|
||||
// rewrite its column in other projects the issue also belongs to.
|
||||
func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
p1 := makeUserProject(t, owner)
|
||||
p2 := makeUserProject(t, owner)
|
||||
|
||||
p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA))
|
||||
p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB))
|
||||
p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p2Col))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// move the issue inside p1 only
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: p1ColB.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// p1 updated as requested
|
||||
pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID)
|
||||
|
||||
// p2 must be untouched
|
||||
pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects")
|
||||
}
|
||||
|
||||
func testAPIUserProjectPermissions(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
||||
|
||||
// milestoneTitle does a best-effort lookup of the milestone's display
|
||||
// name for toast text. List cards expose it as a heading/link inside the
|
||||
// card; the single-milestone view puts it in the page header. Falls back
|
||||
// to a generic label so a toast still fires if the markup shifts.
|
||||
function milestoneTitle(milestoneID: number): string {
|
||||
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${milestoneID}"]`);
|
||||
const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim();
|
||||
if (fromCard) return fromCard;
|
||||
const onSingle = document.querySelector<HTMLElement>(`progress[data-milestone-id="${milestoneID}"]`);
|
||||
if (onSingle) {
|
||||
const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim();
|
||||
if (h) return h;
|
||||
}
|
||||
return 'Milestone';
|
||||
}
|
||||
|
||||
// sessionTag is generated once per page load. The mutation requests on
|
||||
// milestone pages (close/open/delete/edit) flow through the existing
|
||||
// fetch helpers which attach the X-Session-Tag header; the backend
|
||||
// echoes it back inside SSE payloads so the originating tab can suppress
|
||||
// its own echo. We only need the read side here: skip any event whose
|
||||
// session_tag matches ours.
|
||||
let sessionTag = '';
|
||||
|
||||
function ensureSessionTag(): string {
|
||||
if (sessionTag) return sessionTag;
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
sessionTag = globalThis.crypto.randomUUID();
|
||||
} else {
|
||||
sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
return sessionTag;
|
||||
}
|
||||
|
||||
type MilestoneProgressPayload = {
|
||||
repo_id: number;
|
||||
milestone_id: number;
|
||||
open_issues: number;
|
||||
closed_issues: number;
|
||||
completeness: number;
|
||||
session_tag?: string;
|
||||
};
|
||||
|
||||
type MilestoneDeletedPayload = {
|
||||
repo_id: number;
|
||||
milestone_id: number;
|
||||
};
|
||||
|
||||
function isProgressPayload(p: any): p is MilestoneProgressPayload {
|
||||
return p && typeof p.completeness === 'number' && 'open_issues' in p;
|
||||
}
|
||||
|
||||
// patchMilestoneCard updates every progress-bar / counter site for a
|
||||
// single milestone id, covering both the list-card layout (milestones
|
||||
// list, dashboard) and the single-milestone big progress bar.
|
||||
function patchMilestoneCard(payload: MilestoneProgressPayload): void {
|
||||
const selector = `[data-milestone-id="${payload.milestone_id}"]`;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
// The element itself may be a <progress> (single view) or a card
|
||||
// <li> containing a <progress> (list views).
|
||||
const progressEls = el instanceof HTMLProgressElement
|
||||
? [el]
|
||||
: Array.from(el.querySelectorAll<HTMLProgressElement>('progress'));
|
||||
for (const pe of progressEls) {
|
||||
pe.value = payload.completeness;
|
||||
}
|
||||
|
||||
const scope: ParentNode = el instanceof HTMLProgressElement ? document : el;
|
||||
|
||||
for (const pct of scope.querySelectorAll<HTMLElement>('.milestone-completeness-pct')) {
|
||||
// The list cards render just the number; the single-milestone
|
||||
// view renders an i18n HTML fragment ("<strong>N%</strong>
|
||||
// Completed"). Detect which by whether the node already holds a
|
||||
// <strong> child.
|
||||
const strong = pct.querySelector('strong');
|
||||
if (strong) {
|
||||
strong.textContent = `${payload.completeness}%`;
|
||||
} else {
|
||||
pct.textContent = String(payload.completeness);
|
||||
}
|
||||
}
|
||||
for (const oc of scope.querySelectorAll<HTMLElement>('.milestone-open-count')) {
|
||||
oc.textContent = String(payload.open_issues);
|
||||
}
|
||||
for (const cc of scope.querySelectorAll<HTMLElement>('.milestone-closed-count')) {
|
||||
cc.textContent = String(payload.closed_issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
|
||||
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`);
|
||||
if (card) {
|
||||
card.remove();
|
||||
return;
|
||||
}
|
||||
// Single-milestone view: the milestone we are looking at is gone.
|
||||
const single = document.querySelector<HTMLElement>(`progress[data-milestone-id="${payload.milestone_id}"]`);
|
||||
if (single) {
|
||||
const parts = window.location.pathname.split('/');
|
||||
// .../milestone/{id} -> go up to the milestones listing.
|
||||
const idx = parts.lastIndexOf('milestone');
|
||||
const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/';
|
||||
// Delay so the "milestone deleted" warning toast is visible before
|
||||
// the page navigates out from under the viewer.
|
||||
setTimeout(() => { window.location.href = dest }, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchMilestoneEvent(payload: any): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
if (isProgressPayload(payload)) {
|
||||
patchMilestoneCard(payload);
|
||||
const total = payload.open_issues + payload.closed_issues;
|
||||
showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`);
|
||||
} else if ('milestone_id' in payload && 'repo_id' in payload) {
|
||||
const title = milestoneTitle(payload.milestone_id);
|
||||
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
|
||||
showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
// subscribed guards against a double subscription if more than one init
|
||||
// entry point matches the same page (e.g. the dashboard milestones page
|
||||
// is wired both from repo-legacy and dashboard).
|
||||
let subscribed = false;
|
||||
|
||||
// subscribeRepos opens one SharedWorker subscription per distinct repo
|
||||
// id and dispatches every "repo-milestones.{repoID}" event by payload.
|
||||
function subscribeRepos(repoIDs: Set<string>): void {
|
||||
if (subscribed) return;
|
||||
if (!repoIDs.size) return;
|
||||
if (!window.EventSource || !window.SharedWorker) return;
|
||||
subscribed = true;
|
||||
|
||||
ensureSessionTag();
|
||||
|
||||
let worker: UserEventsSharedWorker;
|
||||
try {
|
||||
worker = new UserEventsSharedWorker('repo-milestone-worker');
|
||||
} catch (error) {
|
||||
console.error('milestone SSE: failed to start worker', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventNames = new Set<string>();
|
||||
for (const repoID of repoIDs) {
|
||||
eventNames.add(`repo-milestones.${repoID}`);
|
||||
}
|
||||
|
||||
worker.addMessageEventListener((event: MessageEvent) => {
|
||||
if (!event.data || !eventNames.has(event.data.type)) return;
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(event.data.data);
|
||||
} catch (error) {
|
||||
console.error('milestone SSE: malformed payload', error, event.data);
|
||||
return;
|
||||
}
|
||||
dispatchMilestoneEvent(payload);
|
||||
});
|
||||
worker.startPort();
|
||||
|
||||
for (const name of eventNames) {
|
||||
worker.sharedWorker.port.postMessage({type: 'listen', eventType: name});
|
||||
}
|
||||
}
|
||||
|
||||
// initRepoMilestoneListSSE wires the milestone list page and the
|
||||
// dashboard milestones page: collect every distinct data-repo-id present
|
||||
// on the cards (the dashboard mixes many repos) and subscribe to each.
|
||||
export function initRepoMilestoneListSSE(): void {
|
||||
const cards = document.querySelectorAll<HTMLElement>('li.milestone-card[data-repo-id]');
|
||||
if (!cards.length) return;
|
||||
const repoIDs = new Set<string>();
|
||||
for (const card of cards) {
|
||||
const id = card.getAttribute('data-repo-id');
|
||||
if (id) repoIDs.add(id);
|
||||
}
|
||||
subscribeRepos(repoIDs);
|
||||
}
|
||||
|
||||
// initRepoMilestoneSingleSSE wires the single-milestone issue list view.
|
||||
export function initRepoMilestoneSingleSSE(): void {
|
||||
const progress = document.querySelector<HTMLElement>('progress[data-milestone-id][data-repo-id]');
|
||||
if (!progress) return;
|
||||
const repoID = progress.getAttribute('data-repo-id');
|
||||
if (!repoID) return;
|
||||
subscribeRepos(new Set([repoID]));
|
||||
}
|
||||
@@ -8,6 +8,20 @@ import {toggleFullScreen} from '../utils.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
||||
|
||||
// issueRef returns a short human label for a card, preferring the
|
||||
// rendered "#index" anchor text and falling back to the internal id.
|
||||
function issueRef(card: HTMLElement | null, issueID: number): string {
|
||||
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
|
||||
const m = /#\d+/.exec(idx ?? '');
|
||||
return m ? m[0] : `#${issueID}`;
|
||||
}
|
||||
|
||||
function columnName(board: HTMLElement, columnID: number): string {
|
||||
const t = board.querySelector<HTMLElement>(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim();
|
||||
return t || `column ${columnID}`;
|
||||
}
|
||||
|
||||
const SESSION_TAG_HEADER = 'X-Session-Tag';
|
||||
|
||||
@@ -223,6 +237,12 @@ type CardUnlinkedPayload = EventPayloadBase & {
|
||||
issue_id: number;
|
||||
};
|
||||
|
||||
type CardStateChangedPayload = EventPayloadBase & {
|
||||
project_id: number;
|
||||
issue_id: number;
|
||||
is_closed: boolean;
|
||||
};
|
||||
|
||||
type ColumnUpdatedPayload = {
|
||||
project_id: number;
|
||||
column_id: number;
|
||||
@@ -281,8 +301,10 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
// Card is not currently rendered (filtered out, or new since
|
||||
// page load). A targeted column re-fetch is the safe fallback.
|
||||
refetchColumn(board, payload.to_column_id);
|
||||
showInfoToast(`#${payload.issue_id} → ${columnName(board, payload.to_column_id)}`);
|
||||
return;
|
||||
}
|
||||
const ref = issueRef(card, payload.issue_id);
|
||||
const target = board.querySelector<HTMLElement>(`#board_${payload.to_column_id}`);
|
||||
if (!target) return;
|
||||
const fromColumn = card.parentElement;
|
||||
@@ -293,6 +315,7 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
}
|
||||
const toColumnEl = target.closest<HTMLElement>('.project-column');
|
||||
if (toColumnEl) updateColumnCount(toColumnEl);
|
||||
showInfoToast(`${ref} → ${columnName(board, payload.to_column_id)}`);
|
||||
}
|
||||
|
||||
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
|
||||
@@ -321,15 +344,42 @@ async function refetchColumn(board: HTMLElement, columnID: number): Promise<void
|
||||
function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
refetchColumn(board, payload.column_id); // no await
|
||||
showInfoToast(`#${payload.issue_id} added to ${columnName(board, payload.column_id)}`);
|
||||
}
|
||||
|
||||
function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
|
||||
if (!card) return;
|
||||
const ref = issueRef(card, payload.issue_id);
|
||||
const colEl = card.closest<HTMLElement>('.project-column');
|
||||
card.remove();
|
||||
if (colEl) updateColumnCount(colEl);
|
||||
showInfoToast(`${ref} removed from board`);
|
||||
}
|
||||
|
||||
function handleCardStateChanged(board: HTMLElement, payload: CardStateChangedPayload): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
|
||||
if (!card) return;
|
||||
// Flip the issue state octicon in place (matches templates/shared/issueicon.tmpl).
|
||||
// PR cards carry merged/draft variants we don't recompute here; the column
|
||||
// refetch below keeps state-filtered boards and counts correct regardless.
|
||||
const icon = card.querySelector<SVGElement>('.issue-card-icon svg');
|
||||
if (icon && !icon.classList.contains('octicon-git-pull-request')) {
|
||||
icon.classList.remove('octicon-issue-opened', 'octicon-issue-closed', 'tw-text-green', 'tw-text-red');
|
||||
icon.classList.add(
|
||||
payload.is_closed ? 'octicon-issue-closed' : 'octicon-issue-opened',
|
||||
payload.is_closed ? 'tw-text-red' : 'tw-text-green',
|
||||
);
|
||||
}
|
||||
const ref = issueRef(card, payload.issue_id);
|
||||
// The card's containing column is `#board_{columnID}` (its direct parent).
|
||||
const parent = card.parentElement;
|
||||
if (parent instanceof HTMLElement && parent.id.startsWith('board_')) {
|
||||
refetchColumn(board, Number(parent.id.slice('board_'.length)));
|
||||
}
|
||||
showInfoToast(`${ref} ${payload.is_closed ? 'closed' : 'reopened'}`);
|
||||
}
|
||||
|
||||
function handleColumnCreated(): void {
|
||||
@@ -341,7 +391,11 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (!colEl) return;
|
||||
const titleEl = colEl.querySelector<HTMLElement>('.project-column-title-text');
|
||||
const oldTitle = titleEl?.textContent?.trim();
|
||||
if (titleEl) titleEl.textContent = payload.title;
|
||||
if (oldTitle && oldTitle !== payload.title) {
|
||||
showInfoToast(`Column renamed to “${payload.title}”`);
|
||||
}
|
||||
if (payload.color) {
|
||||
const textColor = contrastColor(payload.color);
|
||||
colEl.style.setProperty('background', payload.color, 'important');
|
||||
@@ -356,7 +410,10 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
|
||||
function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
|
||||
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (colEl) colEl.remove();
|
||||
if (!colEl) return;
|
||||
const name = colEl.querySelector<HTMLElement>('.project-column-title-text')?.textContent?.trim();
|
||||
colEl.remove();
|
||||
showInfoToast(name ? `Column “${name}” removed` : 'A column was removed');
|
||||
}
|
||||
|
||||
function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void {
|
||||
@@ -383,13 +440,12 @@ function handleProjectDeleted(): void {
|
||||
// The board lives at .../projects/{id}; the listing page is the
|
||||
// parent. Falling back to the homepage on any URL we don't
|
||||
// recognise is acceptable since this is a destructive event.
|
||||
// Show a sticky warning first and delay the redirect briefly so the
|
||||
// user understands why the page is about to change under them.
|
||||
showWarningToast('This project was deleted — returning to the project list');
|
||||
const parts = window.location.pathname.split('/');
|
||||
if (parts.length > 1) {
|
||||
parts.pop();
|
||||
window.location.href = parts.join('/') || '/';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/';
|
||||
setTimeout(() => { window.location.href = dest }, 1500);
|
||||
}
|
||||
|
||||
// dispatchProjectEvent picks the right handler for an SSE payload.
|
||||
@@ -397,7 +453,12 @@ function handleProjectDeleted(): void {
|
||||
// types by payload shape; we sniff discriminating fields here. Order
|
||||
// matters: the more specific shapes are checked first.
|
||||
function dispatchProjectEvent(board: HTMLElement, payload: any): void {
|
||||
if ('from_column_id' in payload && 'to_column_id' in payload) {
|
||||
if ('issue_id' in payload && 'is_closed' in payload) {
|
||||
// CardStateChanged: must precede the CardUnlinked branch below, whose
|
||||
// "issue_id and no column_id" shape would otherwise swallow it and
|
||||
// wrongly remove the card on a close/reopen.
|
||||
handleCardStateChanged(board, payload as CardStateChangedPayload);
|
||||
} else if ('from_column_id' in payload && 'to_column_id' in payload) {
|
||||
handleCardMoved(board, payload as CardMovedPayload);
|
||||
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
|
||||
handleCardLinked(board, payload as CardLinkedPayload);
|
||||
|
||||
Reference in New Issue
Block a user