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>
This commit is contained in:
J
2026-06-22 17:46:49 +08:00
parent 329384f052
commit 2a7c8cbd67
14 changed files with 615 additions and 41 deletions

View File

@@ -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 <id>",
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.")

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -164,6 +164,40 @@ multica issue status <child-id> 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>` (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 <id> --assignee <agent> --stage 1 --status todo
multica issue create --title "Research B" --parent <id> --assignee <agent> --stage 1 --status todo
multica issue create --title "Build" --parent <id> --assignee <agent> --stage 2 --status backlog
multica issue create --title "Ship" --parent <id> --assignee <agent> --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 <parent-id> # sub-issues grouped by stage
multica issue status <stage-2-child-id> 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 <issue-id> --assignee <agent> --status todo
multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --status todo
# correct — parked, promote in turn
multica issue create --title "Step 2" --parent <issue-id> --assignee <agent> --status backlog
multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --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 <issue-id> --assignee <agent> --stage 1 --status todo
multica issue create --title "Step 2" --parent <issue-id> --assignee <agent> --stage 2 --status backlog
multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --stage 3 --status backlog
```
## References
@@ -191,4 +227,6 @@ multica issue create --title "Step 3" --parent <issue-id> --assignee <agent> --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.

View File

@@ -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 <id>` (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 |

View File

@@ -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 {

View File

@@ -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}
}

View File

@@ -0,0 +1 @@
ALTER TABLE issue DROP COLUMN IF EXISTS stage;

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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')