package cmd import ( "encoding/json" "fmt" "os" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" ) // MRStatusOutput is the JSON output structure for gt mq status. type MRStatusOutput struct { // Core issue fields ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Priority int `json:"priority"` Type string `json:"type"` Assignee string `json:"assignee,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` ClosedAt string `json:"closed_at,omitempty"` // MR-specific fields Branch string `json:"branch,omitempty"` Target string `json:"target,omitempty"` SourceIssue string `json:"source_issue,omitempty"` Worker string `json:"worker,omitempty"` Rig string `json:"rig,omitempty"` MergeCommit string `json:"merge_commit,omitempty"` CloseReason string `json:"close_reason,omitempty"` // Dependencies DependsOn []DependencyInfo `json:"depends_on,omitempty"` Blocks []DependencyInfo `json:"blocks,omitempty"` } // DependencyInfo represents a dependency or blocker. type DependencyInfo struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Priority int `json:"priority"` Type string `json:"type"` } func runMqStatus(cmd *cobra.Command, args []string) error { mrID := args[0] // Use current working directory for beads operations // (beads repos are per-rig, not per-workspace) workDir, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } // Initialize beads client bd := beads.New(workDir) // Fetch the issue issue, err := bd.Show(mrID) if err != nil { if err == beads.ErrNotFound { return fmt.Errorf("merge request '%s' not found", mrID) } return fmt.Errorf("fetching merge request: %w", err) } // Parse MR-specific fields from description mrFields := beads.ParseMRFields(issue) // Build output structure output := MRStatusOutput{ ID: issue.ID, Title: issue.Title, Status: issue.Status, Priority: issue.Priority, Type: issue.Type, Assignee: issue.Assignee, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, ClosedAt: issue.ClosedAt, } // Add MR fields if present if mrFields != nil { output.Branch = mrFields.Branch output.Target = mrFields.Target output.SourceIssue = mrFields.SourceIssue output.Worker = mrFields.Worker output.Rig = mrFields.Rig output.MergeCommit = mrFields.MergeCommit output.CloseReason = mrFields.CloseReason } // Add dependency info from the issue's Dependencies field for _, dep := range issue.Dependencies { output.DependsOn = append(output.DependsOn, DependencyInfo{ ID: dep.ID, Title: dep.Title, Status: dep.Status, Priority: dep.Priority, Type: dep.Type, }) } // Add blocker info from the issue's Dependents field for _, dep := range issue.Dependents { output.Blocks = append(output.Blocks, DependencyInfo{ ID: dep.ID, Title: dep.Title, Status: dep.Status, Priority: dep.Priority, Type: dep.Type, }) } // JSON output if mqStatusJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(output) } // Human-readable output return printMqStatus(issue, mrFields) } // printMqStatus prints detailed MR status in human-readable format. func printMqStatus(issue *beads.Issue, mrFields *beads.MRFields) error { // Header fmt.Printf("%s %s\n", style.Bold.Render("📋 Merge Request:"), issue.ID) fmt.Printf(" %s\n\n", issue.Title) // Status section fmt.Printf("%s\n", style.Bold.Render("Status")) statusDisplay := formatStatus(issue.Status) fmt.Printf(" State: %s\n", statusDisplay) fmt.Printf(" Priority: P%d\n", issue.Priority) if issue.Type != "" { fmt.Printf(" Type: %s\n", issue.Type) } if issue.Assignee != "" { fmt.Printf(" Assignee: %s\n", issue.Assignee) } // Timestamps fmt.Printf("\n%s\n", style.Bold.Render("Timeline")) if issue.CreatedAt != "" { fmt.Printf(" Created: %s %s\n", issue.CreatedAt, formatTimeAgo(issue.CreatedAt)) } if issue.UpdatedAt != "" && issue.UpdatedAt != issue.CreatedAt { fmt.Printf(" Updated: %s %s\n", issue.UpdatedAt, formatTimeAgo(issue.UpdatedAt)) } if issue.ClosedAt != "" { fmt.Printf(" Closed: %s %s\n", issue.ClosedAt, formatTimeAgo(issue.ClosedAt)) } // MR-specific fields if mrFields != nil { fmt.Printf("\n%s\n", style.Bold.Render("Merge Details")) if mrFields.Branch != "" { fmt.Printf(" Branch: %s\n", mrFields.Branch) } if mrFields.Target != "" { fmt.Printf(" Target: %s\n", mrFields.Target) } if mrFields.SourceIssue != "" { fmt.Printf(" Source Issue: %s\n", mrFields.SourceIssue) } if mrFields.Worker != "" { fmt.Printf(" Worker: %s\n", mrFields.Worker) } if mrFields.Rig != "" { fmt.Printf(" Rig: %s\n", mrFields.Rig) } if mrFields.MergeCommit != "" { fmt.Printf(" Merge Commit: %s\n", mrFields.MergeCommit) } if mrFields.CloseReason != "" { fmt.Printf(" Close Reason: %s\n", mrFields.CloseReason) } } // Dependencies (what this MR is waiting on) if len(issue.Dependencies) > 0 { fmt.Printf("\n%s\n", style.Bold.Render("Waiting On")) for _, dep := range issue.Dependencies { statusIcon := getStatusIcon(dep.Status) fmt.Printf(" %s %s: %s %s\n", statusIcon, dep.ID, truncateString(dep.Title, 50), style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) } } // Blockers (what's waiting on this MR) if len(issue.Dependents) > 0 { fmt.Printf("\n%s\n", style.Bold.Render("Blocking")) for _, dep := range issue.Dependents { statusIcon := getStatusIcon(dep.Status) fmt.Printf(" %s %s: %s %s\n", statusIcon, dep.ID, truncateString(dep.Title, 50), style.Dim.Render(fmt.Sprintf("[%s]", dep.Status))) } } // Description (if present and not just MR fields) desc := getDescriptionWithoutMRFields(issue.Description) if desc != "" { fmt.Printf("\n%s\n", style.Bold.Render("Notes")) // Indent each line for _, line := range strings.Split(desc, "\n") { fmt.Printf(" %s\n", line) } } return nil } // formatStatus formats the status with appropriate styling. func formatStatus(status string) string { switch status { case "open": return style.Info.Render("● open") case "in_progress": return style.Bold.Render("▶ in_progress") case "closed": return style.Dim.Render("✓ closed") default: return status } } // getStatusIcon returns an icon for the given status. func getStatusIcon(status string) string { switch status { case "open": return "○" case "in_progress": return "▶" case "closed": return "✓" default: return "•" } } // formatTimeAgo formats a timestamp as a relative time string. func formatTimeAgo(timestamp string) string { // Try parsing common formats formats := []string{ time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", } var t time.Time var err error for _, format := range formats { t, err = time.Parse(format, timestamp) if err == nil { break } } if err != nil { return "" // Can't parse, return empty } d := time.Since(t) if d < 0 { return style.Dim.Render("(in the future)") } var ago string if d < time.Minute { ago = fmt.Sprintf("%ds ago", int(d.Seconds())) } else if d < time.Hour { ago = fmt.Sprintf("%dm ago", int(d.Minutes())) } else if d < 24*time.Hour { ago = fmt.Sprintf("%dh ago", int(d.Hours())) } else { ago = fmt.Sprintf("%dd ago", int(d.Hours()/24)) } return style.Dim.Render("(" + ago + ")") } // truncateString truncates a string to maxLen, adding "..." if truncated. func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } if maxLen <= 3 { return s[:maxLen] } return s[:maxLen-3] + "..." } // getDescriptionWithoutMRFields returns the description with MR field lines removed. func getDescriptionWithoutMRFields(description string) string { if description == "" { return "" } // Known MR field keys (lowercase) mrKeys := map[string]bool{ "branch": true, "target": true, "source_issue": true, "source-issue": true, "sourceissue": true, "worker": true, "rig": true, "merge_commit": true, "merge-commit": true, "mergecommit": true, "close_reason": true, "close-reason": true, "closereason": true, "type": true, } var lines []string for _, line := range strings.Split(description, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { lines = append(lines, line) continue } // Check if this is an MR field line colonIdx := strings.Index(trimmed, ":") if colonIdx != -1 { key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx])) if mrKeys[key] { continue // Skip MR field lines } } lines = append(lines, line) } // Trim leading/trailing blank lines result := strings.Join(lines, "\n") result = strings.TrimSpace(result) return result }