When the Refinery detects a build error or test failure and refuses to merge, the polecat was never notified. This fixes the notification pipeline by: 1. Adding MERGE_FAILED protocol support to Witness: - PatternMergeFailed regex pattern - ProtoMergeFailed protocol type constant - MergeFailedPayload struct with all failure details - ParseMergeFailed parser function - ClassifyMessage case for MERGE_FAILED 2. Adding HandleMergeFailed handler to Witness: - Parses the failure notification - Sends HIGH priority mail to polecat with fix instructions - Includes branch, issue, failure type, and error details 3. Adding mail notification in Refinery's handleFailureFromQueue: - Creates mail.Router for sending protocol messages - Sends MERGE_FAILED to Witness when merge fails - Includes failure type (build/tests/conflict) and error 4. Adding comprehensive unit tests: - TestParseMergeFailed for full body parsing - TestParseMergeFailed_MinimalBody for minimal body - TestParseMergeFailed_InvalidSubject for error handling - ClassifyMessage test cases for MERGE_FAILED Fixes #114 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
312 lines
8.2 KiB
Go
312 lines
8.2 KiB
Go
package witness
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestClassifyMessage(t *testing.T) {
|
|
tests := []struct {
|
|
subject string
|
|
expected ProtocolType
|
|
}{
|
|
{"POLECAT_DONE nux", ProtoPolecatDone},
|
|
{"POLECAT_DONE ace", ProtoPolecatDone},
|
|
{"LIFECYCLE:Shutdown nux", ProtoLifecycleShutdown},
|
|
{"HELP: Tests failing", ProtoHelp},
|
|
{"HELP: Git conflict", ProtoHelp},
|
|
{"MERGED nux", ProtoMerged},
|
|
{"MERGED valkyrie", ProtoMerged},
|
|
{"MERGE_FAILED nux", ProtoMergeFailed},
|
|
{"MERGE_FAILED ace", ProtoMergeFailed},
|
|
{"🤝 HANDOFF: Patrol context", ProtoHandoff},
|
|
{"🤝HANDOFF: No space", ProtoHandoff},
|
|
{"SWARM_START", ProtoSwarmStart},
|
|
{"Unknown message", ProtoUnknown},
|
|
{"", ProtoUnknown},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.subject, func(t *testing.T) {
|
|
result := ClassifyMessage(tc.subject)
|
|
if result != tc.expected {
|
|
t.Errorf("ClassifyMessage(%q) = %v, want %v", tc.subject, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePolecatDone(t *testing.T) {
|
|
subject := "POLECAT_DONE nux"
|
|
body := `Exit: MERGED
|
|
Issue: gt-abc123
|
|
MR: gt-mr-xyz
|
|
Branch: feature-branch`
|
|
|
|
payload, err := ParsePolecatDone(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolecatDone() error = %v", err)
|
|
}
|
|
|
|
if payload.PolecatName != "nux" {
|
|
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux")
|
|
}
|
|
if payload.Exit != "MERGED" {
|
|
t.Errorf("Exit = %q, want %q", payload.Exit, "MERGED")
|
|
}
|
|
if payload.IssueID != "gt-abc123" {
|
|
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
|
}
|
|
if payload.MRID != "gt-mr-xyz" {
|
|
t.Errorf("MRID = %q, want %q", payload.MRID, "gt-mr-xyz")
|
|
}
|
|
if payload.Branch != "feature-branch" {
|
|
t.Errorf("Branch = %q, want %q", payload.Branch, "feature-branch")
|
|
}
|
|
}
|
|
|
|
func TestParsePolecatDone_MinimalBody(t *testing.T) {
|
|
subject := "POLECAT_DONE ace"
|
|
body := "Exit: DEFERRED"
|
|
|
|
payload, err := ParsePolecatDone(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParsePolecatDone() error = %v", err)
|
|
}
|
|
|
|
if payload.PolecatName != "ace" {
|
|
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "ace")
|
|
}
|
|
if payload.Exit != "DEFERRED" {
|
|
t.Errorf("Exit = %q, want %q", payload.Exit, "DEFERRED")
|
|
}
|
|
if payload.IssueID != "" {
|
|
t.Errorf("IssueID = %q, want empty", payload.IssueID)
|
|
}
|
|
}
|
|
|
|
func TestParsePolecatDone_InvalidSubject(t *testing.T) {
|
|
_, err := ParsePolecatDone("Invalid subject", "body")
|
|
if err == nil {
|
|
t.Error("ParsePolecatDone() expected error for invalid subject")
|
|
}
|
|
}
|
|
|
|
func TestParseHelp(t *testing.T) {
|
|
subject := "HELP: Tests failing on CI"
|
|
body := `Agent: gastown/polecats/nux
|
|
Issue: gt-abc123
|
|
Problem: Unit tests timeout after 30 seconds
|
|
Tried: Increased timeout, checked for deadlocks`
|
|
|
|
payload, err := ParseHelp(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParseHelp() error = %v", err)
|
|
}
|
|
|
|
if payload.Topic != "Tests failing on CI" {
|
|
t.Errorf("Topic = %q, want %q", payload.Topic, "Tests failing on CI")
|
|
}
|
|
if payload.Agent != "gastown/polecats/nux" {
|
|
t.Errorf("Agent = %q, want %q", payload.Agent, "gastown/polecats/nux")
|
|
}
|
|
if payload.IssueID != "gt-abc123" {
|
|
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
|
}
|
|
if payload.Problem != "Unit tests timeout after 30 seconds" {
|
|
t.Errorf("Problem = %q, want %q", payload.Problem, "Unit tests timeout after 30 seconds")
|
|
}
|
|
if payload.Tried != "Increased timeout, checked for deadlocks" {
|
|
t.Errorf("Tried = %q, want %q", payload.Tried, "Increased timeout, checked for deadlocks")
|
|
}
|
|
}
|
|
|
|
func TestParseHelp_InvalidSubject(t *testing.T) {
|
|
_, err := ParseHelp("Not a help message", "body")
|
|
if err == nil {
|
|
t.Error("ParseHelp() expected error for invalid subject")
|
|
}
|
|
}
|
|
|
|
func TestParseMerged(t *testing.T) {
|
|
subject := "MERGED nux"
|
|
body := `Branch: feature-nux
|
|
Issue: gt-abc123
|
|
Merged-At: 2025-12-30T10:30:00Z`
|
|
|
|
payload, err := ParseMerged(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParseMerged() error = %v", err)
|
|
}
|
|
|
|
if payload.PolecatName != "nux" {
|
|
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux")
|
|
}
|
|
if payload.Branch != "feature-nux" {
|
|
t.Errorf("Branch = %q, want %q", payload.Branch, "feature-nux")
|
|
}
|
|
if payload.IssueID != "gt-abc123" {
|
|
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
|
}
|
|
if payload.MergedAt.IsZero() {
|
|
t.Error("MergedAt should not be zero")
|
|
}
|
|
}
|
|
|
|
func TestParseMerged_InvalidSubject(t *testing.T) {
|
|
_, err := ParseMerged("Not merged", "body")
|
|
if err == nil {
|
|
t.Error("ParseMerged() expected error for invalid subject")
|
|
}
|
|
}
|
|
|
|
func TestParseMergeFailed(t *testing.T) {
|
|
subject := "MERGE_FAILED nux"
|
|
body := `Branch: feature-nux
|
|
Issue: gt-abc123
|
|
FailureType: tests
|
|
Error: unit tests failed with 3 errors`
|
|
|
|
payload, err := ParseMergeFailed(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParseMergeFailed() error = %v", err)
|
|
}
|
|
|
|
if payload.PolecatName != "nux" {
|
|
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux")
|
|
}
|
|
if payload.Branch != "feature-nux" {
|
|
t.Errorf("Branch = %q, want %q", payload.Branch, "feature-nux")
|
|
}
|
|
if payload.IssueID != "gt-abc123" {
|
|
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
|
}
|
|
if payload.FailureType != "tests" {
|
|
t.Errorf("FailureType = %q, want %q", payload.FailureType, "tests")
|
|
}
|
|
if payload.Error != "unit tests failed with 3 errors" {
|
|
t.Errorf("Error = %q, want %q", payload.Error, "unit tests failed with 3 errors")
|
|
}
|
|
if payload.FailedAt.IsZero() {
|
|
t.Error("FailedAt should not be zero")
|
|
}
|
|
}
|
|
|
|
func TestParseMergeFailed_MinimalBody(t *testing.T) {
|
|
subject := "MERGE_FAILED ace"
|
|
body := "FailureType: build"
|
|
|
|
payload, err := ParseMergeFailed(subject, body)
|
|
if err != nil {
|
|
t.Fatalf("ParseMergeFailed() error = %v", err)
|
|
}
|
|
|
|
if payload.PolecatName != "ace" {
|
|
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "ace")
|
|
}
|
|
if payload.FailureType != "build" {
|
|
t.Errorf("FailureType = %q, want %q", payload.FailureType, "build")
|
|
}
|
|
if payload.Branch != "" {
|
|
t.Errorf("Branch = %q, want empty", payload.Branch)
|
|
}
|
|
}
|
|
|
|
func TestParseMergeFailed_InvalidSubject(t *testing.T) {
|
|
_, err := ParseMergeFailed("Not a merge failed", "body")
|
|
if err == nil {
|
|
t.Error("ParseMergeFailed() expected error for invalid subject")
|
|
}
|
|
}
|
|
|
|
func TestCleanupWispLabels(t *testing.T) {
|
|
labels := CleanupWispLabels("nux", "pending")
|
|
|
|
expected := []string{"cleanup", "polecat:nux", "state:pending"}
|
|
if len(labels) != len(expected) {
|
|
t.Fatalf("CleanupWispLabels() returned %d labels, want %d", len(labels), len(expected))
|
|
}
|
|
|
|
for i, label := range labels {
|
|
if label != expected[i] {
|
|
t.Errorf("labels[%d] = %q, want %q", i, label, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssessHelpRequest_GitConflict(t *testing.T) {
|
|
payload := &HelpPayload{
|
|
Topic: "Git issue",
|
|
Problem: "Merge conflict in main.go",
|
|
}
|
|
|
|
assessment := AssessHelpRequest(payload)
|
|
|
|
if assessment.CanHelp {
|
|
t.Error("Should not be able to help with git conflicts")
|
|
}
|
|
if !assessment.NeedsEscalation {
|
|
t.Error("Git conflicts should need escalation")
|
|
}
|
|
}
|
|
|
|
func TestAssessHelpRequest_GitPush(t *testing.T) {
|
|
payload := &HelpPayload{
|
|
Topic: "Git push failing",
|
|
Problem: "Cannot push to remote",
|
|
}
|
|
|
|
assessment := AssessHelpRequest(payload)
|
|
|
|
if !assessment.CanHelp {
|
|
t.Error("Should be able to help with git push issues")
|
|
}
|
|
if assessment.HelpAction == "" {
|
|
t.Error("HelpAction should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestAssessHelpRequest_TestFailures(t *testing.T) {
|
|
payload := &HelpPayload{
|
|
Topic: "Test failures",
|
|
Problem: "Tests fail on CI",
|
|
}
|
|
|
|
assessment := AssessHelpRequest(payload)
|
|
|
|
if assessment.CanHelp {
|
|
t.Error("Should not be able to help with test failures")
|
|
}
|
|
if !assessment.NeedsEscalation {
|
|
t.Error("Test failures should need escalation")
|
|
}
|
|
}
|
|
|
|
func TestAssessHelpRequest_RequirementsUnclear(t *testing.T) {
|
|
payload := &HelpPayload{
|
|
Topic: "Requirements unclear",
|
|
Problem: "Don't understand the requirements for this task",
|
|
}
|
|
|
|
assessment := AssessHelpRequest(payload)
|
|
|
|
if assessment.CanHelp {
|
|
t.Error("Should not be able to help with unclear requirements")
|
|
}
|
|
if !assessment.NeedsEscalation {
|
|
t.Error("Unclear requirements should need escalation")
|
|
}
|
|
}
|
|
|
|
func TestAssessHelpRequest_BuildIssues(t *testing.T) {
|
|
payload := &HelpPayload{
|
|
Topic: "Build failing",
|
|
Problem: "Cannot compile the project",
|
|
}
|
|
|
|
assessment := AssessHelpRequest(payload)
|
|
|
|
if !assessment.CanHelp {
|
|
t.Error("Should be able to help with build issues")
|
|
}
|
|
}
|