diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 4cc9cb56c..3f60c6f69 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -9,6 +9,8 @@ import ( "net/http" "net/url" "os" + "sort" + "strconv" "strings" "time" "unicode/utf8" @@ -109,6 +111,14 @@ var issuePullRequestsCmd = &cobra.Command{ RunE: runIssuePullRequests, } +var issueChildrenCmd = &cobra.Command{ + Use: "children ", + Aliases: []string{"subissues"}, + Short: "List an issue's sub-issues grouped by stage", + Args: exactArgs(1), + RunE: runIssueChildren, +} + var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new issue", @@ -263,6 +273,7 @@ func init() { issueCmd.AddCommand(issueListCmd) issueCmd.AddCommand(issueGetCmd) issueCmd.AddCommand(issuePullRequestsCmd) + issueCmd.AddCommand(issueChildrenCmd) issueCmd.AddCommand(issueCreateCmd) issueCmd.AddCommand(issueUpdateCmd) issueCmd.AddCommand(issueAssignCmd) @@ -301,6 +312,9 @@ func init() { // issue pull-requests issuePullRequestsCmd.Flags().String("output", "table", "Output format: table or json") + issueChildrenCmd.Flags().String("output", "table", "Output format: table or json") + issueChildrenCmd.Flags().Bool("full-id", false, "Show full UUIDs in table output") + // issue create issueCreateCmd.Flags().String("title", "", "Issue title (required)") issueCreateCmd.Flags().String("description", "", "Issue description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)") @@ -311,6 +325,7 @@ func init() { issueCreateCmd.Flags().String("assignee", "", "Assignee name (member, agent, or squad; fuzzy match)") issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --assignee)") issueCreateCmd.Flags().String("parent", "", "Parent issue ID") + issueCreateCmd.Flags().Int("stage", 0, "Stage ordinal (>=1) grouping this sub-issue into an ordered barrier group under its parent; omit for unstaged. The parent assignee is woken only when every sub-issue in a stage finishes.") issueCreateCmd.Flags().String("project", "", "Project ID") issueCreateCmd.Flags().String("start-date", "", "Start date (calendar day, YYYY-MM-DD)") issueCreateCmd.Flags().String("due-date", "", "Due date (calendar day, YYYY-MM-DD)") @@ -332,6 +347,7 @@ func init() { issueUpdateCmd.Flags().String("start-date", "", "New start date (calendar day, YYYY-MM-DD; pass empty string to clear)") issueUpdateCmd.Flags().String("due-date", "", "New due date (calendar day, YYYY-MM-DD)") issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)") + issueUpdateCmd.Flags().Int("stage", 0, "Stage ordinal (>=1) for this sub-issue; see `issue create --stage`") issueUpdateCmd.Flags().String("output", "json", "Output format: table or json") // issue status @@ -637,6 +653,120 @@ func runIssueGet(cmd *cobra.Command, args []string) error { return cli.PrintJSON(os.Stdout, issue) } +// childStage extracts the integer stage from a child issue response map. +// Returns ok=false when the child is unstaged (stage null/absent). +func childStage(m map[string]any) (int, bool) { + v, ok := m["stage"] + if !ok || v == nil { + return 0, false + } + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, false + } + return int(i), true + } + return 0, false +} + +func runIssueChildren(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := cli.APIContext(context.Background()) + defer cancel() + + issueRef, err := resolveIssueRef(ctx, client, args[0]) + if err != nil { + return fmt.Errorf("resolve issue: %w", err) + } + + var resp struct { + Issues []map[string]any `json:"issues"` + } + if err := client.GetJSON(ctx, "/api/issues/"+issueRef.ID+"/children", &resp); err != nil { + return fmt.Errorf("list child issues: %w", err) + } + children := resp.Issues + + // Order by stage ascending (unstaged last), preserving the API's + // within-stage order (position, then created_at desc). + sort.SliceStable(children, func(i, j int) bool { + si, oki := childStage(children[i]) + sj, okj := childStage(children[j]) + if oki != okj { + return oki // staged before unstaged + } + return si < sj + }) + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + actors := loadActorDisplayLookup(ctx, client) + headers := []string{"STAGE", "KEY", "TITLE", "STATUS", "PRIORITY", "ASSIGNEE"} + rows := make([][]string, 0, len(children)) + for _, c := range children { + stageCell := "-" + if s, ok := childStage(c); ok { + stageCell = strconv.Itoa(s) + } + rows = append(rows, []string{ + stageCell, + issueDisplayKey(c), + strVal(c, "title"), + strVal(c, "status"), + strVal(c, "priority"), + formatAssignee(c, actors), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil + } + + // JSON: group by stage so an agent can see, at a glance, how many + // sub-issues there are and which stage each belongs to. + type stageGroup struct { + Stage int `json:"stage"` + Total int `json:"total"` + Done int `json:"done"` + Issues []map[string]any `json:"issues"` + } + stages := []stageGroup{} + unstaged := []map[string]any{} + idxByStage := map[int]int{} + for _, c := range children { + s, ok := childStage(c) + if !ok { + unstaged = append(unstaged, c) + continue + } + gi, seen := idxByStage[s] + if !seen { + stages = append(stages, stageGroup{Stage: s}) + gi = len(stages) - 1 + idxByStage[s] = gi + } + stages[gi].Issues = append(stages[gi].Issues, c) + stages[gi].Total++ + if st := strVal(c, "status"); st == "done" || st == "cancelled" { + stages[gi].Done++ + } + } + return cli.PrintJSON(os.Stdout, map[string]any{ + "total": len(children), + "stages": stages, + "unstaged": unstaged, + }) +} + // isHTTPURL reports whether path is an http:// or https:// URL. // Used to skip URL-shaped values passed to --attachment, which only // accepts local file paths. Trims surrounding whitespace because @@ -735,6 +865,13 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { } body["project_id"] = project.ID } + if cmd.Flags().Changed("stage") { + stage, _ := cmd.Flags().GetInt("stage") + if stage < 1 { + return fmt.Errorf("--stage must be >= 1") + } + body["stage"] = stage + } if v, _ := cmd.Flags().GetString("start-date"); v != "" { body["start_date"] = v } @@ -949,6 +1086,13 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error { body["parent_issue_id"] = parent.ID } } + if cmd.Flags().Changed("stage") { + stage, _ := cmd.Flags().GetInt("stage") + if stage < 1 { + return fmt.Errorf("--stage must be >= 1") + } + body["stage"] = stage + } if len(body) == 0 { return fmt.Errorf("no fields to update; use flags like --title, --status, --priority, --assignee, etc.") diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index a89e627cf..5816d16f1 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -274,6 +274,8 @@ func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr func dateToPtr(d pgtype.Date) *string { return util.DateToPtr(d) } func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) } func int8ToPtr(v pgtype.Int8) *int64 { return util.Int8ToPtr(v) } +func int4ToPtr(v pgtype.Int4) *int32 { return util.Int4ToPtr(v) } +func ptrToInt4(v *int32) pgtype.Int4 { return util.PtrToInt4(v) } // parseUUIDOrBadRequest validates a UUID string sourced from user input // (URL params, request body, headers). On invalid input it writes a 400 diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 81bd83a21..311e7ecff 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -44,10 +44,14 @@ type IssueResponse struct { ParentIssueID *string `json:"parent_issue_id"` ProjectID *string `json:"project_id"` Position float64 `json:"position"` - StartDate *string `json:"start_date"` - DueDate *string `json:"due_date"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Stage groups sub-issues under the same parent into ordered barrier + // groups (null = unstaged). See issue_child_done.go for how a closed + // stage gates the child-done -> parent wake. + Stage *int32 `json:"stage"` + StartDate *string `json:"start_date"` + DueDate *string `json:"due_date"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` // Metadata is the per-issue KV map (see issue_metadata.go). Always emitted // (empty object when unset) so frontend code can `issue.metadata[key]` // without nil-guarding the parent field. @@ -98,6 +102,7 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { ParentIssueID: uuidToPtr(i.ParentIssueID), ProjectID: uuidToPtr(i.ProjectID), Position: i.Position, + Stage: int4ToPtr(i.Stage), StartDate: dateToPtr(i.StartDate), DueDate: dateToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), @@ -125,6 +130,7 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons ParentIssueID: uuidToPtr(i.ParentIssueID), ProjectID: uuidToPtr(i.ProjectID), Position: i.Position, + Stage: int4ToPtr(i.Stage), StartDate: dateToPtr(i.StartDate), DueDate: dateToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), @@ -182,6 +188,7 @@ func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueRes ParentIssueID: uuidToPtr(i.ParentIssueID), ProjectID: uuidToPtr(i.ProjectID), Position: i.Position, + Stage: int4ToPtr(i.Stage), StartDate: dateToPtr(i.StartDate), DueDate: dateToPtr(i.DueDate), CreatedAt: timestampToString(i.CreatedAt), @@ -2049,6 +2056,7 @@ type CreateIssueRequest struct { AssigneeID *string `json:"assignee_id"` ParentIssueID *string `json:"parent_issue_id"` ProjectID *string `json:"project_id"` + Stage *int32 `json:"stage,omitempty"` StartDate *string `json:"start_date"` DueDate *string `json:"due_date"` AttachmentIDs []string `json:"attachment_ids,omitempty"` @@ -2105,6 +2113,10 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { if !validateIssueEnum(w, "priority", priority, validIssuePriorities) { return } + if req.Stage != nil && *req.Stage < 1 { + writeError(w, http.StatusBadRequest, "stage must be >= 1") + return + } var assigneeType pgtype.Text var assigneeID pgtype.UUID @@ -2241,6 +2253,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { DueDate: dueDate, OriginType: originType, OriginID: originID, + Stage: ptrToInt4(req.Stage), AttachmentIDs: attachmentIDs, AllowDuplicate: req.AllowDuplicate, }, service.IssueCreateOpts{ @@ -2298,6 +2311,7 @@ type UpdateIssueRequest struct { DueDate *string `json:"due_date"` ParentIssueID *string `json:"parent_issue_id"` ProjectID *string `json:"project_id"` + Stage *int32 `json:"stage"` // AttachmentIDs lets the description editor bind newly uploaded files to // this issue so they surface in `GET /api/issues/:id/attachments` and the // editor's preview Eye keeps working past a refresh. Existing bindings @@ -2340,6 +2354,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { DueDate: prevIssue.DueDate, ParentIssueID: prevIssue.ParentIssueID, ProjectID: prevIssue.ProjectID, + Stage: prevIssue.Stage, } // COALESCE fields — only set when explicitly provided @@ -2457,6 +2472,17 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { params.ProjectID = pgtype.UUID{Valid: false} } } + if _, ok := rawFields["stage"]; ok { + if req.Stage != nil { + if *req.Stage < 1 { + writeError(w, http.StatusBadRequest, "stage must be >= 1") + return + } + params.Stage = pgtype.Int4{Int32: *req.Stage, Valid: true} + } else { + params.Stage = pgtype.Int4{Valid: false} // explicit null = unstage + } + } // Validate the resulting (assignee_type, assignee_id) pair when the caller // touches either field. Existing data on the issue is left alone if the @@ -2888,6 +2914,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { DueDate: prevIssue.DueDate, ParentIssueID: prevIssue.ParentIssueID, ProjectID: prevIssue.ProjectID, + Stage: prevIssue.Stage, } if req.Updates.Title != nil { @@ -2996,6 +3023,16 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { params.ProjectID = pgtype.UUID{Valid: false} } } + if _, ok := rawUpdates["stage"]; ok { + if req.Updates.Stage != nil { + if *req.Updates.Stage < 1 { + continue + } + params.Stage = pgtype.Int4{Int32: *req.Updates.Stage, Valid: true} + } else { + params.Stage = pgtype.Int4{Valid: false} // explicit null = unstage + } + } // Validate the resulting assignee pair when this batch update touches // either assignee field. Skip the issue silently on failure. diff --git a/server/internal/handler/issue_child_done.go b/server/internal/handler/issue_child_done.go index c629196a2..91b770712 100644 --- a/server/internal/handler/issue_child_done.go +++ b/server/internal/handler/issue_child_done.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sort" "strconv" "strings" @@ -36,6 +37,15 @@ import ( // and there is nothing to "trigger" on a human assignee. Skipping the // comment entirely (Bohan's call on MUL-2538) also sidesteps the // mention question — no comment, no mention, no inbox row. +// - the completion must close a STAGE barrier (MUL-3508). Sub-issues under +// a parent can be grouped into ordered stages via issue.stage; the +// notification + wake fire only when every sibling in the lowest +// unfinished stage is terminal (stageBarrierClosed). An unstaged sibling +// set is one implicit stage, so this fires once when the last sub-issue +// finishes instead of on every child — the default fix for the +// fire-on-every-child cascade reported in #4320. The woken assignee +// decides whether to promote the next stage (agent-driven advancement); +// the server only detects the barrier and wakes. // // The comment is inserted directly via db.Queries (not through the // CreateComment HTTP handler) so it bypasses the generic on_comment trigger @@ -86,20 +96,60 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is return } + // Stage barrier (MUL-3508 / discussion #4320). The notification + assignee + // wake fire only when this completion *closes a stage* — i.e. every sibling + // in the lowest unfinished stage is now terminal. An unstaged sibling set is + // one implicit stage, so this collapses to "wake once when the last + // sub-issue finishes" instead of the old fire-on-every-child behavior that + // caused the surprise cascade. A completion that does not close a stage is + // silent: no comment, no wake. ListChildIssues already reflects this child's + // committed `done` status (the status update commits before this runs). + children, err := h.Queries.ListChildIssues(ctx, parent.ID) + if err != nil { + slog.Warn("child done: failed to list siblings for stage barrier", + "error", err, + "child_id", uuidToString(issue.ID), + "parent_id", uuidToString(parent.ID)) + return + } + if !stageBarrierClosed(children, issue) { + return + } + staged := siblingsAreStaged(children) + prefix := h.getIssuePrefix(ctx, issue.WorkspaceID) identifier := prefix + "-" + strconv.Itoa(int(issue.Number)) childID := uuidToString(issue.ID) title := sanitizeChildTitleForSystemComment(issue.Title) + parentID := uuidToString(parent.ID) // Build the parent-assignee mention prefix. Empty when the parent has no // assignee or the assignee row is missing (deleted member, archived // agent the workspace lost track of, etc.). mentionPrefix := h.buildParentAssigneeMention(ctx, parent) - content := fmt.Sprintf( - "%sSub-issue [%s](mention://issue/%s) — \"%s\" — is done. Before promoting any waiting `backlog` sub-issue, read each sibling's description and only promote items whose stated dependencies are already satisfied — do not rely on this parent's higher-level breakdown alone. If a sibling's description conflicts with that breakdown (e.g. it lists a prerequisite the parent treats as parallel), do NOT change its status — leave it `backlog` and post a comment to confirm first.", - mentionPrefix, identifier, childID, title, - ) + var content string + if staged { + summary, nextStage := stageProgressSummary(children, stageOrdinal(issue)) + var advance string + if nextStage > 0 { + advance = fmt.Sprintf( + " Stage %d is next. Review the full layout with `multica issue children %s`, and if Stage %d's dependencies are satisfied promote its `backlog` sub-issues to `todo` to continue. Read each sub-issue's description first and only promote items whose stated dependencies are already met — do not rely on this parent's higher-level breakdown alone. If a description conflicts with that breakdown, leave it `backlog` and post a comment to confirm first.", + nextStage, parentID, nextStage, + ) + } else { + advance = " This was the final stage. Wrap up the parent — synthesize the results and move it forward, or close it out if nothing remains." + } + content = fmt.Sprintf( + "%sStage %d of this issue is complete — its last sub-issue [%s](mention://issue/%s) — \"%s\" — just finished. Stage progress — %s.%s", + mentionPrefix, stageOrdinal(issue), identifier, childID, title, summary, advance, + ) + } else { + content = fmt.Sprintf( + "%sAll sub-issues are complete — the last one, [%s](mention://issue/%s) — \"%s\", just finished. Continue the parent: synthesize the children's results and move it forward, or close it out if nothing remains.", + mentionPrefix, identifier, childID, title, + ) + } // author_type='system', author_id=zero UUID. The zero UUID is a valid 16 // byte value and the column is NOT NULL; frontend code should branch on @@ -138,6 +188,100 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is h.dispatchParentAssigneeTrigger(ctx, parent, issue, comment, actorType, actorID) } +// isTerminalChildStatus reports whether a child issue status counts as +// "finished" for stage-barrier purposes. Cancelled counts as terminal: a +// cancelled sibling will never complete, so it must not hold a stage open. +func isTerminalChildStatus(status string) bool { + return status == "done" || status == "cancelled" +} + +// stageOrdinal returns the comparable stage ordinal for a child. Unstaged +// children (NULL stage) sort as 0 — before stage 1 — so that in a partially +// staged sibling set they belong to the earliest frontier. In a fully +// unstaged set the value is unused (siblingsAreStaged short-circuits). +func stageOrdinal(c db.Issue) int32 { + if c.Stage.Valid { + return c.Stage.Int32 + } + return 0 +} + +// siblingsAreStaged reports whether any child in the set carries an explicit +// stage. A set with no stages is treated as a single implicit stage. +func siblingsAreStaged(children []db.Issue) bool { + for _, c := range children { + if c.Stage.Valid { + return true + } + } + return false +} + +// stageBarrierClosed reports whether the completion of `completed` closed a +// stage barrier among `children` — the full sibling set under one parent, +// already reflecting completed's terminal status. +// +// - Unstaged sibling set: a single implicit stage. The barrier closes only +// when every child is terminal — the "wake once when the last sub-issue +// finishes" default. +// - Staged sibling set: the completed child's stage S closes when every +// child in stage <= S is terminal (frontier closure). Later stages are +// normally parked in `backlog`, so they cannot fire out of order; the +// caller's idempotency guard collapses any duplicate wake. +func stageBarrierClosed(children []db.Issue, completed db.Issue) bool { + if !siblingsAreStaged(children) { + for _, c := range children { + if !isTerminalChildStatus(c.Status) { + return false + } + } + return true + } + s := stageOrdinal(completed) + for _, c := range children { + if stageOrdinal(c) <= s && !isTerminalChildStatus(c.Status) { + return false + } + } + return true +} + +// stageProgressSummary renders a compact per-stage breakdown for the +// child-done system comment (e.g. "Stage 1: 3/3 done; Stage 2: 0/4 done") and +// returns the lowest stage above closedStage that still has non-terminal +// children — the next group to promote — or 0 when none remain. Only +// meaningful for staged sibling sets. +func stageProgressSummary(children []db.Issue, closedStage int32) (summary string, nextStage int32) { + type agg struct{ total, done int } + byStage := map[int32]*agg{} + order := []int32{} + for _, c := range children { + s := stageOrdinal(c) + a, ok := byStage[s] + if !ok { + a = &agg{} + byStage[s] = a + order = append(order, s) + } + a.total++ + if isTerminalChildStatus(c.Status) { + a.done++ + } + } + sort.Slice(order, func(i, j int) bool { return order[i] < order[j] }) + parts := make([]string, 0, len(order)) + for _, s := range order { + a := byStage[s] + label := fmt.Sprintf("Stage %d: %d/%d done", s, a.done, a.total) + if nextStage == 0 && s > closedStage && a.done < a.total { + nextStage = s + label += " (next)" + } + parts = append(parts, label) + } + return strings.Join(parts, "; "), nextStage +} + // sanitizeChildTitleForSystemComment removes mention-style markdown from a // child issue's title before it is embedded into the parent's system // comment. Smuggled mentions are already harmless on the listener path diff --git a/server/internal/handler/issue_child_done_stage_test.go b/server/internal/handler/issue_child_done_stage_test.go new file mode 100644 index 000000000..435f97a3a --- /dev/null +++ b/server/internal/handler/issue_child_done_stage_test.go @@ -0,0 +1,130 @@ +package handler + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// child builds a sibling row with the given stage (0 = unstaged/NULL) and +// status, the only two fields the stage-barrier logic reads. +func child(stage int32, status string) db.Issue { + c := db.Issue{Status: status} + if stage != 0 { + c.Stage = pgtype.Int4{Int32: stage, Valid: true} + } + return c +} + +func TestStageBarrierClosed_Unstaged(t *testing.T) { + tests := []struct { + name string + children []db.Issue + want bool + }{ + { + name: "last child still leaves a sibling open", + children: []db.Issue{child(0, "done"), child(0, "in_progress")}, + want: false, + }, + { + name: "every child terminal closes the single implicit stage", + children: []db.Issue{child(0, "done"), child(0, "done")}, + want: true, + }, + { + name: "a backlog sibling holds the barrier open (no surprise cascade)", + children: []db.Issue{child(0, "done"), child(0, "backlog")}, + want: false, + }, + { + name: "cancelled counts as terminal", + children: []db.Issue{child(0, "done"), child(0, "cancelled")}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // completed is one of the terminal children; identity doesn't matter + // for the unstaged path. + if got := stageBarrierClosed(tt.children, child(0, "done")); got != tt.want { + t.Fatalf("stageBarrierClosed = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStageBarrierClosed_Staged(t *testing.T) { + // Three stages: 1 has two children, 2 has two, 3 has one. + t.Run("stage 1 not fully done does not fire", func(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "in_progress"), + child(2, "backlog"), child(2, "backlog"), + child(3, "backlog"), + } + if stageBarrierClosed(children, child(1, "done")) { + t.Fatal("expected barrier not closed while stage 1 has an open child") + } + }) + + t.Run("closing stage 1 fires even though later stages are parked", func(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "done"), + child(2, "backlog"), child(2, "backlog"), + child(3, "backlog"), + } + if !stageBarrierClosed(children, child(1, "done")) { + t.Fatal("expected stage 1 barrier to close") + } + }) + + t.Run("closing stage 2 fires when stages 1 and 2 are terminal", func(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "done"), + child(2, "done"), child(2, "done"), + child(3, "backlog"), + } + if !stageBarrierClosed(children, child(2, "done")) { + t.Fatal("expected stage 2 barrier to close") + } + }) + + t.Run("final stage closes once its child finishes", func(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "done"), + child(2, "done"), child(2, "done"), + child(3, "done"), + } + if !stageBarrierClosed(children, child(3, "done")) { + t.Fatal("expected final stage barrier to close") + } + }) +} + +func TestStageProgressSummary(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "done"), child(1, "done"), + child(2, "backlog"), child(2, "backlog"), child(2, "backlog"), child(2, "backlog"), + child(3, "backlog"), child(3, "backlog"), + } + summary, next := stageProgressSummary(children, 1) + want := "Stage 1: 3/3 done; Stage 2: 0/4 done (next); Stage 3: 0/2 done" + if summary != want { + t.Fatalf("summary = %q, want %q", summary, want) + } + if next != 2 { + t.Fatalf("nextStage = %d, want 2", next) + } +} + +func TestStageProgressSummary_FinalStageNoNext(t *testing.T) { + children := []db.Issue{ + child(1, "done"), child(1, "done"), + child(2, "done"), + } + _, next := stageProgressSummary(children, 2) + if next != 0 { + t.Fatalf("nextStage = %d, want 0 (no further stages)", next) + } +} diff --git a/server/internal/service/builtin_skills/multica-working-on-issues/SKILL.md b/server/internal/service/builtin_skills/multica-working-on-issues/SKILL.md index 31002027e..809c3a980 100644 --- a/server/internal/service/builtin_skills/multica-working-on-issues/SKILL.md +++ b/server/internal/service/builtin_skills/multica-working-on-issues/SKILL.md @@ -164,6 +164,40 @@ multica issue status todo # promote when the previous step is truly Creating every serial step as `todo` enqueues the whole chain at once. +### Stages: order sub-issues into barrier groups + +`--stage ` (N ≥ 1) groups sub-issues under the same parent into ordered +stages. The parent assignee is woken **once, when a whole stage finishes** — +i.e. every sub-issue in the lowest unfinished stage has reached a terminal +status (`done`/`cancelled`). A completion that does not close a stage is silent +(no comment, no wake). A sibling set with **no** stages is one implicit stage, +so the parent is woken once when the *last* sub-issue finishes — not on every +child. + +Advancement is agent-driven: the server only detects the closed barrier and +wakes the parent assignee, who then decides whether to promote the next stage's +`backlog` sub-issues to `todo`. + +```bash +# Stage 1 runs now; later stages parked until promoted +multica issue create --title "Research A" --parent --assignee --stage 1 --status todo +multica issue create --title "Research B" --parent --assignee --stage 1 --status todo +multica issue create --title "Build" --parent --assignee --stage 2 --status backlog +multica issue create --title "Ship" --parent --assignee --stage 3 --status backlog +``` + +When both Stage 1 sub-issues finish you (the parent assignee) are woken with a +"Stage 1 complete" comment. Inspect the layout, then promote the next stage: + +```bash +multica issue children # sub-issues grouped by stage +multica issue status todo # promote when its deps are met +``` + +Read each sub-issue's description before promoting and only promote items whose +stated dependencies are met; if a description conflicts with the parent's +breakdown, leave it `backlog` and comment to confirm first. + ## Incorrect → correct PR title (link the issue): @@ -173,16 +207,18 @@ Fix login redirect # incorrect — no issue key, won't link MUL-2759: fix login redirect # correct — links the PR ``` -Serial sub-issues (don't start the whole chain): +Serial / phased sub-issues (don't start the whole chain at once): ```bash -# incorrect — both fire immediately +# incorrect — all fire immediately, no ordering multica issue create --title "Step 2" --parent --assignee --status todo multica issue create --title "Step 3" --parent --assignee --status todo -# correct — parked, promote in turn -multica issue create --title "Step 2" --parent --assignee --status backlog -multica issue create --title "Step 3" --parent --assignee --status backlog +# correct — stage them; Stage 1 runs, later stages park and are promoted as +# each stage's barrier closes +multica issue create --title "Step 1" --parent --assignee --stage 1 --status todo +multica issue create --title "Step 2" --parent --assignee --stage 2 --status backlog +multica issue create --title "Step 3" --parent --assignee --stage 3 --status backlog ``` ## References @@ -191,4 +227,6 @@ multica issue create --title "Step 3" --parent --assignee --s contract above: the `pull-requests` CLI and route, the PR response field list, `derivePRState`, the two-path link (`extractIdentifiers`) vs close-intent (`extractClosingIdentifiers`) proof, the backlog enqueue lines, child-done -notify, and the metadata CLI. Re-derive before depending on an exact line. +notify, the stage column / `stageBarrierClosed` barrier and the `--stage` / +`issue children` CLI, and the metadata CLI. Re-derive before depending on an +exact line. diff --git a/server/internal/service/builtin_skills/multica-working-on-issues/references/working-on-issues-source-map.md b/server/internal/service/builtin_skills/multica-working-on-issues/references/working-on-issues-source-map.md index 8b9d5e3ad..a14e028e6 100644 --- a/server/internal/service/builtin_skills/multica-working-on-issues/references/working-on-issues-source-map.md +++ b/server/internal/service/builtin_skills/multica-working-on-issues/references/working-on-issues-source-map.md @@ -104,13 +104,27 @@ Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only; | `shouldEnqueueAgentTask` returns false for `backlog` (parking lot) | `server/internal/handler/issue.go:2644-2648` | new citation | | Backlog → non-backlog (not done/cancelled) enqueues on update | `server/internal/handler/issue.go:2537-2540` | `:2523` | | Same contract in batch update | `server/internal/handler/issue.go:3021-3024` | new citation | -| Child → `done` posts a system comment on the parent | `server/internal/handler/issue_child_done.go:51` (`notifyParentOfChildDone`; doc comment at `:15`) | func def `:51` | +| Child → `done` notifies + wakes the parent, gated by the stage barrier | `server/internal/handler/issue_child_done.go:66` (`notifyParentOfChildDone`; doc comment at `:15`; barrier gate at `:115`) | func def `:51` | Creation with `--status todo` (or any non-backlog status) on an agent-assigned issue fires the agent immediately; `--status backlog` parks it with the assignee set but no trigger. Promoting `backlog → todo` later fires it then (update path, line 2537). +## Sub-issue stages (barrier wake) + +| Behavior | File:line | +|---|---| +| `issue.stage` column (nullable, `>= 1`) | `server/migrations/123_issue_stage.up.sql` | +| Stage barrier: notify+wake fire only when the lowest unfinished stage is all-terminal; unstaged set = one implicit stage | `server/internal/handler/issue_child_done.go:231` (`stageBarrierClosed`) | +| Per-stage summary + next stage for the wake comment | `server/internal/handler/issue_child_done.go:254` (`stageProgressSummary`) | +| `--stage` on `issue create` / `issue update` | `server/cmd/multica/cmd_issue.go:328,350` | +| `multica issue children ` (sub-issues grouped by stage) | `server/cmd/multica/cmd_issue.go:114,678`; route `GET /api/issues/{id}/children` → `ListChildIssues` | + +Advancement is agent-driven: the server only detects the closed barrier and +wakes the parent assignee. Promoting the next stage's `backlog` sub-issues to +`todo` is the woken agent's decision, not a server side effect. + ## Metadata CLI | Behavior | File:line | diff --git a/server/internal/service/issue.go b/server/internal/service/issue.go index a18a0badc..b7cace560 100644 --- a/server/internal/service/issue.go +++ b/server/internal/service/issue.go @@ -67,6 +67,9 @@ type IssueCreateParams struct { OriginID pgtype.UUID AttachmentIDs []pgtype.UUID AllowDuplicate bool + // Stage groups this issue into an ordered barrier group under its parent + // (NULL = unstaged). See issue_child_done.go for the staged-barrier wake. + Stage pgtype.Int4 } // IssueCreateOpts groups optional knobs for IssueService.Create. Most @@ -145,7 +148,7 @@ type IssueCreateResult struct { // 8. Publish EventIssueCreated to the bus (payload via opts.BroadcastPayload). // 9. Capture the IssueCreated analytics event. // 10. Enqueue an agent task or trigger the squad leader when the issue is -// assigned and not in `backlog`. +// assigned and not in `backlog`. // // Validation that lives in the service (parent existence, project // workspace membership, parent → project back-fill) is enforced here so @@ -239,6 +242,7 @@ func (s *IssueService) Create(ctx context.Context, p IssueCreateParams, opts Iss ProjectID: projectID, OriginType: p.OriginType, OriginID: p.OriginID, + Stage: p.Stage, }) } else { issue, err = qtx.CreateIssue(ctx, db.CreateIssueParams{ @@ -257,6 +261,7 @@ func (s *IssueService) Create(ctx context.Context, p IssueCreateParams, opts Iss DueDate: p.DueDate, Number: issueNumber, ProjectID: projectID, + Stage: p.Stage, }) } if err != nil { diff --git a/server/internal/util/pgx.go b/server/internal/util/pgx.go index 7d9c50e37..5b344067c 100644 --- a/server/internal/util/pgx.go +++ b/server/internal/util/pgx.go @@ -144,3 +144,17 @@ func Int8ToPtr(v pgtype.Int8) *int64 { } 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} +} diff --git a/server/migrations/123_issue_stage.down.sql b/server/migrations/123_issue_stage.down.sql new file mode 100644 index 000000000..08240f7ea --- /dev/null +++ b/server/migrations/123_issue_stage.down.sql @@ -0,0 +1 @@ +ALTER TABLE issue DROP COLUMN IF EXISTS stage; diff --git a/server/migrations/123_issue_stage.up.sql b/server/migrations/123_issue_stage.up.sql new file mode 100644 index 000000000..1141b9ddf --- /dev/null +++ b/server/migrations/123_issue_stage.up.sql @@ -0,0 +1,15 @@ +-- Per-issue `stage` ordinal: groups sub-issues sharing the same parent into +-- ordered barrier groups. NULL = unstaged (the issue does not participate in +-- staged grouping). Stage is interpreted relative to siblings under the same +-- parent_issue_id; a value on a top-level issue is inert. +-- +-- The child-done -> parent notification + assignee wake fires only when a +-- stage barrier closes — i.e. every child in the lowest unfinished stage has +-- reached a terminal status (done/cancelled). Unstaged sibling sets behave as +-- a single implicit stage, so the wake fires once when the last child +-- finishes rather than on every child. See +-- server/internal/handler/issue_child_done.go (stageBarrierClosed). +-- +-- Stages are 1-based; the CHECK keeps the column clean (NULL or >= 1) so the +-- "unstaged" sentinel stays unambiguous. +ALTER TABLE issue ADD COLUMN stage INTEGER CHECK (stage IS NULL OR stage >= 1); diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 8b974ddd2..085df88ae 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -174,10 +174,12 @@ const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, number, project_id + parent_issue_id, position, start_date, due_date, number, project_id, + stage ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 -) 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 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + $16 +) 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 ` type CreateIssueParams struct { @@ -196,6 +198,7 @@ type CreateIssueParams struct { DueDate pgtype.Date `json:"due_date"` Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` + Stage pgtype.Int4 `json:"stage"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -215,6 +218,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.DueDate, arg.Number, arg.ProjectID, + arg.Stage, ) var i Issue err := row.Scan( @@ -242,6 +246,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } @@ -251,11 +256,11 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - origin_type, origin_id + origin_type, origin_id, stage ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17 -) 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 + $16, $17, $18 +) 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 ` type CreateIssueWithOriginParams struct { @@ -276,6 +281,7 @@ type CreateIssueWithOriginParams struct { ProjectID pgtype.UUID `json:"project_id"` OriginType pgtype.Text `json:"origin_type"` OriginID pgtype.UUID `json:"origin_id"` + Stage pgtype.Int4 `json:"stage"` } func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWithOriginParams) (Issue, error) { @@ -297,6 +303,7 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith arg.ProjectID, arg.OriginType, arg.OriginID, + arg.Stage, ) var i Issue err := row.Scan( @@ -324,6 +331,7 @@ func (q *Queries) CreateIssueWithOrigin(ctx context.Context, arg CreateIssueWith &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } @@ -352,7 +360,7 @@ UPDATE issue SET metadata = metadata - $1::text, updated_at = now() WHERE id = $2 AND workspace_id = $3 -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 +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 ` type DeleteIssueMetadataKeyParams struct { @@ -391,12 +399,13 @@ func (q *Queries) DeleteIssueMetadataKey(ctx context.Context, arg DeleteIssueMet &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const findActiveDuplicateIssue = `-- name: FindActiveDuplicateIssue :one -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') AND project_id IS NOT DISTINCT FROM $2::uuid @@ -446,12 +455,13 @@ func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDu &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const getIssue = `-- name: GetIssue :one -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE id = $1 ` @@ -483,12 +493,13 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE workspace_id = $1 AND number = $2 ` @@ -525,12 +536,13 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const getIssueByOrigin = `-- name: GetIssueByOrigin :one -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE workspace_id = $1 AND origin_type = $2 AND origin_id = $3 @@ -576,12 +588,13 @@ func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginPara &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE id = $1 AND workspace_id = $2 ` @@ -618,12 +631,13 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } const listChildIssues = `-- name: ListChildIssues :many -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE parent_issue_id = $1 ORDER BY position ASC, created_at DESC ` @@ -662,6 +676,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ); err != nil { return nil, err } @@ -674,7 +689,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID } const listChildrenByParents = `-- name: ListChildrenByParents :many -SELECT 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 FROM issue +SELECT 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 FROM issue WHERE workspace_id = $1 AND parent_issue_id = ANY($2::uuid[]) ORDER BY parent_issue_id, position ASC, created_at DESC @@ -724,6 +739,7 @@ func (q *Queries) ListChildrenByParents(ctx context.Context, arg ListChildrenByP &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ); err != nil { return nil, err } @@ -738,7 +754,7 @@ func (q *Queries) ListChildrenByParents(ctx context.Context, arg ListChildrenByP const listIssues = `-- name: ListIssues :many SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, - i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage FROM issue i WHERE i.workspace_id = $1 AND ($4::text IS NULL OR i.status = $4) @@ -828,6 +844,7 @@ type ListIssuesRow struct { Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` Metadata []byte `json:"metadata"` + Stage pgtype.Int4 `json:"stage"` } // involves_user_id widens the assignee filter to surface issues where the user @@ -878,6 +895,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI &i.Number, &i.ProjectID, &i.Metadata, + &i.Stage, ); err != nil { return nil, err } @@ -892,7 +910,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI const listOpenIssues = `-- name: ListOpenIssues :many SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, - i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage FROM issue i WHERE i.workspace_id = $1 AND i.status NOT IN ('done', 'cancelled') @@ -968,6 +986,7 @@ type ListOpenIssuesRow struct { Number int32 `json:"number"` ProjectID pgtype.UUID `json:"project_id"` Metadata []byte `json:"metadata"` + Stage pgtype.Int4 `json:"stage"` } // See ListIssues for the semantics of involves_user_id (mirrors the 4-branch @@ -1010,6 +1029,7 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) &i.Number, &i.ProjectID, &i.Metadata, + &i.Stage, ); err != nil { return nil, err } @@ -1068,7 +1088,7 @@ UPDATE issue SET metadata = jsonb_set(metadata, ARRAY[$1::text], $2::jsonb), updated_at = now() WHERE id = $3 AND workspace_id = $4 -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 +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 ` type SetIssueMetadataKeyParams struct { @@ -1115,6 +1135,7 @@ func (q *Queries) SetIssueMetadataKey(ctx context.Context, arg SetIssueMetadataK &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } @@ -1132,9 +1153,10 @@ UPDATE issue SET due_date = $10, parent_issue_id = $11, project_id = $12, + stage = $13, updated_at = now() WHERE id = $1 -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 +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 ` type UpdateIssueParams struct { @@ -1150,6 +1172,7 @@ type UpdateIssueParams struct { DueDate pgtype.Date `json:"due_date"` ParentIssueID pgtype.UUID `json:"parent_issue_id"` ProjectID pgtype.UUID `json:"project_id"` + Stage pgtype.Int4 `json:"stage"` } func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) { @@ -1166,6 +1189,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue arg.DueDate, arg.ParentIssueID, arg.ProjectID, + arg.Stage, ) var i Issue err := row.Scan( @@ -1193,6 +1217,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } @@ -1202,7 +1227,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 AND workspace_id = $3 -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 +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 ` type UpdateIssueStatusParams struct { @@ -1240,6 +1265,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.FirstExecutedAt, &i.StartDate, &i.Metadata, + &i.Stage, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 7fdbab141..1cfbfcd18 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -391,6 +391,7 @@ type Issue struct { FirstExecutedAt pgtype.Timestamptz `json:"first_executed_at"` StartDate pgtype.Date `json:"start_date"` Metadata []byte `json:"metadata"` + Stage pgtype.Int4 `json:"stage"` } type IssueDependency struct { diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index d2aaedb1e..e87ae1b1d 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -7,7 +7,7 @@ -- "Assigned to me"), and the two filters must produce disjoint result sets. SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, - i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage FROM issue i WHERE i.workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status')) @@ -73,9 +73,11 @@ WHERE id = $1 AND workspace_id = $2; INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, number, project_id + parent_issue_id, position, start_date, due_date, number, project_id, + stage ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + sqlc.narg('stage') ) RETURNING *; -- name: GetIssueByNumber :one @@ -95,6 +97,7 @@ UPDATE issue SET due_date = sqlc.narg('due_date'), parent_issue_id = sqlc.narg('parent_issue_id'), project_id = sqlc.narg('project_id'), + stage = sqlc.narg('stage'), updated_at = now() WHERE id = $1 RETURNING *; @@ -112,10 +115,10 @@ INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, position, start_date, due_date, number, project_id, - origin_type, origin_id + origin_type, origin_id, stage ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - sqlc.narg('origin_type'), sqlc.narg('origin_id') + sqlc.narg('origin_type'), sqlc.narg('origin_id'), sqlc.narg('stage') ) RETURNING *; -- name: LockIssueDuplicateKey :exec @@ -144,7 +147,7 @@ DELETE FROM issue WHERE id = $1 AND workspace_id = $2; -- filter; member-direct assignment is intentionally excluded). SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, - i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata, i.stage FROM issue i WHERE i.workspace_id = $1 AND i.status NOT IN ('done', 'cancelled')