- mq.go (400): commands, flags, init, shared helpers - mq_integration.go (606): integration branch create/land/status - mq_status.go (357): status display and formatting - mq_submit.go (219): submit command and branch parsing - mq_list.go (206): list command and filtering Also adds unit tests for helper functions: - formatStatus, getStatusIcon, formatTimeAgo - filterMRsByTarget with edge cases - Test utilities for mocking beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
358 lines
9.0 KiB
Go
358 lines
9.0 KiB
Go
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
|
|
}
|