Merge remote-tracking branch 'origin/polecat/Nux'
This commit is contained in:
+42
-5
@@ -77,11 +77,14 @@ type CreateOptions struct {
|
||||
|
||||
// UpdateOptions specifies options for updating an issue.
|
||||
type UpdateOptions struct {
|
||||
Title *string
|
||||
Status *string
|
||||
Priority *int
|
||||
Description *string
|
||||
Assignee *string
|
||||
Title *string
|
||||
Status *string
|
||||
Priority *int
|
||||
Description *string
|
||||
Assignee *string
|
||||
AddLabels []string // Labels to add
|
||||
RemoveLabels []string // Labels to remove
|
||||
SetLabels []string // Labels to set (replaces all existing)
|
||||
}
|
||||
|
||||
// SyncStatus represents the sync status of the beads repository.
|
||||
@@ -342,6 +345,19 @@ func (b *Beads) Update(id string, opts UpdateOptions) error {
|
||||
if opts.Assignee != nil {
|
||||
args = append(args, "--assignee="+*opts.Assignee)
|
||||
}
|
||||
// Label operations: set-labels replaces all, otherwise use add/remove
|
||||
if len(opts.SetLabels) > 0 {
|
||||
for _, label := range opts.SetLabels {
|
||||
args = append(args, "--set-labels="+label)
|
||||
}
|
||||
} else {
|
||||
for _, label := range opts.AddLabels {
|
||||
args = append(args, "--add-label="+label)
|
||||
}
|
||||
for _, label := range opts.RemoveLabels {
|
||||
args = append(args, "--remove-label="+label)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := b.run(args...)
|
||||
return err
|
||||
@@ -370,6 +386,27 @@ func (b *Beads) CloseWithReason(reason string, ids ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Release moves an in_progress issue back to open status.
|
||||
// This is used to recover stuck steps when a worker dies mid-task.
|
||||
// It clears the assignee so the step can be claimed by another worker.
|
||||
func (b *Beads) Release(id string) error {
|
||||
return b.ReleaseWithReason(id, "")
|
||||
}
|
||||
|
||||
// ReleaseWithReason moves an in_progress issue back to open status with a reason.
|
||||
// The reason is added as a note to the issue for tracking purposes.
|
||||
func (b *Beads) ReleaseWithReason(id, reason string) error {
|
||||
args := []string{"update", id, "--status=open", "--assignee="}
|
||||
|
||||
// Add reason as a note if provided
|
||||
if reason != "" {
|
||||
args = append(args, "--notes=Released: "+reason)
|
||||
}
|
||||
|
||||
_, err := b.run(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency: issue depends on dependsOn.
|
||||
func (b *Beads) AddDependency(issue, dependsOn string) error {
|
||||
_, err := b.run("dep", "add", issue, dependsOn)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var releaseReason string
|
||||
|
||||
var releaseCmd = &cobra.Command{
|
||||
Use: "release <issue-id>...",
|
||||
Short: "Release stuck in_progress issues back to pending",
|
||||
Long: `Release one or more in_progress issues back to open/pending status.
|
||||
|
||||
This is used to recover stuck steps when a worker dies mid-task.
|
||||
The issue is moved to "open" status and the assignee is cleared,
|
||||
allowing another worker to claim and complete it.
|
||||
|
||||
Examples:
|
||||
gt release gt-abc # Release single issue
|
||||
gt release gt-abc gt-def # Release multiple issues
|
||||
gt release gt-abc -r "worker died" # Release with reason
|
||||
|
||||
This implements nondeterministic idempotence - work can be safely
|
||||
retried by releasing and reclaiming stuck steps.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runRelease,
|
||||
}
|
||||
|
||||
func init() {
|
||||
releaseCmd.Flags().StringVarP(&releaseReason, "reason", "r", "", "Reason for releasing (added as note)")
|
||||
rootCmd.AddCommand(releaseCmd)
|
||||
}
|
||||
|
||||
func runRelease(cmd *cobra.Command, args []string) error {
|
||||
// Get working directory for beads
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting working directory: %w", err)
|
||||
}
|
||||
|
||||
bd := beads.New(cwd)
|
||||
|
||||
// Release each issue
|
||||
var released, failed int
|
||||
for _, id := range args {
|
||||
var err error
|
||||
if releaseReason != "" {
|
||||
err = bd.ReleaseWithReason(id, releaseReason)
|
||||
} else {
|
||||
err = bd.Release(id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("%s Failed to release %s: %v\n", style.Dim.Render("✗"), id, err)
|
||||
failed++
|
||||
} else {
|
||||
fmt.Printf("%s Released %s → open\n", style.Bold.Render("✓"), id)
|
||||
released++
|
||||
}
|
||||
}
|
||||
|
||||
// Summary if multiple
|
||||
if len(args) > 1 {
|
||||
fmt.Printf("\nReleased: %d, Failed: %d\n", released, failed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d issue(s) failed to release", failed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
"github.com/steveyegge/gastown/internal/keepalive"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
@@ -180,18 +183,28 @@ func (d *Daemon) pokeMayor() {
|
||||
}
|
||||
|
||||
// pokeWitnesses sends heartbeats to all Witness sessions.
|
||||
// Uses proper rig discovery from rigs.json instead of scanning tmux sessions.
|
||||
func (d *Daemon) pokeWitnesses() {
|
||||
// Find all rigs by looking for witness sessions
|
||||
// Session naming: gt-<rig>-witness
|
||||
sessions, err := d.tmux.ListSessions()
|
||||
if err != nil {
|
||||
d.logger.Printf("Error listing sessions: %v", err)
|
||||
// Discover rigs from configuration
|
||||
rigs := d.discoverRigs()
|
||||
if len(rigs) == 0 {
|
||||
d.logger.Println("No rigs discovered")
|
||||
return
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
// Check if it's a witness session
|
||||
if !isWitnessSession(session) {
|
||||
for _, r := range rigs {
|
||||
session := fmt.Sprintf("gt-%s-witness", r.Name)
|
||||
|
||||
// Check if witness session exists
|
||||
running, err := d.tmux.HasSession(session)
|
||||
if err != nil {
|
||||
d.logger.Printf("Error checking witness session for rig %s: %v", r.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !running {
|
||||
// Rig exists but no witness session - log for visibility
|
||||
d.logger.Printf("Rig %s has no witness session (may need: gt witness start %s)", r.Name, r.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -199,6 +212,70 @@ func (d *Daemon) pokeWitnesses() {
|
||||
}
|
||||
}
|
||||
|
||||
// discoverRigs finds all registered rigs using the rig manager.
|
||||
// Falls back to directory scanning if rigs.json is not available.
|
||||
func (d *Daemon) discoverRigs() []*rig.Rig {
|
||||
// Load rigs config from mayor/rigs.json
|
||||
rigsConfigPath := filepath.Join(d.config.TownRoot, "mayor", "rigs.json")
|
||||
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||
if err != nil {
|
||||
// Try fallback: scan town directory for rig directories
|
||||
return d.discoverRigsFromDirectory()
|
||||
}
|
||||
|
||||
// Use rig manager for proper discovery
|
||||
g := git.NewGit(d.config.TownRoot)
|
||||
mgr := rig.NewManager(d.config.TownRoot, rigsConfig, g)
|
||||
rigs, err := mgr.DiscoverRigs()
|
||||
if err != nil {
|
||||
d.logger.Printf("Error discovering rigs from config: %v", err)
|
||||
return d.discoverRigsFromDirectory()
|
||||
}
|
||||
|
||||
return rigs
|
||||
}
|
||||
|
||||
// discoverRigsFromDirectory scans the town directory for rig directories.
|
||||
// A directory is considered a rig if it has a .beads subdirectory or config.json.
|
||||
func (d *Daemon) discoverRigsFromDirectory() []*rig.Rig {
|
||||
entries, err := os.ReadDir(d.config.TownRoot)
|
||||
if err != nil {
|
||||
d.logger.Printf("Error reading town directory: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var rigs []*rig.Rig
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
// Skip known non-rig directories
|
||||
if name == "mayor" || name == "daemon" || name == ".git" || name[0] == '.' {
|
||||
continue
|
||||
}
|
||||
|
||||
dirPath := filepath.Join(d.config.TownRoot, name)
|
||||
|
||||
// Check for .beads directory (indicates a rig)
|
||||
beadsPath := filepath.Join(dirPath, ".beads")
|
||||
if _, err := os.Stat(beadsPath); err == nil {
|
||||
rigs = append(rigs, &rig.Rig{Name: name, Path: dirPath})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for config.json with type: rig
|
||||
configPath := filepath.Join(dirPath, "config.json")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// For simplicity, assume any directory with config.json is a rig
|
||||
rigs = append(rigs, &rig.Rig{Name: name, Path: dirPath})
|
||||
}
|
||||
}
|
||||
|
||||
return rigs
|
||||
}
|
||||
|
||||
// pokeWitness sends a heartbeat to a single witness session with backoff.
|
||||
func (d *Daemon) pokeWitness(session string) {
|
||||
// Extract rig name from session (gt-<rig>-witness -> <rig>)
|
||||
|
||||
@@ -278,6 +278,59 @@ func (mr *MergeRequest) IsClosed() bool {
|
||||
return mr.Status == MRClosed
|
||||
}
|
||||
|
||||
// FailureType categorizes merge failures for appropriate handling.
|
||||
type FailureType string
|
||||
|
||||
const (
|
||||
// FailureNone indicates no failure (success).
|
||||
FailureNone FailureType = ""
|
||||
|
||||
// FailureConflict indicates merge conflicts with target branch.
|
||||
FailureConflict FailureType = "conflict"
|
||||
|
||||
// FailureTestsFail indicates tests failed after merge.
|
||||
FailureTestsFail FailureType = "tests_fail"
|
||||
|
||||
// FailureBuildFail indicates build failed after merge.
|
||||
FailureBuildFail FailureType = "build_fail"
|
||||
|
||||
// FailureFlakyTest indicates a potentially flaky test failure (may retry).
|
||||
FailureFlakyTest FailureType = "flaky_test"
|
||||
|
||||
// FailurePushFail indicates push to remote failed.
|
||||
FailurePushFail FailureType = "push_fail"
|
||||
|
||||
// FailureFetch indicates fetch of source branch failed.
|
||||
FailureFetch FailureType = "fetch_fail"
|
||||
|
||||
// FailureCheckout indicates checkout of target branch failed.
|
||||
FailureCheckout FailureType = "checkout_fail"
|
||||
)
|
||||
|
||||
// FailureLabel returns the beads label for this failure type.
|
||||
func (f FailureType) FailureLabel() string {
|
||||
switch f {
|
||||
case FailureConflict:
|
||||
return "needs-rebase"
|
||||
case FailureTestsFail, FailureBuildFail, FailureFlakyTest:
|
||||
return "needs-fix"
|
||||
case FailurePushFail:
|
||||
return "needs-retry"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldAssignToWorker returns true if this failure should be assigned back to the worker.
|
||||
func (f FailureType) ShouldAssignToWorker() bool {
|
||||
switch f {
|
||||
case FailureConflict, FailureTestsFail, FailureBuildFail, FailureFlakyTest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsOpen returns true if the MR is in an open state (waiting for processing).
|
||||
func (mr *MergeRequest) IsOpen() bool {
|
||||
return mr.Status == MROpen
|
||||
|
||||
@@ -256,47 +256,52 @@ func TestMergeRequest_StatusChecks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRequest_Rejection(t *testing.T) {
|
||||
t.Run("reject from open succeeds", func(t *testing.T) {
|
||||
mr := &MergeRequest{Status: MROpen}
|
||||
err := mr.Close(CloseReasonRejected)
|
||||
if err != nil {
|
||||
t.Errorf("Close(rejected) unexpected error: %v", err)
|
||||
}
|
||||
if mr.Status != MRClosed {
|
||||
t.Errorf("Close(rejected) status = %s, want %s", mr.Status, MRClosed)
|
||||
}
|
||||
if mr.CloseReason != CloseReasonRejected {
|
||||
t.Errorf("Close(rejected) closeReason = %s, want %s", mr.CloseReason, CloseReasonRejected)
|
||||
}
|
||||
})
|
||||
func TestFailureType_FailureLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
failureType FailureType
|
||||
wantLabel string
|
||||
}{
|
||||
{FailureNone, ""},
|
||||
{FailureConflict, "needs-rebase"},
|
||||
{FailureTestsFail, "needs-fix"},
|
||||
{FailureBuildFail, "needs-fix"},
|
||||
{FailureFlakyTest, "needs-fix"},
|
||||
{FailurePushFail, "needs-retry"},
|
||||
{FailureFetch, ""},
|
||||
{FailureCheckout, ""},
|
||||
}
|
||||
|
||||
t.Run("reject from in_progress succeeds", func(t *testing.T) {
|
||||
mr := &MergeRequest{Status: MRInProgress}
|
||||
err := mr.Close(CloseReasonRejected)
|
||||
if err != nil {
|
||||
t.Errorf("Close(rejected) unexpected error: %v", err)
|
||||
}
|
||||
if mr.Status != MRClosed {
|
||||
t.Errorf("Close(rejected) status = %s, want %s", mr.Status, MRClosed)
|
||||
}
|
||||
if mr.CloseReason != CloseReasonRejected {
|
||||
t.Errorf("Close(rejected) closeReason = %s, want %s", mr.CloseReason, CloseReasonRejected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject from closed fails", func(t *testing.T) {
|
||||
mr := &MergeRequest{Status: MRClosed, CloseReason: CloseReasonMerged}
|
||||
err := mr.Close(CloseReasonRejected)
|
||||
if err == nil {
|
||||
t.Error("Close(rejected) expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrClosedImmutable) {
|
||||
t.Errorf("Close(rejected) error = %v, want %v", err, ErrClosedImmutable)
|
||||
}
|
||||
// CloseReason should not change
|
||||
if mr.CloseReason != CloseReasonMerged {
|
||||
t.Errorf("Close(rejected) closeReason changed from %s to %s", CloseReasonMerged, mr.CloseReason)
|
||||
}
|
||||
})
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.failureType), func(t *testing.T) {
|
||||
got := tt.failureType.FailureLabel()
|
||||
if got != tt.wantLabel {
|
||||
t.Errorf("FailureLabel() = %q, want %q", got, tt.wantLabel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureType_ShouldAssignToWorker(t *testing.T) {
|
||||
tests := []struct {
|
||||
failureType FailureType
|
||||
wantAssign bool
|
||||
}{
|
||||
{FailureNone, false},
|
||||
{FailureConflict, true},
|
||||
{FailureTestsFail, true},
|
||||
{FailureBuildFail, true},
|
||||
{FailureFlakyTest, true},
|
||||
{FailurePushFail, false},
|
||||
{FailureFetch, false},
|
||||
{FailureCheckout, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.failureType), func(t *testing.T) {
|
||||
got := tt.failureType.ShouldAssignToWorker()
|
||||
if got != tt.wantAssign {
|
||||
t.Errorf("ShouldAssignToWorker() = %v, want %v", got, tt.wantAssign)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user