Merge pull request 'fix(project): push SSE update when an issue on a board is closed/reopened' (#20) from fix/issue-19-sse-state into main

This commit was merged in pull request #20.
This commit is contained in:
2026-05-17 20:59:50 +03:00
4 changed files with 85 additions and 2 deletions
+21
View File
@@ -12,8 +12,27 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
milestone_events "code.gitea.io/gitea/services/milestone_events" milestone_events "code.gitea.io/gitea/services/milestone_events"
notify_service "code.gitea.io/gitea/services/notify" 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. // CloseIssue close an issue.
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error { func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
var comment *issues_model.Comment var comment *issues_model.Comment
@@ -40,6 +59,7 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model
} }
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true) notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
publishProjectCardStateChanged(ctx, issue, true)
return nil return nil
} }
@@ -57,6 +77,7 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode
} }
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false) notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
publishProjectCardStateChanged(ctx, issue, false)
return nil return nil
} }
+19
View File
@@ -69,6 +69,17 @@ type CardUnlinked struct {
SessionTag string `json:"session_tag,omitempty"` 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. // ColumnCreated is emitted when a new column is added to a project.
type ColumnCreated struct { type ColumnCreated struct {
ProjectID int64 `json:"project_id"` ProjectID int64 `json:"project_id"`
@@ -277,6 +288,14 @@ func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
go publishEvent(detach(ctx), payload.ProjectID, payload) 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. // PublishColumnCreated fans out a ColumnCreated event for the given payload.
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) { func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
go publishEvent(detach(ctx), payload.ProjectID, payload) go publishEvent(detach(ctx), payload.ProjectID, payload)
+8
View File
@@ -112,6 +112,14 @@ func TestPublishHelpers_NameAndPayload(t *testing.T) {
}, },
wantData: CardUnlinked{ProjectID: 12, IssueID: 8}, wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
}, },
{
name: "card.state_changed",
wantName: "project-board.12",
invoke: func(ctx context.Context) {
PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true})
},
wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true},
},
{ {
name: "column.created", name: "column.created",
wantName: "project-board.13", wantName: "project-board.13",
+37 -2
View File
@@ -14,7 +14,7 @@ import {showInfoToast, showWarningToast} from '../modules/toast.ts';
// rendered "#index" anchor text and falling back to the internal id. // rendered "#index" anchor text and falling back to the internal id.
function issueRef(card: HTMLElement | null, issueID: number): string { function issueRef(card: HTMLElement | null, issueID: number): string {
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim(); const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
const m = idx?.match(/#\d+/); const m = /#\d+/.exec(idx ?? '');
return m ? m[0] : `#${issueID}`; return m ? m[0] : `#${issueID}`;
} }
@@ -237,6 +237,12 @@ type CardUnlinkedPayload = EventPayloadBase & {
issue_id: number; issue_id: number;
}; };
type CardStateChangedPayload = EventPayloadBase & {
project_id: number;
issue_id: number;
is_closed: boolean;
};
type ColumnUpdatedPayload = { type ColumnUpdatedPayload = {
project_id: number; project_id: number;
column_id: number; column_id: number;
@@ -352,6 +358,30 @@ function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): v
showInfoToast(`${ref} removed from board`); 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 { function handleColumnCreated(): void {
// Rare event; reload is cheap and avoids client-side template duplication. // Rare event; reload is cheap and avoids client-side template duplication.
window.location.reload(); window.location.reload();
@@ -423,7 +453,12 @@ function handleProjectDeleted(): void {
// types by payload shape; we sniff discriminating fields here. Order // types by payload shape; we sniff discriminating fields here. Order
// matters: the more specific shapes are checked first. // matters: the more specific shapes are checked first.
function dispatchProjectEvent(board: HTMLElement, payload: any): void { 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); handleCardMoved(board, payload as CardMovedPayload);
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) { } else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
handleCardLinked(board, payload as CardLinkedPayload); handleCardLinked(board, payload as CardLinkedPayload);