diff --git a/github/template/template_custom/template.go b/github/template/template_custom/template.go index 2b27ffc..ee65d09 100644 --- a/github/template/template_custom/template.go +++ b/github/template/template_custom/template.go @@ -1,6 +1,7 @@ package template_custom import ( + "errors" "fmt" "os" "path" @@ -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 = "" + defaultEndAnchor = "" +) + // 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 @@ -91,18 +102,32 @@ 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 ( @@ -110,21 +135,34 @@ const ( 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 } } diff --git a/github/template/template_custom/template_test.go b/github/template/template_custom/template_test.go index 1d37f3d..5394d0c 100644 --- a/github/template/template_custom/template_test.go +++ b/github/template/template_custom/template_test.go @@ -2,6 +2,7 @@ package template_custom import ( "context" + "errors" "os" "path/filepath" "strings" @@ -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 + + + +Generated body content + + +`, + }, + { + name: "existing_markers_replace_content_between_default_anchors", + prTemplate: ` +# PR Template + +content before markers + + + old spr stack goes here (to be replaced) + +`, + body: "New commit body", + pr: nil, + expectedError: nil, + expected: ` +# PR Template + +content before markers + + +New commit body + + +`, + }, + { + 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 + + +Old body content + + +`, + }, + expectedError: nil, + expected: `# PR Template + +Initial description + + +Updated commit body + + +`, + }, + { + name: "error_missing_end_anchor_in_existing_PR_body", + prTemplate: `# PR Template + +description +`, + body: "New body", + pr: &github.PullRequest{ + Body: `# PR Template + +description + + +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 + + +Content 1 + +Content 2 + +`, + }, + 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 + + +Content + +More content + +`, + }, + 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) + }) + } +}