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.
|
// TestResolveBeadsDir tests the redirect following logic.
|
||||||
func TestResolveBeadsDir(t *testing.T) {
|
func TestResolveBeadsDir(t *testing.T) {
|
||||||
// Create temp directory structure
|
// Create temp directory structure
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type AttachmentFields struct {
|
|||||||
AttachedAt string // ISO 8601 timestamp when attached
|
AttachedAt string // ISO 8601 timestamp when attached
|
||||||
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
||||||
DispatchedBy string // Agent ID that dispatched this work (for completion notification)
|
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.
|
// ParseAttachmentFields extracts attachment fields from an issue's description.
|
||||||
@@ -65,6 +66,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields {
|
|||||||
case "dispatched_by", "dispatched-by", "dispatchedby":
|
case "dispatched_by", "dispatched-by", "dispatchedby":
|
||||||
fields.DispatchedBy = value
|
fields.DispatchedBy = value
|
||||||
hasFields = true
|
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 != "" {
|
if fields.DispatchedBy != "" {
|
||||||
lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
|
lines = append(lines, "dispatched_by: "+fields.DispatchedBy)
|
||||||
}
|
}
|
||||||
|
if fields.NoMerge {
|
||||||
|
lines = append(lines, "no_merge: true")
|
||||||
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,9 @@ func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
|
|||||||
"dispatched_by": true,
|
"dispatched_by": true,
|
||||||
"dispatched-by": true,
|
"dispatched-by": true,
|
||||||
"dispatchedby": true,
|
"dispatchedby": true,
|
||||||
|
"no_merge": true,
|
||||||
|
"no-merge": true,
|
||||||
|
"nomerge": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect non-attachment lines from existing description
|
// Collect non-attachment lines from existing description
|
||||||
|
|||||||
@@ -310,6 +310,38 @@ func runDone(cmd *cobra.Command, args []string) error {
|
|||||||
// Initialize beads
|
// Initialize beads
|
||||||
bd := beads.New(beads.ResolveBeadsDir(cwd))
|
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)
|
// Determine target branch (auto-detect integration branch if applicable)
|
||||||
target := defaultBranch
|
target := defaultBranch
|
||||||
autoTarget, err := detectIntegrationBranch(bd, g, issueID)
|
autoTarget, err := detectIntegrationBranch(bd, g, issueID)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ var (
|
|||||||
slingAccount string // --account: Claude Code account handle to use
|
slingAccount string // --account: Claude Code account handle to use
|
||||||
slingAgent string // --agent: override runtime agent for this sling/spawn
|
slingAgent string // --agent: override runtime agent for this sling/spawn
|
||||||
slingNoConvoy bool // --no-convoy: skip auto-convoy creation
|
slingNoConvoy bool // --no-convoy: skip auto-convoy creation
|
||||||
|
slingNoMerge bool // --no-merge: skip merge queue on completion (for upstream PRs/human review)
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
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().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(&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(&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)
|
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.
|
// Record the attached molecule in the BASE bead's description.
|
||||||
// This field points to the wisp (compound root) and enables:
|
// This field points to the wisp (compound root) and enables:
|
||||||
// - gt hook/gt prime: follow attached_molecule to show molecule steps
|
// - gt hook/gt prime: follow attached_molecule to show molecule steps
|
||||||
|
|||||||
@@ -235,6 +235,53 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
|
|||||||
return nil
|
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.
|
// injectStartPrompt sends a prompt to the target pane to start working.
|
||||||
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
||||||
func injectStartPrompt(pane, beadID, subject, args string) error {
|
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)
|
"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