Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 55 additions & 17 deletions github/template/template_custom/template.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package template_custom

import (
"errors"
"fmt"
"os"
"path"
Expand Down Expand Up @@ -78,6 +79,16 @@ func (t *CustomTemplatizer) readPRTemplate() (string, error) {
return string(pullRequestTemplateBytes), nil
}

const (
// Default anchors mark where the stack content is inserted in PR templates.
// Can be overridden in RepoConfig using PRTemplateInsertStart and PRTemplateInsertEnd.
// If anchors are not found in the template, content is appended to the PR.
// Implemented as HTML comments so they don't appear in rendered Markdown.

defaultStartAnchor = "<!-- SPR-STACK-START -->"
defaultEndAnchor = "<!-- SPR-STACK-END -->"
)

// insertBodyIntoPRTemplate inserts a text body into the given PR template and returns the result as a string.
// It uses the PRTemplateInsertStart and PRTemplateInsertEnd values defined in RepoConfig to determine where the body
// should be inserted in the PR template. If there are issues finding the correct place to insert the body
Expand All @@ -91,40 +102,67 @@ func (t *CustomTemplatizer) insertBodyIntoPRTemplate(body, prTemplate string, pr
templateOrExistingPRBody = pr.Body
}

startPRTemplateSection, err := getSectionOfPRTemplate(templateOrExistingPRBody, t.repoConfig.PRTemplateInsertStart, BeforeMatch)
startAnchor := t.repoConfig.PRTemplateInsertStart
if startAnchor == "" {
startAnchor = defaultStartAnchor
}

endAnchor := t.repoConfig.PRTemplateInsertEnd
if endAnchor == "" {
endAnchor = defaultEndAnchor
}

startPRTemplateSection, err := getSectionOfPRTemplate(templateOrExistingPRBody, startAnchor, BeforeMatch)
if err == ErrNoMatchesFound && startAnchor == defaultStartAnchor {
// Default append mode: if no anchors found in the template, append body at the end.
return fmt.Sprintf("%s\n\n%s\n%s\n\n%s\n", templateOrExistingPRBody, startAnchor, body, endAnchor), nil
}

if err != nil {
return "", fmt.Errorf("%w: PR template insert start = '%v'", err, t.repoConfig.PRTemplateInsertStart)
return "", fmt.Errorf("%w: PR template insert start = '%v'", err, startAnchor)
}

endPRTemplateSection, err := getSectionOfPRTemplate(templateOrExistingPRBody, t.repoConfig.PRTemplateInsertEnd, AfterMatch)
endPRTemplateSection, err := getSectionOfPRTemplate(templateOrExistingPRBody, endAnchor, AfterMatch)
if err != nil {
return "", fmt.Errorf("%w: PR template insert end = '%v'", err, t.repoConfig.PRTemplateInsertEnd)
return "", fmt.Errorf("%w: PR template insert end = '%v'", err, endAnchor)
}

return fmt.Sprintf("%v%v\n%v\n\n%v%v", startPRTemplateSection, t.repoConfig.PRTemplateInsertStart, body,
t.repoConfig.PRTemplateInsertEnd, endPRTemplateSection), nil
return fmt.Sprintf("%v%v\n%v\n\n%v%v", startPRTemplateSection, startAnchor, body, endAnchor, endPRTemplateSection), nil
}

const (
BeforeMatch = iota
AfterMatch
)

var (
// Error returned when no matches are found in a PR template
ErrNoMatchesFound = fmt.Errorf("no matches found")
// Error returned when multiple matches are found in a PR template
ErrMultipleMatchesFound = fmt.Errorf("multiple matches found")
)

// getSectionOfPRTemplate searches text for a matching searchString and will return the text before or after the
// match as a string. If there are no matches or more than one match is found, an error will be returned.
func getSectionOfPRTemplate(text, searchString string, returnMatch int) (string, error) {
split := strings.Split(text, searchString)
switch len(split) {
case 2:
if returnMatch == BeforeMatch {
return split[0], nil
} else if returnMatch == AfterMatch {
return split[1], nil
}
return "", fmt.Errorf("invalid enum value")
// Check occurrence count in a single pass
count := strings.Count(text, searchString)
switch count {
case 0:
return "", ErrNoMatchesFound
case 1:
return "", fmt.Errorf("no matches found")
// Expected case: exactly one match
idx := strings.Index(text, searchString)
switch returnMatch {
case BeforeMatch:
return text[:idx], nil
case AfterMatch:
return text[idx+len(searchString):], nil
default:
return "", errors.New("invalid enum value")
}
default:
return "", fmt.Errorf("multiple matches found")
// count > 1
return "", ErrMultipleMatchesFound
}
}
170 changes: 170 additions & 0 deletions github/template/template_custom/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package template_custom

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -514,3 +515,172 @@ func TestFormatBodyCurrentCommitIndicator(t *testing.T) {
assert.Contains(t, result, "#2 ⬅")
assert.NotContains(t, result, "#1 ⬅")
}

// TestInsertBodyIntoPRTemplateDefaultAnchors tests default anchor behavior for creation, replacement, and update scenarios
func TestInsertBodyIntoPRTemplateDefaultAnchors(t *testing.T) {
tests := []struct {
name string
prTemplate string
body string
pr *github.PullRequest
expectedError error
expected string
}{
{
name: "no_markers_exist_append_with_default_anchors",
prTemplate: `
# PR Template

content
`,
body: "Generated body content",
pr: nil,
expectedError: nil,
expected: `
# PR Template

content


<!-- SPR-STACK-START -->
Generated body content

<!-- SPR-STACK-END -->
`,
},
{
name: "existing_markers_replace_content_between_default_anchors",
prTemplate: `
# PR Template

content before markers

<!-- SPR-STACK-START -->
old spr stack goes here (to be replaced)
<!-- SPR-STACK-END -->
`,
body: "New commit body",
pr: nil,
expectedError: nil,
expected: `
# PR Template

content before markers

<!-- SPR-STACK-START -->
New commit body

<!-- SPR-STACK-END -->
`,
},
{
name: "pr_update_use_existing_PR_body_and_replace_content",
prTemplate: `# PR Template

Initial description
`,
body: "Updated commit body",
pr: &github.PullRequest{
Body: `# PR Template

Initial description

<!-- SPR-STACK-START -->
Old body content

<!-- SPR-STACK-END -->
`,
},
expectedError: nil,
expected: `# PR Template

Initial description

<!-- SPR-STACK-START -->
Updated commit body

<!-- SPR-STACK-END -->
`,
},
{
name: "error_missing_end_anchor_in_existing_PR_body",
prTemplate: `# PR Template

description
`,
body: "New body",
pr: &github.PullRequest{
Body: `# PR Template

description

<!-- SPR-STACK-START -->
Old content
`,
},
expectedError: ErrNoMatchesFound,
expected: "",
},
{
name: "error_multiple_start_anchors_in_existing_PR_body",
prTemplate: `# PR Template

description
`,
body: "New body",
pr: &github.PullRequest{
Body: `# PR Template

<!-- SPR-STACK-START -->
Content 1
<!-- SPR-STACK-START -->
Content 2
<!-- SPR-STACK-END -->
`,
},
expectedError: ErrMultipleMatchesFound,
expected: "",
},
{
name: "error_multiple_end_anchors_in_existing_PR_body",
prTemplate: `# PR Template

description
`,
body: "New body",
pr: &github.PullRequest{
Body: `# PR Template

<!-- SPR-STACK-START -->
Content
<!-- SPR-STACK-END -->
More content
<!-- SPR-STACK-END -->
`,
},
expectedError: ErrMultipleMatchesFound,
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repoConfig := &config.RepoConfig{
PRTemplatePath: "pr_template.md",
// No custom anchors - use defaults
}
gitcmd := &mockGit{rootDir: "/tmp"}
templatizer := NewCustomTemplatizer(repoConfig, gitcmd)

result, err := templatizer.insertBodyIntoPRTemplate(tt.body, tt.prTemplate, tt.pr)

if tt.expectedError != nil {
assert.True(t, errors.Is(err, tt.expectedError), "expected error %v, got %v", tt.expectedError, err)
return
}

require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
Loading