* bd sync: 2026-01-05 06:22:43 * bd sync: 2026-01-05 07:08:42 * bd sync: 2026-01-05 07:24:58 * feat: Add code coverage PR comment to GitHub Actions Adds a step to the CI workflow that: - Collects code coverage during test runs - Parses per-package coverage percentages - Posts a markdown table comment on PRs with: - Overall coverage percentage - Per-package breakdown table - Updates existing comment on subsequent pushes Closes: ga-tl5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): handle fork PR permissions for coverage comment Fork PRs cannot write comments via GITHUB_TOKEN due to security restrictions. Add condition to skip comment step for external PRs and upload coverage report as artifact instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(ci): separate coverage into dedicated job - Test job now uploads coverage.out and test-output.txt as artifacts - New Coverage Report job runs after tests complete - Downloads coverage data, generates report, uploads as artifact - Always uploads coverage-report artifact (for both fork and internal PRs) - Comments on PR only for internal PRs (fork PRs get notice message) - Cleaner separation of concerns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): coverage job waits for both test and integration Coverage Report job now depends on [test, integration] to ensure it only runs after all test stages complete successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): restore Coverage Report job after Test and Integration Coverage Report job now properly: - Depends on [test, integration] - waits for both to complete - Downloads coverage data from Test job - Generates and uploads coverage-report artifact - Comments on internal PRs only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add debugging output to TestInstallTownRoleSlots Add logging for gt install output and bd list to help diagnose CI failures where agent beads may not be created. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): update beads to @main and fix lint errors - Change CI to install beads from @main instead of @latest (latest release doesn't support role/agent issue types) - Remove error return from cleanBeadsRuntimeFiles since all errors are intentionally ignored (best-effort cleanup) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): pin beads to v0.44.0 for agent/role types Beads main recently extracted Gas Town-specific types (agent, role, etc.) from core. Pin CI to v0.44.0 which still has these types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): unpin beads version back to @latest Beads v0.46.0 now supports agent/role types again. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove stale gastown/.beads files from PR These beads files are local runtime state that shouldn't be committed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
376 lines
11 KiB
Go
376 lines
11 KiB
Go
//go:build integration
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
)
|
|
|
|
// TestInstallCreatesCorrectStructure validates that a fresh gt install
|
|
// creates the expected directory structure and configuration files.
|
|
func TestInstallCreatesCorrectStructure(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
// Build gt binary for testing
|
|
gtBinary := buildGT(t)
|
|
|
|
// Run gt install
|
|
cmd := exec.Command(gtBinary, "install", hqPath, "--name", "test-town")
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Verify directory structure
|
|
assertDirExists(t, hqPath, "HQ root")
|
|
assertDirExists(t, filepath.Join(hqPath, "mayor"), "mayor/")
|
|
|
|
// Verify mayor/town.json
|
|
townPath := filepath.Join(hqPath, "mayor", "town.json")
|
|
assertFileExists(t, townPath, "mayor/town.json")
|
|
|
|
townConfig, err := config.LoadTownConfig(townPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to load town.json: %v", err)
|
|
}
|
|
if townConfig.Type != "town" {
|
|
t.Errorf("town.json type = %q, want %q", townConfig.Type, "town")
|
|
}
|
|
if townConfig.Name != "test-town" {
|
|
t.Errorf("town.json name = %q, want %q", townConfig.Name, "test-town")
|
|
}
|
|
|
|
// Verify mayor/rigs.json
|
|
rigsPath := filepath.Join(hqPath, "mayor", "rigs.json")
|
|
assertFileExists(t, rigsPath, "mayor/rigs.json")
|
|
|
|
rigsConfig, err := config.LoadRigsConfig(rigsPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to load rigs.json: %v", err)
|
|
}
|
|
if len(rigsConfig.Rigs) != 0 {
|
|
t.Errorf("rigs.json should be empty, got %d rigs", len(rigsConfig.Rigs))
|
|
}
|
|
|
|
// Verify CLAUDE.md exists in mayor/ (not town root, to avoid inheritance pollution)
|
|
claudePath := filepath.Join(hqPath, "mayor", "CLAUDE.md")
|
|
assertFileExists(t, claudePath, "mayor/CLAUDE.md")
|
|
|
|
// Verify Claude settings exist in mayor/.claude/ (not town root/.claude/)
|
|
// Mayor settings go here to avoid polluting child workspaces via directory traversal
|
|
mayorSettingsPath := filepath.Join(hqPath, "mayor", ".claude", "settings.json")
|
|
assertFileExists(t, mayorSettingsPath, "mayor/.claude/settings.json")
|
|
|
|
// Verify deacon settings exist in deacon/.claude/
|
|
deaconSettingsPath := filepath.Join(hqPath, "deacon", ".claude", "settings.json")
|
|
assertFileExists(t, deaconSettingsPath, "deacon/.claude/settings.json")
|
|
}
|
|
|
|
// TestInstallBeadsHasCorrectPrefix validates that beads is initialized
|
|
// with the correct "hq-" prefix for town-level beads.
|
|
func TestInstallBeadsHasCorrectPrefix(t *testing.T) {
|
|
// Skip if bd is not available
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping beads prefix test")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
// Build gt binary for testing
|
|
gtBinary := buildGT(t)
|
|
|
|
// Run gt install (includes beads init by default)
|
|
cmd := exec.Command(gtBinary, "install", hqPath)
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Verify .beads/ directory exists
|
|
beadsDir := filepath.Join(hqPath, ".beads")
|
|
assertDirExists(t, beadsDir, ".beads/")
|
|
|
|
// Verify beads database was created
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
assertFileExists(t, dbPath, ".beads/beads.db")
|
|
|
|
// Verify prefix by running bd config get issue_prefix
|
|
// Use --no-daemon to avoid daemon startup issues in test environment
|
|
bdCmd := exec.Command("bd", "--no-daemon", "config", "get", "issue_prefix")
|
|
bdCmd.Dir = hqPath
|
|
prefixOutput, err := bdCmd.Output() // Use Output() to get only stdout
|
|
if err != nil {
|
|
// If Output() fails, try CombinedOutput for better error info
|
|
combinedOut, _ := exec.Command("bd", "--no-daemon", "config", "get", "issue_prefix").CombinedOutput()
|
|
t.Fatalf("bd config get issue_prefix failed: %v\nOutput: %s", err, combinedOut)
|
|
}
|
|
|
|
prefix := strings.TrimSpace(string(prefixOutput))
|
|
if prefix != "hq" {
|
|
t.Errorf("beads issue_prefix = %q, want %q", prefix, "hq")
|
|
}
|
|
}
|
|
|
|
// TestInstallTownRoleSlots validates that town-level agent beads
|
|
// have their role slot set after install.
|
|
func TestInstallTownRoleSlots(t *testing.T) {
|
|
// Skip if bd is not available
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping role slot test")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
gtBinary := buildGT(t)
|
|
|
|
// Run gt install (includes beads init by default)
|
|
cmd := exec.Command(gtBinary, "install", hqPath)
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Log install output for CI debugging
|
|
t.Logf("gt install output:\n%s", output)
|
|
|
|
// Verify beads directory was created
|
|
beadsDir := filepath.Join(hqPath, ".beads")
|
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
|
t.Fatalf("beads directory not created at %s", beadsDir)
|
|
}
|
|
|
|
// List beads for debugging
|
|
listCmd := exec.Command("bd", "--no-daemon", "list", "--type=agent")
|
|
listCmd.Dir = hqPath
|
|
listOutput, _ := listCmd.CombinedOutput()
|
|
t.Logf("bd list --type=agent output:\n%s", listOutput)
|
|
|
|
assertSlotValue(t, hqPath, "hq-mayor", "role", "hq-mayor-role")
|
|
assertSlotValue(t, hqPath, "hq-deacon", "role", "hq-deacon-role")
|
|
}
|
|
|
|
// TestInstallIdempotent validates that running gt install twice
|
|
// on the same directory fails without --force flag.
|
|
func TestInstallIdempotent(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
gtBinary := buildGT(t)
|
|
|
|
// First install should succeed
|
|
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("first install failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Second install without --force should fail
|
|
cmd = exec.Command(gtBinary, "install", hqPath, "--no-beads")
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("second install should have failed without --force")
|
|
}
|
|
if !strings.Contains(string(output), "already a Gas Town HQ") {
|
|
t.Errorf("expected 'already a Gas Town HQ' error, got: %s", output)
|
|
}
|
|
|
|
// Third install with --force should succeed
|
|
cmd = exec.Command(gtBinary, "install", hqPath, "--no-beads", "--force")
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("install with --force failed: %v\nOutput: %s", err, output)
|
|
}
|
|
}
|
|
|
|
// TestInstallFormulasProvisioned validates that embedded formulas are copied
|
|
// to .beads/formulas/ during installation.
|
|
func TestInstallFormulasProvisioned(t *testing.T) {
|
|
// Skip if bd is not available
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd not installed, skipping formulas test")
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
gtBinary := buildGT(t)
|
|
|
|
// Run gt install (includes beads and formula provisioning)
|
|
cmd := exec.Command(gtBinary, "install", hqPath)
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Verify .beads/formulas/ directory exists
|
|
formulasDir := filepath.Join(hqPath, ".beads", "formulas")
|
|
assertDirExists(t, formulasDir, ".beads/formulas/")
|
|
|
|
// Verify at least some expected formulas exist
|
|
expectedFormulas := []string{
|
|
"mol-deacon-patrol.formula.toml",
|
|
"mol-refinery-patrol.formula.toml",
|
|
"code-review.formula.toml",
|
|
}
|
|
for _, f := range expectedFormulas {
|
|
formulaPath := filepath.Join(formulasDir, f)
|
|
assertFileExists(t, formulaPath, f)
|
|
}
|
|
|
|
// Verify the count matches embedded formulas
|
|
entries, err := os.ReadDir(formulasDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to read formulas dir: %v", err)
|
|
}
|
|
// Count only formula files (not directories)
|
|
var fileCount int
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
fileCount++
|
|
}
|
|
}
|
|
// Should have at least 20 formulas (allows for some variation)
|
|
if fileCount < 20 {
|
|
t.Errorf("expected at least 20 formulas, got %d", fileCount)
|
|
}
|
|
}
|
|
|
|
// TestInstallNoBeadsFlag validates that --no-beads skips beads initialization.
|
|
func TestInstallNoBeadsFlag(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
hqPath := filepath.Join(tmpDir, "test-hq")
|
|
|
|
gtBinary := buildGT(t)
|
|
|
|
// Run gt install with --no-beads
|
|
cmd := exec.Command(gtBinary, "install", hqPath, "--no-beads")
|
|
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("gt install --no-beads failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
// Verify .beads/ directory does NOT exist
|
|
beadsDir := filepath.Join(hqPath, ".beads")
|
|
if _, err := os.Stat(beadsDir); !os.IsNotExist(err) {
|
|
t.Errorf(".beads/ should not exist with --no-beads flag")
|
|
}
|
|
}
|
|
|
|
// buildGT builds the gt binary and returns its path.
|
|
// It caches the build across tests in the same run.
|
|
var cachedGTBinary string
|
|
|
|
func buildGT(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
if cachedGTBinary != "" {
|
|
// Verify cached binary still exists
|
|
if _, err := os.Stat(cachedGTBinary); err == nil {
|
|
return cachedGTBinary
|
|
}
|
|
// Binary was cleaned up, rebuild
|
|
cachedGTBinary = ""
|
|
}
|
|
|
|
// Find project root (where go.mod is)
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("failed to get working directory: %v", err)
|
|
}
|
|
|
|
// Walk up to find go.mod
|
|
projectRoot := wd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
|
|
break
|
|
}
|
|
parent := filepath.Dir(projectRoot)
|
|
if parent == projectRoot {
|
|
t.Fatal("could not find project root (go.mod)")
|
|
}
|
|
projectRoot = parent
|
|
}
|
|
|
|
// Build gt binary to a persistent temp location (not per-test)
|
|
tmpDir := os.TempDir()
|
|
tmpBinary := filepath.Join(tmpDir, "gt-integration-test")
|
|
cmd := exec.Command("go", "build", "-o", tmpBinary, "./cmd/gt")
|
|
cmd.Dir = projectRoot
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("failed to build gt: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
cachedGTBinary = tmpBinary
|
|
return tmpBinary
|
|
}
|
|
|
|
// assertDirExists checks that the given path exists and is a directory.
|
|
func assertDirExists(t *testing.T, path, name string) {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Errorf("%s does not exist: %v", name, err)
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
t.Errorf("%s is not a directory", name)
|
|
}
|
|
}
|
|
|
|
// assertFileExists checks that the given path exists and is a file.
|
|
func assertFileExists(t *testing.T, path, name string) {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Errorf("%s does not exist: %v", name, err)
|
|
return
|
|
}
|
|
if info.IsDir() {
|
|
t.Errorf("%s is a directory, expected file", name)
|
|
}
|
|
}
|
|
|
|
func assertSlotValue(t *testing.T, townRoot, issueID, slot, want string) {
|
|
t.Helper()
|
|
cmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID)
|
|
cmd.Dir = townRoot
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
debugCmd := exec.Command("bd", "--no-daemon", "--json", "slot", "show", issueID)
|
|
debugCmd.Dir = townRoot
|
|
combined, _ := debugCmd.CombinedOutput()
|
|
t.Fatalf("bd slot show %s failed: %v\nOutput: %s", issueID, err, combined)
|
|
}
|
|
|
|
var parsed struct {
|
|
Slots map[string]*string `json:"slots"`
|
|
}
|
|
if err := json.Unmarshal(output, &parsed); err != nil {
|
|
t.Fatalf("parsing slot show output failed: %v\nOutput: %s", err, output)
|
|
}
|
|
|
|
var got string
|
|
if value, ok := parsed.Slots[slot]; ok && value != nil {
|
|
got = *value
|
|
}
|
|
if got != want {
|
|
t.Fatalf("slot %s for %s = %q, want %q", slot, issueID, got, want)
|
|
}
|
|
}
|