From c19ecab35d0207ccc447f2e86293939d2a397fba Mon Sep 17 00:00:00 2001 From: oleks Date: Sun, 17 May 2026 20:58:17 +0300 Subject: [PATCH] fix(project): push SSE update when an issue on a board is closed/reopened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing or reopening an issue did not notify project boards that carry it as a card, so board tabs showed stale state until a manual reload. CloseIssue/ReopenIssue only published milestone events and the issue timeline notification — nothing project-scoped. Add a CardStateChanged project event, published per linked project from CloseIssue/ReopenIssue (best-effort; never fails the state change). The board frontend flips the issue-state octicon in place and refetches the affected column so state-filtered boards and counts stay correct. The dispatch check precedes the CardUnlinked branch so a close/reopen is not mistaken for a card removal. Also switch a pre-existing String#match to RegExp#exec in the same file to keep it lint-clean. Closes #19 --- services/issue/status.go | 21 ++++++++++++++ services/project_events/events.go | 19 +++++++++++++ services/project_events/events_test.go | 8 ++++++ web_src/js/features/repo-projects.ts | 39 ++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/services/issue/status.go b/services/issue/status.go index e9325de110..d5c517c26f 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -12,8 +12,27 @@ import ( "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 @@ -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) + publishProjectCardStateChanged(ctx, issue, true) 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) + publishProjectCardStateChanged(ctx, issue, false) return nil } diff --git a/services/project_events/events.go b/services/project_events/events.go index 936f32e47e..46f2bf443b 100644 --- a/services/project_events/events.go +++ b/services/project_events/events.go @@ -69,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"` @@ -277,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) diff --git a/services/project_events/events_test.go b/services/project_events/events_test.go index 81a8f5a99d..bdb1584de9 100644 --- a/services/project_events/events_test.go +++ b/services/project_events/events_test.go @@ -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", diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 314a4a4825..8a1b230650 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -14,7 +14,7 @@ import {showInfoToast, showWarningToast} from '../modules/toast.ts'; // 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 = idx?.match(/#\d+/); + const m = /#\d+/.exec(idx ?? ''); return m ? m[0] : `#${issueID}`; } @@ -237,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; @@ -352,6 +358,30 @@ function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): v 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(`.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('.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 { // Rare event; reload is cheap and avoids client-side template duplication. window.location.reload(); @@ -423,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);