Code review improvements: - Make isNumericRunID delegate to isNumericID (DRY) - Extract workflowNameMatches() for clearer, more robust matching - Handle hint→filename matching (e.g., "release" matches "release.yml") - Add TestWorkflowNameMatches with comprehensive test cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
364 lines
9.6 KiB
Go
364 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/ncruces/go-sqlite3/driver"
|
|
_ "github.com/ncruces/go-sqlite3/embed"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestShouldCheckGate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
awaitType string
|
|
typeFilter string
|
|
want bool
|
|
}{
|
|
// Empty filter matches all
|
|
{"empty filter matches gh:run", "gh:run", "", true},
|
|
{"empty filter matches gh:pr", "gh:pr", "", true},
|
|
{"empty filter matches timer", "timer", "", true},
|
|
{"empty filter matches human", "human", "", true},
|
|
{"empty filter matches bead", "bead", "", true},
|
|
|
|
// "all" filter matches all
|
|
{"all filter matches gh:run", "gh:run", "all", true},
|
|
{"all filter matches gh:pr", "gh:pr", "all", true},
|
|
{"all filter matches timer", "timer", "all", true},
|
|
{"all filter matches bead", "bead", "all", true},
|
|
|
|
// "gh" filter matches all GitHub types
|
|
{"gh filter matches gh:run", "gh:run", "gh", true},
|
|
{"gh filter matches gh:pr", "gh:pr", "gh", true},
|
|
{"gh filter does not match timer", "timer", "gh", false},
|
|
{"gh filter does not match human", "human", "gh", false},
|
|
{"gh filter does not match bead", "bead", "gh", false},
|
|
|
|
// Exact type filters
|
|
{"gh:run filter matches gh:run", "gh:run", "gh:run", true},
|
|
{"gh:run filter does not match gh:pr", "gh:pr", "gh:run", false},
|
|
{"gh:pr filter matches gh:pr", "gh:pr", "gh:pr", true},
|
|
{"gh:pr filter does not match gh:run", "gh:run", "gh:pr", false},
|
|
{"timer filter matches timer", "timer", "timer", true},
|
|
{"timer filter does not match gh:run", "gh:run", "timer", false},
|
|
{"bead filter matches bead", "bead", "bead", true},
|
|
{"bead filter does not match timer", "timer", "bead", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gate := &types.Issue{
|
|
AwaitType: tt.awaitType,
|
|
}
|
|
got := shouldCheckGate(gate, tt.typeFilter)
|
|
if got != tt.want {
|
|
t.Errorf("shouldCheckGate(%q, %q) = %v, want %v",
|
|
tt.awaitType, tt.typeFilter, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckBeadGate_InvalidFormat(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
awaitID string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "empty",
|
|
awaitID: "",
|
|
wantErr: "invalid await_id format",
|
|
},
|
|
{
|
|
name: "no colon",
|
|
awaitID: "gastown-gt-abc",
|
|
wantErr: "invalid await_id format",
|
|
},
|
|
{
|
|
name: "missing rig",
|
|
awaitID: ":gt-abc",
|
|
wantErr: "await_id missing rig name",
|
|
},
|
|
{
|
|
name: "missing bead",
|
|
awaitID: "gastown:",
|
|
wantErr: "await_id missing rig name or bead ID",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
satisfied, reason := checkBeadGate(ctx, tt.awaitID)
|
|
if satisfied {
|
|
t.Errorf("expected not satisfied for %q", tt.awaitID)
|
|
}
|
|
if reason == "" {
|
|
t.Error("expected reason to be set")
|
|
}
|
|
// Just check the error message contains the expected substring
|
|
if tt.wantErr != "" && !gateTestContainsIgnoreCase(reason, tt.wantErr) {
|
|
t.Errorf("reason %q does not contain %q", reason, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckBeadGate_RigNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create a temp directory with a minimal beads setup
|
|
tmpDir, err := os.MkdirTemp("", "gate_test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Change to temp dir
|
|
origDir, _ := os.Getwd()
|
|
defer os.Chdir(origDir)
|
|
os.Chdir(tmpDir)
|
|
|
|
// Try to check a gate for a non-existent rig
|
|
satisfied, reason := checkBeadGate(ctx, "nonexistent:some-id")
|
|
if satisfied {
|
|
t.Error("expected not satisfied for non-existent rig")
|
|
}
|
|
if reason == "" {
|
|
t.Error("expected reason to be set")
|
|
}
|
|
// The error should mention the rig not being found
|
|
if !gateTestContainsIgnoreCase(reason, "not found") && !gateTestContainsIgnoreCase(reason, "could not find") {
|
|
t.Errorf("reason should mention not found: %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestCheckBeadGate_TargetClosed(t *testing.T) {
|
|
// Create a temporary database that simulates a target rig
|
|
tmpDir, err := os.MkdirTemp("", "bead_gate_test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create a minimal database with a closed issue
|
|
dbPath := filepath.Join(tmpDir, "beads.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create minimal schema
|
|
_, err = db.Exec(`
|
|
CREATE TABLE issues (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT,
|
|
title TEXT,
|
|
created_at TEXT,
|
|
updated_at TEXT
|
|
)
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Insert a closed issue
|
|
now := time.Now().Format(time.RFC3339)
|
|
_, err = db.Exec(`
|
|
INSERT INTO issues (id, status, title, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, "gt-test123", string(types.StatusClosed), "Test Issue", now, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Insert an open issue
|
|
_, err = db.Exec(`
|
|
INSERT INTO issues (id, status, title, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, "gt-open456", string(types.StatusOpen), "Open Issue", now, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
db.Close()
|
|
|
|
// Note: This test can't fully exercise checkBeadGate because it relies on
|
|
// routing.ResolveBeadsDirForRig which needs a proper routes.jsonl setup.
|
|
// The full integration test would need the town/rig infrastructure.
|
|
// For now, we just verify the function signature and basic error handling.
|
|
t.Log("Database created with closed issue gt-test123 and open issue gt-open456")
|
|
t.Log("Full integration testing requires routes.jsonl setup")
|
|
}
|
|
|
|
func TestIsNumericID(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
// Numeric IDs
|
|
{"12345", true},
|
|
{"12345678901234567890", true},
|
|
{"0", true},
|
|
{"1", true},
|
|
|
|
// Non-numeric (workflow names, etc.)
|
|
{"", false},
|
|
{"release.yml", false},
|
|
{"CI", false},
|
|
{"release", false},
|
|
{"123abc", false},
|
|
{"abc123", false},
|
|
{"12.34", false},
|
|
{"-123", false},
|
|
{"123-456", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := isNumericID(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("isNumericID(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNeedsDiscovery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
awaitType string
|
|
awaitID string
|
|
want bool
|
|
}{
|
|
// gh:run gates
|
|
{"gh:run empty await_id", "gh:run", "", true},
|
|
{"gh:run workflow name hint", "gh:run", "release.yml", true},
|
|
{"gh:run workflow name without ext", "gh:run", "CI", true},
|
|
{"gh:run numeric run ID", "gh:run", "12345", false},
|
|
{"gh:run large numeric ID", "gh:run", "12345678901234567890", false},
|
|
|
|
// Other gate types should not need discovery
|
|
{"gh:pr gate", "gh:pr", "", false},
|
|
{"timer gate", "timer", "", false},
|
|
{"human gate", "human", "", false},
|
|
{"bead gate", "bead", "rig:id", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gate := &types.Issue{
|
|
AwaitType: tt.awaitType,
|
|
AwaitID: tt.awaitID,
|
|
}
|
|
got := needsDiscovery(gate)
|
|
if got != tt.want {
|
|
t.Errorf("needsDiscovery(%q, %q) = %v, want %v",
|
|
tt.awaitType, tt.awaitID, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetWorkflowNameHint(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
awaitID string
|
|
want string
|
|
}{
|
|
{"empty", "", ""},
|
|
{"numeric ID", "12345", ""},
|
|
{"workflow name", "release.yml", "release.yml"},
|
|
{"workflow name yaml", "ci.yaml", "ci.yaml"},
|
|
{"workflow name no ext", "CI", "CI"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gate := &types.Issue{AwaitID: tt.awaitID}
|
|
got := getWorkflowNameHint(gate)
|
|
if got != tt.want {
|
|
t.Errorf("getWorkflowNameHint(%q) = %q, want %q", tt.awaitID, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkflowNameMatches(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hint string
|
|
workflowName string
|
|
runName string
|
|
want bool
|
|
}{
|
|
// Exact matches
|
|
{"exact workflow name", "Release", "Release", "release.yml", true},
|
|
{"exact run name", "release.yml", "Release", "release.yml", true},
|
|
{"case insensitive workflow", "release", "Release", "release.yml", true},
|
|
{"case insensitive run", "RELEASE.YML", "Release", "release.yml", true},
|
|
|
|
// Hint with suffix, match display name without
|
|
{"hint yml vs display name", "release.yml", "release", "ci.yml", true},
|
|
{"hint yaml vs display name", "release.yaml", "release", "ci.yaml", true},
|
|
|
|
// Hint without suffix, match filename with suffix
|
|
{"hint base vs filename yml", "release", "CI", "release.yml", true},
|
|
{"hint base vs filename yaml", "release", "CI", "release.yaml", true},
|
|
|
|
// No match
|
|
{"no match different name", "release", "CI", "ci.yml", false},
|
|
{"no match partial", "rel", "Release", "release.yml", false},
|
|
{"empty hint", "", "Release", "release.yml", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := workflowNameMatches(tt.hint, tt.workflowName, tt.runName)
|
|
if got != tt.want {
|
|
t.Errorf("workflowNameMatches(%q, %q, %q) = %v, want %v",
|
|
tt.hint, tt.workflowName, tt.runName, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// gateTestContainsIgnoreCase checks if haystack contains needle (case-insensitive)
|
|
func gateTestContainsIgnoreCase(haystack, needle string) bool {
|
|
return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle))
|
|
}
|
|
|
|
func gateTestContains(s, substr string) bool {
|
|
return len(s) >= len(substr) && gateTestFindSubstring(s, substr) >= 0
|
|
}
|
|
|
|
func gateTestLowerCase(s string) string {
|
|
b := []byte(s)
|
|
for i := range b {
|
|
if b[i] >= 'A' && b[i] <= 'Z' {
|
|
b[i] += 32
|
|
}
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func gateTestFindSubstring(s, substr string) int {
|
|
if len(substr) == 0 {
|
|
return 0
|
|
}
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|