From 8c9fb7c083e2a325839c931ac8d2c2d1aa4cfa30 Mon Sep 17 00:00:00 2001 From: smileknife Date: Sat, 17 Oct 2020 01:49:54 +0800 Subject: [PATCH] [review_tool]add new features and make some improvements Signed-off-by: smileknife --- advisors/helper/reviewer_checklist.yaml | 61 ++-- advisors/review_tool.py | 362 ++++++++++++++++++++---- 2 files changed, 351 insertions(+), 72 deletions(-) diff --git a/advisors/helper/reviewer_checklist.yaml b/advisors/helper/reviewer_checklist.yaml index acb282f4..d7351f3a 100644 --- a/advisors/helper/reviewer_checklist.yaml +++ b/advisors/helper/reviewer_checklist.yaml @@ -3,27 +3,27 @@ basic: - name: PR-title-check condition: null - claim: PR的标题是否清晰易懂? + claim: PR的标题清晰易懂 explain: 提交标题应该一句话说明本提交实现的内容。 - name: PR-content-check condition: null - claim: PR的内容描述是否详细具体? + claim: PR的内容描述详细具体 explain: 提交的描述应该用一段话说明本提交的背景和实现原理。 - name: PR-consistency-check condition: null - claim: PR和实际代码修改是否一致? + claim: PR和实际代码修改和内容描述一致 explain: 提交的说明文字应该和实际代码修改内容保持一致。 - name: PR-standard-check condition: null - claim: PR是否符合gitee的规范检查要求? + claim: PR符合gitee的规范检查要求 explain: 码云对提交的缺陷扫描、规范扫描告警每一条都需要确认。 - name: PR-one-submission condition: null - claim: PR中是否只有一次提交? + claim: 建议PR中只有一次提交 explain: 如果PR中包含多次提交,建议整合成一个,保持提交记录整洁。 - name: PR-temp-check @@ -34,19 +34,24 @@ basic: - name: static-check condition: code-modified - claim: 新增代码是否符合规范要求? + claim: 新增代码符合规范要求 explain: 修改涉及代码使用 {lang} 语言开发,建议使用 {checker} 检查并清零告警。 OpenSourceCompliance: - - name: license-check + name: license-check-001 condition: new-file-add - claim: 新增代码文件起始是否含有License信息? + claim: 新增代码文件起始包含License信息 explain: 代码文件开头需要有Copyright和License信息,所使用的License需和本项目使用的License兼容。 + - + name: license-check-002 + condition: license-change + claim: 新License与openEuler兼容 + explain: spec中License变化,需确保新的License授权与openEuler兼容。 SecurityPrivacy: - name: sensitive-info-check condition: code-modified - claim: 新增代码是否包含密码、口令、token、密钥等敏感数据? + claim: 新增代码不包含密码、口令、token、密钥等敏感数据 explain: 提交的代码不应包含密码等敏感数据。 customization: community: @@ -60,56 +65,64 @@ customization: claim: "PR必须通过CI检查" explain: "sanity_check.py 在这个 PR 中发现错误。" success: - claim: "是否所有变更的代码仓都被恰当的 SIG 管理?" + claim: "所有变更的代码仓都被恰当的 SIG 管理" explain: "代码仓应当由有能力且有意愿的SIG管理,同一类的软件尽量归属同一个SIG。" lgtm-chk: - claim: "{sig}的维护者是否同意变更?" + claim: "{sig}的维护者同意变更" explain: "需要 {owners} 中至少一人在PR的review中留下 \"/lgtm\" 表示确认。" dlt-chk: - claim: "是否确认删除 {repo} ?" + claim: "已确认删除 {repo} " explain: "为了保证兼容性,技术委员会建议将 {repo} 移动到 sig-recycle 管理一段时间,给用户切换时间。" - name: maintainer-add-explain condition: maintainer-change - claim: "如果新增维护者,有没有对他/她能力的客观说明?" + claim: "如果新增维护者,已对他/她的能力做客观说明" explain: "PR提交者需要提出相应的举证说明维护者候选人的技术能力与社区活跃程度。" - name: maintainer-change-lgtm condition: maintainer-change - claim: "{sig} 中的其他维护者是否同意增加/删除维护者?" + claim: "{sig} 中的其他维护者已同意增加/删除维护者" explain: "需要 {owners} 中至少两人代表在此 PR 的 review 中留下 \"/lgtm\" 表示确认同意接纳或者移除维护者。" - name: sig-update-lgtm condition: sig-update - claim: "{sig} 是否同意更新 SIG 信息?" + claim: "{sig} 已同意更新 SIG 信息" explain: "{owners} 中是否有代表通过在此 PR 的 review 中留下 \"/lgtm\" 表示同意。" - name: repo-info-check condition: repo-introduce - claim: "是否正确提供代码仓上游信息?" + claim: "已正确提供代码仓上游信息" explain: "使用软件社区的正式官方网址,或者无单独正式官网的情况下,提供主流代码托管商上面对应的项目网址(如github)。不可使用maven等托管库作为官方网址。" - name: repo-name-check condition: repo-introduce - claim: "代码仓名称是否规范?" + claim: "代码仓名称规范" explain: "代码仓名称必须和上游官网/社区保持一致,不可随意命名。不允许以软件包中的子模块作为代码仓名。当软件是某个语言的开发库时,可以使用前缀予以规范化管理(如 python-,perl-等)。" - name: upstream-lifecycle-check condition: repo-introduce - claim: "新引入代码仓的上游是否仍处于生命周期内?" + claim: "新引入代码仓的上游仍处于生命周期内" explain: "已经衰退的软件,生命周期结束,社区停运,或超过5年没有任何更新的软件,原则上不引入openEuler。如果提交者认为非常有必要,可以考虑在 openeuler 中 fork 以后独立维护。" - name: repo-license-check condition: repo-introduce - claim: "新引入的代码仓的 License 授权是否都与 openEuler 兼容?" + claim: "新引入的代码仓的 License 授权与 openEuler 兼容" explain: "openEuler只能接纳可以被允许集成的软件。" - name: repo-copyright-check condition: repo-introduce - claim: "新引入的代码仓的是否有 Copyright 信息?" + claim: "新引入的代码仓有 Copyright 信息" explain: "我们期望软件有明确的 Copyright 信息。" - - name: encrypt-algorithm-check - condition: repo-introduce - claim: "新引入代码仓是否不涉及从美国代码托管网站引入加解密算法的实现?" - explain: "openEuler作为开源项目整体已经备案,无论是否涉及都不需要额外处理。" + name: repo-ownership-check + condition: repo-ownership-change + claim: "{repos} 从 **{sig1}** 移交到 **{sig2}** ,需要双方sig中至少各有一位owner同意" + explain: "需要 **{sig1}** 的 {owners1} 及 **{sig2}** 的 {owners2} 各有至少一名owner代表在此 PR 中留下 \"/lgtm\" 表示同意移交。" + to_recycle: + claim: "{repos} 从 **{sig1}** 移交到 **{sig2}** ,需要**sig-release-management**有代表同意" + explain: "{repos}存在除master外的其它保护分支,需要**sig-release-management**的 {owners} 中至少一名owner代表在此 PR 中留下 \"/lgtm\" 表示同意。" + - + name: branch-check + condition: new-branch-add + claim: "代码仓 {repos} 增加非 master 分支,要得到Release Management团队同意" + explain: "代码仓的非master分支会被发布版本构建使用,因此需要 {owners} 中至少一人在PR的review中留下 \"/lgtm\" 表示确认。" diff --git a/advisors/review_tool.py b/advisors/review_tool.py index 9e983952..3cf91a38 100755 --- a/advisors/review_tool.py +++ b/advisors/review_tool.py @@ -19,14 +19,17 @@ import sys import argparse import subprocess import shutil +import urllib import yaml import gitee CHK_TABLE_HEADER = """ **以下为 openEuler-Advisor 的 review_tool 生成审视要求清单** +如果您是第一次给 openEuler 提交 PR,建议您花一点时间阅读 [Gitee工作流说明](https://gitee.com/openeuler/community/blob/master/zh/contributors/Gitee-workflow.md) + **[Y]** 审视者确认符合要求 | **[N]** 审视者认为不符合要求 | **[NA]** 审视者认为与本PR无关 | **[?]** 审视者无法确认是否符合要求 | **[ ]** 审视过程中 |审视项编号|审视类别|审视要求|审视要求说明|审视结果| -|:--:|:--|:--|:--|:--:| +|:--:|:--:|:--|:--|:--:| """ SANITY_CHK_CMD = "python3 zh/technical-committee/governance/sanity_check.py ." @@ -38,25 +41,27 @@ categorizer = {'PRSubmissionSPEC':'PR提交规范', 'OpenSourceCompliance':'开源合规性', 'SecurityPrivacy':'安全及隐私', 'customization':'定制项'} - +SIGS_URL = "https://gitee.com/openeuler/community/raw/master/sig/sigs.yaml" +headers = {'User-Agent':'Mozilla/5.0 (Windows NT 6.1; WOW 64; rv:23.0) Gecko/20100101 Firefox/23.0'} __NUMBER = 0 -def check_new_code(): +def check_new_code(branch): """ Check if new code file has been introduced """ - lst_files = subprocess.getoutput("git diff --name-only --diff-filter=A remotes/origin/master..") + lst_files = subprocess.getoutput( + "git diff --name-only --diff-filter=A remotes/origin/{}..".format(branch)) return bool(lst_files.splitlines()) -def check_code_lang(): +def check_code_lang(branch): """ Check this PR code programming language """ langs = [] checkers = [] - lst_files = subprocess.getoutput("git diff --name-only remotes/origin/master..") + lst_files = subprocess.getoutput("git diff --name-only remotes/origin/{}..".format(branch)) for item in lst_files.splitlines(): if item.endswith(".py") and "Python" not in langs: langs.append("Python") @@ -76,13 +81,27 @@ def check_code_lang(): return langs, checkers +def check_license_change(branch): + """ + check if spec be modified + """ + lst_files = subprocess.getoutput( + "git diff --name-only --diff-filter=M remotes/origin/{}..".format(branch)) + for item in lst_files.splitlines(): + if item.endswith(".spec"): + lst_lines = subprocess.getoutput( + "git diff remotes/origin/{}.. ".format(branch) + item + " | grep '^+License'") + return bool(lst_lines.splitlines()) + return False + + def load_checklist(chklist_path): """ load configuration """ try: - with open(chklist_path, 'r', encoding = 'utf-8') as f: - return yaml.load(f.read(), Loader = yaml.Loader) + with open(chklist_path, 'r', encoding = 'utf-8') as file_descriptor: + return yaml.load(file_descriptor.read(), Loader = yaml.Loader) except OSError as reason: print("Load yaml failed!" + str(reason)) return None @@ -102,12 +121,191 @@ def check_repository_changes(): """ check if src-openeuler.yaml has been changed """ - lst_files = subprocess.getoutput("git diff --name-only remotes/origin/master..") - for item in lst_files.splitlines(): - if item.startswith("repository/src-openeuler.yaml"): + lst_files = subprocess.getoutput("git diff remotes/origin/master.. \ + repository/src-openeuler.yaml | grep '^+- name' | awk '{print $NF}'") + return bool(lst_files.splitlines()) + + +def load_sigs(sigs_file=""): + """ + Load sigs yaml + """ + if sigs_file: + file_descriptor = open(sigs_file, 'r', encoding = 'utf-8') + sigs = yaml.load(file_descriptor.read(), Loader=yaml.Loader) + else: + req = urllib.request.Request(url=SIGS_URL, headers=headers) + res = urllib.request.urlopen(req) + sigs = yaml.load(res.read().decode("utf-8"), Loader=yaml.Loader) + return sigs['sigs'] + + +def get_repo_sig_ownership(repo, sigs): + """ + Get repository ownership + """ + for sig in sigs: + if repo in sig['repositories']: + return sig['name'] + return "" + + +def load_repositories(repos_file): + """ + Load repository yaml + """ + if repos_file: + file_descriptor = open(repos_file, 'r', encoding = 'utf-8') + repos = yaml.load(file_descriptor.read(), Loader=yaml.Loader) + return repos['repositories'] + return None + + +def is_exist_protected_branch_exclude_master(repo_name, repos): + """ + check there exist other protected branches exclude master + """ + for repo in repos: + if repo_name == repo['name']: + if len(repo['protected_branches']) == 1: + return False return True return False + +def load_sig_owners(sig_name): + """ + Load owners specified sig + """ + owners = [] + owners_file = "sig/{}/OWNERS".format(sig_name) + with open(owners_file, 'r') as file_descriptor: + lines = file_descriptor.readlines() + for line in lines: + if line.strip().startswith('-'): + owner = line.replace('- ', '@').strip() + owners.append(owner) + return owners + + +def get_repo_changes(): + """ + find repositories that ownership changes + """ + dlt_repos = [] + add_repos = [] + + dlt_lines = subprocess.getoutput( + "git diff remotes/origin/master.. sig/sigs.yaml | grep '^-[ ][ ]-' | awk '{print $NF}'") + for dlt_line in dlt_lines.splitlines(): + if dlt_line.startswith("openeuler") or dlt_line.startswith("src-openeuler"): + dlt_repos.append(dlt_line.strip()) + + add_lines = subprocess.getoutput( + "git diff remotes/origin/master.. sig/sigs.yaml | grep '^+[ ][ ]-' | awk '{print $NF}'") + for add_line in add_lines.splitlines(): + if add_line.startswith("openeuler") or add_line.startswith("src-openeuler"): + add_repos.append(add_line.strip()) + + repo_changes = {} + cur_sigs = load_sigs() + tobe_sigs = load_sigs("sig/sigs.yaml") + for dlt_repo in dlt_repos: + if dlt_repo in add_repos: + cur_sig = get_repo_sig_ownership(dlt_repo, cur_sigs) + tobe_sig = get_repo_sig_ownership(dlt_repo, tobe_sigs) + if cur_sig != tobe_sig: + repo = dlt_repo + chg_tuple = (cur_sig, tobe_sig) + res = repo_changes.get(chg_tuple, None) + if res: + repo_changes[chg_tuple].append(repo) + else: + repo_changes[chg_tuple] = [repo] + return repo_changes + + +def check_repository_ownership_changes(info): + """ + Check if repository ownership changes.Example, from sigA to sigB + """ + review_body = "" + + rls_mgmt_owners = load_sig_owners("sig-release-management") + oe_mgmt_repos = load_repositories("repository/openeuler.yaml") + src_oe_mgmt_repos = load_repositories("repository/src-openeuler.yaml") + repo_changes = get_repo_changes() + for sig_changes, repos in repo_changes.items(): + sig1_owners = load_sig_owners(sig_changes[0]) + sig2_owners = load_sig_owners(sig_changes[1]) + if sig_changes[1] == 'sig-recycle': + repos_need_lgtm = [] + for repo in repos: + if repo.startswith('openeuler/'): + mgmt_repos = oe_mgmt_repos + elif repo.startswith('src-openeuler/'): + mgmt_repos = src_oe_mgmt_repos + else: + print("ERROR: repo:%s error" % repo) + mgmt_repos = None + repo_name = repo.split('/')[1] + if is_exist_protected_branch_exclude_master(repo_name, mgmt_repos): + repos_need_lgtm.append(repo) + + item = join_check_item(categorizer['customization'], + info['claim'], info['explain']) + review_body += item.format(repos=" ".join(repos), sig1=sig_changes[0], sig2=sig_changes[1], + owners1=" ".join(sig1_owners), owners2=" ".join(sig2_owners)) + if repos_need_lgtm: + item = join_check_item(categorizer['customization'], + info['to_recycle']['claim'], info['to_recycle']['explain']) + review_body += item.format(repos=" ".join(repos_need_lgtm), sig1=sig_changes[0], + sig2=sig_changes[1], owners=" ".join(rls_mgmt_owners)) + repos_need_lgtm.clear() + return review_body + + +def get_repo(branch, repos): + """ + get repo of specific branch + """ + for repo in repos: + if branch in repo['protected_branches']: + return repo['name'] + return None + + +def check_branch_add(info): + """ + check if new branch add in repo + """ + review_body = "" + repos = [] + add_lines = subprocess.getoutput( + "git diff remotes/origin/master.. \ + repository/openeuler.yaml | grep '^+[ ][ ]-' | awk '{print $NF}'") + openeuler_repos = load_repositories("repository/openeuler.yaml") + for add_line in add_lines.splitlines(): + if add_line.strip() != "master": + repo = get_repo(add_line.strip(), openeuler_repos) + if repo: + repos.append(repo) + add_lines = subprocess.getoutput( + "git diff remotes/origin/master.. \ + repository/src-openeuler.yaml | grep '^+[ ][ ]-' | awk '{print $NF}'") + src_openeuler_repos = load_repositories("repository/src-openeuler.yaml") + for add_line in add_lines.splitlines(): + if add_line.strip() != "master": + repo = get_repo(add_line.strip(), src_openeuler_repos) + if repo: + repos.append(repo) + if repos: + owners = load_sig_owners("sig-release-management") + item = join_check_item(categorizer['customization'], info['claim'], info['explain']) + review_body += item.format(repos=" ".join(repos), owners=" ".join(owners)) + return review_body + + def check_repository_mgmt_changes(sigs, info): """ Return additional checking item if management of repository has been changed @@ -141,6 +339,8 @@ def check_repository_mgmt_changes(sigs, info): if need_additional_review: review_body += join_check_item(categorizer['customization'], info['success']['claim'], info['success']['explain']) + review_body += join_check_item(categorizer['customization'], + info['success']['claim'], info['success']['explain']) return review_body @@ -193,7 +393,7 @@ def check_sig_information_changes(): return sigs -def basic_review(cklist): +def basic_review(cklist, branch): """ basic review body """ @@ -202,23 +402,23 @@ def basic_review(cklist): for value2 in value1: if value2["condition"] == "code-modified": if value2["name"] == "static-check": - langs, checkers = check_code_lang() + langs, checkers = check_code_lang(branch) if not langs: continue item = join_check_item(categorizer[key1], value2['claim'], value2['explain']) item = item.format(lang="/".join(langs), checker="/".join(checkers)) review_body += item + continue elif value2["condition"] == "new-file-add": - if not check_new_code(): + if not check_new_code(branch): continue - item = join_check_item(categorizer[key1], - value2['claim'], value2['explain']) - review_body += item - else: - item = join_check_item(categorizer[key1], - value2['claim'], value2['explain']) - review_body += item + elif value2['condition'] == 'license-change': + if not check_license_change(branch): + continue + item = join_check_item(categorizer[key1], + value2['claim'], value2['explain']) + review_body += item return review_body @@ -270,10 +470,15 @@ def community_review(custom_items): elif cstm_item['condition'] == 'sanity_check': add_review = check_repository_mgmt_changes(info_sigs, cstm_item) review_body += add_review + elif cstm_item['condition'] == 'repo-ownership-change': + review_body += check_repository_ownership_changes(cstm_item) + elif cstm_item['condition'] == 'new-branch-add': + review_body += check_branch_add(cstm_item) + return review_body -def review(pull_request, repo_name, chklist_path): +def review(pull_request, repo_name, chklist_path, branch): """ Return check list of this PR """ @@ -283,7 +488,7 @@ def review(pull_request, repo_name, chklist_path): review_body = CHK_TABLE_HEADER cklist = load_checklist(chklist_path) - review_body += basic_review(cklist) + review_body += basic_review(cklist, branch) custom_items = cklist['customization'].get(repo_name, None) if custom_items: if repo_name == "community": @@ -296,68 +501,129 @@ def review(pull_request, repo_name, chklist_path): return review_body -def main(): +def check_pr_url(url): """ - Main entrance of the functionality + check whether the URL of Pull Request is valid """ - cur_path = os.path.dirname(os.path.abspath(sys.argv[0])) + if url: + pattern = re.compile(r'https://gitee.com/(open_euler/dashboard/projects/)?' + + r'(openeuler|src-openeuler)/([A-Za-z0-9-_]*)/pulls/(\d+$)') + return pattern.match(url) + return None + +def extract_params(args): + """ + check and extract parameters we need + """ + if args.url and len(args.url) > 0: + res = check_pr_url(args.url) + if res: + group = res.group(2) + repo_name = res.group(3) + pull_id = res.group(4) + return (group, repo_name, pull_id) + print("ERROR: URL is wrong, please check!") + return () + if args.repo and args.pull and len(args.repo) > 0 and len(args.pull) > 0: + group = args.repo.split('/')[0] + repo_name = args.repo.split('/')[1] + pull_id = args.pull + return (group, repo_name, pull_id) + print("WARNING: please specify the URL of PR or repository name and PR's ID.\ + \nDetails use -h/--help option.") + return () + + +def args_parser(cur_path): + """ + arguments parser + """ pars = argparse.ArgumentParser() - pars.add_argument("-n", "--repo", type=str, help="Repository name that include group", - required=True, default=False) - pars.add_argument("-p", "--pull", type=str, help="Number ID of Pull Request", required=True) + pars.add_argument("-n", "--repo", type=str, help="Repository name that include group") + pars.add_argument("-p", "--pull", type=str, help="Number ID of Pull Request") + pars.add_argument("-u", "--url", type=str, help="URL of Pull Request") pars.add_argument("-r", "--reuse", help="Reuse current local git dirctory", action="store_true") - pars.add_argument("-w", "--workdir", type=str, help="Work directory", default=cur_path) + pars.add_argument("-w", "--workdir", type=str, + help="Work directory.Default is current directory.", default=cur_path) - args = pars.parse_args() + return pars.parse_args() - user_gitee = gitee.Gitee() - chklist_path = os.path.join(cur_path, CHECKLIST) + +def prepare(args, group, repo_name, pull_id, branch): + """ + prepare local reposity base and PR branch + """ work_dir = os.path.realpath(args.workdir) if not os.path.exists(work_dir): os.makedirs(work_dir) - gitee_url = "git@gitee.com:{repo}".format(repo=args.repo) - group = args.repo.split('/')[0] - repo_name = args.repo.split('/')[1] + repo = group + "/" + repo_name + gitee_url = "git@gitee.com:{repo}".format(repo=repo) local_path = os.path.join(work_dir, repo_name) if not args.reuse: if os.path.exists(local_path): shutil.rmtree(local_path) - subprocess.call(["git", "clone", gitee_url, local_path]) + ret_code = subprocess.call(["git", "clone", gitee_url, local_path]) + if ret_code != 0: + sys.exit(1) if not os.path.exists(local_path): print("%s not exist, can not use option -r" % local_path) sys.exit(1) os.chdir(local_path) - ret_code = subprocess.call(["git", "checkout", "master"]) + ret_code = subprocess.call(["git", "checkout", branch]) if ret_code != 0: - print("Failed to checkout master branch") + print("Failed to checkout %s branch" % branch) sys.exit(1) - subprocess.call(["git", "branch", "-D", "pr_{n}".format(n=args.pull)]) + subprocess.call(["git", "branch", "-D", "pr_{n}".format(n=pull_id)]) # It's OK to ignore the result ret_code = subprocess.call(["git", "pull"]) if ret_code != 0: - print("Failed to update to latest commit in master branch") + print("Failed to update to latest commit in %s branch" % branch) sys.exit(1) ret_code = subprocess.call(["git", "fetch", gitee_url, - "pull/{n}/head:pr_{n}".format(n=args.pull)]) + "pull/{n}/head:pr_{n}".format(n=pull_id)]) if ret_code != 0: print("Failed to fetch PR") sys.exit(1) - print("You are reviewing pull {n}".format(n=args.pull)) + print("You are reviewing PR:{n}".format(n=pull_id)) + + subprocess.call(["git", "checkout", "pr_{n}".format(n=pull_id)]) - subprocess.call(["git", "checkout", "pr_{n}".format(n=args.pull)]) + subprocess.call(["git", "merge", "--no-edit", "remotes/origin/" + branch]) - subprocess.call(["git", "merge", "--no-edit", "remotes/origin/master"]) - pull_request = user_gitee.get_pr(repo_name, args.pull, group) - review_comment = review(pull_request, repo_name, chklist_path) +def main(): + """ + Main entrance of the functionality + """ + cur_path = os.path.dirname(os.path.abspath(sys.argv[0])) + args = args_parser(cur_path) + params = extract_params(args) + if not params: + sys.exit(1) + group = params[0] + repo_name = params[1] + pull_id = params[2] + + user_gitee = gitee.Gitee() + pull_request = user_gitee.get_pr(repo_name, pull_id, group) + if not pull_request: + print("Failed to get PR:%s of repository:%s, make sure the PR is exist."\ + % (pull_id, repo_name)) + sys.exit(1) + branch = pull_request['base']['label'] + + prepare(args, group, repo_name, pull_id, branch) + + chklist_path = os.path.join(cur_path, CHECKLIST) + review_comment = review(pull_request, repo_name, chklist_path, branch) - user_gitee.create_pr_comment(repo_name, args.pull, review_comment, group) + user_gitee.create_pr_comment(repo_name, pull_id, review_comment, group) if __name__ == "__main__": main() -- Gitee