feat(beads): add MR field parsing for merge-request issues
Implement ParseMRFields and SetMRFields helper functions to extract and update structured fields in merge-request issue descriptions. Fields supported: branch, target, source_issue, worker, rig, merge_commit, close_reason. Features: - Case-insensitive key matching - Alternate key formats (snake_case, kebab-case) - Preserves non-MR prose content - Handles mixed MR fields and prose - Round-trip safe (parse then format preserves data) Closes: gt-h5n.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package beads
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -183,3 +184,445 @@ func TestIntegration(t *testing.T) {
|
||||
t.Logf("Showed issue: %s - %s", issue.ID, issue.Title)
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseMRFields tests parsing MR fields from issue descriptions.
|
||||
func TestParseMRFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
issue *Issue
|
||||
wantNil bool
|
||||
wantFields *MRFields
|
||||
}{
|
||||
{
|
||||
name: "nil issue",
|
||||
issue: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
issue: &Issue{Description: ""},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "no MR fields",
|
||||
issue: &Issue{Description: "This is just plain text\nwith no field markers"},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "all fields",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Nux/gt-xyz
|
||||
target: main
|
||||
source_issue: gt-xyz
|
||||
worker: Nux
|
||||
rig: gastown
|
||||
merge_commit: abc123def
|
||||
close_reason: merged`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-xyz",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-xyz",
|
||||
Worker: "Nux",
|
||||
Rig: "gastown",
|
||||
MergeCommit: "abc123def",
|
||||
CloseReason: "merged",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partial fields",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Toast/gt-abc
|
||||
target: integration/gt-epic
|
||||
source_issue: gt-abc
|
||||
worker: Toast`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Toast/gt-abc",
|
||||
Target: "integration/gt-epic",
|
||||
SourceIssue: "gt-abc",
|
||||
Worker: "Toast",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed with prose",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Capable/gt-def
|
||||
target: main
|
||||
source_issue: gt-def
|
||||
|
||||
This MR fixes a critical bug in the authentication system.
|
||||
Please review carefully.
|
||||
|
||||
worker: Capable
|
||||
rig: wasteland`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Capable/gt-def",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-def",
|
||||
Worker: "Capable",
|
||||
Rig: "wasteland",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alternate key formats",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Max/gt-ghi
|
||||
source-issue: gt-ghi
|
||||
merge-commit: 789xyz`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Max/gt-ghi",
|
||||
SourceIssue: "gt-ghi",
|
||||
MergeCommit: "789xyz",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case insensitive keys",
|
||||
issue: &Issue{
|
||||
Description: `Branch: polecat/Furiosa/gt-jkl
|
||||
TARGET: main
|
||||
Worker: Furiosa
|
||||
RIG: gastown`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Furiosa/gt-jkl",
|
||||
Target: "main",
|
||||
Worker: "Furiosa",
|
||||
Rig: "gastown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra whitespace",
|
||||
issue: &Issue{
|
||||
Description: ` branch: polecat/Nux/gt-mno
|
||||
target:main
|
||||
worker: Nux `,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-mno",
|
||||
Target: "main",
|
||||
Worker: "Nux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores empty values",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Nux/gt-pqr
|
||||
target:
|
||||
source_issue: gt-pqr`,
|
||||
},
|
||||
wantFields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-pqr",
|
||||
SourceIssue: "gt-pqr",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fields := ParseMRFields(tt.issue)
|
||||
|
||||
if tt.wantNil {
|
||||
if fields != nil {
|
||||
t.Errorf("ParseMRFields() = %+v, want nil", fields)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if fields == nil {
|
||||
t.Fatal("ParseMRFields() = nil, want non-nil")
|
||||
}
|
||||
|
||||
if fields.Branch != tt.wantFields.Branch {
|
||||
t.Errorf("Branch = %q, want %q", fields.Branch, tt.wantFields.Branch)
|
||||
}
|
||||
if fields.Target != tt.wantFields.Target {
|
||||
t.Errorf("Target = %q, want %q", fields.Target, tt.wantFields.Target)
|
||||
}
|
||||
if fields.SourceIssue != tt.wantFields.SourceIssue {
|
||||
t.Errorf("SourceIssue = %q, want %q", fields.SourceIssue, tt.wantFields.SourceIssue)
|
||||
}
|
||||
if fields.Worker != tt.wantFields.Worker {
|
||||
t.Errorf("Worker = %q, want %q", fields.Worker, tt.wantFields.Worker)
|
||||
}
|
||||
if fields.Rig != tt.wantFields.Rig {
|
||||
t.Errorf("Rig = %q, want %q", fields.Rig, tt.wantFields.Rig)
|
||||
}
|
||||
if fields.MergeCommit != tt.wantFields.MergeCommit {
|
||||
t.Errorf("MergeCommit = %q, want %q", fields.MergeCommit, tt.wantFields.MergeCommit)
|
||||
}
|
||||
if fields.CloseReason != tt.wantFields.CloseReason {
|
||||
t.Errorf("CloseReason = %q, want %q", fields.CloseReason, tt.wantFields.CloseReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMRFields tests formatting MR fields to string.
|
||||
func TestFormatMRFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields *MRFields
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil fields",
|
||||
fields: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty fields",
|
||||
fields: &MRFields{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "all fields",
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-xyz",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-xyz",
|
||||
Worker: "Nux",
|
||||
Rig: "gastown",
|
||||
MergeCommit: "abc123def",
|
||||
CloseReason: "merged",
|
||||
},
|
||||
want: `branch: polecat/Nux/gt-xyz
|
||||
target: main
|
||||
source_issue: gt-xyz
|
||||
worker: Nux
|
||||
rig: gastown
|
||||
merge_commit: abc123def
|
||||
close_reason: merged`,
|
||||
},
|
||||
{
|
||||
name: "partial fields",
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Toast/gt-abc",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-abc",
|
||||
Worker: "Toast",
|
||||
},
|
||||
want: `branch: polecat/Toast/gt-abc
|
||||
target: main
|
||||
source_issue: gt-abc
|
||||
worker: Toast`,
|
||||
},
|
||||
{
|
||||
name: "only close fields",
|
||||
fields: &MRFields{
|
||||
MergeCommit: "deadbeef",
|
||||
CloseReason: "rejected",
|
||||
},
|
||||
want: `merge_commit: deadbeef
|
||||
close_reason: rejected`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatMRFields(tt.fields)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatMRFields() =\n%q\nwant\n%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetMRFields tests updating issue descriptions with MR fields.
|
||||
func TestSetMRFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
issue *Issue
|
||||
fields *MRFields
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil issue",
|
||||
issue: nil,
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-xyz",
|
||||
Target: "main",
|
||||
},
|
||||
want: `branch: polecat/Nux/gt-xyz
|
||||
target: main`,
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
issue: &Issue{Description: ""},
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-xyz",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-xyz",
|
||||
},
|
||||
want: `branch: polecat/Nux/gt-xyz
|
||||
target: main
|
||||
source_issue: gt-xyz`,
|
||||
},
|
||||
{
|
||||
name: "preserve prose content",
|
||||
issue: &Issue{Description: "This is a description of the work.\n\nIt spans multiple lines."},
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Toast/gt-abc",
|
||||
Worker: "Toast",
|
||||
},
|
||||
want: `branch: polecat/Toast/gt-abc
|
||||
worker: Toast
|
||||
|
||||
This is a description of the work.
|
||||
|
||||
It spans multiple lines.`,
|
||||
},
|
||||
{
|
||||
name: "replace existing fields",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Nux/gt-old
|
||||
target: develop
|
||||
source_issue: gt-old
|
||||
worker: Nux
|
||||
|
||||
Some existing prose content.`,
|
||||
},
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Nux/gt-new",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-new",
|
||||
Worker: "Nux",
|
||||
MergeCommit: "abc123",
|
||||
},
|
||||
want: `branch: polecat/Nux/gt-new
|
||||
target: main
|
||||
source_issue: gt-new
|
||||
worker: Nux
|
||||
merge_commit: abc123
|
||||
|
||||
Some existing prose content.`,
|
||||
},
|
||||
{
|
||||
name: "preserve non-MR key-value lines",
|
||||
issue: &Issue{
|
||||
Description: `branch: polecat/Capable/gt-def
|
||||
custom_field: some value
|
||||
author: someone
|
||||
target: main`,
|
||||
},
|
||||
fields: &MRFields{
|
||||
Branch: "polecat/Capable/gt-ghi",
|
||||
Target: "integration/epic",
|
||||
CloseReason: "merged",
|
||||
},
|
||||
want: `branch: polecat/Capable/gt-ghi
|
||||
target: integration/epic
|
||||
close_reason: merged
|
||||
|
||||
custom_field: some value
|
||||
author: someone`,
|
||||
},
|
||||
{
|
||||
name: "empty fields clears MR data",
|
||||
issue: &Issue{Description: "branch: old\ntarget: old\n\nKeep this text."},
|
||||
fields: &MRFields{},
|
||||
want: "Keep this text.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SetMRFields(tt.issue, tt.fields)
|
||||
if got != tt.want {
|
||||
t.Errorf("SetMRFields() =\n%q\nwant\n%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMRFieldsRoundTrip tests that parse/format round-trips correctly.
|
||||
func TestMRFieldsRoundTrip(t *testing.T) {
|
||||
original := &MRFields{
|
||||
Branch: "polecat/Nux/gt-xyz",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-xyz",
|
||||
Worker: "Nux",
|
||||
Rig: "gastown",
|
||||
MergeCommit: "abc123def789",
|
||||
CloseReason: "merged",
|
||||
}
|
||||
|
||||
// Format to string
|
||||
formatted := FormatMRFields(original)
|
||||
|
||||
// Parse back
|
||||
issue := &Issue{Description: formatted}
|
||||
parsed := ParseMRFields(issue)
|
||||
|
||||
if parsed == nil {
|
||||
t.Fatal("round-trip parse returned nil")
|
||||
}
|
||||
|
||||
if *parsed != *original {
|
||||
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMRFieldsFromDesignDoc tests the example from the design doc.
|
||||
func TestParseMRFieldsFromDesignDoc(t *testing.T) {
|
||||
// Example from docs/merge-queue-design.md
|
||||
description := `branch: polecat/Nux/gt-xyz
|
||||
target: main
|
||||
source_issue: gt-xyz
|
||||
worker: Nux
|
||||
rig: gastown`
|
||||
|
||||
issue := &Issue{Description: description}
|
||||
fields := ParseMRFields(issue)
|
||||
|
||||
if fields == nil {
|
||||
t.Fatal("ParseMRFields returned nil for design doc example")
|
||||
}
|
||||
|
||||
// Verify all fields match the design doc
|
||||
if fields.Branch != "polecat/Nux/gt-xyz" {
|
||||
t.Errorf("Branch = %q, want polecat/Nux/gt-xyz", fields.Branch)
|
||||
}
|
||||
if fields.Target != "main" {
|
||||
t.Errorf("Target = %q, want main", fields.Target)
|
||||
}
|
||||
if fields.SourceIssue != "gt-xyz" {
|
||||
t.Errorf("SourceIssue = %q, want gt-xyz", fields.SourceIssue)
|
||||
}
|
||||
if fields.Worker != "Nux" {
|
||||
t.Errorf("Worker = %q, want Nux", fields.Worker)
|
||||
}
|
||||
if fields.Rig != "gastown" {
|
||||
t.Errorf("Rig = %q, want gastown", fields.Rig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetMRFieldsPreservesURL tests that URLs in prose are preserved.
|
||||
func TestSetMRFieldsPreservesURL(t *testing.T) {
|
||||
// URLs contain colons which could be confused with key: value
|
||||
issue := &Issue{
|
||||
Description: `branch: old-branch
|
||||
Check out https://example.com/path for more info.
|
||||
Also see http://localhost:8080/api`,
|
||||
}
|
||||
|
||||
fields := &MRFields{
|
||||
Branch: "new-branch",
|
||||
Target: "main",
|
||||
}
|
||||
|
||||
result := SetMRFields(issue, fields)
|
||||
|
||||
// URLs should be preserved
|
||||
if !strings.Contains(result, "https://example.com/path") {
|
||||
t.Error("HTTPS URL was not preserved")
|
||||
}
|
||||
if !strings.Contains(result, "http://localhost:8080/api") {
|
||||
t.Error("HTTP URL was not preserved")
|
||||
}
|
||||
if !strings.Contains(result, "branch: new-branch") {
|
||||
t.Error("branch field was not set")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user