feat(sling): add --no-merge flag to skip merge queue (#939)
When contributing to upstream repos or wanting human review before merge, the --no-merge flag keeps polecat work on a feature branch instead of auto-merging to main. Changes: - Add --no-merge flag to gt sling command - Store no_merge field in bead's AttachmentFields - Modify gt done to skip merge queue when no_merge is set - Push branch and mail dispatcher "READY_FOR_REVIEW" instead - Add tests for field parsing and sling flag storage Closes: gt-p7b8 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
a86c7d954f
commit
92ccacffd9
@@ -903,6 +903,80 @@ func TestAttachmentFieldsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoMergeField tests the no_merge field in AttachmentFields.
|
||||
// The no_merge flag tells gt done to skip the merge queue and keep work on a feature branch.
|
||||
func TestNoMergeField(t *testing.T) {
|
||||
t.Run("parse no_merge true", func(t *testing.T) {
|
||||
issue := &Issue{Description: "no_merge: true\ndispatched_by: mayor"}
|
||||
fields := ParseAttachmentFields(issue)
|
||||
if fields == nil {
|
||||
t.Fatal("ParseAttachmentFields() = nil")
|
||||
}
|
||||
if !fields.NoMerge {
|
||||
t.Error("NoMerge should be true")
|
||||
}
|
||||
if fields.DispatchedBy != "mayor" {
|
||||
t.Errorf("DispatchedBy = %q, want 'mayor'", fields.DispatchedBy)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse no_merge false", func(t *testing.T) {
|
||||
issue := &Issue{Description: "no_merge: false\ndispatched_by: crew"}
|
||||
fields := ParseAttachmentFields(issue)
|
||||
if fields == nil {
|
||||
t.Fatal("ParseAttachmentFields() = nil")
|
||||
}
|
||||
if fields.NoMerge {
|
||||
t.Error("NoMerge should be false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse no-merge alternate format", func(t *testing.T) {
|
||||
issue := &Issue{Description: "no-merge: true"}
|
||||
fields := ParseAttachmentFields(issue)
|
||||
if fields == nil {
|
||||
t.Fatal("ParseAttachmentFields() = nil")
|
||||
}
|
||||
if !fields.NoMerge {
|
||||
t.Error("NoMerge should be true with hyphen format")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("format no_merge", func(t *testing.T) {
|
||||
fields := &AttachmentFields{
|
||||
NoMerge: true,
|
||||
DispatchedBy: "mayor",
|
||||
}
|
||||
got := FormatAttachmentFields(fields)
|
||||
if !strings.Contains(got, "no_merge: true") {
|
||||
t.Errorf("FormatAttachmentFields() missing no_merge, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "dispatched_by: mayor") {
|
||||
t.Errorf("FormatAttachmentFields() missing dispatched_by, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("round-trip with no_merge", func(t *testing.T) {
|
||||
original := &AttachmentFields{
|
||||
AttachedMolecule: "mol-test",
|
||||
AttachedAt: "2026-01-24T12:00:00Z",
|
||||
DispatchedBy: "gastown/crew/max",
|
||||
NoMerge: true,
|
||||
}
|
||||
|
||||
formatted := FormatAttachmentFields(original)
|
||||
issue := &Issue{Description: formatted}
|
||||
parsed := ParseAttachmentFields(issue)
|
||||
|
||||
if parsed == nil {
|
||||
t.Fatal("round-trip parse returned nil")
|
||||
}
|
||||
if *parsed != *original {
|
||||
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveBeadsDir tests the redirect following logic.
|
||||
func TestResolveBeadsDir(t *testing.T) {
|
||||
// Create temp directory structure
|
||||
|
||||
@@ -21,6 +21,7 @@ type AttachmentFields struct {
|
||||
AttachedAt string // ISO 8601 timestamp when attached
|
||||
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
||||
DispatchedBy string // Agent ID that dispatched this work (for completion notification)
|
||||
NoMerge bool // If true, gt done skips merge queue (for upstream PRs/human review)
|
||||
}
|
||||
|
||||
// ParseAttachmentFields extracts attachment fields from an issue's description.
|
||||
@@ -65,6 +66,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields {
|
||||
case "dispatched_by", "dispatched-by", "dispatchedby":
|
||||
fields.DispatchedBy = value
|
||||
hasFields = true
|
||||
case "no_merge", "no-merge", "nomerge":
|
||||
fields.NoMerge = strings.ToLower(value) == "true"
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +99,9 @@ func FormatAttachmentFields(fields *AttachmentFields) string {
|
||||
if fields.DispatchedBy != "" {
|
||||
lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
|
||||
}
|
||||
if fields.NoMerge {
|
||||
lines = append(lines, "no_merge: true")
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -117,6 +124,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
|
||||
"dispatched_by": true,
|
||||
"dispatched-by": true,
|
||||
"dispatchedby": true,
|
||||
"no_merge": true,
|
||||
"no-merge": true,
|
||||
"nomerge": true,
|
||||
}
|
||||
|
||||
// Collect non-attachment lines from existing description
|
||||
|
||||
@@ -310,6 +310,38 @@ func runDone(cmd *cobra.Command, args []string) error {
|
||||
// Initialize beads
|
||||
bd := beads.New(beads.ResolveBeadsDir(cwd))
|
||||
|
||||
// Check for no_merge flag - if set, skip merge queue and notify for review
|
||||
sourceIssueForNoMerge, err := bd.Show(issueID)
|
||||
if err == nil {
|
||||
attachmentFields := beads.ParseAttachmentFields(sourceIssueForNoMerge)
|
||||
if attachmentFields != nil && attachmentFields.NoMerge {
|
||||
fmt.Printf("%s No-merge mode: skipping merge queue\n", style.Bold.Render("→"))
|
||||
fmt.Printf(" Branch: %s\n", branch)
|
||||
fmt.Printf(" Issue: %s\n", issueID)
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", style.Dim.Render("Work stays on feature branch for human review."))
|
||||
|
||||
// Mail dispatcher with READY_FOR_REVIEW
|
||||
if dispatcher := attachmentFields.DispatchedBy; dispatcher != "" {
|
||||
townRouter := mail.NewRouter(townRoot)
|
||||
reviewMsg := &mail.Message{
|
||||
To: dispatcher,
|
||||
From: detectSender(),
|
||||
Subject: fmt.Sprintf("READY_FOR_REVIEW: %s", issueID),
|
||||
Body: fmt.Sprintf("Branch: %s\nIssue: %s\nReady for review.", branch, issueID),
|
||||
}
|
||||
if err := townRouter.Send(reviewMsg); err != nil {
|
||||
style.PrintWarning("could not notify dispatcher: %v", err)
|
||||
} else {
|
||||
fmt.Printf("%s Dispatcher notified: READY_FOR_REVIEW\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// Skip MR creation, go to witness notification
|
||||
goto notifyWitness
|
||||
}
|
||||
}
|
||||
|
||||
// Determine target branch (auto-detect integration branch if applicable)
|
||||
target := defaultBranch
|
||||
autoTarget, err := detectIntegrationBranch(bd, g, issueID)
|
||||
|
||||
@@ -96,6 +96,7 @@ var (
|
||||
slingAccount string // --account: Claude Code account handle to use
|
||||
slingAgent string // --agent: override runtime agent for this sling/spawn
|
||||
slingNoConvoy bool // --no-convoy: skip auto-convoy creation
|
||||
slingNoMerge bool // --no-merge: skip merge queue on completion (for upstream PRs/human review)
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -113,6 +114,7 @@ func init() {
|
||||
slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)")
|
||||
slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling")
|
||||
slingCmd.Flags().BoolVar(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)")
|
||||
slingCmd.Flags().BoolVar(&slingNoMerge, "no-merge", false, "Skip merge queue on completion (keep work on feature branch for review)")
|
||||
|
||||
rootCmd.AddCommand(slingCmd)
|
||||
}
|
||||
@@ -494,6 +496,15 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Store no_merge flag in bead (skips merge queue on completion)
|
||||
if slingNoMerge {
|
||||
if err := storeNoMergeInBead(beadID, true); err != nil {
|
||||
fmt.Printf("%s Could not store no_merge in bead: %v\n", style.Dim.Render("Warning:"), err)
|
||||
} else {
|
||||
fmt.Printf("%s No-merge mode enabled (work stays on feature branch)\n", style.Bold.Render("✓"))
|
||||
}
|
||||
}
|
||||
|
||||
// Record the attached molecule in the BASE bead's description.
|
||||
// This field points to the wisp (compound root) and enables:
|
||||
// - gt hook/gt prime: follow attached_molecule to show molecule steps
|
||||
|
||||
@@ -235,6 +235,53 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeNoMergeInBead sets the no_merge field in a bead's description.
|
||||
// When set, gt done will skip the merge queue and keep work on the feature branch.
|
||||
// This is useful for upstream contributions or when human review is needed before merge.
|
||||
func storeNoMergeInBead(beadID string, noMerge bool) error {
|
||||
if !noMerge {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the bead to preserve existing description content
|
||||
showCmd := exec.Command("bd", "show", beadID, "--json")
|
||||
out, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bead: %w", err)
|
||||
}
|
||||
|
||||
// Parse the bead
|
||||
var issues []beads.Issue
|
||||
if err := json.Unmarshal(out, &issues); err != nil {
|
||||
return fmt.Errorf("parsing bead: %w", err)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return fmt.Errorf("bead not found")
|
||||
}
|
||||
issue := &issues[0]
|
||||
|
||||
// Get or create attachment fields
|
||||
fields := beads.ParseAttachmentFields(issue)
|
||||
if fields == nil {
|
||||
fields = &beads.AttachmentFields{}
|
||||
}
|
||||
|
||||
// Set the no_merge flag
|
||||
fields.NoMerge = true
|
||||
|
||||
// Update the description
|
||||
newDesc := beads.SetAttachmentFields(issue, fields)
|
||||
|
||||
// Update the bead
|
||||
updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc)
|
||||
updateCmd.Stderr = os.Stderr
|
||||
if err := updateCmd.Run(); err != nil {
|
||||
return fmt.Errorf("updating bead description: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectStartPrompt sends a prompt to the target pane to start working.
|
||||
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
||||
func injectStartPrompt(pane, beadID, subject, args string) error {
|
||||
|
||||
@@ -1034,3 +1034,107 @@ exit /b 0
|
||||
"Log output:\n%s\nAttached log:\n%s", string(logBytes), attachedLog)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingNoMergeFlag verifies that gt sling --no-merge stores the no_merge flag
|
||||
// in the bead's description. This flag tells gt done to skip the merge queue
|
||||
// and keep work on the feature branch for human review.
|
||||
func TestSlingNoMergeFlag(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
|
||||
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
// Create stub bd that logs update commands
|
||||
binDir := filepath.Join(townRoot, "bin")
|
||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir binDir: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(townRoot, "bd.log")
|
||||
bdScript := `#!/bin/sh
|
||||
set -e
|
||||
echo "ARGS:$*" >> "${BD_LOG}"
|
||||
if [ "$1" = "--no-daemon" ]; then
|
||||
shift
|
||||
fi
|
||||
cmd="$1"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
show)
|
||||
echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]'
|
||||
;;
|
||||
update)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
bdScriptWindows := `@echo off
|
||||
setlocal enableextensions
|
||||
echo ARGS:%*>>"%BD_LOG%"
|
||||
set "cmd=%1"
|
||||
if "%cmd%"=="--no-daemon" set "cmd=%2"
|
||||
if "%cmd%"=="show" (
|
||||
echo [{"title":"Test issue","status":"open","assignee":"","description":""}]
|
||||
exit /b 0
|
||||
)
|
||||
if "%cmd%"=="update" exit /b 0
|
||||
exit /b 0
|
||||
`
|
||||
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
|
||||
|
||||
t.Setenv("BD_LOG", logPath)
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv(EnvGTRole, "mayor")
|
||||
t.Setenv("GT_CREW", "")
|
||||
t.Setenv("GT_POLECAT", "")
|
||||
t.Setenv("TMUX_PANE", "")
|
||||
t.Setenv("GT_TEST_NO_NUDGE", "1")
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Save and restore global flags
|
||||
prevDryRun := slingDryRun
|
||||
prevNoConvoy := slingNoConvoy
|
||||
prevNoMerge := slingNoMerge
|
||||
t.Cleanup(func() {
|
||||
slingDryRun = prevDryRun
|
||||
slingNoConvoy = prevNoConvoy
|
||||
slingNoMerge = prevNoMerge
|
||||
})
|
||||
|
||||
slingDryRun = false
|
||||
slingNoConvoy = true
|
||||
slingNoMerge = true // This is what we're testing
|
||||
|
||||
if err := runSling(nil, []string{"gt-test123"}); err != nil {
|
||||
t.Fatalf("runSling: %v", err)
|
||||
}
|
||||
|
||||
logBytes, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read bd log: %v", err)
|
||||
}
|
||||
|
||||
// Look for update command that includes no_merge in description
|
||||
logLines := strings.Split(string(logBytes), "\n")
|
||||
foundNoMerge := false
|
||||
for _, line := range logLines {
|
||||
if strings.Contains(line, "update") && strings.Contains(line, "no_merge") {
|
||||
foundNoMerge = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundNoMerge {
|
||||
t.Errorf("--no-merge flag not stored in bead description\nLog:\n%s", string(logBytes))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user