| #!/usr/bin/env python |
| |
| # Copyright (c) 2020, NVIDIA CORPORATION. |
| # Copyright (c) 2020-2021, Intel CORPORATION. |
| # |
| # Licensed 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. |
| |
| """A simple changelog generator |
| |
| This tool takes list of release versions to generate `CHANGELOG.md`. |
| The changelog will include all merged PRs w/o `[bot]` postfix, |
| and issues that include the labels `bug`, `enhancement`, `performance`, |
| minus any issues with the labels `wontfix`, `invalid` or `duplicate`. |
| |
| For each project there should be an issue subsection for, |
| Features: all issues with label `enhancement` |
| Performance: all issues with label `performance` |
| Bugs fixed: all issues with label `bug` |
| |
| To deduplicate section, the priority should be `Bugs fixed > Performance > Features` |
| |
| |
| Release version from arguments will be used to map project name. |
| Mapping Pattern: |
| release -> project: Release {release} |
| e.g. |
| 0.1 -> project: Release 0.1 |
| 0.2 -> project: Release 0.2 |
| |
| Dependencies: |
| - requests |
| |
| Usage: |
| cd gluten/ |
| |
| # Python 3.x is required, generate changelog for Release 1.1.0 to ./CHANGELOG.md |
| dev/generate-changelog --token=<GITHUB_PERSONAL_ACCESS_TOKEN> --releases=1.1.0 --path=./CHANGELOG.md |
| |
| """ |
| import os |
| import sys |
| from argparse import ArgumentParser |
| from collections import OrderedDict |
| from datetime import date |
| |
| import requests |
| |
| # Constants |
| RELEASE = "Release" |
| PULL_REQUESTS = "pullRequests" |
| ISSUES = "issues" |
| # Subtitles |
| INVALID = 'Invalid' |
| BUGS_FIXED = 'Bugs Fixed' |
| PERFORMANCE = 'Performance' |
| FEATURES = 'Features' |
| PRS = 'PRs' |
| # Labels |
| LABEL_WONTFIX, LABEL_INVALID, LABEL_DUPLICATE = 'wontfix', 'invalid', 'duplicate' |
| LABEL_BUG = 'bug' |
| LABEL_PERFORMANCE = 'performance' |
| LABEL_FEATURE = 'enhancement' |
| # Queries |
| query_pr = """ |
| query ($after: String) { |
| repository(name: "gluten", owner: "oap-project") { |
| pullRequests(states: [MERGED], first: 100, after: $after) { |
| totalCount |
| nodes { |
| number |
| title |
| state |
| url |
| labels(first: 10) { |
| nodes { |
| name |
| } |
| } |
| projectCards(first: 10) { |
| nodes { |
| project { |
| name |
| } |
| column { |
| name |
| } |
| } |
| } |
| projectItems(first: 10) { |
| nodes { |
| project { |
| title |
| } |
| } |
| } |
| mergedAt |
| } |
| pageInfo { |
| hasNextPage |
| endCursor |
| } |
| } |
| } |
| } |
| """ |
| query_issue = """ |
| query ($after: String) { |
| repository(name: "gluten", owner: "oap-project") { |
| issues(states: [CLOSED], labels: ["enhancement", "performance", "bug"], first: 100, after: $after) { |
| totalCount |
| nodes { |
| number |
| title |
| state |
| url |
| labels(first: 10) { |
| nodes { |
| name |
| } |
| } |
| projectCards(first: 10) { |
| nodes { |
| project { |
| name |
| } |
| column { |
| name |
| } |
| } |
| } |
| projectItems(first: 10) { |
| nodes { |
| project { |
| title |
| } |
| } |
| } |
| closedAt |
| } |
| pageInfo { |
| hasNextPage |
| endCursor |
| } |
| } |
| } |
| } |
| """ |
| |
| |
| def process_changelog(resource_type: str, changelog: dict, releases: set, projects: set, no_project_prs: list, |
| token: str): |
| if resource_type == PULL_REQUESTS: |
| items = process_pr(releases=releases, token=token) |
| time_field = 'mergedAt' |
| elif resource_type == ISSUES: |
| items = process_issue(token=token) |
| time_field = 'closedAt' |
| else: |
| print(f"[process_changelog] Invalid type: {resource_type}") |
| sys.exit(1) |
| |
| for item in items: |
| if len(item['projectItems']['nodes']) == 0: |
| if resource_type == PULL_REQUESTS: |
| if '[bot]' in item['title']: # skip auto-gen PR, created by our github actions workflows |
| continue |
| no_project_prs.append(item) |
| continue |
| |
| project = item['projectItems']['nodes'][0]['project']['title'] |
| if not release_project(project, projects): |
| continue |
| |
| if project not in changelog: |
| changelog[project] = { |
| FEATURES: [], |
| PERFORMANCE: [], |
| BUGS_FIXED: [], |
| PRS: [], |
| } |
| |
| labels = set() |
| for label in item['labels']['nodes']: |
| labels.add(label['name']) |
| category = rules(labels) |
| if resource_type == ISSUES and category == INVALID: |
| continue |
| if resource_type == PULL_REQUESTS: |
| if '[bot]' in item['title']: # skip auto-gen PR, created by our github actions workflows |
| continue |
| category = PRS |
| |
| changelog[project][category].append({ |
| "number": item['number'], |
| "title": item['title'], |
| "url": item['url'], |
| "time": item[time_field], |
| }) |
| |
| |
| def process_pr(releases: set, token: str): |
| pr = [] |
| pr.extend(fetch(resource_type=PULL_REQUESTS, token=token, |
| variables={'project': f"{RELEASE} {rel}" for rel in releases})) |
| return pr |
| |
| |
| def process_issue(token: str): |
| return fetch(resource_type=ISSUES, token=token) |
| |
| |
| def fetch(resource_type: str, token: str, variables: dict = None): |
| items = [] |
| if resource_type == PULL_REQUESTS: |
| q = query_pr |
| elif resource_type == ISSUES: |
| q = query_issue |
| variables = {} |
| else: |
| return items |
| |
| has_next = True |
| while has_next: |
| res = post(query=q, token=token, variable=variables) |
| if res.status_code == 200: |
| d = res.json() |
| has_next = d['data']['repository'][resource_type]["pageInfo"]["hasNextPage"] |
| variables['after'] = d['data']['repository'][resource_type]["pageInfo"]["endCursor"] |
| items.extend(d['data']['repository'][resource_type]['nodes']) |
| else: |
| raise Exception("Query failed to run by returning code of {}. {}".format(res.status_code, q)) |
| return items |
| |
| |
| def post(query: str, token: str, variable: dict): |
| return requests.post('https://api.github.com/graphql', |
| json={'query': query, 'variables': variable}, |
| headers={"Authorization": f"token {token}"}) |
| |
| |
| def release_project(project_name: str, projects: set): |
| if project_name in projects: |
| return True |
| return False |
| |
| |
| def rules(labels: set): |
| if LABEL_WONTFIX in labels or LABEL_INVALID in labels or LABEL_DUPLICATE in labels: |
| return INVALID |
| if LABEL_BUG in labels: |
| return BUGS_FIXED |
| if LABEL_PERFORMANCE in labels: |
| return PERFORMANCE |
| if LABEL_FEATURE in labels: |
| return FEATURES |
| return INVALID |
| |
| |
| def form_changelog(path: str, changelog: dict): |
| sorted_dict = OrderedDict(sorted(changelog.items(), reverse=True)) |
| subsections = "" |
| for project_name, issues in sorted_dict.items(): |
| subsections += f"\n\n## {project_name}" |
| subsections += form_subsection(issues, FEATURES) |
| subsections += form_subsection(issues, PERFORMANCE) |
| subsections += form_subsection(issues, BUGS_FIXED) |
| subsections += form_subsection(issues, PRS) |
| print("Subsection Content =====") |
| print(f" {subsections} ") |
| markdown = f"""# Change log |
| Generated on {date.today()}{subsections} |
| """ |
| with open(path, "w") as file: |
| file.write(markdown) |
| |
| |
| def form_subsection(issues: dict, subtitle: str): |
| if len(issues[subtitle]) == 0: |
| return '' |
| subsection = f"\n\n### {subtitle}" |
| subsection += "\n|||\n|:---|:---|" |
| for issue in sorted(issues[subtitle], key=lambda x: x['time'], reverse=True): |
| subsection += f"\n|[#{issue['number']}]({issue['url']})|{issue['title']}|" |
| return subsection |
| |
| |
| def print_no_project_pr(no_project_prs: list): |
| if len(no_project_prs) != 0: |
| print("Merged Pull Requests w/o Project:") |
| for pr in no_project_prs: |
| print(f"#{pr['number']} {pr['title']} {pr['url']}") |
| |
| |
| def main(rels: str, path: str, token: str): |
| print('Generating changelog ...') |
| |
| try: |
| changelog = {} # changelog dict |
| releases = {x.strip() for x in rels.split(',')} |
| projects = {f"{RELEASE} {rel}" for rel in releases} |
| no_project_prs = [] # list of merge pr w/o project |
| |
| print('Processing issues ...') |
| process_changelog(resource_type=ISSUES, changelog=changelog, |
| releases=releases, projects=projects, |
| no_project_prs=no_project_prs, token=token) |
| print('Processing pull requests ...') |
| process_changelog(resource_type=PULL_REQUESTS, changelog=changelog, |
| releases=releases, projects=projects, |
| no_project_prs=no_project_prs, token=token) |
| # form doc |
| print('Processing changelog ...') |
| form_changelog(path=path, changelog=changelog) |
| except Exception as e: # pylint: disable=broad-except |
| print(e) |
| sys.exit(1) |
| |
| print('Done.') |
| # post action |
| print_no_project_pr(no_project_prs=no_project_prs) |
| |
| |
| if __name__ == '__main__': |
| parser = ArgumentParser(description="Changelog Generator") |
| parser.add_argument("--releases", help="list of release versions, separated by comma", |
| default="0.5.0") |
| parser.add_argument("--token", help="github token, will use GITHUB_TOKEN if empty", default='') |
| parser.add_argument("--path", help="path for generated changelog file", default='./CHANGELOG.md') |
| args = parser.parse_args() |
| |
| github_token = args.token if args.token else os.environ.get('GITHUB_TOKEN') |
| assert github_token, 'env GITHUB_TOKEN should not be empty' |
| |
| main(rels=args.releases, path=args.path, token=github_token) |