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
31 changes: 30 additions & 1 deletion .github/actions/sync/shared-config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ def git(*args)
vale_ini = ".vale.ini"
dependabot_template_yaml = ".github/actions/sync/dependabot.template.yml"
dependabot_yaml = ".github/dependabot.yml"
check_template_rb = ".github/scripts/check_template.rb"
docs_workflow_yaml = ".github/workflows/docs.yml"
actionlint_workflow_yaml = ".github/workflows/actionlint.yml"
check_issues_workflow_yaml = ".github/workflows/check-issues.yml"
check_prs_workflow_yaml = ".github/workflows/check-prs.yml"
check_workflow_yamls = [
check_issues_workflow_yaml,
check_prs_workflow_yaml,
].freeze
stale_issues_workflow_yaml = ".github/workflows/stale-issues.yml"
zizmor_yml = ".github/zizmor.yml"
codeql_extensions_homebrew_actions_yml = ".github/codeql/extensions/homebrew-actions.yml"
Expand Down Expand Up @@ -138,6 +145,11 @@ def git(*args)
patchelf.rb
ruby-macho
].freeze
template_check_repositories = %w[
brew
homebrew-core
homebrew-cask
].freeze
rejected_docs_basenames = %w[
_config.yml
CNAME
Expand All @@ -155,8 +167,10 @@ def git(*args)
ruby_version,
rubocop_yaml,
dependabot_yaml,
check_template_rb,
deprecated_lock_threads,
actionlint_workflow_yaml,
*check_workflow_yamls,
stale_issues_workflow_yaml,
zizmor_yml,
codeql_extensions_homebrew_actions_yml,
Expand Down Expand Up @@ -249,7 +263,8 @@ def git(*args)
"# This file is synced from `Homebrew/brew` by the `.github` repository, do not modify it directly.\n" \
"#{homebrew_rubocop_config}\n",
)
when dependabot_yaml, actionlint_workflow_yaml, stale_issues_workflow_yaml,
when dependabot_yaml, actionlint_workflow_yaml, check_issues_workflow_yaml, check_prs_workflow_yaml,
stale_issues_workflow_yaml,
zizmor_yml, codeql_extensions_homebrew_actions_yml
contents = if path == dependabot_yaml
dependabot_config
Expand All @@ -259,6 +274,11 @@ def git(*args)
# ensure we don't replace the non-dependabot template files in this repository
next if repository_name == ".github"

if check_workflow_yamls.include?(path) && template_check_repositories.none?(repository_name)
FileUtils.rm_f target_path
next
end

Pathname(path).read
.chomp
end
Expand All @@ -268,6 +288,15 @@ def git(*args)
"# This file is synced from the `.github` repository, do not modify it directly.\n" \
"#{contents}\n",
)
when check_template_rb
next if path == target_path.to_s

if template_check_repositories.none?(repository_name)
FileUtils.rm_f target_path
next
end

FileUtils.cp path, target_path
when deprecated_lock_threads
next unless target_path.exist?

Expand Down
88 changes: 88 additions & 0 deletions .github/scripts/check_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require "yaml"

AI_MENTION = /\b(?:AI|LLM)\b/i
CHECKBOX_MARKER = /\A- \[[ xX]\] /
HTML_COMMENT_LINE = /\A<!--.*-->\z/
ISSUE_FORM_HEADING_MARKER = "### "
MARKDOWN_HEADING = /\A#+ /
MARKDOWN_HORIZONTAL_LINE = /\A-+\z/
NORMALISED_CHECKBOX_MARKER = "- [ ] "
REQUIRED_TEMPLATE_PERCENTAGE = 75
PERCENTAGE_SCALE = 100

lines = lambda do |path|
File.read(path, mode: "rb")
.encode("UTF-8", invalid: :replace, undef: :replace)
.lines(chomp: true)
end

normalised_lines = lambda do |path|
lines.call(path).each_with_object([]) do |line, normalised_lines|
line = line.strip.sub(CHECKBOX_MARKER, NORMALISED_CHECKBOX_MARKER)
next if line.empty?
next if line.match?(MARKDOWN_HORIZONTAL_LINE)
next if line.match?(HTML_COMMENT_LINE)

normalised_lines << line
end.uniq
end

case ARGV.fetch(0)
when "pull-request"
# Pass when the body keeps at least REQUIRED_TEMPLATE_PERCENTAGE of the template's
# headings and checkboxes combined (ticked or not) and still discloses AI usage:
# either the template's AI disclosure checkbox (whose label mentions AI) or any
# mention of AI/LLM in the text. This blocks bodies that strip out the template
# (e.g. AI-generated pull requests) without caring whether boxes are ticked.
normalised_body = normalised_lines.call(ARGV.fetch(1))
template_items = normalised_lines.call(ARGV.fetch(2)).select do |line|
line.start_with?(NORMALISED_CHECKBOX_MARKER) || line.match?(MARKDOWN_HEADING)
end
present_count = template_items.count { |item| normalised_body.include?(item) }
preserves_template = present_count * PERCENTAGE_SCALE >= template_items.count * REQUIRED_TEMPLATE_PERCENTAGE
discloses_ai = normalised_body.any? { |line| line.match?(AI_MENTION) }

puts preserves_template && discloses_ai
when "issue"
# Pass when the body keeps at least REQUIRED_TEMPLATE_PERCENTAGE of some template's
# headings and checkboxes combined (ticked or not). Counting headings as well as
# checkboxes lets the feature template (a single checkbox) be told apart from a
# stripped body. The missing items from the closest template are reported on stderr.
body_lines = lines.call(ARGV.fetch(1))

templates = Dir.glob("#{ARGV.fetch(2)}/*.{yml,yaml}").filter_map do |template_path|
fields = YAML.safe_load_file(template_path).fetch("body", [])
headings = fields.filter_map { |field| field.dig("attributes", "label") if field["type"] != "markdown" }
checkboxes = fields.flat_map do |field|
next [] if field["type"] != "checkboxes"

field.fetch("attributes", {}).fetch("options", []).map { |option| option.fetch("label") }
end
items = headings.map { |label| [:heading, label] } + checkboxes.map { |label| [:checkbox, label] }
next if items.empty?

missing = items.reject { |_kind, label| body_lines.any? { |line| line.include?(label) } }
present_count = items.length - missing.length
{ total: items.length, present_count: present_count, missing: missing }
end

preserves_template = templates.any? do |template|
template[:present_count] * PERCENTAGE_SCALE >= template[:total] * REQUIRED_TEMPLATE_PERCENTAGE
end

if preserves_template
puts true
else
puts false
closest_missing = templates.min_by { |template| template[:missing].length }&.fetch(:missing)
closest_missing&.each do |kind, label|
warn((kind == :checkbox) ? "- [ ] #{label}" : "- `#{ISSUE_FORM_HEADING_MARKER}#{label}` section")
end
end
else
warn "Usage: check_template.rb pull-request BODY TEMPLATE"
warn " check_template.rb issue BODY TEMPLATE_DIRECTORY"
exit 1
end
162 changes: 162 additions & 0 deletions .github/workflows/check-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: Check issues

on:
issues:
types:
- opened
- edited
- reopened

permissions: {}

defaults:
run:
shell: bash -euo pipefail {0}

concurrency:
group: "check-issue-${{ github.event.issue.number }}"
cancel-in-progress: true

jobs:
manage:
# Restrict this write-token workflow to repositories with supported templates.
# The first step also fails if a repository checkout has occurred.
if: >-
contains(fromJSON('["Homebrew/brew", "Homebrew/homebrew-core", "Homebrew/homebrew-cask"]'), github.repository) &&
github.event.issue.user.login != 'BrewTestBot' &&
github.event.issue.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
# Read the trusted base-branch issue templates and checker through the API;
# write only the issue state needed here.
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
steps:
- name: Verify no checkout
run: |
if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null
then
echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2
exit 1
fi

- name: Write issue body
env:
# Bind issue-controlled strings as environment variables instead of
# interpolating them into shell code.
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
mkdir -p "${RUNNER_TEMP:?}/check-issues/templates"
printf "%s" "${ISSUE_BODY}" >"${RUNNER_TEMP}/check-issues/body"

- name: Fetch issue templates
run: |
# Validate against the brew, homebrew-core and homebrew-cask templates so
# issues transferred between these repositories are not closed for using a
# sibling repository's (valid) template.
for repo in Homebrew/brew Homebrew/homebrew-core Homebrew/homebrew-cask
do
gh api "repos/${repo}/contents/.github/ISSUE_TEMPLATE?ref=main" \
--jq '.[] | select(.type == "file" and (.name | test("\\.ya?ml$")) and .name != "config.yml") | .path' |
while IFS= read -r template_path
do
gh api "repos/${repo}/contents/${template_path}?ref=main" \
--jq ".content" |
base64 --decode >"${RUNNER_TEMP}/check-issues/templates/${repo//\//-}-${template_path##*/}"
done
done

- name: Fetch template checker
run: |
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/scripts/check_template.rb?ref=main" \
--jq ".content" |
base64 --decode >"${RUNNER_TEMP:?}/check_template.rb"

- name: Check issue template
id: template
run: |
complete_template="$(
ruby "${RUNNER_TEMP:?}/check_template.rb" issue \
"${RUNNER_TEMP}/check-issues/body" \
"${RUNNER_TEMP}/check-issues/templates" \
2>"${RUNNER_TEMP}/check-issues/missing-checkboxes"
)"
case "${complete_template}" in
true | false) ;;
*)
echo "Unexpected template completion result: ${complete_template}" >&2
exit 1
;;
esac

echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}"

- name: Find incomplete template comment
id: comments
if: >-
(github.event.issue.state == 'closed' &&
steps.template.outputs.complete_template == 'true') ||
(github.event.issue.state != 'closed' &&
steps.template.outputs.complete_template == 'false')
run: |
comment_ids="$(
gh api --paginate "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- incomplete-issue-template -->"))) | .id'
)"
if [[ -n "${comment_ids}" ]]
then
echo "has_incomplete_template_comment=true" >>"${GITHUB_OUTPUT:?}"
else
echo "has_incomplete_template_comment=false" >>"${GITHUB_OUTPUT:?}"
fi

- name: Find issue closer
id: closer
if: >-
github.event.issue.state == 'closed' &&
steps.template.outputs.complete_template == 'true'
run: |
closed_by="$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" --jq ".closed_by.login // \"\"")"
echo "closed_by=${closed_by}" >>"${GITHUB_OUTPUT:?}"

- name: Reopen completed issue
if: >-
github.event.issue.state == 'closed' &&
steps.template.outputs.complete_template == 'true' &&
steps.closer.outputs.closed_by == 'github-actions[bot]' &&
steps.comments.outputs.has_incomplete_template_comment == 'true'
run: |
gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \
-f state=open

- name: Comment on incomplete issue
if: >-
github.event.issue.state != 'closed' &&
steps.template.outputs.complete_template == 'false' &&
steps.comments.outputs.has_incomplete_template_comment != 'true'
run: |
gh api --method POST "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \
--raw-field body="$(
cat <<COMMENT
<!-- incomplete-issue-template -->
Thanks for your issue. This has been closed because it appears to use an incomplete or outdated issue template.

Please edit this issue to restore the following sections and checkboxes from the current issue template (you do not need to tick the checkboxes):

$(cat "${RUNNER_TEMP:?}/check-issues/missing-checkboxes")

This workflow will reopen this issue automatically once they are present. **Do not create a new issue for this.**
COMMENT
)"

- name: Close incomplete issue
if: >-
github.event.issue.state != 'closed' &&
steps.template.outputs.complete_template == 'false'
run: |
gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \
-f state=closed \
-f state_reason=not_planned
Loading
Loading