Add internal/protocol/ package with handlers for: - MERGE_READY (witness→refinery): worker done, branch ready - MERGED (refinery→witness): merge succeeded, cleanup ok - MERGE_FAILED (refinery→witness): merge failed, needs rework - REWORK_REQUEST (refinery→witness): rebase needed due to conflicts Package structure: - types.go: Protocol message types and payload structs - messages.go: Message builders and body parsers - handlers.go: Handler interface and registry for dispatch - witness_handlers.go: DefaultWitnessHandler implementation - refinery_handlers.go: DefaultRefineryHandler implementation - protocol_test.go: Comprehensive test coverage Also updated docs/mail-protocol.md with: - MERGE_FAILED and REWORK_REQUEST message type documentation - Merge failure and rebase required flow diagrams - Reference to internal/protocol/ package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
289 lines
8.5 KiB
Go
289 lines
8.5 KiB
Go
package protocol
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/mail"
|
|
)
|
|
|
|
// NewMergeReadyMessage creates a MERGE_READY protocol message.
|
|
// Sent by Witness to Refinery when a polecat's work is verified and ready.
|
|
func NewMergeReadyMessage(rig, polecat, branch, issue string) *mail.Message {
|
|
payload := MergeReadyPayload{
|
|
Branch: branch,
|
|
Issue: issue,
|
|
Polecat: polecat,
|
|
Rig: rig,
|
|
Verified: "clean git state, issue closed",
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
body := formatMergeReadyBody(payload)
|
|
|
|
msg := mail.NewMessage(
|
|
fmt.Sprintf("%s/witness", rig),
|
|
fmt.Sprintf("%s/refinery", rig),
|
|
fmt.Sprintf("MERGE_READY %s", polecat),
|
|
body,
|
|
)
|
|
msg.Priority = mail.PriorityHigh
|
|
msg.Type = mail.TypeTask
|
|
|
|
return msg
|
|
}
|
|
|
|
// formatMergeReadyBody formats the body of a MERGE_READY message.
|
|
func formatMergeReadyBody(p MergeReadyPayload) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
|
|
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
|
|
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
|
|
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
|
|
if p.Verified != "" {
|
|
sb.WriteString(fmt.Sprintf("Verified: %s\n", p.Verified))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// NewMergedMessage creates a MERGED protocol message.
|
|
// Sent by Refinery to Witness when a branch is successfully merged.
|
|
func NewMergedMessage(rig, polecat, branch, issue, targetBranch, mergeCommit string) *mail.Message {
|
|
payload := MergedPayload{
|
|
Branch: branch,
|
|
Issue: issue,
|
|
Polecat: polecat,
|
|
Rig: rig,
|
|
MergedAt: time.Now(),
|
|
MergeCommit: mergeCommit,
|
|
TargetBranch: targetBranch,
|
|
}
|
|
|
|
body := formatMergedBody(payload)
|
|
|
|
msg := mail.NewMessage(
|
|
fmt.Sprintf("%s/refinery", rig),
|
|
fmt.Sprintf("%s/witness", rig),
|
|
fmt.Sprintf("MERGED %s", polecat),
|
|
body,
|
|
)
|
|
msg.Priority = mail.PriorityHigh
|
|
msg.Type = mail.TypeNotification
|
|
|
|
return msg
|
|
}
|
|
|
|
// formatMergedBody formats the body of a MERGED message.
|
|
func formatMergedBody(p MergedPayload) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
|
|
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
|
|
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
|
|
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
|
|
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
|
|
sb.WriteString(fmt.Sprintf("Merged-At: %s\n", p.MergedAt.Format(time.RFC3339)))
|
|
if p.MergeCommit != "" {
|
|
sb.WriteString(fmt.Sprintf("Merge-Commit: %s\n", p.MergeCommit))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// NewMergeFailedMessage creates a MERGE_FAILED protocol message.
|
|
// Sent by Refinery to Witness when merge fails (tests, build, etc.).
|
|
func NewMergeFailedMessage(rig, polecat, branch, issue, targetBranch, failureType, errorMsg string) *mail.Message {
|
|
payload := MergeFailedPayload{
|
|
Branch: branch,
|
|
Issue: issue,
|
|
Polecat: polecat,
|
|
Rig: rig,
|
|
FailedAt: time.Now(),
|
|
FailureType: failureType,
|
|
Error: errorMsg,
|
|
TargetBranch: targetBranch,
|
|
}
|
|
|
|
body := formatMergeFailedBody(payload)
|
|
|
|
msg := mail.NewMessage(
|
|
fmt.Sprintf("%s/refinery", rig),
|
|
fmt.Sprintf("%s/witness", rig),
|
|
fmt.Sprintf("MERGE_FAILED %s", polecat),
|
|
body,
|
|
)
|
|
msg.Priority = mail.PriorityHigh
|
|
msg.Type = mail.TypeTask
|
|
|
|
return msg
|
|
}
|
|
|
|
// formatMergeFailedBody formats the body of a MERGE_FAILED message.
|
|
func formatMergeFailedBody(p MergeFailedPayload) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
|
|
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
|
|
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
|
|
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
|
|
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
|
|
sb.WriteString(fmt.Sprintf("Failed-At: %s\n", p.FailedAt.Format(time.RFC3339)))
|
|
sb.WriteString(fmt.Sprintf("Failure-Type: %s\n", p.FailureType))
|
|
sb.WriteString(fmt.Sprintf("Error: %s\n", p.Error))
|
|
return sb.String()
|
|
}
|
|
|
|
// NewReworkRequestMessage creates a REWORK_REQUEST protocol message.
|
|
// Sent by Refinery to Witness when a branch needs rebasing due to conflicts.
|
|
func NewReworkRequestMessage(rig, polecat, branch, issue, targetBranch string, conflictFiles []string) *mail.Message {
|
|
payload := ReworkRequestPayload{
|
|
Branch: branch,
|
|
Issue: issue,
|
|
Polecat: polecat,
|
|
Rig: rig,
|
|
RequestedAt: time.Now(),
|
|
TargetBranch: targetBranch,
|
|
ConflictFiles: conflictFiles,
|
|
Instructions: formatRebaseInstructions(targetBranch),
|
|
}
|
|
|
|
body := formatReworkRequestBody(payload)
|
|
|
|
msg := mail.NewMessage(
|
|
fmt.Sprintf("%s/refinery", rig),
|
|
fmt.Sprintf("%s/witness", rig),
|
|
fmt.Sprintf("REWORK_REQUEST %s", polecat),
|
|
body,
|
|
)
|
|
msg.Priority = mail.PriorityHigh
|
|
msg.Type = mail.TypeTask
|
|
|
|
return msg
|
|
}
|
|
|
|
// formatReworkRequestBody formats the body of a REWORK_REQUEST message.
|
|
func formatReworkRequestBody(p ReworkRequestPayload) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
|
|
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
|
|
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
|
|
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
|
|
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
|
|
sb.WriteString(fmt.Sprintf("Requested-At: %s\n", p.RequestedAt.Format(time.RFC3339)))
|
|
|
|
if len(p.ConflictFiles) > 0 {
|
|
sb.WriteString(fmt.Sprintf("Conflict-Files: %s\n", strings.Join(p.ConflictFiles, ", ")))
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
sb.WriteString(p.Instructions)
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// formatRebaseInstructions returns standard rebase instructions.
|
|
func formatRebaseInstructions(targetBranch string) string {
|
|
return fmt.Sprintf(`Please rebase your changes onto %s:
|
|
|
|
git fetch origin
|
|
git rebase origin/%s
|
|
# Resolve any conflicts
|
|
git push -f
|
|
|
|
The Refinery will retry the merge after rebase is complete.`, targetBranch, targetBranch)
|
|
}
|
|
|
|
// ParseMergeReadyPayload parses a MERGE_READY message body into a payload.
|
|
func ParseMergeReadyPayload(body string) *MergeReadyPayload {
|
|
return &MergeReadyPayload{
|
|
Branch: parseField(body, "Branch"),
|
|
Issue: parseField(body, "Issue"),
|
|
Polecat: parseField(body, "Polecat"),
|
|
Rig: parseField(body, "Rig"),
|
|
Verified: parseField(body, "Verified"),
|
|
Timestamp: time.Now(), // Use current time if not parseable
|
|
}
|
|
}
|
|
|
|
// ParseMergedPayload parses a MERGED message body into a payload.
|
|
func ParseMergedPayload(body string) *MergedPayload {
|
|
payload := &MergedPayload{
|
|
Branch: parseField(body, "Branch"),
|
|
Issue: parseField(body, "Issue"),
|
|
Polecat: parseField(body, "Polecat"),
|
|
Rig: parseField(body, "Rig"),
|
|
TargetBranch: parseField(body, "Target"),
|
|
MergeCommit: parseField(body, "Merge-Commit"),
|
|
}
|
|
|
|
// Parse timestamp
|
|
if ts := parseField(body, "Merged-At"); ts != "" {
|
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
|
payload.MergedAt = t
|
|
}
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// ParseMergeFailedPayload parses a MERGE_FAILED message body into a payload.
|
|
func ParseMergeFailedPayload(body string) *MergeFailedPayload {
|
|
payload := &MergeFailedPayload{
|
|
Branch: parseField(body, "Branch"),
|
|
Issue: parseField(body, "Issue"),
|
|
Polecat: parseField(body, "Polecat"),
|
|
Rig: parseField(body, "Rig"),
|
|
TargetBranch: parseField(body, "Target"),
|
|
FailureType: parseField(body, "Failure-Type"),
|
|
Error: parseField(body, "Error"),
|
|
}
|
|
|
|
// Parse timestamp
|
|
if ts := parseField(body, "Failed-At"); ts != "" {
|
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
|
payload.FailedAt = t
|
|
}
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// ParseReworkRequestPayload parses a REWORK_REQUEST message body into a payload.
|
|
func ParseReworkRequestPayload(body string) *ReworkRequestPayload {
|
|
payload := &ReworkRequestPayload{
|
|
Branch: parseField(body, "Branch"),
|
|
Issue: parseField(body, "Issue"),
|
|
Polecat: parseField(body, "Polecat"),
|
|
Rig: parseField(body, "Rig"),
|
|
TargetBranch: parseField(body, "Target"),
|
|
}
|
|
|
|
// Parse timestamp
|
|
if ts := parseField(body, "Requested-At"); ts != "" {
|
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
|
payload.RequestedAt = t
|
|
}
|
|
}
|
|
|
|
// Parse conflict files
|
|
if files := parseField(body, "Conflict-Files"); files != "" {
|
|
payload.ConflictFiles = strings.Split(files, ", ")
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// parseField extracts a field value from a key-value body format.
|
|
// Format: "Key: value"
|
|
func parseField(body, key string) string {
|
|
lines := strings.Split(body, "\n")
|
|
prefix := key + ": "
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, prefix) {
|
|
return strings.TrimPrefix(line, prefix)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|