blob: 5bf1a23b69e195da8650350e5a4a3a93b6cb22a7 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import inspect
import os
import re
import sys
# PR titles are now validated by the separate GitHub Action workflow
# This script only validates PR body content and GitHub issue links for fix PRs
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# ____ _ __ __ _ _ _ _ _ #
# | __ ) ___ __| |_ _ \ \ / /_ _| (_) __| | __ _| |_(_) ___ _ __ #
# | _ \ / _ \ / _` | | | | \ \ / / _` | | |/ _` |/ _` | __| |/ _ \| '_ \ #
# | |_) | (_) | (_| | |_| | \ V / (_| | | | (_| | (_| | |_| | (_) | | | | #
# |____/ \___/ \__,_|\__, | \_/ \__,_|_|_|\__,_|\__,_|\__|_|\___/|_| |_| #
# |___/ #
# #
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#Enums for the outcome of parsing a single line
class Outcomes:
#error was found so we should stop parsing and exit with error
ERROR = 0
#continue to parse the next line
CONTINUE = 1
#All requirements for the current section have been met so we should start
#parsing the next section
NEXTSECTION = 2
#parsing has concluded successfully, exit with no error
SUCCESS = 3
#Holds the data for a section
# PARAMS
# name: str - name of the parse section
# identifier: str - line that signifies the start of a section
# linesAfter: set of str - default lines in the template that we ignore when
# verifying that the user filled out the section
class ParseSectionData:
def __init__(self, name: str, identifier: str, linesAfter: str):
self.name = name
self.identifier = identifier
self.linesAfter = linesAfter
self.prevSection = ""
self.nextSection = ""
#returns true if line matches the identifier
def identify(self, line: str):
return line == self.identifier
#returns true if user has added new text to the section
def identifyAfter(self, line: str):
return line not in self.linesAfter
#Special holder of data for risk level because the identifier line is modified
#by the user
class RiskLevelData(ParseSectionData):
def __init__(self, name: str, identifier: str, linesAfter):
super().__init__(name, identifier, linesAfter)
#we check that the line start with the identifier because the identifier
#line will be modified when filled out correctly
def identify(self, line: str):
return line.startswith(self.identifier)
#Holds all the parse section data in a map
# PARAMS
# psd: list of ParseSectionData - a list of the data for all the parse sections
class ParseSections:
def __init__(self, psd):
self.sections = {}
assert len(psd) > 0
for i in range(len(psd)):
prevI = i - 1
nextI = i + 1
if prevI < 0:
psd[i].prevSection = "START"
else:
psd[i].prevSection = psd[prevI].name
if nextI >= len(psd):
psd[i].nextSection = "END"
else:
psd[i].nextSection = psd[nextI].name
self.sections[psd[i].name] = psd[i]
#returns true if line is an identifier for a section that is not value
# PARAMS
# line: str - the line that we are parsing
# value: str - the name of the parse section that we are parsing
def validateOthers(self, line: str, value: str):
for name in self.sections:
if name != value:
if self.sections[name].identify(line):
return True
return False
#gets the name of the section identified in the line
# PARAMS
# line: str - the line that we are parsing
# RETURN
# string - name of the section if the identifier is found, else none
def getSectionName(self, line: str):
for name in self.sections:
if self.sections[name].identify(line):
return name
return None
#returns the ParseSectionData that is named name
def get(self, name):
return self.sections.get(name)
#Main class used to parse a section
class ParseSection:
def __init__(self, data: ParseSectionData, sections: ParseSections, debug=False):
#set to true when the sections identifier is found
self.found = False
#true if debug messages should be printed
self.debug = debug
#the data for this parse section
self.data = data
#data of all the parse sections
self.sections = sections
#prints error message if debug is set to true
def error(self, line: str, lineno: str, message: str):
if self.debug:
pyline = inspect.getframeinfo(inspect.stack()[1][0]).lineno
print(f"::error file=pr_compliance.py,line={pyline}::{message}")
if lineno != "" and line != "":
print(f"::error file=pr_compliance.py,line={pyline}::found on line {lineno}: {line}")
print(f"::debug::state: {self.data.name}, found: {self.found},")
#returns the name of the next section
def nextSection(self):
return self.data.nextSection
#Returns true if we have already found the section identifier and line is
#not in the default template
def validateAfter(self, line):
return self.found and self.data.identifyAfter(line)
#Decides what outcome occurs when the section identifier is found
def processIdentify(self, line, lineno):
if self.found:
#since we have already found the identifier, this means that we have
#found a duplicate of the identifier
self.error(line, lineno, f"Duplicate {self.data.identifier} section found")
return Outcomes.ERROR
self.found = True
return Outcomes.CONTINUE
def makeValidateOthersErrorMessage(self, line):
if self.found:
if self.nextSection() != "END" and self.sections.sections[self.nextSection()].identify(line):
#we found the next identifier but haven't found a description
#yet for this section
return f"Section {self.data.identifier} is missing a description"
#we found a section other than the next section
return f"Section {self.data.identifier} should be followed by section {self.data.nextSection}"
#section identifier has not been found yet
sectionFound = self.sections.getSectionName(line)
if sectionFound is None:
print("ERROR: none found even though validateOthers returned True")
exit(1)
elif sectionFound == self.data.prevSection:
#we have not found the current section identifier but we found the
#previous section identifier again
return f"Duplicate {self.data.prevSection} section found"
if self.data.prevSection == "START":
return f"Section {self.data.identifier} should be the first section"
if sectionFound == self.data.nextSection:
return f"Missing section {self.data.identifier} between {self.data.prevSection} and {self.data.nextSection}"
return f"Section {self.data.identifier} was expected after section {self.data.prevSection}"
#Decides the outcome state by processing line
def validateLine(self,line,lineno):
if self.data.identify(line):
#we have found the identifier so we should decide what to do
return self.processIdentify(line,lineno)
elif self.sections.validateOthers(line, self.data.name):
#we have found the identifier for another section
#figure out what the error is
self.error(line,lineno,self.makeValidateOthersErrorMessage(line))
return Outcomes.ERROR
elif self.validateAfter(line):
#the pr author has added new text to this section so we consider it
#to be filled out
if self.nextSection() == "END":
#if next section is "END" then there are no more sections
#to process
return Outcomes.SUCCESS
return Outcomes.NEXTSECTION
return Outcomes.CONTINUE
#We do not check this section for data
#currently just used for the checklist where they just need to check boxes
class NoDataSection(ParseSection):
def __init__(self, data: ParseSectionData, sections: ParseSections, debug=False):
super().__init__(data, sections, debug)
#After finding the identifier we don't need to look for anything else so we
#can just go to the next section or terminate if this is the last
def processIdentify(self, line, lineno):
o = super().processIdentify(line, lineno)
if o == Outcomes.CONTINUE:
if self.nextSection() == "END":
return Outcomes.SUCCESS
else:
return Outcomes.NEXTSECTION
return o
#Class that orchestrates the validation of the entire body
class ValidateBody:
def __init__(self, body: str, firstSection: str, sections: ParseSections, debug=False):
#the body of the pr post
self.body = body
#true if debug messages should be printed
self.debug = debug
#the name of the first section of the post
self.firstSection = firstSection
#the current section we are processing
self.section = None
#ParseSections which holds the data for all the sections
self.sections = sections
#Updates self.section to the next section
def nextSection(self):
#get the name of the section to parse
sectionName = self.firstSection
if self.section is not None:
sectionName = self.section.nextSection()
#get the data for that section
data = self.sections.get(sectionName)
if data is None:
print(f"ERROR with your parse section setup. Parse section {sectionName} not found")
exit(-3)
#create the section
if data.name == "CHECKLIST":
self.section = NoDataSection(data=data, sections=self.sections, debug=self.debug)
else:
self.section = ParseSection(data=data, sections=self.sections, debug=self.debug)
#Returns true if the body complies with the validation rules else false
def validate(self, pr_title=None):
#instantiate self.section since it starts null
self.nextSection()
#validate each line
for lineno, line in enumerate(self.body.splitlines(), 1):
#ignore empty lines
if len(line) == 0:
continue
#run the parse section validation
o = self.section.validateLine(line, lineno)
#decide what to do based on outcome
if o == Outcomes.ERROR:
return False
elif o == Outcomes.SUCCESS:
return True
elif o == Outcomes.NEXTSECTION:
self.nextSection()
#if we get through all the lines without a success outcome, then the
#body does not comply
if self.section.data.nextSection == "END":
if self.section.found:
self.section.error("", "", f"Section {self.section.data.identifier} is missing a description")
return False
self.section.error("", "", f"Missing section {self.section.data.identifier} at the end")
return False
self.section.error("","", "Please make sure you have filled out the template correctly. You can find a blank template in /.github/PULL_REQUEST_TEMPLATE.md")
return False
#Generate the validator for the current template.
#needs to be manually updated
def make_default_validator(body, debug=False):
problemStatement = ParseSectionData("PROBLEM_STATEMENT",
"### Describe the issue this Pull Request addresses",
{"<!-- Either describe the issue inline here with motivation behind the changes",
" (or) link to an issue by including `Closes #<issue-number>` for context.",
" If this PR includes changes to the storage format, public APIs,",
" or has breaking changes, use `!` (e.g., feat!: ...) -->"})
changelogs = ParseSectionData("CHANGE_LOGS",
"### Summary and Changelog",
{"<!-- Short, plain-English summary of what users gain or what changed in behavior.",
" Followed by a detailed log of all the changes. Highlight if any code was copied. -->"})
impact = ParseSectionData("IMPACT",
"### Impact",
{"<!-- Describe any public API or user-facing feature change or any performance impact. -->"})
risklevel = RiskLevelData("RISK_LEVEL",
"### Risk Level",
{"<!-- Accepted values: none, low, medium or high. Other than `none`, explain the risk.",
" If medium or high, explain what verification was done to mitigate the risks. -->"})
docsUpdate = ParseSectionData("DOCUMENTATION_UPDATE",
"### Documentation Update",
{"<!-- Describe any necessary documentation update if there is any new feature, config, or user-facing change. If not, put \"none\".",
"",
"- The config description must be updated if new configs are added or the default value of the configs are changed.",
"- Any new feature or user-facing change requires updating the Hudi website. Please follow the",
" [instruction](https://hudi.apache.org/contribute/developer-setup#website) to make changes to the website. -->"})
checklist = ParseSectionData("CHECKLIST",
"### Contributor's checklist",
{})
parseSections = ParseSections([problemStatement, changelogs, impact, risklevel, docsUpdate, checklist])
return ValidateBody(body, "PROBLEM_STATEMENT", parseSections, debug)
#takes a list of strings and returns a string of those lines separated by \n
def joinLines(lines):
return "\n".join(lines)
#runs a test for parsing the body
# PARAMS
# name: str - the name of the test
# body: str - the body to parse
# isTrue: bool - True if the body complies with our validation rules
# debug: bool - True if we want to print debug information
# title: str - the PR title (optional, for fix PR validation)
def run_test(name: str, body: str, isTrue: bool, debug: bool, title: str = None):
validator = make_default_validator(body, debug)
if isTrue != validator.validate(title):
print(f"{name} - FAILED")
return False
print(f"{name} - PASSED")
return True
# Given a list of sections which are lists of strings, it combines them into one
# giant string that is the body to be parsed
def build_body(sections):
res = ""
for s in sections:
res += joinLines(s) + "\n"
return res
# Tests for validating the body of a pr. Returns true if all tests pass
def test_body():
DEBUG_MESSAGES = False
#Create sections that we will combine to create bodies to test validation on
template_problem_statement = [
"### Describe the issue this Pull Request addresses",
"",
"<!-- Either describe the issue inline here with motivation behind the changes",
" (or) link to an issue by including `Closes #<issue-number>` for context.",
" If this PR includes changes to the storage format, public APIs,",
" or has breaking changes, use `!` (e.g., feat!: ...) -->",
""
]
good_problem_statement = template_problem_statement.copy()
good_problem_statement[1] = "problem statement description"
template_changelogs = [
"### Summary and Changelog",
"",
"<!-- Short, plain-English summary of what users gain or what changed in behavior.",
" Followed by a detailed log of all the changes. Highlight if any code was copied. -->",
""
]
good_changelogs = template_changelogs.copy()
good_changelogs[1] = "changelogs description"
template_impact = [
"### Impact",
"",
"<!-- Describe any public API or user-facing feature change or any performance impact. -->",
""
]
good_impact = template_impact.copy()
good_impact[1] = "impact description"
template_risklevel = [
"### Risk Level",
"",
"<!-- Accepted values: none, low, medium or high. Other than `none`, explain the risk.",
" If medium or high, explain what verification was done to mitigate the risks. -->",
""
]
good_risklevel = template_risklevel.copy()
good_risklevel[1] = "none"
template_docs_update = [
"### Documentation Update",
"",
"<!-- Describe any necessary documentation update if there is any new feature, config, or user-facing change. If not, put \"none\".",
"",
"- The config description must be updated if new configs are added or the default value of the configs are changed.",
"- Any new feature or user-facing change requires updating the Hudi website. Please follow the",
" [instruction](https://hudi.apache.org/contribute/developer-setup#website) to make changes to the website. -->",
""
]
good_docs_update = template_docs_update.copy()
good_docs_update[1] = "update docs"
template_checklist = [
"### Contributor's checklist",
"",
"- [ ] Read through [contributor's guide](https://hudi.apache.org/contribute/how-to-contribute)",
"- [ ] Enough context is provided in the sections above",
"- [ ] Adequate tests were added if applicable"
]
# list of sections that when combined from a valid body
good_sections = [good_problem_statement, good_changelogs, good_impact, good_risklevel, good_docs_update,
template_checklist]
# list of sections that when combined from the template
template_sections = [template_problem_statement, template_changelogs, template_impact, template_risklevel,
template_docs_update, template_checklist]
tests_passed = True
#Test section not filled out
#no need to test checklist section
for i in range(len(good_sections)-1):
test_sections = []
for j in range(len(good_sections)):
if j != i:
test_sections.append(good_sections[j].copy())
else:
test_sections.append(template_sections[j].copy())
tests_passed = run_test(f"template section not filled out: {i}", build_body(test_sections), False, DEBUG_MESSAGES) and tests_passed
#Test duplicate section
for i in range(len(good_sections)-1):
test_sections = []
for j in range(len(good_sections)):
test_sections.append(good_sections[j].copy())
if j == i:
test_sections.append(good_sections[j].copy())
tests_passed = run_test(f"duplicate section: {i}", build_body(test_sections), False, DEBUG_MESSAGES) and tests_passed
#Test out of order section
for i in range(len(good_sections)-1):
test_sections = []
for j in range(len(good_sections)):
test_sections.append(good_sections[j].copy())
for k in range(i+1,len(good_sections)):
test_sections[i], test_sections[k] = test_sections[k],test_sections[i]
tests_passed = run_test(f"Swapped sections: {i}, {k}", build_body(test_sections), False, DEBUG_MESSAGES) and tests_passed
#Test missing section
for i in range(len(good_sections)):
test_sections = []
for j in range(len(good_sections)):
if i != j:
test_sections.append(good_sections[j].copy())
tests_passed = run_test(f"Missing Section: {i}", build_body(test_sections), False, DEBUG_MESSAGES) and tests_passed
#Test good body:
tests_passed = run_test("good documentation", build_body(good_sections), True, DEBUG_MESSAGES) and tests_passed
# Test fix PR issue link requirement
fix_changelogs_with_issue = good_changelogs.copy()
fix_changelogs_with_issue[1] = "Fixed bug. Fixes #123"
fix_sections_with_issue = [good_problem_statement, fix_changelogs_with_issue, good_impact, good_risklevel,
good_docs_update,
template_checklist]
tests_passed = run_test("fix PR with issue link", build_body(fix_sections_with_issue), True,
DEBUG_MESSAGES,
"fix: fix bug") and tests_passed
tests_passed = run_test("fix PR with scope and issue link", build_body(fix_sections_with_issue), True,
DEBUG_MESSAGES,
"fix(index): fix bug") and tests_passed
fix_changelogs_with_github_url = good_changelogs.copy()
fix_changelogs_with_github_url[1] = "Fixed bug. See https://github.com/apache/hudi/issues/123"
fix_sections_with_github_url = [good_problem_statement, fix_changelogs_with_github_url, good_impact, good_risklevel,
good_docs_update,
template_checklist]
tests_passed = run_test("fix PR with GitHub URL", build_body(fix_sections_with_github_url), True,
DEBUG_MESSAGES,
"fix: fix bug") and tests_passed
fix_changelogs_without_issue = good_changelogs.copy()
fix_changelogs_without_issue[1] = "Fixed bug without issue reference"
fix_sections_without_issue = [good_problem_statement, fix_changelogs_without_issue, good_impact, good_risklevel,
good_docs_update,
template_checklist]
tests_passed = run_test("fix PR without issue link", build_body(fix_sections_without_issue), True,
DEBUG_MESSAGES,
"fix: fix bug") and tests_passed
# Non-fix PRs should not require issue links
tests_passed = run_test("feat PR without issue link", build_body(good_sections), True, DEBUG_MESSAGES,
"feat: new feature") and tests_passed
print("*****")
if tests_passed:
print("All body tests passed")
else:
print("Some body tests failed")
print("*****")
return tests_passed
if __name__ == '__main__':
if len(sys.argv) > 1:
body_tests = test_body()
if body_tests:
exit(0)
else:
exit(-1)
title = os.getenv("REQUEST_TITLE")
body = os.getenv("REQUEST_BODY")
if title is None:
print("no title")
exit(-1)
if body is None:
print("no pr body")
exit(-1)
validator = make_default_validator(body, True)
if not validator.validate(title):
exit(-1)
exit(0)