Skip to content

Commit 5c6a3c7

Browse files
committed
Automatic changelog generation
1 parent ba54f3c commit 5c6a3c7

File tree

3 files changed

+257
-18
lines changed

3 files changed

+257
-18
lines changed

.github/workflows/changelog.yml

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: Update changelog
2+
3+
on:
4+
workflow_dispatch: {}
5+
schedule:
6+
- cron: '00 03 * * 1-5'
7+
pull_request:
8+
branches:
9+
- '**'
10+
types:
11+
- opened
12+
- synchronize
13+
- reopened
14+
15+
jobs:
16+
changelog:
17+
name: Update changelog
18+
runs-on: ubuntu-latest
19+
strategy:
20+
permissions:
21+
contents: write
22+
pull_requests: write
23+
env:
24+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25+
26+
steps:
27+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
28+
with:
29+
token: ${{ secrets.GITHUB_TOKEN }}
30+
fetch-depth: 0
31+
32+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
33+
with:
34+
python-version: "3.12"
35+
36+
- name: Install dependencies
37+
shell: bash
38+
run: |
39+
python -m venv venv --copies
40+
source venv/bin/activate
41+
python -m pip install --upgrade pip requests
42+
python --version
43+
pip freeze
44+
45+
- name: Run script
46+
shell: bash
47+
run: |
48+
chmod +x utils/scripts/update_change_log.sh
49+
source venv/bin/activate
50+
./utils/scripts/update_change_log.sh
51+
52+
- name: Configure git
53+
run: |
54+
# Set the git user.
55+
git config --global user.name 'github-actions[bot]'
56+
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
57+
58+
- name: Add files
59+
run: |
60+
git add "CHANGELOG.md"
61+
62+
- name: Variables
63+
id: vars
64+
run: |
65+
BRANCH_NAME="auto-changelog"
66+
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
67+
68+
# Check if branch already exists - skip if it does
69+
if git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then
70+
echo "Branch $BRANCH_NAME already exists, skipping"
71+
echo "branch_exists=true" >> "$GITHUB_OUTPUT"
72+
exit 0
73+
fi
74+
75+
echo "branch_exists=false" >> "$GITHUB_OUTPUT"
76+
77+
- name: Create branch
78+
if: steps.vars.outputs.branch_exists == 'false'
79+
run: |
80+
echo "Creating branch ${{ steps.vars.outputs.branch_name }}..."
81+
git checkout -b ${{ steps.vars.outputs.branch_name }}
82+
83+
echo "Pushing empty branch ${{ steps.vars.outputs.branch_name }}..."
84+
git push -u origin ${{ steps.vars.outputs.branch_name }}
85+
86+
- name: Create commit
87+
if: steps.vars.outputs.branch_exists == 'false'
88+
run: |
89+
echo "Committing..."
90+
git commit -m "Update changelog" --no-verify
91+
92+
- name: Push signed commits
93+
if: steps.vars.outputs.branch_exists == 'false'
94+
uses: Asana/push-signed-commits@d615ca88d8e1a946734c24970d1e7a6c56f34897
95+
with:
96+
github-token: $GITHUB_TOKEN
97+
repo: DataDog/system-tests
98+
local_branch_name: ${{ steps.vars.outputs.branch_name }}
99+
remote_name: origin
100+
remote_branch_name: ${{ steps.vars.outputs.branch_name }}
101+
102+
- name: Create Pull Request
103+
if: steps.vars.outputs.branch_exists == 'false'
104+
shell: bash
105+
run: |
106+
BRANCH_NAME="${{ steps.vars.outputs.branch_name }}"
107+
108+
# Create or reference PR
109+
if gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' | grep -q .; then
110+
PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number')
111+
echo "PR already exists: #$PR_NUMBER"
112+
else
113+
gh pr create \
114+
--title "Auto-update changelog" \
115+
--body "Automated update of CHANGELOG.md" \
116+
--head "$BRANCH_NAME" \
117+
--base main
118+
fi

utils/scripts/get-change-log.py

100644100755
Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,140 @@
1-
from collections import defaultdict
1+
#!/usr/bin/env python3
2+
import os
3+
import sys
4+
import argparse
5+
from datetime import datetime, timedelta, UTC
6+
from typing import Any
27
import requests
38

4-
page = 1
9+
API_URL = "https://api.github.com/graphql"
510

6-
data = defaultdict(list)
11+
QUERY = """
12+
query ($query: String!, $after: String) {
13+
search(type: ISSUE, query: $query, first: 100, after: $after) {
14+
pageInfo { hasNextPage endCursor }
15+
nodes {
16+
... on PullRequest {
17+
title
18+
url
19+
author { login }
20+
mergedAt
21+
labels(first: 100) {
22+
nodes { name }
23+
}
24+
}
25+
}
26+
}
27+
}
28+
"""
729

8-
for page in range(1, 7):
9-
r = requests.get(
10-
"https://api.github.com/repos/DataDog/system-tests/pulls",
11-
params={"state": "closed", "per_page": "100", "page": str(page)},
12-
timeout=10,
30+
31+
def last_completed_month_range(month: int | None = None) -> tuple[str, str, int, int]:
32+
today = datetime.now(UTC).date()
33+
34+
if month is not None:
35+
# Validate month
36+
if not 1 <= month <= 12: # noqa: PLR2004
37+
raise ValueError("Month must be between 1 and 12")
38+
39+
# Use the specified month for the current year
40+
target_date = today.replace(month=month, day=1)
41+
42+
# If the specified month is in the future, use the previous year
43+
if target_date > today:
44+
target_date = target_date.replace(year=today.year - 1)
45+
46+
# Get the last day of the specified month
47+
if month == 12: # noqa: PLR2004
48+
next_month = target_date.replace(year=target_date.year + 1, month=1)
49+
else:
50+
next_month = target_date.replace(month=month + 1)
51+
52+
last_day = next_month - timedelta(days=1)
53+
return target_date.isoformat(), last_day.isoformat(), target_date.year, target_date.month
54+
# Original logic for last completed month
55+
first_of_current = today.replace(day=1)
56+
last_of_prev = first_of_current - timedelta(days=1)
57+
first_of_prev = last_of_prev.replace(day=1)
58+
return first_of_prev.isoformat(), last_of_prev.isoformat(), first_of_prev.year, first_of_prev.month
59+
60+
61+
def gh_request(token: str, query: str, variables: dict[str, Any]) -> dict[str, Any]:
62+
r = requests.post(
63+
API_URL,
64+
headers={
65+
"Authorization": f"Bearer {token}",
66+
"Accept": "application/vnd.github+json",
67+
},
68+
json={"query": query, "variables": variables},
69+
timeout=30,
70+
)
71+
r.raise_for_status()
72+
j = r.json()
73+
if "errors" in j:
74+
raise RuntimeError(j["errors"])
75+
return j["data"]
76+
77+
78+
def print_pr_data(month: int | None = None) -> None:
79+
token = os.environ.get("GITHUB_TOKEN")
80+
if not token:
81+
print("Please set GITHUB_TOKEN in your environment.")
82+
sys.exit(1)
83+
84+
start_date, end_date, year, month = last_completed_month_range(month)
85+
q = f"repo:DataDog/system-tests is:pr merged:{start_date}..{end_date}"
86+
target_label = "build-python-base-images"
87+
88+
pr_prints = []
89+
n_prs = 0
90+
91+
after = None
92+
while True:
93+
data = gh_request(token, QUERY, {"query": q, "after": after})
94+
95+
for pr in data["search"]["nodes"]:
96+
n_prs += 1
97+
labels = [labels["name"] for labels in pr["labels"]["nodes"]]
98+
# if True:
99+
if target_label in labels:
100+
merged_at = pr["mergedAt"][:10]
101+
title = pr["title"]
102+
url = pr["url"]
103+
author = pr["author"]["login"]
104+
105+
pr_prints.append(f"* {merged_at} [{title}]({url}) by @{author}")
106+
107+
if not data["search"]["pageInfo"]["hasNextPage"]:
108+
break
109+
after = data["search"]["pageInfo"]["endCursor"]
110+
111+
print(f"### {year}-{month:02d} ({n_prs} PR merged)\n")
112+
for line in pr_prints:
113+
print(line)
114+
115+
116+
def main() -> None:
117+
parser = argparse.ArgumentParser(
118+
description="Generate changelog for system-tests repository",
119+
formatter_class=argparse.RawDescriptionHelpFormatter,
120+
epilog="""
121+
Examples:
122+
python get-change-log.py # Use last completed month
123+
python get-change-log.py --month 12 # Use December of current year
124+
python get-change-log.py --month 1 # Use January of current year
125+
""",
126+
)
127+
128+
parser.add_argument(
129+
"--month",
130+
type=int,
131+
choices=range(1, 13),
132+
help="Month to generate changelog for (1-12). If not specified, uses the last completed month.",
13133
)
14134

15-
for pr in r.json():
16-
if pr["merged_at"]:
17-
data[pr["merged_at"][:7]].append(pr)
135+
args = parser.parse_args()
136+
print_pr_data(args.month)
18137

19-
for month in sorted(data, reverse=True):
20-
prs = data[month]
21138

22-
print(f"\n\n### {month} ({len(prs)} PR merged)\n")
23-
for pr in prs:
24-
pr["merged_at"] = pr["merged_at"][:10]
25-
pr["author"] = pr["user"]["login"]
26-
print("* {merged_at} [{title}]({html_url}) by @{author}".format(**pr))
139+
if __name__ == "__main__":
140+
main()

utils/scripts/update_change_log.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
3+
(head -n 4 CHANGELOG.md
4+
python utils/scripts/get-change-log.py "$@"
5+
echo ""
6+
tail -n +4 CHANGELOG.md) > new_CHANGELOG.md
7+
mv new_CHANGELOG.md CHANGELOG.md

0 commit comments

Comments
 (0)