Repository: pydantic/pydantic

Issue:
Improve making history (changelog)
<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary
Changes apply to the script `release/make_history` which uses gitpython (GitPython) package. The file is no longer named `make_history.py` but `update_history.py`, which is optional.

<!-- Please give a short summary of the changes. -->

## Related issue number
N/A

<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [x] Unit tests for the changes exist
* [x] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Selected Reviewer: @davidhewitt


Discussion:
please review
Definitely don't want to change the file name.

Personally I'm not that keen on changing unless there's a good reason.

More impactfully, @davidhewitt suggested some changes here - https://github.com/pydantic/pydantic/pull/7244#discussion_r1304585752, maybe we could use that approach instead?
@samuelcolvin I updated the lockfile, I'll let you do the final approval since you're more familiar with this part of the release process
I just added author of the commit / pull request in the bullets. (which I forgot to do)

Resolving patch:
diff --git a/release/make_history.py b/release/make_history.py
--- a/release/make_history.py
+++ b/release/make_history.py
@@ -1,9 +1,9 @@
 from __future__ import annotations as _annotations
 
+import json
 import re
 import subprocess
 import sys
-from dataclasses import dataclass
 from datetime import date
 from pathlib import Path
 
@@ -23,14 +23,12 @@ def main():
         print(f'WARNING: v{new_version} already in history, stopping')
         sys.exit(1)
 
-    commits = get_commits()
-    commits_bullets = '\n'.join(f'* {c}' for c in commits)
-
     title = f'v{new_version} ({date.today():%Y-%m-%d})'
+    notes = get_notes(new_version)
     new_chunk = (
         f'## {title}\n\n'
         f'[GitHub release](https://github.com/pydantic/pydantic/releases/tag/v{new_version})\n\n'
-        f'{commits_bullets}\n\n'
+        f'{notes}\n\n'
     )
     history = new_chunk + history_content
 
@@ -38,69 +36,50 @@ def main():
     print(f'\nSUCCESS: added "{title}" section to {history_path.relative_to(root_dir)}')
 
 
-def get_commits() -> list[Commit]:
+def get_notes(new_version: str) -> str:
     last_tag = get_last_tag()
-    raw_commits = run('git', 'log', f'{last_tag}..main', '--oneline')
-    commits = [Commit.from_line(line) for line in raw_commits.splitlines()]
-    commits = [c for c in commits if c]
-    print(f'found {len(commits)} commits since {last_tag}')
-    add_author(commits)
-    return commits
-
-
-@dataclass
-class Commit:
-    short_sha: str
-    message: str
-    pr: int
-    author: str | None = None
-
-    @classmethod
-    def from_line(cls, line: str) -> Commit | None:
-        short_sha, message = line.split(' ', 1)
-        message, last_word = message.rsplit(' ', 1)
-        m = re.fullmatch(r'\(#(\d+)\)', last_word)
-        if m:
-            pr = int(m.group(1))
-            return cls(short_sha, message, pr)
-
-    def __str__(self):
-        return f'{self.message} by @{self.author} in [#{self.pr}](https://github.com/pydantic/pydantic/pull/{self.pr})'
-
-
-def add_author(commits: list[Commit]) -> None:
-    print('Getting PR authors from GitHub...')
-    session = requests.Session()
-    headers = {
-        'Accept': 'application/vnd.github+json',
-        'x-github-api-version': '2022-11-28',
-    }
-    missing = {c.pr for c in commits}
-    for page in range(1, 10):
-        print(f'getting GH pulls page {page}, looking for {len(missing)} missing authors...')
-        params = {'per_page': 100, 'page': page, 'direction': 'desc', 'sort': 'updated', 'state': 'closed'}
-        r = session.get('https://api.github.com/repos/pydantic/pydantic/pulls', headers=headers, params=params)
-        r.raise_for_status()
-        for pr in r.json():
-            pr_number = pr['number']
-            # debug(pr_number, missing, pr_number in missing)
-            if pr_number in missing:
-                for c in commits:
-                    if c.pr == pr_number:
-                        missing.remove(pr_number)
-                        c.author = pr['user']['login']
-                        break
-        if not missing:
-            print(f'all authors found after page {page}')
-            return
-        else:
-            print(f'{len(missing)} authors still missing after page {page}')
+    auth_token = get_gh_auth_token()
+
+    data = {'target_committish': 'main', 'previous_tag_name': last_tag, 'tag_name': f'v{new_version}'}
+    response = requests.post(
+        'https://api.github.com/repos/pydantic/pydantic/releases/generate-notes',
+        headers={
+            'Accept': 'application/vnd.github+json',
+            'Authorization': f'Bearer {auth_token}',
+            'x-github-api-version': '2022-11-28',
+        },
+        data=json.dumps(data),
+    )
+    response.raise_for_status()
+
+    body = response.json()['body']
+    body = body.removeprefix('<!-- Release notes generated using configuration in .github/release.yml at main -->\n\n')
+    body = body.removeprefix("## What's Changed\n")
+
+    body = re.sub(
+        pattern='https://github.com/pydantic/pydantic/pull/(\\d+)',
+        repl=r'[#\1](https://github.com/pydantic/pydantic/pull/\1)',
+        string=body,
+    )
+
+    # Remove "full changelog" link
+    body = re.sub(
+        pattern=r'\*\*Full Changelog\*\*: https://.*$',
+        repl='',
+        string=body,
+    )
+
+    return body.strip()
 
 
 def get_last_tag():
     return run('git', 'describe', '--tags', '--abbrev=0')
 
 
+def get_gh_auth_token():
+    return run('gh', 'auth', 'token')
+
+
 def run(*args: str) -> str:
     p = subprocess.run(args, stdout=subprocess.PIPE, check=True, encoding='utf-8')
     return p.stdout.strip()


Test patch: