Skip to content

Commit 7c63ed4

Browse files
authored
Merge pull request #22424 from Homebrew/lush-theater
Close API-created issues that do not match a template
2 parents fe6709d + 5a7e1ac commit 7c63ed4

3 files changed

Lines changed: 197 additions & 52 deletions

File tree

.github/scripts/check_template.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
require "yaml"
4+
5+
CHECKBOX_MARKER = /\A- \[[ xX]\] /
6+
CHECKED_CHECKBOX_MARKER = /\A- \[[xX]\] /
7+
HTML_COMMENT_LINE = /\A<!--.*-->\z/
8+
ISSUE_FORM_HEADING = /\A### /
9+
MARKDOWN_HORIZONTAL_LINE = /\A-+\z/
10+
NO_RESPONSE = "_No response_"
11+
NORMALISED_CHECKBOX_MARKER = "- [ ] "
12+
REQUIRED_TEMPLATE_PERCENTAGE = 75
13+
PERCENTAGE_SCALE = 100
14+
15+
lines = lambda do |path|
16+
File.read(path, mode: "rb")
17+
.encode("UTF-8", invalid: :replace, undef: :replace)
18+
.lines(chomp: true)
19+
end
20+
21+
normalised_lines = lambda do |path|
22+
lines.call(path).each_with_object([]) do |line, normalised_lines|
23+
line = line.strip.sub(CHECKBOX_MARKER, NORMALISED_CHECKBOX_MARKER)
24+
next if line.empty?
25+
next if line.match?(MARKDOWN_HORIZONTAL_LINE)
26+
next if line.match?(HTML_COMMENT_LINE)
27+
28+
normalised_lines << line
29+
end.uniq
30+
end
31+
32+
case ARGV.fetch(0)
33+
when "pull-request"
34+
pr_body_path = ARGV.fetch(1)
35+
template_path = ARGV.fetch(2)
36+
pr_lines = normalised_lines.call(pr_body_path)
37+
template_lines = normalised_lines.call(template_path)
38+
matching_template_lines = template_lines.count { |line| pr_lines.include?(line) }
39+
scaled_matching_percentage = matching_template_lines * PERCENTAGE_SCALE
40+
scaled_required_percentage = template_lines.count * REQUIRED_TEMPLATE_PERCENTAGE
41+
preserves_template = scaled_matching_percentage >= scaled_required_percentage
42+
has_checked_checkbox = lines.call(pr_body_path).any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) }
43+
has_non_template_content = (pr_lines - template_lines).any?
44+
45+
puts preserves_template && (has_checked_checkbox || has_non_template_content)
46+
when "issue"
47+
issue_body = File.read(ARGV.fetch(1), mode: "rb")
48+
.encode("UTF-8", invalid: :replace, undef: :replace)
49+
issue_lines = issue_body.lines(chomp: true)
50+
issue_field_responses = {}
51+
issue_lines.each do |line|
52+
if line.match?(ISSUE_FORM_HEADING)
53+
issue_field_responses[line.delete_prefix("### ").strip] = []
54+
elsif issue_field_responses.any?
55+
issue_field_responses.fetch(issue_field_responses.keys.last) << line
56+
end
57+
end
58+
59+
puts(Dir.glob("#{ARGV.fetch(2)}/*.{yml,yaml}").any? do |template_path|
60+
required_fields = []
61+
required_checkboxes = []
62+
63+
YAML.safe_load_file(template_path).fetch("body", []).each do |field|
64+
attributes = field.fetch("attributes", {})
65+
case field["type"]
66+
when "checkboxes"
67+
attributes.fetch("options", []).each do |option|
68+
required_checkboxes << option.fetch("label") if option["required"]
69+
end
70+
when "dropdown", "input", "textarea"
71+
required_fields << attributes.fetch("label") if field.dig("validations", "required")
72+
end
73+
end
74+
next false if required_fields.empty? && required_checkboxes.empty?
75+
76+
required_fields.all? do |field|
77+
issue_field_responses.fetch(field, []).any? do |line|
78+
!line.strip.empty? && line.strip != NO_RESPONSE
79+
end
80+
end &&
81+
required_checkboxes.all? do |checkbox|
82+
issue_lines.any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) && line.include?(checkbox) }
83+
end
84+
end)
85+
else
86+
warn "Usage: check_template.rb pull-request BODY TEMPLATE"
87+
warn " check_template.rb issue BODY TEMPLATE_DIRECTORY"
88+
exit 1
89+
end

.github/workflows/check-issues.yml

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Check issues
2+
3+
on:
4+
issues:
5+
types:
6+
- opened
7+
8+
permissions: {}
9+
10+
defaults:
11+
run:
12+
shell: bash -euo pipefail {0}
13+
14+
concurrency:
15+
group: "check-issue-${{ github.event.issue.number }}"
16+
cancel-in-progress: true
17+
18+
jobs:
19+
manage:
20+
# Restrict this write-token workflow to Homebrew/brew. The first step also
21+
# fails if a repository checkout has occurred.
22+
if: >-
23+
github.repository == 'Homebrew/brew' &&
24+
github.event.issue.user.login != 'BrewTestBot' &&
25+
github.event.issue.user.login != 'dependabot[bot]'
26+
runs-on: ubuntu-latest
27+
permissions:
28+
# Read the trusted base-branch issue templates and checker through the API;
29+
# write only the issue state needed here.
30+
contents: read
31+
issues: write
32+
env:
33+
GH_TOKEN: ${{ github.token }}
34+
steps:
35+
- name: Verify no checkout
36+
run: |
37+
if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null
38+
then
39+
echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2
40+
exit 1
41+
fi
42+
43+
- name: Write issue body
44+
env:
45+
# Bind issue-controlled strings as environment variables instead of
46+
# interpolating them into shell code.
47+
ISSUE_BODY: ${{ github.event.issue.body }}
48+
run: |
49+
mkdir -p "${RUNNER_TEMP:?}/check-issues/templates"
50+
printf "%s" "${ISSUE_BODY}" >"${RUNNER_TEMP}/check-issues/body"
51+
52+
- name: Fetch issue templates
53+
run: |
54+
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/ISSUE_TEMPLATE?ref=main" \
55+
--jq '.[] | select(.type == "file" and (.name | test("\\.ya?ml$")) and .name != "config.yml") | .path' |
56+
while IFS= read -r template_path
57+
do
58+
gh api "repos/${GITHUB_REPOSITORY:?}/contents/${template_path}?ref=main" \
59+
--jq ".content" |
60+
base64 --decode >"${RUNNER_TEMP}/check-issues/templates/${template_path##*/}"
61+
done
62+
63+
- name: Fetch template checker
64+
run: |
65+
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/scripts/check_template.rb?ref=main" \
66+
--jq ".content" |
67+
base64 --decode >"${RUNNER_TEMP:?}/check_template.rb"
68+
69+
- name: Check issue template
70+
id: template
71+
run: |
72+
complete_template="$(
73+
ruby "${RUNNER_TEMP:?}/check_template.rb" issue \
74+
"${RUNNER_TEMP}/check-issues/body" \
75+
"${RUNNER_TEMP}/check-issues/templates"
76+
)"
77+
case "${complete_template}" in
78+
true | false) ;;
79+
*)
80+
echo "Unexpected template completion result: ${complete_template}" >&2
81+
exit 1
82+
;;
83+
esac
84+
85+
echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}"
86+
87+
- name: Close incomplete issue
88+
if: steps.template.outputs.complete_template == 'false'
89+
run: |
90+
gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${{ github.event.issue.number }}" \
91+
-f state=closed \
92+
-f state_reason=not_planned
Lines changed: 16 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Close incomplete pull requests
1+
name: Check pull requests
22

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

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

2222
jobs:
@@ -29,8 +29,8 @@ jobs:
2929
github.event.pull_request.user.login != 'dependabot[bot]'
3030
runs-on: ubuntu-latest
3131
permissions:
32-
# Read the trusted base-branch pull request template through the API; write only
33-
# the issue comment and pull request state needed here.
32+
# Read the trusted base-branch pull request template and checker through
33+
# the API; write only the issue comment and pull request state needed here.
3434
contents: read
3535
issues: write
3636
pull-requests: write
@@ -60,64 +60,28 @@ jobs:
6060
# reopen forked pull requests. Keep this self-contained and never execute
6161
# pull request code from this step.
6262
63-
mkdir -p "${RUNNER_TEMP:?}/incomplete-prs"
64-
printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/incomplete-prs/pr-body"
63+
mkdir -p "${RUNNER_TEMP:?}/check-prs"
64+
printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/check-prs/body"
6565
6666
- name: Fetch pull request template
6767
run: |
6868
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=main" \
6969
--jq ".content" |
70-
base64 --decode >"${RUNNER_TEMP:?}/incomplete-prs/template"
70+
base64 --decode >"${RUNNER_TEMP:?}/check-prs/template"
71+
72+
- name: Fetch template checker
73+
run: |
74+
gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/scripts/check_template.rb?ref=main" \
75+
--jq ".content" |
76+
base64 --decode >"${RUNNER_TEMP:?}/check_template.rb"
7177
7278
- name: Check pull request template
7379
id: template
7480
run: |
7581
complete_template="$(
76-
ruby - \
77-
"${RUNNER_TEMP}/incomplete-prs/pr-body" \
78-
"${RUNNER_TEMP}/incomplete-prs/template" <<'RUBY'
79-
CHECKBOX_MARKER = /\A- \[[ xX]\] /
80-
CHECKED_CHECKBOX_MARKER = /\A- \[[xX]\] /
81-
HTML_COMMENT_LINE = /\A<!--.*-->\z/
82-
MARKDOWN_HORIZONTAL_LINE = /\A-+\z/
83-
NORMALISED_CHECKBOX_MARKER = '- [ ] '
84-
REQUIRED_TEMPLATE_PERCENTAGE = 75
85-
PERCENTAGE_SCALE = 100
86-
87-
def lines(path)
88-
File.read(path, mode: 'rb')
89-
.encode('UTF-8', invalid: :replace, undef: :replace)
90-
.lines(chomp: true)
91-
end
92-
93-
def normalise_lines(path)
94-
lines(path).each_with_object([]) do |line, normalised_lines|
95-
line = line.strip.sub(CHECKBOX_MARKER, NORMALISED_CHECKBOX_MARKER)
96-
# Ignore blank lines.
97-
next if line.empty?
98-
# Ignore --- markdown horizontal lines.
99-
next if line.match?(MARKDOWN_HORIZONTAL_LINE)
100-
# Ignore <!-- HTML comments -->
101-
next if line.match?(HTML_COMMENT_LINE)
102-
103-
normalised_lines << line
104-
end.uniq
105-
end
106-
107-
pr_body_path = ARGV.fetch(0)
108-
template_path = ARGV.fetch(1)
109-
110-
pr_lines = normalise_lines(pr_body_path)
111-
template_lines = normalise_lines(template_path)
112-
matching_template_lines = template_lines.count { |line| pr_lines.include?(line) }
113-
scaled_matching_percentage = matching_template_lines * PERCENTAGE_SCALE
114-
scaled_required_percentage = template_lines.count * REQUIRED_TEMPLATE_PERCENTAGE
115-
preserves_template = scaled_matching_percentage >= scaled_required_percentage
116-
has_checked_checkbox = lines(pr_body_path).any? { |line| line.match?(CHECKED_CHECKBOX_MARKER) }
117-
has_non_template_content = (pr_lines - template_lines).any?
118-
119-
puts preserves_template && (has_checked_checkbox || has_non_template_content)
120-
RUBY
82+
ruby "${RUNNER_TEMP:?}/check_template.rb" pull-request \
83+
"${RUNNER_TEMP}/check-prs/body" \
84+
"${RUNNER_TEMP}/check-prs/template"
12185
)"
12286
case "${complete_template}" in
12387
true | false) ;;

0 commit comments

Comments
 (0)