Repository: InditexTech/gh-sherpa (go)

Issue:
Add Pull Request template support for create-pr command
### Motivation

Describe here the motivation of the request.

We are using a pull request template to fill the description of the PR automatically when it's created. We'd like the cpr command supports pull request templates.

### Acceptance criteria

- [ ] Add a parameter to the cpr command to include pull request template in the description of the PR we want to create.


Pull request description:
Add Pull Request template support for create-pr command
Closes #99

Patch:
diff --git a/cmd/create_pull_request/cmd.go b/cmd/create_pull_request/cmd.go
index 134056e..dca1319 100644
--- a/cmd/create_pull_request/cmd.go
+++ b/cmd/create_pull_request/cmd.go
@@ -33,6 +33,7 @@ type createPullRequestFlags struct {
 	NoDraft          bool
 	NoCloseIssue     bool
 	UseDefaultValues bool
+	TemplatePath     string
 }
 
 var flags createPullRequestFlags
@@ -43,6 +44,7 @@ func init() {
 	Command.PersistentFlags().BoolVar(&flags.NoFetch, "no-fetch", false, "does not fetch the base branch")
 	Command.PersistentFlags().BoolVar(&flags.NoDraft, "no-draft", false, "create the pull request in ready for review mode")
 	Command.PersistentFlags().BoolVarP(&flags.NoCloseIssue, "no-close-issue", "n", false, "do not close the GitHub issue after merging the pull request")
+	Command.PersistentFlags().StringVar(&flags.TemplatePath, "template", "", "path to a pull request template file")
 }
 
 func runCommand(cmd *cobra.Command, _ []string) error {
@@ -83,6 +85,7 @@ func runCommand(cmd *cobra.Command, _ []string) error {
 		IsInteractive:   isInteractive,
 		DraftPR:         !flags.NoDraft,
 		CloseIssue:      !flags.NoCloseIssue,
+		TemplatePath:    flags.TemplatePath,
 	}
 	createPullRequestUseCase := use_cases.CreatePullRequest{
 		Cfg:                     createPullRequestConfig,
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 5929bbf..d426e32 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -85,15 +85,10 @@ gh sherpa create-pr, cpr [flags]
 * `--yes, -y`: The pull request will be created without confirmation.
 * `--no-draft`: The pull request will be created in ready for review mode. By default is in draft mode.
 * `--no-close-issue`: The GitHub issue will not be closed when the pull request is merged. By default is closed.
+* `--template`: Path to a pull request template file
 
 ### Possible scenarios
 
-#### Create a pull request associated to an existing local branch
-
-```sh
-gh sherpa create-pr
-```
-
 #### Create a branch and pull request in draft-mode associated to an issue
 
 ```sh
@@ -104,10 +99,28 @@ gh sherpa create-pr -i 750
 gh sherpa create-pr -i SHERPA-71
 ```
 
-#### Create a branch and pull request in ready for review mode associated to an issue without confirmation
+#### Create a branch and pull request associated to an issue without confirmation
+
+```sh
+gh sherpa create-pr -i SHERPA-71 --yes
+```
+
+#### Create a branch and pull request given a template
+
+```sh
+gh sherpa create-pr --issue 750 --template docs/pull_request_template.md
+```
+
+#### Create a pull request associated to an existing local branch
+
+```sh
+gh sherpa create-pr
+```
+
+#### Create a branch and pull request in ready for review mode
 
 ```sh
-gh sherpa create-pr -i 750 --yes --no-draft
+gh sherpa create-pr -i 750 --no-draft
 ```
 
 #### Create a branch and pull request with target branch main
diff --git a/internal/domain/providers.go b/internal/domain/providers.go
index 4dd199e..8e4d403 100644
--- a/internal/domain/providers.go
+++ b/internal/domain/providers.go
@@ -26,6 +26,7 @@ type GitProvider interface {
 	RemoteBranchExists(branch string) (exists bool)
 	CommitEmpty(message string) (err error)
 	PushBranch(branch string) (err error)
+	GetRepositoryRoot() (rootPath string, err error)
 }
 
 type BranchProvider interface {
diff --git a/internal/domain/pull_request.go b/internal/domain/pull_request.go
index 6ffe2f4..36177f7 100644
--- a/internal/domain/pull_request.go
+++ b/internal/domain/pull_request.go
@@ -9,4 +9,5 @@ type PullRequest struct {
 	HeadRefName string
 	BaseRefName string
 	Labels      []Label
+	Body        string
 }
diff --git a/internal/fakes/domain/fake_git_provider.go b/internal/fakes/domain/fake_git_provider.go
index 0b3668a..d9e4bbe 100644
--- a/internal/fakes/domain/fake_git_provider.go
+++ b/internal/fakes/domain/fake_git_provider.go
@@ -3,6 +3,7 @@ package domain
 import (
 	"errors"
 	"fmt"
+	"os"
 	"slices"
 	"strings"
 
@@ -154,3 +155,13 @@ func (f *FakeGitProvider) PushBranch(branch string) (err error) {
 func (f *FakeGitProvider) IsCurrentBranch(branch string) bool {
 	return f.CurrentBranch == branch
 }
+
+func (f *FakeGitProvider) GetRepositoryRoot() (rootPath string, err error) {
+	// In the tests, we want it to return the current working directory so that relative paths
+	// are resolved correctly to the real files in testdata/
+	dir, err := os.Getwd()
+	if err != nil {
+		return "", err
+	}
+	return dir, nil
+}
diff --git a/internal/fakes/domain/fake_pull_request_provider.go b/internal/fakes/domain/fake_pull_request_provider.go
index c0c6efd..8947121 100644
--- a/internal/fakes/domain/fake_pull_request_provider.go
+++ b/internal/fakes/domain/fake_pull_request_provider.go
@@ -26,6 +26,15 @@ func (f *FakePullRequestProvider) AddPullRequest(branchName string, pr domain.Pu
 	f.PullRequests[branchName] = &pr
 }
 
+// GetLastCreatedPR returns the last pull request created for a branch
+func (f *FakePullRequestProvider) GetPullRequestForBranchBody(branch string) (body string, exists bool) {
+	pr := f.PullRequests[branch]
+	if pr == nil {
+		return "", false
+	}
+	return pr.Body, true
+}
+
 func (f *FakePullRequestProvider) HasPullRequestForBranch(branch string) bool {
 	pr := f.PullRequests[branch]
 
@@ -70,6 +79,7 @@ func (f *FakePullRequestProvider) CreatePullRequest(title string, body string, b
 		HeadRefName: headBranch,
 		BaseRefName: baseBranch,
 		Labels:      prLabels,
+		Body:        body,
 	}
 
 	f.PullRequests[headBranch] = pr
diff --git a/internal/git/git.go b/internal/git/git.go
index ffa0000..f0239a8 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -191,3 +191,14 @@ func CommitSigningEnabled() bool {
 
 	return err == nil && strings.Contains(stdout, "true")
 }
+
+func (p *Provider) GetRepositoryRoot() (rootPath string, err error) {
+	args := []string{"rev-parse", "--show-toplevel"}
+
+	out, err := runGitCommand(args...)
+	if err != nil {
+		return "", fmt.Errorf("failed to get repository root: %w", err)
+	}
+
+	return strings.TrimSpace(out), nil
+}
diff --git a/internal/use_cases/create_pull_request.go b/internal/use_cases/create_pull_request.go
index 1e27202..a1bc4da 100644
--- a/internal/use_cases/create_pull_request.go
+++ b/internal/use_cases/create_pull_request.go
@@ -1,7 +1,11 @@
 package use_cases
 
 import (
+	"errors"
 	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
 
 	"github.com/InditexTech/gh-sherpa/internal/branches"
 	"github.com/InditexTech/gh-sherpa/internal/domain"
@@ -31,6 +35,7 @@ type CreatePullRequestConfiguration struct {
 	DraftPR         bool
 	IsInteractive   bool
 	CloseIssue      bool
+	TemplatePath    string
 }
 
 type CreatePullRequest struct {
@@ -43,8 +48,50 @@ type CreatePullRequest struct {
 	BranchProvider          domain.BranchProvider
 }
 
+func normalizeTemplatePath(git domain.GitProvider, path string) (string, error) {
+	if path == "" {
+		return "", nil
+	}
+
+	if !filepath.IsAbs(path) {
+		repoRoot, err := git.GetRepositoryRoot()
+		if err != nil {
+			return "", fmt.Errorf("failed to determine repository root: %w", err)
+		}
+		path = filepath.Join(repoRoot, path)
+	}
+
+	return path, nil
+}
+
+func validateTemplateFile(templatePath string, git domain.GitProvider) error {
+	if templatePath == "" {
+		return nil // No template to validate
+	}
+
+	normalizedPath, err := normalizeTemplatePath(git, templatePath)
+	if err != nil {
+		return err
+	}
+
+	if _, err := os.Stat(normalizedPath); err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return fmt.Errorf("template file does not exist: %s", templatePath)
+		}
+
+		return fmt.Errorf("error accessing template file %s: %w", templatePath, err)
+	}
+
+	return nil
+}
+
 // Execute executes the create pull request use case
 func (cpr CreatePullRequest) Execute() error {
+	// Validate template if specified
+	if err := validateTemplateFile(cpr.Cfg.TemplatePath, cpr.Git); err != nil {
+		return err
+	}
+
 	isInteractive := cpr.Cfg.IsInteractive
 	fromLocalBranch := cpr.Cfg.IssueID == ""
 
@@ -209,6 +256,7 @@ func (cpr *CreatePullRequest) pushChanges(branchName string) (err error) {
 }
 
 func (cpr *CreatePullRequest) getPullRequestTitleAndBody(issue domain.Issue) (title string, body string, err error) {
+	// Determine base title and issue reference body based on tracker type
 	switch issue.TrackerType() {
 	case domain.IssueTrackerTypeGithub:
 		title = issue.Title()
@@ -224,7 +272,28 @@ func (cpr *CreatePullRequest) getPullRequestTitleAndBody(issue domain.Issue) (ti
 		body = fmt.Sprintf("Relates to [%s](%s)", issue.ID(), issue.URL())
 
 	default:
-		err = fmt.Errorf("issue tracker %s is not supported", issue.TrackerType())
+		return "", "", fmt.Errorf("issue tracker %s is not supported", issue.TrackerType())
+	}
+
+	// If template path is provided, read and append it after the issue reference
+	// (we already validated that the file exists in validateTemplateFile)
+	if cpr.Cfg.TemplatePath != "" {
+		var templateContent []byte
+
+		// Get normalized template path (absolute path)
+		templatePath, err := normalizeTemplatePath(cpr.Git, cpr.Cfg.TemplatePath)
+		if err != nil {
+			return "", "", err
+		}
+
+		// Read template file (we know it exists because we checked in validateTemplateFile)
+		templateContent, err = os.ReadFile(templatePath)
+		if err != nil {
+			return "", "", fmt.Errorf("failed to read template file: %w", err)
+		}
+
+		// Reference to issue first, then template content
+		body = body + "\n\n" + string(templateContent)
 	}
 
 	return
diff --git a/version b/version
index 26aaba0..f0bb29e 100644
--- a/version
+++ b/version
@@ -1,1 +1,1 @@
-1.2.0
+1.3.0


Test patch:
diff --git a/internal/use_cases/create_pull_request_test.go b/internal/use_cases/create_pull_request_test.go
index 1429d8b..bfc09c6 100644
--- a/internal/use_cases/create_pull_request_test.go
+++ b/internal/use_cases/create_pull_request_test.go
@@ -348,6 +348,93 @@ func (s *CreateGithubPullRequestExecutionTestSuite) TestCreatePullRequestExecuti
 		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
 	})
 
+	s.Run("should return error if template file does not exist", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/GH-3-template-test"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Set invalid template path
+		s.uc.Cfg.TemplatePath = "non_existent_template.md"
+
+		err := s.uc.Execute()
+
+		// Should error early with template not found error
+		s.Error(err)
+		s.Contains(err.Error(), "template file does not exist")
+		s.False(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+	})
+
+	s.Run("should work with relative template path", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/GH-3-relative-path-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Usar una ruta relativa
+		s.uc.Cfg.TemplatePath = "testdata/test_template.md"
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		// Verificar contenido del PR
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "This is a test PR template")
+	})
+
+	s.Run("should work with absolute template path", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/GH-3-absolute-path-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Usar una ruta absoluta
+		dir, _ := os.Getwd()
+		s.uc.Cfg.TemplatePath = filepath.Join(dir, "testdata", "test_template.md")
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		// Verificar contenido del PR
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "This is a test PR template")
+	})
+
+	s.Run("should create pull request with template content", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/GH-3-valid-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Set valid template path and disable CloseIssue flag to use "Related to" instead of "Closes"
+		dir, _ := os.Getwd()
+		templatePath := filepath.Join(dir, "testdata", "test_template.md")
+		s.uc.Cfg.TemplatePath = templatePath
+		s.uc.Cfg.CloseIssue = false
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		// Verify PR body content
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "Related to #3")    // Issue reference appears first
+		s.Contains(body, "Template Content") // Template content follows
+		s.Contains(body, "This is a test PR template")
+	})
+
 	s.Run("should error if could not get issue", func() {
 		branchName := "feature/GH-6-with-no-remote-branch"
 		s.gitProvider.CurrentBranch = branchName
@@ -704,6 +791,88 @@ func (s *CreateJiraPullRequestExecutionTestSuite) TestCreatePullRequestExecution
 		s.NoError(err)
 	})
 
+	s.Run("should return error if template file does not exist", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/PROJECTKEY-3-template-test"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Set invalid template path
+		s.uc.Cfg.TemplatePath = "non_existent_template.md"
+
+		err := s.uc.Execute()
+
+		// Should error early with template not found error
+		s.Error(err)
+		s.Contains(err.Error(), "template file does not exist")
+		s.False(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+	})
+
+	s.Run("should work with relative template path", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/PROJECTKEY-3-relative-path-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		s.uc.Cfg.TemplatePath = "testdata/test_template.md"
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "This is a test PR template")
+	})
+
+	s.Run("should work with absolute template path", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/PROJECTKEY-3-absolute-path-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		dir, _ := os.Getwd()
+		s.uc.Cfg.TemplatePath = filepath.Join(dir, "testdata", "test_template.md")
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "This is a test PR template")
+	})
+
+	s.Run("should create pull request with template content for Jira issue", func() {
+		s.gitProvider.ResetRemoteBranches()
+		branchName := "feature/PROJECTKEY-3-valid-template"
+		s.gitProvider.CurrentBranch = branchName
+		s.gitProvider.AddLocalBranches(branchName)
+		s.branchProvider.SetBranchName(branchName)
+
+		// Set valid template path
+		dir, _ := os.Getwd()
+		templatePath := filepath.Join(dir, "testdata", "test_template.md")
+		s.uc.Cfg.TemplatePath = templatePath
+
+		err := s.uc.Execute()
+
+		s.NoError(err)
+		s.True(s.pullRequestProvider.HasPullRequestForBranch(branchName))
+
+		// Verify PR body content
+		body, exists := s.pullRequestProvider.GetPullRequestForBranchBody(branchName)
+		s.True(exists)
+		s.Contains(body, "Relates to [PROJECTKEY-3]") // Issue reference appears first
+		s.Contains(body, "Template Content")          // Template content follows
+		s.Contains(body, "This is a test PR template")
+	})
+
 	s.Run("should error if could not get issue", func() {
 		branchName := "feature/PROJECTKEY-6-with-no-remote-branch"
 		s.gitProvider.CurrentBranch = branchName
diff --git a/internal/use_cases/testdata/test_template.md b/internal/use_cases/testdata/test_template.md
new file mode 100644
index 0000000..349d6f8
--- /dev/null
+++ b/internal/use_cases/testdata/test_template.md
@@ -0,0 +1,11 @@
+## Template Content
+
+This is a test PR template
+
+- Item 1
+- Item 2
+- Item 3
+
+### Additional information
+
+This should be included in the PR body