The misclassified-wisps check could detect issues that should be wisps but couldn't fix them because bd update lacked an --ephemeral flag. Now that beads supports `bd update <id> --ephemeral` (steveyegge/beads#1263), implement the actual fix to mark detected issues as ephemeral. Closes #852 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
224 lines
6.0 KiB
Go
224 lines
6.0 KiB
Go
package doctor
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
)
|
|
|
|
// CheckMisclassifiedWisps detects issues that should be marked as wisps but aren't.
|
|
// Wisps are ephemeral issues for operational workflows (patrols, MRs, mail).
|
|
// This check finds issues that have wisp characteristics but lack the wisp:true flag.
|
|
type CheckMisclassifiedWisps struct {
|
|
FixableCheck
|
|
misclassified []misclassifiedWisp
|
|
misclassifiedRigs map[string]int // rig -> count
|
|
}
|
|
|
|
type misclassifiedWisp struct {
|
|
rigName string
|
|
id string
|
|
title string
|
|
reason string
|
|
}
|
|
|
|
// NewCheckMisclassifiedWisps creates a new misclassified wisp check.
|
|
func NewCheckMisclassifiedWisps() *CheckMisclassifiedWisps {
|
|
return &CheckMisclassifiedWisps{
|
|
FixableCheck: FixableCheck{
|
|
BaseCheck: BaseCheck{
|
|
CheckName: "misclassified-wisps",
|
|
CheckDescription: "Detect issues that should be wisps but aren't marked as ephemeral",
|
|
CheckCategory: CategoryCleanup,
|
|
},
|
|
},
|
|
misclassifiedRigs: make(map[string]int),
|
|
}
|
|
}
|
|
|
|
// Run checks for misclassified wisps in each rig.
|
|
func (c *CheckMisclassifiedWisps) Run(ctx *CheckContext) *CheckResult {
|
|
c.misclassified = nil
|
|
c.misclassifiedRigs = make(map[string]int)
|
|
|
|
rigs, err := discoverRigs(ctx.TownRoot)
|
|
if err != nil {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: "Failed to discover rigs",
|
|
Details: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
if len(rigs) == 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No rigs configured",
|
|
}
|
|
}
|
|
|
|
var details []string
|
|
|
|
for _, rigName := range rigs {
|
|
rigPath := filepath.Join(ctx.TownRoot, rigName)
|
|
found := c.findMisclassifiedWisps(rigPath, rigName)
|
|
if len(found) > 0 {
|
|
c.misclassified = append(c.misclassified, found...)
|
|
c.misclassifiedRigs[rigName] = len(found)
|
|
details = append(details, fmt.Sprintf("%s: %d misclassified wisp(s)", rigName, len(found)))
|
|
}
|
|
}
|
|
|
|
// Also check town-level beads
|
|
townFound := c.findMisclassifiedWisps(ctx.TownRoot, "town")
|
|
if len(townFound) > 0 {
|
|
c.misclassified = append(c.misclassified, townFound...)
|
|
c.misclassifiedRigs["town"] = len(townFound)
|
|
details = append(details, fmt.Sprintf("town: %d misclassified wisp(s)", len(townFound)))
|
|
}
|
|
|
|
total := len(c.misclassified)
|
|
if total > 0 {
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusWarning,
|
|
Message: fmt.Sprintf("%d issue(s) should be marked as wisps", total),
|
|
Details: details,
|
|
FixHint: "Run 'gt doctor --fix' to mark these issues as ephemeral",
|
|
}
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusOK,
|
|
Message: "No misclassified wisps found",
|
|
}
|
|
}
|
|
|
|
// findMisclassifiedWisps finds issues that should be wisps but aren't in a single location.
|
|
func (c *CheckMisclassifiedWisps) findMisclassifiedWisps(path string, rigName string) []misclassifiedWisp {
|
|
beadsDir := beads.ResolveBeadsDir(path)
|
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
file, err := os.Open(issuesPath)
|
|
if err != nil {
|
|
return nil // No issues file
|
|
}
|
|
defer file.Close()
|
|
|
|
var found []misclassifiedWisp
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Type string `json:"issue_type"`
|
|
Labels []string `json:"labels"`
|
|
Wisp bool `json:"wisp"`
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Skip issues already marked as wisps
|
|
if issue.Wisp {
|
|
continue
|
|
}
|
|
|
|
// Skip closed issues - they're done, no need to reclassify
|
|
if issue.Status == "closed" {
|
|
continue
|
|
}
|
|
|
|
// Check for wisp characteristics
|
|
if reason := c.shouldBeWisp(issue.ID, issue.Title, issue.Type, issue.Labels); reason != "" {
|
|
found = append(found, misclassifiedWisp{
|
|
rigName: rigName,
|
|
id: issue.ID,
|
|
title: issue.Title,
|
|
reason: reason,
|
|
})
|
|
}
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
// shouldBeWisp checks if an issue has characteristics indicating it should be a wisp.
|
|
// Returns the reason string if it should be a wisp, empty string otherwise.
|
|
func (c *CheckMisclassifiedWisps) shouldBeWisp(id, title, issueType string, labels []string) string {
|
|
// Check for merge-request type - these should always be wisps
|
|
if issueType == "merge-request" {
|
|
return "merge-request type should be ephemeral"
|
|
}
|
|
|
|
// Check for patrol-related labels
|
|
for _, label := range labels {
|
|
if strings.Contains(label, "patrol") {
|
|
return "patrol label indicates ephemeral workflow"
|
|
}
|
|
if label == "gt:mail" || label == "gt:handoff" {
|
|
return "mail/handoff label indicates ephemeral message"
|
|
}
|
|
}
|
|
|
|
// Check for formula instance patterns in ID
|
|
// Formula instances typically have IDs like "mol-<formula>-<hash>" or "<formula>.<step>"
|
|
if strings.HasPrefix(id, "mol-") && strings.Contains(id, "-patrol") {
|
|
return "patrol molecule ID pattern"
|
|
}
|
|
|
|
// Check for specific title patterns indicating operational work
|
|
lowerTitle := strings.ToLower(title)
|
|
if strings.Contains(lowerTitle, "patrol cycle") ||
|
|
strings.Contains(lowerTitle, "witness patrol") ||
|
|
strings.Contains(lowerTitle, "deacon patrol") ||
|
|
strings.Contains(lowerTitle, "refinery patrol") {
|
|
return "patrol title indicates ephemeral workflow"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// Fix marks misclassified issues as wisps using bd update --ephemeral.
|
|
func (c *CheckMisclassifiedWisps) Fix(ctx *CheckContext) error {
|
|
if len(c.misclassified) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var lastErr error
|
|
|
|
for _, wisp := range c.misclassified {
|
|
// Determine working directory: town-level or rig-level
|
|
var workDir string
|
|
if wisp.rigName == "town" {
|
|
workDir = ctx.TownRoot
|
|
} else {
|
|
workDir = filepath.Join(ctx.TownRoot, wisp.rigName)
|
|
}
|
|
|
|
// Run bd update <id> --ephemeral
|
|
cmd := exec.Command("bd", "update", wisp.id, "--ephemeral")
|
|
cmd.Dir = workDir
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
lastErr = fmt.Errorf("%s/%s: %v (%s)", wisp.rigName, wisp.id, err, string(output))
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|