Skip to content

Commit 1751714

Browse files
committed
Split template checks
- Keep issue and pull request checks isolated so each workflow has a narrower trigger and permission surface. - Share template matching in `.github/scripts/check-template.rb` so issue and pull request behaviour stays consistent. - Close new API-created issues that do not match any issue template.
1 parent 0b7a7c7 commit 1751714

3 files changed

Lines changed: 183 additions & 52 deletions

File tree

.github/scripts/check-template.rb

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