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
89 changes: 89 additions & 0 deletions .github/scripts/check_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

require "yaml"

CHECKBOX_MARKER = /\A- \[[ xX]\] /
CHECKED_CHECKBOX_MARKER = /\A- \[[xX]\] /
HTML_COMMENT_LINE = /\A<!--.*-->\z/

Check failure

Code scanning / CodeQL

Bad HTML filtering regexp High

This regular expression does not match comments containing newlines.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
MikeMcQuaid marked this conversation as resolved.
Dismissed
ISSUE_FORM_HEADING = /\A### /
MARKDOWN_HORIZONTAL_LINE = /\A-+\z/
NO_RESPONSE = "_No response_"
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"
pr_body_path = ARGV.fetch(1)
template_path = ARGV.fetch(2)
pr_lines = normalised_lines.call(pr_body_path)
template_lines = normalised_lines.call(template_path)
matching_template_lines = template_lines.count { |line| pr_lines.include?(line) }
scaled_matching_percentage = matching_template_lines * PERCENTAGE_SCALE
scaled_required_percentage = template_lines.count * REQUIRED_TEMPLATE_PERCENTAGE
preserves_template = scaled_matching_percentage >= scaled_required_percentage
has_checked_checkbox = lines.call(pr_body_path).any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) }
has_non_template_content = (pr_lines - template_lines).any?

puts preserves_template && (has_checked_checkbox || has_non_template_content)
when "issue"
issue_body = File.read(ARGV.fetch(1), mode: "rb")
.encode("UTF-8", invalid: :replace, undef: :replace)
issue_lines = issue_body.lines(chomp: true)
issue_field_responses = {}
issue_lines.each do |line|
if line.match?(ISSUE_FORM_HEADING)
issue_field_responses[line.delete_prefix("### ").strip] = []
elsif issue_field_responses.any?
issue_field_responses.fetch(issue_field_responses.keys.last) << line
end
end

puts(Dir.glob("#{ARGV.fetch(2)}/*.{yml,yaml}").any? do |template_path|
required_fields = []
required_checkboxes = []

YAML.safe_load_file(template_path).fetch("body", []).each do |field|
attributes = field.fetch("attributes", {})
case field["type"]
when "checkboxes"
attributes.fetch("options", []).each do |option|
required_checkboxes << option.fetch("label") if option["required"]
end
when "dropdown", "input", "textarea"
required_fields << attributes.fetch("label") if field.dig("validations", "required")
end
end
next false if required_fields.empty? && required_checkboxes.empty?

required_fields.all? do |field|
issue_field_responses.fetch(field, []).any? do |line|
!line.strip.empty? && line.strip != NO_RESPONSE
end
end &&
required_checkboxes.all? do |checkbox|
issue_lines.any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) && line.include?(checkbox) }
end
end)
else
warn "Usage: check_template.rb pull-request BODY TEMPLATE"
warn " check_template.rb issue BODY TEMPLATE_DIRECTORY"
exit 1
end
92 changes: 92 additions & 0 deletions .github/workflows/check-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Check issues

on:
issues:
types:
- opened

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 Homebrew/brew. The first step also
# fails if a repository checkout has occurred.
if: >-
github.repository == 'Homebrew/brew' &&
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 }}
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: |
gh api "repos/${GITHUB_REPOSITORY:?}/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/${GITHUB_REPOSITORY:?}/contents/${template_path}?ref=main" \
--jq ".content" |
base64 --decode >"${RUNNER_TEMP}/check-issues/templates/${template_path##*/}"
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"
)"
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: Close incomplete issue
if: steps.template.outputs.complete_template == 'false'
run: |
gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${{ github.event.issue.number }}" \
-f state=closed \
-f state_reason=not_planned
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Close incomplete pull requests
name: Check pull requests

on:
# `pull_request_target` has a write token, so this workflow must only ever run trusted
Expand All @@ -16,7 +16,7 @@ defaults:
shell: bash -euo pipefail {0}

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

jobs:
Expand All @@ -29,8 +29,8 @@ jobs:
github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
# Read the trusted base-branch pull request template through the API; write only
# the issue comment and pull request state needed here.
# Read the trusted base-branch pull request template and checker through
# the API; write only the issue comment and pull request state needed here.
contents: read
issues: write
pull-requests: write
Expand Down Expand Up @@ -60,64 +60,28 @@ jobs:
# reopen forked pull requests. Keep this self-contained and never execute
# pull request code from this step.

mkdir -p "${RUNNER_TEMP:?}/incomplete-prs"
printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/incomplete-prs/pr-body"
mkdir -p "${RUNNER_TEMP:?}/check-prs"
printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/check-prs/body"

- name: Fetch pull request template
run: |
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=main" \
--jq ".content" |
base64 --decode >"${RUNNER_TEMP:?}/incomplete-prs/template"
base64 --decode >"${RUNNER_TEMP:?}/check-prs/template"

- 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 pull request template
id: template
run: |
complete_template="$(
ruby - \
"${RUNNER_TEMP}/incomplete-prs/pr-body" \
"${RUNNER_TEMP}/incomplete-prs/template" <<'RUBY'
CHECKBOX_MARKER = /\A- \[[ xX]\] /
CHECKED_CHECKBOX_MARKER = /\A- \[[xX]\] /
HTML_COMMENT_LINE = /\A<!--.*-->\z/
MARKDOWN_HORIZONTAL_LINE = /\A-+\z/
NORMALISED_CHECKBOX_MARKER = '- [ ] '
REQUIRED_TEMPLATE_PERCENTAGE = 75
PERCENTAGE_SCALE = 100

def lines(path)
File.read(path, mode: 'rb')
.encode('UTF-8', invalid: :replace, undef: :replace)
.lines(chomp: true)
end

def normalise_lines(path)
lines(path).each_with_object([]) do |line, normalised_lines|
line = line.strip.sub(CHECKBOX_MARKER, NORMALISED_CHECKBOX_MARKER)
# Ignore blank lines.
next if line.empty?
# Ignore --- markdown horizontal lines.
next if line.match?(MARKDOWN_HORIZONTAL_LINE)
# Ignore <!-- HTML comments -->
next if line.match?(HTML_COMMENT_LINE)

normalised_lines << line
end.uniq
end

pr_body_path = ARGV.fetch(0)
template_path = ARGV.fetch(1)

pr_lines = normalise_lines(pr_body_path)
template_lines = normalise_lines(template_path)
matching_template_lines = template_lines.count { |line| pr_lines.include?(line) }
scaled_matching_percentage = matching_template_lines * PERCENTAGE_SCALE
scaled_required_percentage = template_lines.count * REQUIRED_TEMPLATE_PERCENTAGE
preserves_template = scaled_matching_percentage >= scaled_required_percentage
has_checked_checkbox = lines(pr_body_path).any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) }
has_non_template_content = (pr_lines - template_lines).any?

puts preserves_template && (has_checked_checkbox || has_non_template_content)
RUBY
ruby "${RUNNER_TEMP:?}/check_template.rb" pull-request \
"${RUNNER_TEMP}/check-prs/body" \
"${RUNNER_TEMP}/check-prs/template"
)"
case "${complete_template}" in
true | false) ;;
Expand Down
Loading