5 Commits

Author SHA1 Message Date
oleks c45ea82fd1 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 2026-05-17 20:59:50 +03:00
oleks c19ecab35d fix(project): push SSE update when an issue on a board is closed/reopened
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
2026-05-17 20:58:17 +03:00
oleks ad46f6cde8 Merge pull request 'fix(projects): scope project-issue move to its own project' (#18) from fix/user-project-move-multiproject-detach into main 2026-05-17 17:06:19 +03:00
Claude 078459c497 fix(projects): scope project-issue move to its own project
MoveIssuesOnProjectColumn updated `project_issue` with a WHERE clause on
issue_id only. An issue assigned to several projects has one project_issue
row per project, so moving it within one project rewrote project_board_id
for every project the issue belonged to, detaching it from all the others.

Scope the UPDATE to (issue_id, project_id) so only the target project's
row changes. Mirrors the fix already present in upstream/main.

Adds an integration regression test asserting an issue in two user
projects keeps its column in the other project after a move. Fixes #17.
2026-05-17 16:59:31 +03:00
Oleks 1cd81ff925 feat(api): state filter + populated num_issues on project columns
Adds two improvements to the user/org/repo project-board REST API:

* state filter on column-issues endpoints (issue #4)
  GET /api/v1/{users,orgs}/{name}/-/projects/{id}/columns/{col}/issues
  GET /api/v1/repos/{owner}/{repo}/projects/{id}/columns/{col}/issues
  Now accept ?state=open|closed|all (default open), matching the convention
  on the project-list endpoint and on /repos/.../issues. Applied at the
  IssuesOptions layer so all three scopes inherit the filter.

* populated num_issues / num_open_issues / num_closed_issues on column-list
  (issue #5)
  ColumnList.LoadIssueCounts runs two grouped queries against project_issue
  joined with issue (one open, one closed). All three List*Columns handlers
  call it before converting, so num_issues stops being null and consumers
  can render a kanban summary in a single round trip instead of N+1.

Tests:
* unit: empty-input fast path on ColumnList.LoadIssueCounts.
* integration: extended testAPIListProjectColumnIssues / -User / -Org to
  close an issue, then verify default=open hides it, state=closed and
  state=all return it, and the column-list response carries the correct
  open/closed/total split.

Closes #4, closes #5
2026-05-15 21:54:35 +03:00
6 changed files with 134 additions and 3 deletions
+21
View File
@@ -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
}
+19
View File
@@ -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)
+8
View File
@@ -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",
+10 -1
View File
@@ -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
}
@@ -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"})
+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.
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<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 {
// 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);