mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-08 06:45:55 +02:00
* feat(issues): stage sub-issues so the parent wakes per stage, not per child Sub-issues under a parent can be grouped into ordered stages (issue.stage). The child-done -> parent notification + assignee wake now fire only when a stage barrier closes: every sub-issue in the lowest unfinished stage has reached a terminal status (done/cancelled). An unstaged sibling set is one implicit stage, so the parent is woken once when the last sub-issue finishes instead of on every child — the default fix for the fire-on-every-child cascade reported in discussion #4320 / MUL-3508. Stage advancement stays agent-driven: the server only detects the closed barrier and wakes the parent assignee, who decides whether to promote the next stage. - DB: nullable issue.stage (CHECK >= 1) + sqlc regen - API: stage on issue create/update/response and batch update - CLI: `issue create`/`issue update` --stage; new `issue children` command that lists sub-issues grouped by stage (table + json) - stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the wake comment now stage-aware, plus unit tests - skill docs (multica-working-on-issues SKILL.md + source map) Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a follow-up; the API already returns stage for it to consume. MUL-3508 Co-authored-by: multica-agent <github@multica.ai> * fix(issues): address review on stage barrier (cancel, batch, unstaged) Resolves the three blockers from the PR review: 1. Cancel can close a stage. The child-done barrier now fires on any non-terminal -> terminal transition (done OR cancelled), not just done. isTerminalChildStatus already treats cancelled as terminal (a cancelled sibling never finishes, so it must not hold a stage open), so a cancelled last-open child now closes its stage and wakes the parent. Keying on the transition also makes a later cancelled -> done edit a no-op, avoiding a lagging duplicate wake. 2. Batch update of stage no longer no-ops. `hasMutation` now includes "stage", so `{"updates":{"stage":N}}` persists instead of returning {"updated": 0}. 3. Unstaged children no longer participate in the staged frontier. In a staged sibling set, NULL-stage children neither hold a stage open nor fire on their own completion, and the wake comment no longer renders "Stage 0". This matches migration 123 ("NULL does not participate in staged grouping") and the CLI's separate unstaged group, removing the footgun where an unstaged backlog child silently blocked Stage 1. Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a staged set, stage summary skips unstaged, and a stage-only batch update persists. MUL-3508 Co-authored-by: multica-agent <github@multica.ai> * feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage Frontend for the sub-issue stage feature (web + desktop, shared via packages): - core: `stage` on the Issue type + create/update request types; zod IssueSchema parses it (defaults to null for older backends) with schema tests for the numeric and omitted cases. - StagePicker component (mirrors the other property pickers): "No stage" + Stage 1..N, offering one beyond the current/sibling max. - Create-issue modal: a Stage pill, shown only when a parent is selected, threaded into the create payload. - Issue detail sidebar: an editable Stage row + "add property" entry, gated to sub-issues (issues with a parent). - Sub-issue list grouped by stage with per-stage headers (flat when unstaged). - i18n: stage keys across en / zh-Hans / ja / ko (parity test passes). Verified: full typecheck (6/6), core (591) + views (1433) vitest suites, lint clean (no new findings). Backend/CLI shipped earlier in this PR. MUL-3508 Co-authored-by: multica-agent <github@multica.ai> * test(issues): add stage to Issue fixtures merged from main The merge brought in new Issue fixtures that predate the required `stage` field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy the Issue type (mobile reuses core's IssueSchema for parsing, so only the sentinel needs it). MUL-3508 Co-authored-by: multica-agent <github@multica.ai> * fix(web): feed StagePicker the sibling max stage so higher stages stay selectable The StagePicker accepts maxStage to extend its option list beyond the floored Stage 1-3, but neither call site passed it, so a parent with an existing Stage 4/5 child could not pick that stage when creating a new sub-issue or editing one in the sidebar. - Compute the sibling max stage at both call sites: the create modal now loads the parent's children (childIssuesOptions) and the detail sidebar reuses the already-loaded parentChildIssues. - Extract maxSiblingStage + stageOptions as pure helpers on stage-picker and unit-test them (the regression: a Stage 5 sibling keeps Stage 5 selectable and offers Stage 6). MUL-3508 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
161 lines
4.1 KiB
Go
161 lines
4.1 KiB
Go
package util
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// ParseUUID parses s into a pgtype.UUID. Invalid input returns an error
|
|
// instead of a zero-valued UUID — silently dropping bad input has caused
|
|
// data-loss bugs (e.g. DELETE matching no rows, returning 204 success).
|
|
//
|
|
// Use this at any boundary where s comes from user input (URL params,
|
|
// request bodies, headers) and pair it with a 4xx response on error.
|
|
// For trusted, already-validated UUID strings (sqlc round-trips, fixtures),
|
|
// use MustParseUUID instead.
|
|
func ParseUUID(s string) (pgtype.UUID, error) {
|
|
var u pgtype.UUID
|
|
if err := u.Scan(s); err != nil {
|
|
return u, fmt.Errorf("invalid UUID %q: %w", s, err)
|
|
}
|
|
if !u.Valid {
|
|
return u, fmt.Errorf("invalid UUID: %q", s)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// MustParseUUID parses s into a pgtype.UUID and panics on invalid input.
|
|
// Reserve for trusted callers (already-validated round-trips, test fixtures).
|
|
// At a request boundary, use ParseUUID and surface a 4xx instead.
|
|
func MustParseUUID(s string) pgtype.UUID {
|
|
u, err := ParseUUID(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func UUIDToString(u pgtype.UUID) string {
|
|
if !u.Valid {
|
|
return ""
|
|
}
|
|
b := u.Bytes
|
|
dst := make([]byte, 36)
|
|
hex.Encode(dst[0:8], b[0:4])
|
|
dst[8] = '-'
|
|
hex.Encode(dst[9:13], b[4:6])
|
|
dst[13] = '-'
|
|
hex.Encode(dst[14:18], b[6:8])
|
|
dst[18] = '-'
|
|
hex.Encode(dst[19:23], b[8:10])
|
|
dst[23] = '-'
|
|
hex.Encode(dst[24:36], b[10:16])
|
|
return string(dst)
|
|
}
|
|
|
|
func TextToPtr(t pgtype.Text) *string {
|
|
if !t.Valid {
|
|
return nil
|
|
}
|
|
return &t.String
|
|
}
|
|
|
|
func PtrToText(s *string) pgtype.Text {
|
|
if s == nil {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: *s, Valid: true}
|
|
}
|
|
|
|
func StrToText(s string) pgtype.Text {
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
func TimestampToString(t pgtype.Timestamptz) string {
|
|
if !t.Valid {
|
|
return ""
|
|
}
|
|
return t.Time.Format(time.RFC3339)
|
|
}
|
|
|
|
func TimestampToPtr(t pgtype.Timestamptz) *string {
|
|
if !t.Valid {
|
|
return nil
|
|
}
|
|
s := t.Time.Format(time.RFC3339)
|
|
return &s
|
|
}
|
|
|
|
// DateToPtr formats a pgtype.Date as a date-only "YYYY-MM-DD" string, or nil
|
|
// when unset. Issue start_date/due_date are calendar days with no time-of-day
|
|
// or timezone, so they must never be rendered through an instant.
|
|
func DateToPtr(d pgtype.Date) *string {
|
|
if !d.Valid {
|
|
return nil
|
|
}
|
|
s := d.Time.Format(time.DateOnly)
|
|
return &s
|
|
}
|
|
|
|
// ParseCalendarDate parses a calendar day from a "YYYY-MM-DD" string into a
|
|
// pgtype.Date carrying no time-of-day or timezone.
|
|
//
|
|
// For backward compatibility it ALSO accepts an RFC3339 timestamp, but ONLY
|
|
// when it lands exactly on a UTC day boundary (e.g. "2026-03-01T00:00:00Z"),
|
|
// which unambiguously denotes that calendar day. A non-midnight instant is a
|
|
// legacy local-midnight-as-UTC value (e.g. UTC+8 sends "2026-02-28T16:00:00Z"
|
|
// for the picked day 2026-03-01) whose intended calendar day is unrecoverable —
|
|
// it is rejected loudly rather than silently stored as the wrong day. New
|
|
// clients always send "YYYY-MM-DD".
|
|
func ParseCalendarDate(s string) (pgtype.Date, error) {
|
|
if t, err := time.Parse(time.DateOnly, s); err == nil {
|
|
return pgtype.Date{Time: t, Valid: true}, nil
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
u := t.UTC()
|
|
if u.Hour() == 0 && u.Minute() == 0 && u.Second() == 0 && u.Nanosecond() == 0 {
|
|
return pgtype.Date{
|
|
Time: time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC),
|
|
Valid: true,
|
|
}, nil
|
|
}
|
|
return pgtype.Date{}, fmt.Errorf("invalid date %q: timestamps must be a UTC midnight boundary (e.g. 2026-03-01T00:00:00Z); use YYYY-MM-DD", s)
|
|
}
|
|
return pgtype.Date{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", s)
|
|
}
|
|
|
|
func UUIDToPtr(u pgtype.UUID) *string {
|
|
if !u.Valid {
|
|
return nil
|
|
}
|
|
s := UUIDToString(u)
|
|
return &s
|
|
}
|
|
|
|
func Int8ToPtr(v pgtype.Int8) *int64 {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
return &v.Int64
|
|
}
|
|
|
|
func Int4ToPtr(v pgtype.Int4) *int32 {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
return &v.Int32
|
|
}
|
|
|
|
func PtrToInt4(v *int32) pgtype.Int4 {
|
|
if v == nil {
|
|
return pgtype.Int4{}
|
|
}
|
|
return pgtype.Int4{Int32: *v, Valid: true}
|
|
}
|