mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(server): add the duplicate-mark column and keep it correct (MUL-7349) Expand step for marking an issue as a duplicate of another. A duplicate is an ordinary cancelled issue that records which issue it duplicates; there is no duplicate status. This release adds the column and the code that maintains it, and nothing writes a pointer yet. - A mark survives only a write that leaves an already cancelled issue cancelled. Every other write drops it, so reopening removes a mark and cancelling again does not bring one back. Both status-writing queries carry the rule, which covers the UI, batch updates and background writers. - Deleting an issue clears the pointers of its duplicates in the same transaction, the way deleting a parent detaches its children. Shipping this before marking is what makes marking safe to roll back: the release a rollback lands on already clears the pointer. Tests seed a mark the way the next release will write it. Co-authored-by: multica-agent <[email protected]> * fix(server): compute issue updates from the locked row (MUL-7349) UpdateIssue built its next_* values in a CTE that read the row before the UPDATE took its row lock. A write that waited there behind another transaction then resumed with the values it had read before the wait and wrote them back, so an ordinary priority or status edit could restore a duplicate mark the other transaction had just cleared — by reopening the issue or by deleting its original — and did_change/did_activity were decided against the pre-wait row as well. Lock the row in the CTE instead, so every computed value comes from the row the statement is about to write. Tests hold the clearing transaction open and wait for the update to block on the row lock, so the interleaving is deterministic; both fail without this change. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
394 lines
12 KiB
Go
394 lines
12 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: issue_property.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const countActiveIssueProperties = `-- name: CountActiveIssueProperties :one
|
|
SELECT COUNT(*) FROM issue_property
|
|
WHERE workspace_id = $1 AND archived_at IS NULL
|
|
`
|
|
|
|
func (q *Queries) CountActiveIssueProperties(ctx context.Context, workspaceID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countActiveIssueProperties, workspaceID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const countIssuesUsingPropertyOptions = `-- name: CountIssuesUsingPropertyOptions :many
|
|
SELECT opt::text AS option_id, COUNT(i.id) AS usage_count
|
|
FROM unnest($1::text[]) AS opt
|
|
LEFT JOIN issue i
|
|
ON i.workspace_id = $2::uuid
|
|
AND (i.properties -> $3::text) ? opt
|
|
GROUP BY opt
|
|
HAVING COUNT(i.id) > 0
|
|
`
|
|
|
|
type CountIssuesUsingPropertyOptionsParams struct {
|
|
OptionIds []string `json:"option_ids"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
PropertyKey string `json:"property_key"`
|
|
}
|
|
|
|
type CountIssuesUsingPropertyOptionsRow struct {
|
|
OptionID string `json:"option_id"`
|
|
UsageCount int64 `json:"usage_count"`
|
|
}
|
|
|
|
// Usage census for specific option ids of one property. jsonb `?` matches
|
|
// both value shapes: array element for multi_select, string equality for
|
|
// select. Only options with at least one referencing issue come back.
|
|
func (q *Queries) CountIssuesUsingPropertyOptions(ctx context.Context, arg CountIssuesUsingPropertyOptionsParams) ([]CountIssuesUsingPropertyOptionsRow, error) {
|
|
rows, err := q.db.Query(ctx, countIssuesUsingPropertyOptions, arg.OptionIds, arg.WorkspaceID, arg.PropertyKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []CountIssuesUsingPropertyOptionsRow{}
|
|
for rows.Next() {
|
|
var i CountIssuesUsingPropertyOptionsRow
|
|
if err := rows.Scan(&i.OptionID, &i.UsageCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const createIssueProperty = `-- name: CreateIssueProperty :one
|
|
INSERT INTO issue_property (workspace_id, name, type, description, icon, config, position)
|
|
SELECT $1::uuid,
|
|
$2::text,
|
|
$3::text,
|
|
$4::text,
|
|
$5::text,
|
|
$6::jsonb,
|
|
COALESCE((SELECT MAX(position) FROM issue_property WHERE workspace_id = $1::uuid), 0) + 1
|
|
RETURNING id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon
|
|
`
|
|
|
|
type CreateIssuePropertyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
Config []byte `json:"config"`
|
|
}
|
|
|
|
// New definitions append to the end of the catalog: position = max + 1.
|
|
func (q *Queries) CreateIssueProperty(ctx context.Context, arg CreateIssuePropertyParams) (IssueProperty, error) {
|
|
row := q.db.QueryRow(ctx, createIssueProperty,
|
|
arg.WorkspaceID,
|
|
arg.Name,
|
|
arg.Type,
|
|
arg.Description,
|
|
arg.Icon,
|
|
arg.Config,
|
|
)
|
|
var i IssueProperty
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Type,
|
|
&i.Description,
|
|
&i.Config,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Icon,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteIssuePropertyValue = `-- name: DeleteIssuePropertyValue :one
|
|
UPDATE issue
|
|
SET properties = properties - $1::text,
|
|
revision = revision + CASE WHEN properties ? $1::text THEN 1 ELSE 0 END,
|
|
last_activity_at = CASE
|
|
WHEN properties ? $1::text
|
|
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
|
|
ELSE last_activity_at
|
|
END,
|
|
updated_at = CASE WHEN properties ? $1::text THEN now() ELSE updated_at END
|
|
WHERE id = $2::uuid AND workspace_id = $3::uuid
|
|
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at, triage_state, duplicate_of_issue_id
|
|
`
|
|
|
|
type DeleteIssuePropertyValueParams struct {
|
|
Key string `json:"key"`
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) DeleteIssuePropertyValue(ctx context.Context, arg DeleteIssuePropertyValueParams) (Issue, error) {
|
|
row := q.db.QueryRow(ctx, deleteIssuePropertyValue, arg.Key, arg.ID, arg.WorkspaceID)
|
|
var i Issue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Status,
|
|
&i.Priority,
|
|
&i.AssigneeType,
|
|
&i.AssigneeID,
|
|
&i.CreatorType,
|
|
&i.CreatorID,
|
|
&i.ParentIssueID,
|
|
&i.AcceptanceCriteria,
|
|
&i.ContextRefs,
|
|
&i.Position,
|
|
&i.DueDate,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Number,
|
|
&i.ProjectID,
|
|
&i.OriginType,
|
|
&i.OriginID,
|
|
&i.FirstExecutedAt,
|
|
&i.StartDate,
|
|
&i.Metadata,
|
|
&i.Stage,
|
|
&i.Properties,
|
|
&i.Revision,
|
|
&i.LastActivityAt,
|
|
&i.TriageState,
|
|
&i.DuplicateOfIssueID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getIssueProperty = `-- name: GetIssueProperty :one
|
|
SELECT id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon FROM issue_property
|
|
WHERE id = $1 AND workspace_id = $2
|
|
`
|
|
|
|
type GetIssuePropertyParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) GetIssueProperty(ctx context.Context, arg GetIssuePropertyParams) (IssueProperty, error) {
|
|
row := q.db.QueryRow(ctx, getIssueProperty, arg.ID, arg.WorkspaceID)
|
|
var i IssueProperty
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Type,
|
|
&i.Description,
|
|
&i.Config,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Icon,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listIssueProperties = `-- name: ListIssueProperties :many
|
|
SELECT p.id, p.workspace_id, p.name, p.type, p.description, p.config, p.position, p.archived_at, p.created_at, p.updated_at, p.icon,
|
|
(
|
|
SELECT COUNT(*) FROM issue i
|
|
WHERE i.workspace_id = p.workspace_id
|
|
AND i.properties ? p.id::text
|
|
)::bigint AS usage_count
|
|
FROM issue_property p
|
|
WHERE p.workspace_id = $1::uuid
|
|
AND ($2::bool OR p.archived_at IS NULL)
|
|
ORDER BY p.position ASC, LOWER(p.name) ASC
|
|
`
|
|
|
|
type ListIssuePropertiesParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
IncludeArchived bool `json:"include_archived"`
|
|
}
|
|
|
|
type ListIssuePropertiesRow struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Description string `json:"description"`
|
|
Config []byte `json:"config"`
|
|
Position float64 `json:"position"`
|
|
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
|
Icon string `json:"icon"`
|
|
UsageCount int64 `json:"usage_count"`
|
|
}
|
|
|
|
// usage_count = number of issues in the workspace that currently carry a
|
|
// value for this property. `properties ? id` is a seq scan today; fine at
|
|
// the 20-definition / small-workspace scale this feature targets.
|
|
func (q *Queries) ListIssueProperties(ctx context.Context, arg ListIssuePropertiesParams) ([]ListIssuePropertiesRow, error) {
|
|
rows, err := q.db.Query(ctx, listIssueProperties, arg.WorkspaceID, arg.IncludeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListIssuePropertiesRow{}
|
|
for rows.Next() {
|
|
var i ListIssuePropertiesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Type,
|
|
&i.Description,
|
|
&i.Config,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Icon,
|
|
&i.UsageCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const setIssuePropertyValue = `-- name: SetIssuePropertyValue :one
|
|
UPDATE issue
|
|
SET properties = jsonb_set(properties, ARRAY[$1::text], $2::jsonb, true),
|
|
revision = revision + CASE WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb THEN 1 ELSE 0 END,
|
|
last_activity_at = CASE
|
|
WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb
|
|
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
|
|
ELSE last_activity_at
|
|
END,
|
|
updated_at = CASE WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb THEN now() ELSE updated_at END
|
|
WHERE id = $3::uuid AND workspace_id = $4::uuid
|
|
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at, triage_state, duplicate_of_issue_id
|
|
`
|
|
|
|
type SetIssuePropertyValueParams struct {
|
|
Key string `json:"key"`
|
|
Value []byte `json:"value"`
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
// Single-key atomic write: concurrent writers on different property keys
|
|
// never clobber each other. Unlike SetIssueMetadataKey, a no-op still updates
|
|
// and returns the issue row.
|
|
func (q *Queries) SetIssuePropertyValue(ctx context.Context, arg SetIssuePropertyValueParams) (Issue, error) {
|
|
row := q.db.QueryRow(ctx, setIssuePropertyValue,
|
|
arg.Key,
|
|
arg.Value,
|
|
arg.ID,
|
|
arg.WorkspaceID,
|
|
)
|
|
var i Issue
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Title,
|
|
&i.Description,
|
|
&i.Status,
|
|
&i.Priority,
|
|
&i.AssigneeType,
|
|
&i.AssigneeID,
|
|
&i.CreatorType,
|
|
&i.CreatorID,
|
|
&i.ParentIssueID,
|
|
&i.AcceptanceCriteria,
|
|
&i.ContextRefs,
|
|
&i.Position,
|
|
&i.DueDate,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Number,
|
|
&i.ProjectID,
|
|
&i.OriginType,
|
|
&i.OriginID,
|
|
&i.FirstExecutedAt,
|
|
&i.StartDate,
|
|
&i.Metadata,
|
|
&i.Stage,
|
|
&i.Properties,
|
|
&i.Revision,
|
|
&i.LastActivityAt,
|
|
&i.TriageState,
|
|
&i.DuplicateOfIssueID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const updateIssueProperty = `-- name: UpdateIssueProperty :one
|
|
UPDATE issue_property SET
|
|
name = COALESCE($3, name),
|
|
description = COALESCE($4, description),
|
|
icon = COALESCE($5, icon),
|
|
config = COALESCE($6, config),
|
|
archived_at = CASE WHEN $7::bool THEN $8 ELSE archived_at END,
|
|
updated_at = now()
|
|
WHERE id = $1 AND workspace_id = $2
|
|
RETURNING id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon
|
|
`
|
|
|
|
type UpdateIssuePropertyParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Name pgtype.Text `json:"name"`
|
|
Description pgtype.Text `json:"description"`
|
|
Icon pgtype.Text `json:"icon"`
|
|
Config []byte `json:"config"`
|
|
ArchivedSet bool `json:"archived_set"`
|
|
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
|
}
|
|
|
|
// `type` is deliberately immutable — changing it would silently invalidate
|
|
// existing values. archived_set/archived_at implement tri-state semantics:
|
|
// archived_set=false leaves archived_at untouched.
|
|
func (q *Queries) UpdateIssueProperty(ctx context.Context, arg UpdateIssuePropertyParams) (IssueProperty, error) {
|
|
row := q.db.QueryRow(ctx, updateIssueProperty,
|
|
arg.ID,
|
|
arg.WorkspaceID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.Icon,
|
|
arg.Config,
|
|
arg.ArchivedSet,
|
|
arg.ArchivedAt,
|
|
)
|
|
var i IssueProperty
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.WorkspaceID,
|
|
&i.Name,
|
|
&i.Type,
|
|
&i.Description,
|
|
&i.Config,
|
|
&i.Position,
|
|
&i.ArchivedAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Icon,
|
|
)
|
|
return i, err
|
|
}
|