mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat(issues): stage sub-issues so the parent wakes per stage, not per child
Sub-issues under a parent can be grouped into ordered stages (issue.stage). The child-done -> parent notification + assignee wake now fire only when a stage barrier closes: every sub-issue in the lowest unfinished stage has reached a terminal status (done/cancelled). An unstaged sibling set is one implicit stage, so the parent is woken once when the last sub-issue finishes instead of on every child — the default fix for the fire-on-every-child cascade reported in discussion #4320 / MUL-3508. Stage advancement stays agent-driven: the server only detects the closed barrier and wakes the parent assignee, who decides whether to promote the next stage. - DB: nullable issue.stage (CHECK >= 1) + sqlc regen - API: stage on issue create/update/response and batch update - CLI: `issue create`/`issue update` --stage; new `issue children` command that lists sub-issues grouped by stage (table + json) - stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the wake comment now stage-aware, plus unit tests - skill docs (multica-working-on-issues SKILL.md + source map) Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a follow-up; the API already returns stage for it to consume. MUL-3508 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
130
server/internal/handler/issue_child_done_stage_test.go
Normal file
130
server/internal/handler/issue_child_done_stage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
1
server/migrations/123_issue_stage.down.sql
Normal file
1
server/migrations/123_issue_stage.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE issue DROP COLUMN IF EXISTS stage;
|
||||
15
server/migrations/123_issue_stage.up.sql
Normal file
15
server/migrations/123_issue_stage.up.sql
Normal 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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user