Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New version of propose_changelog.py #4908

Merged
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 110 additions & 168 deletions docs/propose_changelog.py
Original file line number Diff line number Diff line change
@@ -1,177 +1,119 @@
#!/usr/bin/env python
#
# Copyright 2017-2023 Axel Huebl
# Copyright 2024 Julian J. Lenz
#
# License: GPLv3+
#
# requirements:
# PyGithub
# curses-menu
# pyyaml
#
"""
propose_changelog.py

This little tool queries the Github API for merged pull requests corresponding to the given milestone and labelled by the label "changelog".
The obtained list is categorised and printed to stdout. Suggested usage is:

```bash
$ MILESTONE="0.8.0 / Next stable" # or whatever version you're interested in
$ GH_PTA="<your Github personal access token>"
chillenzer marked this conversation as resolved.
Show resolved Hide resolved
$ python propose_changelog.py "$MILESTONE" $GH_PTA > changelog.txt
# edit `changelog.txt` according to your needs
```

For a typical end user running this once or twice a day, the environment variable "GH_PTA" can be empty.
If you are running this frequently, e.g., during a debug session, you might want to
[acquire a personal acces token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
and store it in this environment variable. The most restricted one with public repository read access is sufficient.

For adjustments to the categorisation, you can simply change the global variable `CATEGORIES`.
"""

from github import Github
from cursesmenu import SelectionMenu

# config
# see: https://github.com/settings/tokens
g = Github("add token with public repo read rights here")
org = g.get_organization("ComputationalRadiationPhysics")
repo = org.get_repo("picongpu")
milestones = repo.get_milestones(sort="asc", state="all")

m_list = list(map(lambda m: m.title, milestones))
menu = SelectionMenu(m_list, "Select a Milestone")
menu.show()
menu.join()
m_sel = menu.selected_option

print(m_list[m_sel], milestones[m_sel].number)

# get pulls (all pulls are also issues but not vice versa)
issues = repo.get_issues(state="closed", sort="updated", direction="asc", milestone=milestones[m_sel])
# Use this in the future, but pagination seems broken in it:
# search_string = 'repo:ComputationalRadiationPhysics/picongpu ' +
# 'type:pr is:merged milestone:"'+milestones[m_sel].title+'"'
# print(search_string)
# issues = g.search_issues(search_string)


# categories
user = {"input": []}
bugs = {"core": [], "pmacc": [], "plugin": [], "tools": [], "other": []}
features = {"core": [], "pmacc": [], "plugin": [], "tools": [], "other": []}
refactoring = {"core": [], "pmacc": [], "plugin": [], "tools": [], "other": []}
misc = {"docs": [], "other": []}

for i in issues:
# skip issues, only pull requests
if i.pull_request is None:
continue

# filter out bugs that only appeared in development
pr_nr = i.number
pr_labels = i.labels
pr_labels_names = list(map(lambda x: x.name, pr_labels))
pr_url = "https://github.com/ComputationalRadiationPhysics/picongpu/pull/"
if "bug" in pr_labels_names:
if "affects latest release" not in pr_labels_names:
print("Filtering out development-only bug:")
print(" #" + str(pr_nr) + " " + i.title)
print(" " + pr_url + str(pr_nr))
continue

# filter out closed (unmerged) PRs
pr = repo.get_pull(pr_nr)
if not pr.merged:
print("Filtering out unmerged PR:")
print(" #" + str(pr_nr) + " " + i.title)
print(" " + pr_url + str(pr_nr))
continue

# sort by categories
pr_title = i.title
if "component: user input" in pr_labels_names:
user["input"].append(i.title + " #" + str(pr_nr))
if "affects latest release" in pr_labels_names:
if "component: core" in pr_labels_names:
bugs["core"].append(i.title + " #" + str(pr_nr))
elif "component: PMacc" in pr_labels_names:
bugs["pmacc"].append(i.title + " #" + str(pr_nr))
elif "component: plugin" in pr_labels_names:
bugs["plugin"].append(i.title + " #" + str(pr_nr))
elif "component: tools" in pr_labels_names:
bugs["tools"].append(i.title + " #" + str(pr_nr))
else:
bugs["other"].append(i.title + " #" + str(pr_nr))
continue
if "refactoring" in pr_labels_names:
if "component: core" in pr_labels_names:
refactoring["core"].append(i.title + " #" + str(pr_nr))
elif "component: PMacc" in pr_labels_names:
refactoring["pmacc"].append(i.title + " #" + str(pr_nr))
elif "component: plugin" in pr_labels_names:
refactoring["plugin"].append(i.title + " #" + str(pr_nr))
elif "component: tools" in pr_labels_names:
refactoring["tools"].append(i.title + " #" + str(pr_nr))
else:
refactoring["other"].append(i.title + " #" + str(pr_nr))
continue
# all others are features
if "component: core" in pr_labels_names:
features["core"].append(i.title + " #" + str(pr_nr))
continue
elif "component: PMacc" in pr_labels_names:
features["pmacc"].append(i.title + " #" + str(pr_nr))
continue
elif "component: plugin" in pr_labels_names:
features["plugin"].append(i.title + " #" + str(pr_nr))
continue
elif "component: tools" in pr_labels_names:
features["tools"].append(i.title + " #" + str(pr_nr))
continue
# all leftovers are miscellaneous changes
if "documentation" in pr_labels_names:
misc["docs"].append(i.title + " #" + str(pr_nr))
continue
misc["other"].append(i.title + " #" + str(pr_nr))

print("")
print("**User Input Changes:**")
for p in user["input"]:
print(" - " + p)

print("")
print("**New Features:**")
print(" - PIC:")
for p in features["core"]:
print(" - " + p)
print(" - PMacc:")
for p in features["pmacc"]:
print(" - " + p)
print(" - plugins:")
for p in features["plugin"]:
print(" - " + p)
print(" - tools:")
for p in features["tools"]:
print(" - " + p)
for p in features["other"]:
print(" - " + p)

print("")
print("**Bug Fixes:**")
print(" - PIC:")
for p in bugs["core"]:
print(" - " + p)
print(" - PMacc:")
for p in bugs["pmacc"]:
print(" - " + p)
print(" - plugins:")
for p in bugs["plugin"]:
print(" - " + p)
print(" - tools:")
for p in bugs["tools"]:
print(" - " + p)
for p in bugs["other"]:
print(" - " + p)

print("")
print("**Misc:**")
print(" - refactoring:")
print(" - PIC:")
for p in refactoring["core"]:
print(" - " + p)
print(" - PMacc:")
for p in refactoring["pmacc"]:
print(" - " + p)
print(" - plugins:")
for p in refactoring["plugin"]:
print(" - " + p)
print(" - tools:")
for p in refactoring["tools"]:
print(" - " + p)
for p in refactoring["other"]:
print(" - " + p)
print(" - documentation:")
for p in misc["docs"]:
print(" - " + p)
for p in misc["other"]:
print(" - " + p)
import sys
import yaml


def make_lambda(main_condition, component=None):
"""Factory for lambdas encoding the categorisation conditions. Only needed to capture the current iteration by value."""
if component:
return lambda pr: contains_label(pr, f"component: {component}") and main_condition(pr)

# Okay, admittedly this is just a lazy way to sneak the 'other' category into this interface:
# A careful review would probably request to split this function up and give both of them better names.
PrometheusPi marked this conversation as resolved.
Show resolved Hide resolved
return lambda pr: all(
not contains_label(pr, f"component: {component}") for component in COMPONENTS.values()
) and main_condition(pr)


# map the changelog naming to the tag naming, e.g., "component: core", etc.
COMPONENTS = {"PIC": "core", "PMacc": "PMacc", "plugins": "plugin", "tools": "tools"}

# describe how to detect the main categories
MAIN_CATEGORIES = {
"Features": lambda pr: not contains_label(pr, "bug") and not contains_label(pr, "refactoring"),
"Bug Fixes": lambda pr: contains_label(pr, "bug") and contains_label(pr, "affects latest release"),
"Refactoring": lambda pr: not contains_label(pr, "bug") and contains_label(pr, "refactoring"),
"Documentation": lambda pr: contains_label(pr, "documentation"),
}

# This is the main configuration point: The changelog will have the same tree structure as this nested dict. The leaves
# however will be replaced with something roughly equivalent to `list(filter(func, PRs))` if `func` is the corresponding
# function constituting a leave of `CATEGORIES`. In order to save some typing, the bulk of the categorisation is
# formulated as a cartesion product of `MAIN_CATEGORIES` and `COMPONENTS` but this is only for convenience. Feel free
# to change this or amend this by hand afterwards.
CATEGORIES = {
"User Input Changes": (lambda pr: contains_label(pr, "component: user input")),
} | {
main_cat: {
# This is important: If you create the lambda in-place, variables are captured by reference and the
# corresponding value is only fetched upon a call. This leads to all lambdas using the last value of the
# iteration.
name: make_lambda(main_condition, component)
for name, component in COMPONENTS.items()
}
| {"other": make_lambda(main_condition)}
for main_cat, main_condition in MAIN_CATEGORIES.items()
}


def contains_label(issue, label):
"""Helper function to check if an issue is labelled by label."""
return label in map(lambda lab: lab.name, issue.labels)


def categorise(prs, categories_or_condition):
"""Recursively run over the given categories and filter the PRs according to their conditions."""
if not isinstance(categories_or_condition, dict):
return list(filter(categories_or_condition, prs))
return {key: categorise(prs, val) for key, val in categories_or_condition.items()}


def apply_to_leaves(func, mapping):
"""Helper function to recursively apply to the leaves of a nested dictionary (applying to values of a list individually)."""
if isinstance(mapping, dict):
return {key: apply_to_leaves(func, val) for key, val in mapping.items()}
if isinstance(mapping, list):
return list(map(func, mapping))
return func(mapping)
chillenzer marked this conversation as resolved.
Show resolved Hide resolved


def to_string(categories):
"""Transform our nested dictionary of GH PR objects into something readable."""
return yaml.dump(apply_to_leaves(lambda pr: f"{pr.title} #{pr.number}", categories))


def pull_requests(gh_key, version):
"""Query the Github API for the kind of PRs we need."""
return Github(gh_key).search_issues(
f'repo:ComputationalRadiationPhysics/picongpu type:pr is:merged milestone:"{version}" label:changelog'
)


def main(version, gh_key=None):
chillenzer marked this conversation as resolved.
Show resolved Hide resolved
"""Main logic: Download, categorise, print."""
print(to_string(categorise(pull_requests(gh_key, version), CATEGORIES)))


if __name__ == "__main__":
main(*sys.argv[1:])
chillenzer marked this conversation as resolved.
Show resolved Hide resolved