mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* perf(issues): resolve a page's duplicate originals in one read (MUL-7349) Filling duplicate_of read each distinct original with its own primary-key lookup, so a page of duplicates of different issues cost one round trip per row. Pages now pass the originals their rows point at to newStatusCategoryFiller, which loads them with one ListIssueRefsInWorkspace read, the same way labelsByIssue loads a page's labels. List, open issues, grouped, search and table rows do this; a response built on its own still resolves its original, so a caller that passes nothing is slower, never wrong. A pointer left on a reopened issue is not looked up at all, and an original that no longer exists is looked up once and left off. Co-authored-by: multica-agent <[email protected]> * fix(issues): stop logging stale duplicate pointers as unmarks (MUL-7349) Servers from before the duplicate column reopened duplicates and deleted originals without clearing the pointer. After an upgrade, the next write to such an issue clears it, and the activity log compared raw pointers, so it logged "removed duplicate mark" for a mark that no longer counted and dropped the real status row in its place. Event publishers now send only live marks: a pointer counts while the issue is cancelled, the same rule reads apply. Separately, the status row is dropped only when a duplicate row was actually written to stand in for it; when the original is gone there is no duplicate row, and the reopen used to leave no activity at all. Co-authored-by: multica-agent <[email protected]> * fix(issues): keep duplicate marks in background issue snapshots (MUL-7349) IssueToMap hardcoded duplicate_of to null on the assumption that no background publisher renders a marked issue. PublishAttachmentsChanged does: a channel /issue with media re-reads the issue after a download of up to 45 seconds, and a mark made in that window was broadcast as null. Clients patch their cache with that snapshot, so the list and board marker disappeared until the next refetch. IssueToMapResolved now resolves duplicate_of the way the HTTP rendering does; only a cancelled issue with a pointer costs a read. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
187 lines
7.2 KiB
Go
187 lines
7.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Duplicate marks (MUL-7349). A duplicate is an ordinary cancelled issue that
|
|
// remembers which issue it duplicates: there is no duplicate status. Setting
|
|
// the mark goes through UpdateIssue as a status write, and any status change
|
|
// away from cancelled clears it in SQL (UpdateIssue / UpdateIssueStatus).
|
|
//
|
|
// The relation stays one level deep in both directions: a target must not be
|
|
// a duplicate itself, and an issue that others are marked as duplicates of
|
|
// cannot be marked. Nothing is collapsed or re-pointed automatically.
|
|
//
|
|
// The column and the code that maintains it shipped one release earlier, so a
|
|
// rollback lands on a server that still clears the pointer when an issue is
|
|
// reopened or its original deleted. Reads below additionally require the
|
|
// duplicate to be cancelled and its original to exist, which keeps a rollback
|
|
// that goes back further than that release readable rather than wrong.
|
|
|
|
var (
|
|
errDuplicateTargetNotFound = errors.New("duplicate target not found in this workspace")
|
|
errDuplicateTargetIsDuplicate = errors.New("duplicate target is itself a duplicate")
|
|
errIssueHasDuplicates = errors.New("issue has duplicates")
|
|
)
|
|
|
|
// parseDuplicateMark reads duplicate_of_issue_id from an update request. When
|
|
// present it turns the request into a status write to cancelled, so the mark
|
|
// shares the status resolution, status_changed event and stage-barrier
|
|
// handling of cancelling by hand. It returns an invalid UUID when the request
|
|
// does not mark, and false after writing an error response.
|
|
func parseDuplicateMark(w http.ResponseWriter, req *UpdateIssueRequest, rawFields map[string]json.RawMessage, issueID pgtype.UUID) (pgtype.UUID, bool) {
|
|
if _, present := rawFields["duplicate_of_issue_id"]; !present {
|
|
return pgtype.UUID{}, true
|
|
}
|
|
if req.DuplicateOfIssueID == nil {
|
|
writeError(w, http.StatusBadRequest, "duplicate_of_issue_id cannot be null; change the status to remove a duplicate mark")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
targetID, ok := parseUUIDOrBadRequest(w, *req.DuplicateOfIssueID, "duplicate_of_issue_id")
|
|
if !ok {
|
|
return pgtype.UUID{}, false
|
|
}
|
|
if targetID == issueID {
|
|
writeError(w, http.StatusBadRequest, "an issue cannot be a duplicate of itself")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
if req.Status != nil && *req.Status != issuestatus.Cancelled {
|
|
writeError(w, http.StatusBadRequest, "a duplicate is cancelled; status must be cancelled or omitted")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
// The description/attachment path locks attachments before the issue row,
|
|
// while a mark locks two issue rows first. Keeping them apart keeps each
|
|
// path's lock order fixed.
|
|
if req.Description != nil || req.TitleBase != nil || req.DescriptionBase != nil || len(req.AttachmentIDs) > 0 {
|
|
writeError(w, http.StatusBadRequest, "duplicate_of_issue_id cannot be combined with description or attachment changes")
|
|
return pgtype.UUID{}, false
|
|
}
|
|
cancelled := issuestatus.Cancelled
|
|
req.Status = &cancelled
|
|
return targetID, true
|
|
}
|
|
|
|
// lockAndCheckDuplicateMark validates a mark inside the update transaction.
|
|
// Both issues are locked first, so marks that share an issue run one after
|
|
// the other and each validates what the previous one wrote. Deleting the
|
|
// target also locks it, so a mark never commits a pointer to a deleted issue.
|
|
func lockAndCheckDuplicateMark(ctx context.Context, q *db.Queries, workspaceID, issueID, targetID pgtype.UUID) error {
|
|
rows, err := q.LockIssuesForDuplicateMark(ctx, db.LockIssuesForDuplicateMarkParams{
|
|
WorkspaceID: workspaceID,
|
|
IssueIds: []pgtype.UUID{issueID, targetID},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("lock issues for duplicate mark: %w", err)
|
|
}
|
|
targetFound := false
|
|
for _, row := range rows {
|
|
if row.ID != targetID {
|
|
continue
|
|
}
|
|
targetFound = true
|
|
if row.IsDuplicate {
|
|
return errDuplicateTargetIsDuplicate
|
|
}
|
|
}
|
|
if !targetFound {
|
|
return errDuplicateTargetNotFound
|
|
}
|
|
hasDuplicates, err := q.IssueHasDuplicates(ctx, db.IssueHasDuplicatesParams{
|
|
WorkspaceID: workspaceID,
|
|
IssueID: issueID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("check issue duplicates: %w", err)
|
|
}
|
|
if hasDuplicates {
|
|
return errIssueHasDuplicates
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeDuplicateMarkError(w http.ResponseWriter, err error) bool {
|
|
switch {
|
|
case errors.Is(err, errDuplicateTargetNotFound):
|
|
writeError(w, http.StatusBadRequest, "duplicate target not found in this workspace")
|
|
case errors.Is(err, errDuplicateTargetIsDuplicate):
|
|
writeErrorCode(w, http.StatusConflict, "duplicate_target_is_duplicate",
|
|
"the target issue is itself a duplicate; mark this issue as a duplicate of its original instead")
|
|
case errors.Is(err, errIssueHasDuplicates):
|
|
writeErrorCode(w, http.StatusConflict, "issue_has_duplicates",
|
|
"other issues are marked as duplicates of this issue; remove those marks first")
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// liveDuplicateMark renders one end of a mark change for an issue:updated
|
|
// payload: the pointer only while it counts, i.e. the issue is cancelled. A
|
|
// pointer an older server left on a reopened issue is no mark, so a write
|
|
// that clears it is not a mark being removed.
|
|
func liveDuplicateMark(status string, id pgtype.UUID) *string {
|
|
return uuidToPtr(duplicateOfPointer(status, id))
|
|
}
|
|
|
|
// ListIssueDuplicates returns both sides of an issue's duplicate relation: the
|
|
// original it duplicates, if any, and the issues marked as its duplicates.
|
|
func (h *Handler) ListIssueDuplicates(w http.ResponseWriter, r *http.Request) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
|
fill := h.newStatusCategoryFiller(r.Context(), issue.WorkspaceID)
|
|
|
|
var duplicateOf *IssueResponse
|
|
if issue.Status == issuestatus.Cancelled && issue.DuplicateOfIssueID.Valid {
|
|
original, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
|
ID: issue.DuplicateOfIssueID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
resp := issueToResponse(original, prefix)
|
|
fill(&resp)
|
|
duplicateOf = &resp
|
|
case !isNotFound(err):
|
|
slog.Warn("load duplicate original failed", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issue.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to load duplicate original")
|
|
return
|
|
}
|
|
}
|
|
|
|
rows, err := h.Queries.ListIssueDuplicates(r.Context(), db.ListIssueDuplicatesParams{
|
|
WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("list issue duplicates failed", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issue.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to list duplicates")
|
|
return
|
|
}
|
|
duplicates := make([]IssueResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
resp := issueToResponse(row, prefix)
|
|
fill(&resp)
|
|
duplicates = append(duplicates, resp)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"duplicate_of": duplicateOf,
|
|
"duplicates": duplicates,
|
|
})
|
|
}
|