diff --git a/.github/actions/sync/issue_bug.template.yml b/.github/actions/sync/issue_bug.template.yml new file mode 100644 index 00000000..f587b197 --- /dev/null +++ b/.github/actions/sync/issue_bug.template.yml @@ -0,0 +1,33 @@ +name: New issue for Reproducible Bug +description: "If you're sure it's reproducible and not just your machine: submit an issue so we can investigate." +type: "Bug" +body: + - type: checkboxes + attributes: + label: Verification + description: Verify you've followed these steps. If you can't truthfully check these boxes, open a discussion at https://github.com/orgs/Homebrew/discussions instead. + options: + - label: I searched for similar issues at https://github.com/%s/issues and found no duplicates. + required: true + - label: I did not use AI/LLM to create this issue, or I disclosed the tool and model used; I will answer maintainer questions myself without AI/LLM. + required: true + - type: textarea + attributes: + label: What were you trying to do (and why)? + validations: + required: true + - type: textarea + attributes: + label: What happened? + validations: + required: true + - type: textarea + attributes: + label: What did you expect to happen? + validations: + required: true + - type: textarea + attributes: + label: Step-by-step reproduction instructions + validations: + required: true diff --git a/.github/actions/sync/issue_config.template.yml b/.github/actions/sync/issue_config.template.yml new file mode 100644 index 00000000..aa82dc3c --- /dev/null +++ b/.github/actions/sync/issue_config.template.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false + +contact_links: + - name: Get help in GitHub Discussions + url: https://github.com/orgs/Homebrew/discussions + about: Have a question? Not sure if your issue affects everyone reproducibly? The quickest way to get help is on Homebrew's GitHub Discussions! diff --git a/.github/actions/sync/pull_request.template.md b/.github/actions/sync/pull_request.template.md new file mode 100644 index 00000000..64170443 --- /dev/null +++ b/.github/actions/sync/pull_request.template.md @@ -0,0 +1,17 @@ +----- + + + + +- [ ] Have you checked for other open [Pull Requests](https://github.com/%s/pulls) for the same change? +- [ ] Have you explained what your changes do and why you'd like them included? +- [ ] Have you written new tests where it makes sense? +- [ ] Have you successfully run this repository's tests and checks locally? + +----- + +- [ ] I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM. + + + +----- diff --git a/.github/actions/sync/shared-config.rb b/.github/actions/sync/shared-config.rb index 9ab50bba..bc78f1d9 100755 --- a/.github/actions/sync/shared-config.rb +++ b/.github/actions/sync/shared-config.rb @@ -63,6 +63,16 @@ def safe_system(*args) codeowners_header = "# This file is synced from the `.github` repository, do not modify it directly.\n" # GitHub uses the first of these that exists and ignores the others. codeowners_paths = [codeowners, "CODEOWNERS", "docs/CODEOWNERS"].freeze +pull_request_template = ".github/PULL_REQUEST_TEMPLATE.md" +template_sources = { + pull_request_template => ".github/actions/sync/pull_request.template.md", + ".github/ISSUE_TEMPLATE/bug.yml" => ".github/actions/sync/issue_bug.template.yml", + ".github/ISSUE_TEMPLATE/config.yml" => ".github/actions/sync/issue_config.template.yml", +}.freeze +# GitHub reads templates from any of these locations, case-insensitively. +# It no longer reads single-file `ISSUE_TEMPLATE.md` issue templates, only `ISSUE_TEMPLATE/` directories. +existing_pull_request_template = %r{\A(?:\.github/|docs/)?pull_request_template(?:\.\w+)?(?:/|\z)}i +existing_issue_template = %r{\A(?:\.github/|docs/)?issue_template/}i vale_ini = ".vale.ini" dependabot_template_yaml = ".github/actions/sync/dependabot.template.yml" dependabot_yaml = ".github/dependabot.yml" @@ -219,6 +229,12 @@ def safe_system(*args) tracked_files = out.split("\0") +out, err, status = Open3.capture3("git", "-C", target_directory, "ls-files", "-z", "--others", "--exclude-standard") +raise err unless status.success? + +# Local syncs can run on checkouts with uncommitted template changes, so look at the files that are actually there. +working_tree_files = (tracked_files + out.split("\0")).select { |path| (target_directory_path/path).exist? } + swift_manifests = ["Package.swift", "Package.resolved"].freeze licenses_enabled ||= tracked_files.any? { |path| swift_manifests.include?(File.basename(path)) } @@ -238,6 +254,7 @@ def safe_system(*args) rubocop_yaml, coderabbit_config_ts, codeowners, + *template_sources.keys, dependabot_yaml, *license_check_paths, check_template_rb, @@ -450,6 +467,23 @@ def safe_system(*args) next unless target_path.exist? git "-C", target_directory, "rm", path + when *template_sources.keys + # A template here would become the default for every repository without one, including unsynced ones. + next if repository_name == ".github" + + # Install templates once and then leave them to the repository, like any template it already has. + contents = format(Pathname(template_sources.fetch(path)).read, repository: "Homebrew/#{repository_name}") + if path == pull_request_template + own_templates = working_tree_files.grep(existing_pull_request_template) - [path] + # GitHub prefers `.github/`, so remove an unedited synced template once the repository adds its own elsewhere. + FileUtils.rm_f target_path if own_templates.any? && target_path.file? && target_path.read == contents + next if own_templates.any? + elsif working_tree_files.any?(existing_issue_template) + next + end + next if target_path.exist? + + target_path.write(contents) else next if path == target_path.to_s diff --git a/.github/actions/sync/shared-config_spec.rb b/.github/actions/sync/shared-config_spec.rb index 30c958f2..4bbcf434 100644 --- a/.github/actions/sync/shared-config_spec.rb +++ b/.github/actions/sync/shared-config_spec.rb @@ -47,6 +47,10 @@ def git(*args, directory: target) successful_command("git", "-C", directory.to_s, *args).chomp end + def tracked_templates(pattern) + git("ls-files").lines(chomp: true).grep(pattern) + end + def commit_fixture(directory: target) git("add", "--all", directory:) git("commit", "--quiet", "--message", "Fixture", directory:) @@ -162,6 +166,19 @@ def synced_contents(path) entries = own_codeowners.lines.reject { |line| line.include?("coderabbit*") }.join "#{entries}/.coderabbit* @Homebrew/maintainers\n" end + let(:pull_request_template) { ".github/PULL_REQUEST_TEMPLATE.md" } + let(:expected_templates) do + { + pull_request_template => "pull_request.template.md", + ".github/ISSUE_TEMPLATE/bug.yml" => "issue_bug.template.yml", + ".github/ISSUE_TEMPLATE/config.yml" => "issue_config.template.yml", + }.transform_values do |source| + format((source_repository/".github/actions/sync/#{source}").read, + repository: "Homebrew/#{File.basename(repository)}") + end + end + let(:issue_templates) { expected_templates.keys - [pull_request_template] } + let(:own_template) { "- [ ] Have you run `make test`?\n" } let(:original_gemfile) { "source \"https://rubygems.org\"\n\nruby file: \".ruby-version\"\n\ngem \"rake\"\n" } let(:custom_gemfile) { "source \"https://rubygems.org\"\n\ngem \"rake\"\n" } let(:expected_gemfile) do @@ -315,6 +332,130 @@ def synced_contents(path) end end + it "installs issue and pull request templates linking to the repository, repeatedly" do + commit_fixture + sync_target + expected_templates.each { |path, contents| expect((target/path).read).to eq(contents) } + expect((target/pull_request_template).read).to include("https://github.com/Homebrew/no-ruby/pulls") + expect((target/".github/ISSUE_TEMPLATE/bug.yml").read).to include("https://github.com/Homebrew/no-ruby/issues") + expect_idempotent_sync + end + + it "keeps an installed template after the repository edits it" do + commit_fixture + sync_target + write pull_request_template, own_template + commit_fixture + expect_idempotent_sync + expect((target/pull_request_template).read).to eq(own_template) + end + + it "removes an unedited pull request template once the repository adds its own elsewhere" do + commit_fixture + sync_target + write "PULL_REQUEST_TEMPLATE.md", own_template + commit_fixture + sync_target + expect(target/pull_request_template).not_to exist + expect(tracked_templates(/pull_request_template/i)).to eq(["PULL_REQUEST_TEMPLATE.md"]) + expect_idempotent_sync + end + + it "keeps the pull request template when the repository's own one is deleted but not committed" do + commit_fixture + sync_target + write "PULL_REQUEST_TEMPLATE.md", own_template + commit_fixture + FileUtils.rm target/"PULL_REQUEST_TEMPLATE.md" + sync_target + expect((target/pull_request_template).read).to eq(expected_templates.fetch(pull_request_template)) + end + + it "installs issue templates when the last one is deleted but not committed" do + write ".github/ISSUE_TEMPLATE/feature.yml", own_template + commit_fixture + FileUtils.rm target/".github/ISSUE_TEMPLATE/feature.yml" + sync_target + issue_templates.each do |issue_template| + expect((target/issue_template).read).to eq(expected_templates.fetch(issue_template)) + end + end + + { + ".github/PULL_REQUEST_TEMPLATE.md" => /pull_request_template/i, + "PULL_REQUEST_TEMPLATE.md" => /pull_request_template/i, + ".github/ISSUE_TEMPLATE/feature.yml" => /issue_template/i, + }.each do |path, pattern| + it "keeps an uncommitted #{path} without adding a shared template beside it" do + commit_fixture + write path, own_template + sync_target + expect((target/path).read).to eq(own_template) + expect(tracked_templates(pattern)).to eq([path]) + end + end + + %w[ + .github/PULL_REQUEST_TEMPLATE.md + .github/pull_request_template.md + PULL_REQUEST_TEMPLATE.md + docs/pull_request_template.md + .github/PULL_REQUEST_TEMPLATE/bug_fix.md + ].each do |path| + it "keeps a repository's own pull request template at #{path} and installs issue templates" do + write path, own_template + commit_fixture + sync_target + expect((target/path).read).to eq(own_template) + expect(tracked_templates(/pull_request_template/i)).to eq([path]) + issue_templates.each do |issue_template| + expect((target/issue_template).read).to eq(expected_templates.fetch(issue_template)) + end + end + end + + %w[ + .github/ISSUE_TEMPLATE/feature.yml + .github/ISSUE_TEMPLATE/config.yml + ISSUE_TEMPLATE/bug.md + docs/ISSUE_TEMPLATE/bug.md + ].each do |path| + it "keeps a repository's own issue template at #{path} and installs the pull request template" do + write path, own_template + commit_fixture + sync_target + expect((target/path).read).to eq(own_template) + expect(tracked_templates(/issue_template/i)).to eq([path]) + expect((target/pull_request_template).read).to eq(expected_templates.fetch(pull_request_template)) + end + end + + %w[ + .github/issue_template.md + ISSUE_TEMPLATE.md + docs/ISSUE_TEMPLATE.md + ].each do |path| + it "installs issue templates alongside the retired single-file template at #{path}" do + write path, own_template + commit_fixture + sync_target + expect((target/path).read).to eq(own_template) + issue_templates.each do |issue_template| + expect((target/issue_template).read).to eq(expected_templates.fetch(issue_template)) + end + end + end + + context "with the .github repository" do + let(:repository) { "source/.github" } + + it "does not install templates" do + commit_fixture + sync_target + expected_templates.each_key { |path| expect(target/path).not_to exist } + end + end + %w[ Package.swift Package.resolved