diff --git a/examples-copier/configs/pr-template-example.yaml b/examples-copier/configs/pr-template-example.yaml new file mode 100644 index 0000000..bab5660 --- /dev/null +++ b/examples-copier/configs/pr-template-example.yaml @@ -0,0 +1,130 @@ +# Example: Using PR Templates from Target Repository +# This configuration demonstrates the hybrid PR template approach + +source_repo: "myorg/source-repo" +source_branch: "main" + +copy_rules: + # Example 1: Use target repo's PR template with appended copier info + - name: "use-target-template-with-append" + source_pattern: + type: "prefix" + pattern: "examples/" + targets: + - repo: "myorg/target-repo" + branch: "main" + path_transform: "code-examples/${relative_path}" + commit_strategy: + type: "pull_request" + pr_title: "Update code examples from ${source_repo}" + + # Fetch and use the PR template from the target repository + use_target_pr_template: true + + # Optional: Specify custom template path (defaults to .github/pull_request_template.md) + pr_template_path: ".github/pull_request_template.md" + + # Append copier-specific information after the template + pr_body_append: | + + --- + ## 🤖 Automated Copy Information + + This PR was automatically created by the examples-copier service. + + - **Source Repository**: ${source_repo} + - **Source Branch**: ${source_branch} + - **Source PR**: #${pr_number} + - **Source Commit**: ${commit_sha} + - **Files Updated**: ${file_count} + - **Copy Rule**: ${rule_name} + + ### Review Checklist + - [ ] Verify all files copied correctly + - [ ] Check for any breaking changes + - [ ] Run tests locally + + auto_merge: false + + # Example 2: Use target repo's PR template without appending + - name: "use-target-template-only" + source_pattern: + type: "prefix" + pattern: "docs/" + targets: + - repo: "myorg/target-repo" + branch: "main" + path_transform: "documentation/${relative_path}" + commit_strategy: + type: "pull_request" + pr_title: "Update documentation from ${source_repo}" + + # Just use the target repo's template as-is + use_target_pr_template: true + + auto_merge: false + + # Example 3: Traditional approach - define PR body in config + - name: "define-pr-body-in-config" + source_pattern: + type: "prefix" + pattern: "scripts/" + targets: + - repo: "myorg/target-repo" + branch: "main" + path_transform: "scripts/${relative_path}" + commit_strategy: + type: "pull_request" + pr_title: "Update scripts from ${source_repo}" + + # Define the entire PR body in the config + pr_body: | + ## Automated Update + + This PR updates scripts from the source repository. + + ### Details + - **Source**: ${source_repo} + - **PR**: #${pr_number} + - **Commit**: ${commit_sha} + - **Files**: ${file_count} + + ### Testing + Please verify that all scripts work as expected. + + auto_merge: false + + # Example 4: Fallback behavior - template not found + - name: "template-with-fallback" + source_pattern: + type: "prefix" + pattern: "config/" + targets: + - repo: "myorg/target-repo" + branch: "main" + path_transform: "config/${relative_path}" + commit_strategy: + type: "pull_request" + pr_title: "Update config from ${source_repo}" + + # Try to use target template, but provide fallback via pr_body + use_target_pr_template: true + pr_template_path: ".github/pull_request_template.md" + + # This will be used if the template file is not found + pr_body: | + ## Configuration Update + + Automated update of configuration files. + + - Source: ${source_repo} + - Files: ${file_count} + + # This will be appended if the template IS found + pr_body_append: | + + --- + **Automated by**: examples-copier + + auto_merge: false + diff --git a/examples-copier/docs/CONFIGURATION-GUIDE.md b/examples-copier/docs/CONFIGURATION-GUIDE.md index 7735c14..dd33397 100644 --- a/examples-copier/docs/CONFIGURATION-GUIDE.md +++ b/examples-copier/docs/CONFIGURATION-GUIDE.md @@ -321,7 +321,7 @@ commit_strategy: pr_title: "Update ${lang} examples" pr_body: | Automated update of ${lang} examples - + Files updated: ${file_count} Source: ${source_repo} PR: #${pr_number} @@ -334,12 +334,50 @@ commit_strategy: - `pr_body` - (optional) PR body template - `auto_merge` - (optional) Auto-merge if checks pass (default: false) - `commit_message` - (optional) Commit message template +- `use_target_pr_template` - (optional) Fetch PR template from target repo (default: false) +- `pr_template_path` - (optional) Path to PR template in target repo (default: `.github/pull_request_template.md`) +- `pr_body_append` - (optional) Additional content to append after the template **Use When:** - Changes require review - You want CI checks to run - Multiple approvers needed +#### Using PR Templates from Target Repository + +You can configure the copier to use PR templates that exist in the target repository: + +```yaml +commit_strategy: + type: "pull_request" + use_target_pr_template: true + pr_template_path: ".github/pull_request_template.md" # Optional, this is the default + pr_body_append: | + + --- + **Automated Copy Information:** + - Source: ${source_repo} + - PR: #${pr_number} + - Commit: ${commit_sha} + - Files: ${file_count} + auto_merge: false +``` + +**How it works:** +1. The copier fetches the PR template file from the target repository +2. Uses the template content as the PR body +3. Optionally appends additional copier-specific information via `pr_body_append` +4. If the template file is not found, falls back to using `pr_body` (if configured) + +**Template Variables:** +Both `pr_body` and `pr_body_append` support template variables like `${source_repo}`, `${pr_number}`, `${file_count}`, etc. + +**Common PR Template Locations:** +- `.github/pull_request_template.md` (default, hidden directory) +- `pull_request_template.md` (root directory) +- `docs/pull_request_template.md` (docs directory) +- `.github/PULL_REQUEST_TEMPLATE/template_name.md` (multiple templates) + ### Batch Commit Batches multiple files into fewer commits. diff --git a/examples-copier/docs/PATTERN-MATCHING-GUIDE.md b/examples-copier/docs/PATTERN-MATCHING-GUIDE.md index 564b4dc..56702a0 100644 --- a/examples-copier/docs/PATTERN-MATCHING-GUIDE.md +++ b/examples-copier/docs/PATTERN-MATCHING-GUIDE.md @@ -12,6 +12,7 @@ Complete guide to pattern matching and path transformation in the examples-copie - [Path Transformation](#path-transformation) - [Built-in Variables](#built-in-variables) - [Common Patterns](#common-patterns) +- [Ignore Patterns](#ignore-patterns) - [Testing Patterns](#testing-patterns) - [Best Practices](#best-practices) - [Troubleshooting](#troubleshooting) @@ -462,6 +463,274 @@ examples/go/main.go → docs/examples/go/main.go examples/python/test.py → docs/examples/python/test.py ``` +## Ignore Patterns + +### Basic Template + +```yaml +source_pattern: + type: "regex" + pattern: "^base/path/(?P(?!IGNORE_PATTERN).+)$" +``` + +### Common Ignore Patterns + +#### Ignore Single Directory + +```yaml +# Ignore node_modules/ +pattern: "^mflix/client/(?P(?!node_modules/).+)$" +``` + +#### Ignore Multiple Directories + +```yaml +# Ignore node_modules/, .next/, dist/, build/ +pattern: "^mflix/client/(?P(?!(?:node_modules|.next|dist|build)/).+)$" +``` + +#### Ignore Files by Extension + +```yaml +# Ignore .test.js files +pattern: "^mflix/server/(?P(?!.*\\.test\\.js$).+)$" + +# Ignore .pyc files +pattern: "^mflix/server/(?P(?!.*\\.pyc$).+)$" + +# Ignore multiple extensions +pattern: "^mflix/server/(?P(?!.*\\.(?:test\\.js|spec\\.js|pyc)$).+)$" +``` + +#### Ignore Files by Name Pattern + +```yaml +# Ignore test files (containing "test" or "Test") +pattern: "^mflix/server/(?P(?!.*[Tt]est.*).+)$" + +# Ignore files starting with "test_" +pattern: "^mflix/server/(?P(?!test_.*).+)$" + +# Ignore files ending with "_test.py" +pattern: "^mflix/server/(?P(?!.*_test\\.py$).+)$" +``` + +#### Ignore Hidden Files + +```yaml +# Ignore files starting with . +pattern: "^mflix/client/(?P(?!\\.).+)$" +``` + +#### Ignore Specific File + +```yaml +# Ignore config.json +pattern: "^mflix/client/(?P(?!config\\.json$).+)$" + +# Ignore .gitignore in server directory +pattern: "^mflix/server/java-spring/(?P(?!\\.gitignore$).+)$" +``` + +### Language-Specific Patterns + +#### JavaScript/Node.js + +```yaml +# Ignore common Node.js artifacts +pattern: "^path/(?P(?!(?:node_modules|dist|build|coverage|\\.next)/).+)$" + +# Ignore test files +pattern: "^path/(?P(?!.*\\.(?:test|spec)\\.(?:js|jsx|ts|tsx)$).+)$" + +# Ignore config files +pattern: "^path/(?P(?!.*\\.config\\.js$).+)$" +``` + +#### Python + +```yaml +# Ignore Python artifacts +pattern: "^path/(?P(?!(?:__pycache__|.*\\.pyc$|\\.pytest_cache)).+)$" + +# Ignore test files +pattern: "^path/(?P(?!(?:test_.*\\.py$|.*_test\\.py$)).+)$" + +# Ignore virtual environments +pattern: "^path/(?P(?!(?:venv|env|\\.venv)/).+)$" +``` + +#### Java + +```yaml +# Ignore build artifacts +pattern: "^path/(?P(?!(?:target|build|out)/).+)$" + +# Ignore test files +pattern: "^path/(?P(?!.*[Tt]est\\.java$).+)$" + +# Ignore compiled classes +pattern: "^path/(?P(?!.*\\.class$).+)$" +``` + +#### Go + +```yaml +# Ignore test files +pattern: "^path/(?P(?!.*_test\\.go$).+)$" + +# Ignore vendor directory +pattern: "^path/(?P(?!vendor/).+)$" +``` + +### Complex Ignore Patterns + +#### Ignore Multiple Patterns (OR) + +```yaml +# Ignore: test files OR config files OR markdown files +pattern: "^path/(?P(?!.*(?:_test\\.go|\\.config\\.js|\\.md)$).+)$" +``` + +#### Ignore at Any Depth + +```yaml +# Ignore node_modules at any level +pattern: "^path/(?P(?!.*node_modules.*).+)$" +``` + +#### Ignore Multiple Directories with Different Patterns + +```yaml +# Ignore: build directories AND test files +pattern: "^path/(?P(?!(?:build|dist|out)/|.*_test\\.py$).+)$" +``` + +### Real-World Examples + +#### Next.js Project + +```yaml +source_pattern: + type: "regex" + pattern: "^mflix/client/(?P(?!(?:node_modules|.next|out|build|\\.cache|coverage)/).+)$" +``` + +**Ignores**: node_modules/, .next/, out/, build/, .cache/, coverage/ + +#### Express Server + +```yaml +source_pattern: + type: "regex" + pattern: "^mflix/server/express/(?P(?!(?:node_modules|.*\\.test\\.js$|.*\\.spec\\.js$)).+)$" +``` + +**Ignores**: node_modules/, *.test.js, *.spec.js + +#### Python Flask Server + +```yaml +source_pattern: + type: "regex" + pattern: "^mflix/server/python/(?P(?!(?:__pycache__|.*\\.pyc$|test_.*\\.py$|.*_test\\.py$|\\.pytest_cache|venv)/).+)$" +``` + +**Ignores**: __pycache__/, *.pyc, test_*.py, *_test.py, .pytest_cache/, venv/ + +#### Java Spring Boot + +```yaml +source_pattern: + type: "regex" + pattern: "^mflix/server/java-spring/(?P(?!(?:target|.*[Tt]est\\.java$|.*\\.class$)).+)$" +``` + +**Ignores**: target/, *Test.java, *test.java, *.class + +## Testing Your Pattern + +```bash +cd examples-copier + +# Test if a file matches +./config-validator test-pattern \ + -type regex \ + -pattern "^mflix/client/(?P(?!node_modules/).+)$" \ + -file "mflix/client/src/App.js" +# Should match: ✅ + +./config-validator test-pattern \ + -type regex \ + -pattern "^mflix/client/(?P(?!node_modules/).+)$" \ + -file "mflix/client/node_modules/react/index.js" +# Should NOT match: ❌ +``` + +## Common Mistakes + +### ❌ Wrong: Dot not escaped + +```yaml +pattern: "^path/(?P(?!.test.js$).+)$" +``` + +### ✅ Right: Dot escaped + +```yaml +pattern: "^path/(?P(?!.*\\.test\\.js$).+)$" +``` + +--- + +### ❌ Wrong: Negative lookahead at end + +```yaml +pattern: "^path/.+(?!node_modules/)$" +``` + +### ✅ Right: Negative lookahead at start + +```yaml +pattern: "^path/(?!node_modules/).+$" +``` + +--- + +### ❌ Wrong: Missing capture group + +```yaml +pattern: "^path/(?!node_modules/).+$" +``` + +### ✅ Right: With named capture group + +```yaml +pattern: "^path/(?P(?!node_modules/).+)$" +``` + +## Cheat Sheet + +| Goal | Pattern | +|------|---------| +| Ignore 1 dir | `(?!dirname/)` | +| Ignore 2+ dirs | `(?!(?:dir1\|dir2\|dir3)/)` | +| Ignore extension | `(?!.*\\.ext$)` | +| Ignore 2+ extensions | `(?!.*\\.(?:ext1\|ext2)$)` | +| Ignore prefix | `(?!prefix.*)` | +| Ignore suffix | `(?!.*suffix$)` | +| Ignore hidden files | `(?!\\.)` | +| Ignore test files | `(?!.*[Tt]est.*)` | +| Ignore at any depth | `(?!.*pattern.*)` | + +## See Also + +- [Ignoring Files Guide](IGNORING-FILES-GUIDE.md) - Detailed guide with explanations +- [Pattern Matching Guide](PATTERN-MATCHING-GUIDE.md) - Complete pattern reference +- [Example Config](../configs/mflix-with-ignores.yaml) - Real-world examples + + + ## Testing Patterns ### Using config-validator diff --git a/examples-copier/services/github_write_to_target.go b/examples-copier/services/github_write_to_target.go index e80a35e..30dba6b 100644 --- a/examples-copier/services/github_write_to_target.go +++ b/examples-copier/services/github_write_to_target.go @@ -48,6 +48,34 @@ func normalizeRepoName(repoName string) string { return repoOwner() + "/" + repoName } +// fetchFileFromRepo fetches a file from a target repository +// Returns the file content as a string, or an error if the file doesn't exist or can't be fetched +func fetchFileFromRepo(ctx context.Context, client *github.Client, repo, branch, filePath string) (string, error) { + owner, repoName := parseRepoPath(repo) + + fileContent, _, _, err := client.Repositories.GetContents( + ctx, + owner, + repoName, + filePath, + &github.RepositoryContentGetOptions{ + Ref: branch, + }, + ) + + if err != nil { + return "", fmt.Errorf("failed to fetch file %s from %s: %w", filePath, repo, err) + } + + // Decode the content + content, err := fileContent.GetContent() + if err != nil { + return "", fmt.Errorf("failed to decode file content: %w", err) + } + + return content, nil +} + // AddFilesToTargetRepoBranch uploads files to the target repository branch // using the specified commit strategy (direct or via pull request). func AddFilesToTargetRepoBranch() { @@ -91,6 +119,11 @@ func AddFilesToTargetRepoBranch() { // Get auto-merge setting from value mergeWithoutReview := value.AutoMergePR + // Get PR template settings + useTargetPRTemplate := value.UseTargetPRTemplate + prTemplatePath := value.PRTemplatePath + prBodyAppend := value.PRBodyAppend + switch strategy { case "direct": // commits directly to the target branch LogInfo(fmt.Sprintf("Using direct commit strategy for %s on branch %s", key.RepoName, key.BranchPath)) @@ -99,7 +132,7 @@ func AddFilesToTargetRepoBranch() { } default: // "pr" or "pull_request" strategy LogInfo(fmt.Sprintf("Using PR commit strategy for %s on branch %s (auto_merge=%v)", key.RepoName, key.BranchPath, mergeWithoutReview)) - if err := addFilesViaPR(ctx, client, key, value.Content, commitMsg, prTitle, prBody, mergeWithoutReview); err != nil { + if err := addFilesViaPR(ctx, client, key, value.Content, commitMsg, prTitle, prBody, mergeWithoutReview, useTargetPRTemplate, prTemplatePath, prBodyAppend); err != nil { LogCritical(fmt.Sprintf("Failed via PR path: %v\n", err)) } } @@ -124,20 +157,52 @@ func createPullRequest(ctx context.Context, client *github.Client, repo, head, b // addFilesViaPR creates a temporary branch, commits files to it using the provided commitMessage, // opens a pull request with prTitle and prBody, and optionally merges it automatically. +// If useTargetPRTemplate is true, it fetches the PR template from the target repo and uses it as the PR body. func addFilesViaPR(ctx context.Context, client *github.Client, key UploadKey, files []github.RepositoryContent, commitMessage string, prTitle string, prBody string, mergeWithoutReview bool, + useTargetPRTemplate bool, prTemplatePath string, prBodyAppend string, ) error { tempBranch := "copier/" + time.Now().UTC().Format("20060102-150405") // 1) Create branch off the target branch specified in key.BranchPath or default to "main" baseBranch := strings.TrimPrefix(key.BranchPath, "refs/heads/") + + // 2) Fetch PR template from target repo if configured + if useTargetPRTemplate { + // Default to standard GitHub PR template location if not specified + templatePath := prTemplatePath + if templatePath == "" { + templatePath = ".github/pull_request_template.md" + } + + LogInfo(fmt.Sprintf("Fetching PR template from %s at %s", key.RepoName, templatePath)) + templateContent, err := fetchFileFromRepo(ctx, client, key.RepoName, baseBranch, templatePath) + if err != nil { + // Log warning but continue - don't fail the PR creation if template is missing + LogWarning(fmt.Sprintf("Failed to fetch PR template from %s: %v. Using configured pr_body instead.", templatePath, err)) + } else { + // Use template as the base PR body + prBody = templateContent + LogInfo(fmt.Sprintf("Successfully fetched PR template (%d characters)", len(templateContent))) + } + } + + // 3) Append additional content if configured + if prBodyAppend != "" { + if prBody != "" { + prBody = prBody + "\n\n" + prBodyAppend + } else { + prBody = prBodyAppend + } + } + newRef, err := createBranch(ctx, client, key.RepoName, tempBranch, baseBranch) if err != nil { return fmt.Errorf("create branch: %w", err) } _ = newRef // we just need it created; ref is not reused directly - // 2) Commit files to temp branch + // 4) Commit files to temp branch entries := make(map[string]string, len(files)) for _, f := range files { content, _ := f.GetContent() @@ -153,14 +218,14 @@ func addFilesViaPR(ctx context.Context, client *github.Client, key UploadKey, return fmt.Errorf("create commit on temp branch: %w", err) } - // 3) Create PR from temp branch to base branch + // 5) Create PR from temp branch to base branch base := strings.TrimPrefix(key.BranchPath, "refs/heads/") pr, err := createPullRequest(ctx, client, key.RepoName, tempBranch, base, prTitle, prBody) if err != nil { return fmt.Errorf("create PR: %w", err) } - // 4) Optionally merge the PR without review if MergeWithoutReview is true + // 6) Optionally merge the PR without review if MergeWithoutReview is true LogInfo(fmt.Sprintf("PR created: #%d from %s to %s", pr.GetNumber(), tempBranch, base)) LogInfo(fmt.Sprintf("PR URL: %s", pr.GetHTMLURL())) if mergeWithoutReview { diff --git a/examples-copier/services/webhook_handler_new.go b/examples-copier/services/webhook_handler_new.go index 11333a9..6e54599 100644 --- a/examples-copier/services/webhook_handler_new.go +++ b/examples-copier/services/webhook_handler_new.go @@ -539,6 +539,11 @@ func queueFileForUploadWithStrategy(target types.TargetConfig, file github.Repos entry.CommitStrategy = types.CommitStrategy(target.CommitStrategy.Type) entry.AutoMergePR = target.CommitStrategy.AutoMerge + // Set PR template configuration + entry.UseTargetPRTemplate = target.CommitStrategy.UseTargetPRTemplate + entry.PRTemplatePath = target.CommitStrategy.PRTemplatePath + entry.PRBodyAppend = target.CommitStrategy.PRBodyAppend + // Add file to content first so we can get accurate file count entry.Content = append(entry.Content, file) @@ -564,6 +569,11 @@ func queueFileForUploadWithStrategy(target types.TargetConfig, file github.Repos entry.PRBody = container.MessageTemplater.RenderPRBody(target.CommitStrategy.PRBody, msgCtx) } + // Render pr_body_append if provided + if target.CommitStrategy.PRBodyAppend != "" { + entry.PRBodyAppend = container.MessageTemplater.RenderPRBody(target.CommitStrategy.PRBodyAppend, msgCtx) + } + container.FileStateService.AddFileToUpload(key, entry) } diff --git a/examples-copier/types/config.go b/examples-copier/types/config.go index ce8da64..2ad5559 100644 --- a/examples-copier/types/config.go +++ b/examples-copier/types/config.go @@ -55,12 +55,15 @@ type TargetConfig struct { // CommitStrategyConfig defines how to commit changes type CommitStrategyConfig struct { - Type string `yaml:"type" json:"type"` // "direct", "pull_request", or "batch" - CommitMessage string `yaml:"commit_message,omitempty" json:"commit_message,omitempty"` - PRTitle string `yaml:"pr_title,omitempty" json:"pr_title,omitempty"` - PRBody string `yaml:"pr_body,omitempty" json:"pr_body,omitempty"` - AutoMerge bool `yaml:"auto_merge,omitempty" json:"auto_merge,omitempty"` - BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` + Type string `yaml:"type" json:"type"` // "direct", "pull_request", or "batch" + CommitMessage string `yaml:"commit_message,omitempty" json:"commit_message,omitempty"` + PRTitle string `yaml:"pr_title,omitempty" json:"pr_title,omitempty"` + PRBody string `yaml:"pr_body,omitempty" json:"pr_body,omitempty"` + AutoMerge bool `yaml:"auto_merge,omitempty" json:"auto_merge,omitempty"` + BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` + UseTargetPRTemplate bool `yaml:"use_target_pr_template,omitempty" json:"use_target_pr_template,omitempty"` // Fetch PR template from target repo + PRTemplatePath string `yaml:"pr_template_path,omitempty" json:"pr_template_path,omitempty"` // Path to PR template in target repo (defaults to .github/pull_request_template.md) + PRBodyAppend string `yaml:"pr_body_append,omitempty" json:"pr_body_append,omitempty"` // Additional content to append after the template } // DeprecationConfig defines deprecation tracking settings diff --git a/examples-copier/types/types.go b/examples-copier/types/types.go index 1331bb8..9308b88 100644 --- a/examples-copier/types/types.go +++ b/examples-copier/types/types.go @@ -104,13 +104,16 @@ type UploadKey struct { } type UploadFileContent struct { - TargetBranch string `json:"target_branch"` - Content []github.RepositoryContent `json:"content"` - CommitStrategy CommitStrategy `json:"commit_strategy,omitempty"` - CommitMessage string `json:"commit_message,omitempty"` - PRTitle string `json:"pr_title,omitempty"` - PRBody string `json:"pr_body,omitempty"` - AutoMergePR bool `json:"auto_merge_pr,omitempty"` + TargetBranch string `json:"target_branch"` + Content []github.RepositoryContent `json:"content"` + CommitStrategy CommitStrategy `json:"commit_strategy,omitempty"` + CommitMessage string `json:"commit_message,omitempty"` + PRTitle string `json:"pr_title,omitempty"` + PRBody string `json:"pr_body,omitempty"` + AutoMergePR bool `json:"auto_merge_pr,omitempty"` + UseTargetPRTemplate bool `json:"use_target_pr_template,omitempty"` // Fetch PR template from target repo + PRTemplatePath string `json:"pr_template_path,omitempty"` // Path to PR template in target repo + PRBodyAppend string `json:"pr_body_append,omitempty"` // Additional content to append after template } // CommitStrategy represents the strategy for committing changes