pretty-release-notes 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pretty_release_notes/__init__.py +38 -0
- pretty_release_notes/__main__.py +6 -0
- pretty_release_notes/adapters/__init__.py +1 -0
- pretty_release_notes/adapters/cli_progress.py +25 -0
- pretty_release_notes/api.py +239 -0
- pretty_release_notes/core/__init__.py +1 -0
- pretty_release_notes/core/config.py +166 -0
- pretty_release_notes/core/config_loader.py +221 -0
- pretty_release_notes/core/execution.py +74 -0
- pretty_release_notes/core/interfaces.py +35 -0
- pretty_release_notes/database.py +152 -0
- pretty_release_notes/generator.py +317 -0
- pretty_release_notes/github_client.py +232 -0
- pretty_release_notes/main.py +245 -0
- pretty_release_notes/models/__init__.py +15 -0
- pretty_release_notes/models/_utils.py +47 -0
- pretty_release_notes/models/change.py +34 -0
- pretty_release_notes/models/commit.py +75 -0
- pretty_release_notes/models/issue.py +17 -0
- pretty_release_notes/models/pull_request.py +208 -0
- pretty_release_notes/models/release_notes.py +241 -0
- pretty_release_notes/models/release_notes_line.py +36 -0
- pretty_release_notes/models/repository.py +20 -0
- pretty_release_notes/openai_client.py +165 -0
- pretty_release_notes/prompt.txt +34 -0
- pretty_release_notes/py.typed +0 -0
- pretty_release_notes/setup_command.py +336 -0
- pretty_release_notes/ui.py +46 -0
- pretty_release_notes/web/__init__.py +1 -0
- pretty_release_notes/web/app.py +193 -0
- pretty_release_notes/web/server.py +11 -0
- pretty_release_notes-1.4.0.dist-info/METADATA +335 -0
- pretty_release_notes-1.4.0.dist-info/RECORD +37 -0
- pretty_release_notes-1.4.0.dist-info/WHEEL +5 -0
- pretty_release_notes-1.4.0.dist-info/entry_points.txt +2 -0
- pretty_release_notes-1.4.0.dist-info/licenses/LICENSE +7 -0
- pretty_release_notes-1.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Execution strategies for parallel processing."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExecutionStrategy(ABC):
|
|
11
|
+
"""Abstract strategy for parallel execution."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def execute_parallel(
|
|
15
|
+
self,
|
|
16
|
+
tasks: list[Callable[[], Any]],
|
|
17
|
+
) -> list[Any]:
|
|
18
|
+
"""Execute tasks in parallel and return results."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ThreadingStrategy(ExecutionStrategy):
|
|
23
|
+
"""Original threading implementation for backward compatibility."""
|
|
24
|
+
|
|
25
|
+
def execute_parallel(
|
|
26
|
+
self,
|
|
27
|
+
tasks: list[Callable[[], Any]],
|
|
28
|
+
) -> list[Any]:
|
|
29
|
+
threads = []
|
|
30
|
+
results = [None] * len(tasks)
|
|
31
|
+
|
|
32
|
+
def run_task(index: int, task: Callable[[], Any]):
|
|
33
|
+
results[index] = task()
|
|
34
|
+
|
|
35
|
+
for i, task in enumerate(tasks):
|
|
36
|
+
thread = threading.Thread(target=run_task, args=(i, task))
|
|
37
|
+
threads.append(thread)
|
|
38
|
+
thread.start()
|
|
39
|
+
|
|
40
|
+
for thread in threads:
|
|
41
|
+
thread.join()
|
|
42
|
+
|
|
43
|
+
return results
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ThreadPoolStrategy(ExecutionStrategy):
|
|
47
|
+
"""Thread pool implementation for better resource management."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, max_workers: int = 10):
|
|
50
|
+
self.max_workers = max_workers
|
|
51
|
+
|
|
52
|
+
def execute_parallel(
|
|
53
|
+
self,
|
|
54
|
+
tasks: list[Callable[[], Any]],
|
|
55
|
+
) -> list[Any]:
|
|
56
|
+
if not tasks:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
results = []
|
|
60
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
61
|
+
futures = [executor.submit(task) for task in tasks]
|
|
62
|
+
for future in as_completed(futures):
|
|
63
|
+
results.append(future.result())
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SequentialStrategy(ExecutionStrategy):
|
|
68
|
+
"""Sequential execution for debugging or environments without threading."""
|
|
69
|
+
|
|
70
|
+
def execute_parallel(
|
|
71
|
+
self,
|
|
72
|
+
tasks: list[Callable[[], Any]],
|
|
73
|
+
) -> list[Any]:
|
|
74
|
+
return [task() for task in tasks]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ProgressEvent:
|
|
8
|
+
type: str # "info", "success", "error", "markdown"
|
|
9
|
+
message: str
|
|
10
|
+
metadata: dict[str, Any] | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProgressReporter(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def report(self, event: ProgressEvent) -> None:
|
|
16
|
+
"""Report a progress event."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NullProgressReporter(ProgressReporter):
|
|
21
|
+
"""No-op reporter for library usage."""
|
|
22
|
+
|
|
23
|
+
def report(self, event: ProgressEvent) -> None:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CompositeProgressReporter(ProgressReporter):
|
|
28
|
+
"""Combine multiple reporters."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, reporters: list[ProgressReporter]):
|
|
31
|
+
self.reporters = reporters
|
|
32
|
+
|
|
33
|
+
def report(self, event: ProgressEvent) -> None:
|
|
34
|
+
for reporter in self.reporters:
|
|
35
|
+
reporter.report(event)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import threading
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from csv import DictReader, DictWriter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .models import Repository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Database:
|
|
13
|
+
def __init__(self, path: Path):
|
|
14
|
+
self.path = path
|
|
15
|
+
|
|
16
|
+
def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CSVDatabase(Database):
|
|
27
|
+
columns = ["owner", "repo", "pr_no", "sentence"]
|
|
28
|
+
|
|
29
|
+
def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
|
|
30
|
+
if not self.path.exists():
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
sentence = None
|
|
34
|
+
with open(self.path) as f:
|
|
35
|
+
reader = DictReader(f, fieldnames=self.columns)
|
|
36
|
+
for row in reader:
|
|
37
|
+
if row["owner"] == repository.owner and row["repo"] == repository.name and row["pr_no"] == pr_no:
|
|
38
|
+
sentence = row["sentence"]
|
|
39
|
+
|
|
40
|
+
return sentence
|
|
41
|
+
|
|
42
|
+
def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
|
|
43
|
+
write_header = not self.path.exists()
|
|
44
|
+
with open(self.path, "a") as f:
|
|
45
|
+
writer = DictWriter(f, self.columns)
|
|
46
|
+
|
|
47
|
+
if write_header:
|
|
48
|
+
writer.writeheader()
|
|
49
|
+
|
|
50
|
+
writer.writerow(
|
|
51
|
+
{
|
|
52
|
+
"owner": repository.owner,
|
|
53
|
+
"repo": repository.name,
|
|
54
|
+
"pr_no": pr_no,
|
|
55
|
+
"sentence": sentence,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
|
|
60
|
+
with open(self.path) as f:
|
|
61
|
+
reader = DictReader(f, fieldnames=self.columns)
|
|
62
|
+
rows = [
|
|
63
|
+
row
|
|
64
|
+
for row in reader
|
|
65
|
+
if row["owner"] != repository.owner or row["repo"] != repository.name or row["pr_no"] != pr_no
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
with open(self.path, "w") as f:
|
|
69
|
+
writer = DictWriter(f, self.columns)
|
|
70
|
+
writer.writeheader()
|
|
71
|
+
writer.writerows(rows)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SQLiteDatabase(Database):
|
|
75
|
+
"""Thread-safe SQLite database with thread-local connections."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, path: Path):
|
|
78
|
+
self.path = path
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._local = threading.local()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def connection(self):
|
|
84
|
+
"""Thread-local database connection."""
|
|
85
|
+
if not hasattr(self._local, "conn"):
|
|
86
|
+
self._local.conn = sqlite3.connect(self.path)
|
|
87
|
+
self._local.cursor = self._local.conn.cursor()
|
|
88
|
+
self._create_table()
|
|
89
|
+
return self._local.conn
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def cursor(self):
|
|
93
|
+
"""Thread-local cursor."""
|
|
94
|
+
_ = self.connection # Ensure connection exists
|
|
95
|
+
return self._local.cursor
|
|
96
|
+
|
|
97
|
+
@contextmanager
|
|
98
|
+
def transaction(self):
|
|
99
|
+
"""Context manager for transactions with locking."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
try:
|
|
102
|
+
yield self.cursor
|
|
103
|
+
self.connection.commit()
|
|
104
|
+
except Exception:
|
|
105
|
+
self.connection.rollback()
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
|
|
109
|
+
# Read operations don't need locking, just use thread-local connection
|
|
110
|
+
self.cursor.execute(
|
|
111
|
+
"SELECT sentence FROM sentences WHERE owner = ? AND repo = ? AND pr_no = ? ORDER BY rowid DESC LIMIT 1",
|
|
112
|
+
(repository.owner, repository.name, pr_no),
|
|
113
|
+
)
|
|
114
|
+
result = self.cursor.fetchone()
|
|
115
|
+
return result[0] if result else None
|
|
116
|
+
|
|
117
|
+
def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
|
|
118
|
+
with self.transaction():
|
|
119
|
+
self.cursor.execute(
|
|
120
|
+
"INSERT INTO sentences (owner, repo, pr_no, sentence) VALUES (?, ?, ?, ?)",
|
|
121
|
+
(repository.owner, repository.name, pr_no, sentence),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
|
|
125
|
+
with self.transaction():
|
|
126
|
+
self.cursor.execute(
|
|
127
|
+
"DELETE FROM sentences WHERE owner = ? AND repo = ? AND pr_no = ?",
|
|
128
|
+
(repository.owner, repository.name, pr_no),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _create_table(self):
|
|
132
|
+
"""Create table and index if they don't exist."""
|
|
133
|
+
self.cursor.execute("CREATE TABLE IF NOT EXISTS sentences (owner TEXT, repo TEXT, pr_no TEXT, sentence TEXT)")
|
|
134
|
+
self.cursor.execute("CREATE INDEX IF NOT EXISTS idx_owner_repo_pr_no ON sentences (owner, repo, pr_no)")
|
|
135
|
+
self.connection.commit()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_db(db_type: str, db_name: str) -> Database:
|
|
139
|
+
db_path = Path(db_name)
|
|
140
|
+
|
|
141
|
+
# If path is relative (not absolute), use ~/.pretty-release-notes/ as base directory
|
|
142
|
+
if not db_path.is_absolute():
|
|
143
|
+
base_dir = Path.home() / ".pretty-release-notes"
|
|
144
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
db_path = base_dir / db_name
|
|
146
|
+
|
|
147
|
+
if db_type == "csv":
|
|
148
|
+
return CSVDatabase(db_path.with_suffix(".csv"))
|
|
149
|
+
elif db_type == "sqlite":
|
|
150
|
+
return SQLiteDatabase(db_path.with_suffix(".sqlite"))
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(f"Invalid database type: {db_type}")
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from requests import HTTPError
|
|
7
|
+
|
|
8
|
+
from .core.config import ReleaseNotesConfig
|
|
9
|
+
from .core.execution import ExecutionStrategy, ThreadPoolStrategy
|
|
10
|
+
from .core.interfaces import NullProgressReporter, ProgressEvent, ProgressReporter
|
|
11
|
+
from .database import get_db
|
|
12
|
+
from .github_client import GitHubClient
|
|
13
|
+
from .models import ReleaseNotes, ReleaseNotesLine, Repository
|
|
14
|
+
from .openai_client import format_model_name, get_chat_response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _unwrap_retry_error(error: Exception) -> Exception:
|
|
18
|
+
while True:
|
|
19
|
+
last_attempt = getattr(error, "last_attempt", None)
|
|
20
|
+
exception_getter = cast(Callable[[], object] | None, getattr(last_attempt, "exception", None))
|
|
21
|
+
if exception_getter is None:
|
|
22
|
+
return error
|
|
23
|
+
last_exception: object = exception_getter()
|
|
24
|
+
if not isinstance(last_exception, Exception) or last_exception is error:
|
|
25
|
+
return error
|
|
26
|
+
error = last_exception
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_error(error: Exception) -> str:
|
|
30
|
+
unwrapped_error = _unwrap_retry_error(error)
|
|
31
|
+
error_message = str(unwrapped_error).strip()
|
|
32
|
+
if not error_message:
|
|
33
|
+
return unwrapped_error.__class__.__name__
|
|
34
|
+
return f"{unwrapped_error.__class__.__name__}: {error_message}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _normalize_whitespace(value: str) -> str:
|
|
38
|
+
return " ".join(value.split())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _describe_change(change: object) -> str:
|
|
42
|
+
title = getattr(change, "title", None)
|
|
43
|
+
if isinstance(title, str) and title.strip():
|
|
44
|
+
pr_id = getattr(change, "id", None)
|
|
45
|
+
prefix = f"PR #{pr_id}" if pr_id is not None else "PR"
|
|
46
|
+
return f"{prefix}: {_normalize_whitespace(title)}"
|
|
47
|
+
|
|
48
|
+
message = getattr(change, "message", None)
|
|
49
|
+
if isinstance(message, str) and message.strip():
|
|
50
|
+
commit_id = getattr(change, "id", None)
|
|
51
|
+
short_commit_id = str(commit_id)[:7] if commit_id else None
|
|
52
|
+
prefix = f"Commit {short_commit_id}" if short_commit_id else "Commit"
|
|
53
|
+
first_line = _normalize_whitespace(message.splitlines()[0])
|
|
54
|
+
return f"{prefix}: {first_line}"
|
|
55
|
+
|
|
56
|
+
html_url = getattr(change, "html_url", None)
|
|
57
|
+
if isinstance(html_url, str) and html_url.strip():
|
|
58
|
+
return html_url
|
|
59
|
+
|
|
60
|
+
return type(change).__name__
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReleaseNotesGenerator:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
config: ReleaseNotesConfig,
|
|
67
|
+
progress_reporter: ProgressReporter | None = None,
|
|
68
|
+
execution_strategy: ExecutionStrategy | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.config = config
|
|
71
|
+
self.github = GitHubClient(config.github.token)
|
|
72
|
+
self.repository: Repository | None = None
|
|
73
|
+
self.progress = progress_reporter or NullProgressReporter()
|
|
74
|
+
self.execution = execution_strategy or ThreadPoolStrategy()
|
|
75
|
+
|
|
76
|
+
# Store config values for easy access
|
|
77
|
+
self.exclude_change_types = config.filters.exclude_change_types
|
|
78
|
+
self.exclude_change_labels = config.filters.exclude_change_labels
|
|
79
|
+
self.exclude_authors = config.filters.exclude_authors
|
|
80
|
+
self.grouping = config.grouping
|
|
81
|
+
self.llm_api_key = config.llm.api_key
|
|
82
|
+
self.llm_model = config.llm.model
|
|
83
|
+
self.max_patch_size = config.llm.max_patch_size
|
|
84
|
+
self.reasoning_effort = config.llm.reasoning_effort
|
|
85
|
+
self.prompt_path = config.prompt_path
|
|
86
|
+
self.db_type = config.database.type
|
|
87
|
+
self.db_name = config.database.name
|
|
88
|
+
self.db_read_enabled = config.database.read_enabled
|
|
89
|
+
self.db_write_enabled = config.database.write_enabled
|
|
90
|
+
self.force_use_commits = config.force_use_commits
|
|
91
|
+
|
|
92
|
+
def initialize_repository(self, owner: str, name: str):
|
|
93
|
+
self.repository = self.github.get_repository(owner, name)
|
|
94
|
+
|
|
95
|
+
def generate(self, tag: str, previous_tag_name: str | None = None) -> str:
|
|
96
|
+
"""Generate release notes for a given tag.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tag: Git tag for the release
|
|
100
|
+
previous_tag_name: Optional previous tag to use as starting point
|
|
101
|
+
"""
|
|
102
|
+
assert self.repository is not None, "Repository must be initialized before generating release notes"
|
|
103
|
+
release = self._get_release(tag)
|
|
104
|
+
old_body = release["body"]
|
|
105
|
+
|
|
106
|
+
self.progress.report(
|
|
107
|
+
ProgressEvent(
|
|
108
|
+
type="release_notes",
|
|
109
|
+
message=old_body,
|
|
110
|
+
metadata={"heading": "Current Release Notes"},
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not previous_tag_name:
|
|
115
|
+
previous_tag_name = get_previous_tag_name(old_body, self.repository)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
gh_notes = self.github.generate_release_notes(self.repository, tag, previous_tag_name)
|
|
119
|
+
new_body = gh_notes["body"]
|
|
120
|
+
self.progress.report(
|
|
121
|
+
ProgressEvent(
|
|
122
|
+
type="release_notes",
|
|
123
|
+
message=new_body,
|
|
124
|
+
metadata={"heading": "Regenerated by GitHub"},
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
except HTTPError as e:
|
|
128
|
+
if e.response.status_code not in [403, 404]:
|
|
129
|
+
raise e
|
|
130
|
+
|
|
131
|
+
self.progress.report(
|
|
132
|
+
ProgressEvent(
|
|
133
|
+
type="error",
|
|
134
|
+
message="No permission to regenerate release notes, trying to proceed with old ones.",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
new_body = old_body
|
|
138
|
+
|
|
139
|
+
self.progress.report(ProgressEvent(type="markdown", message="# Rewriting ..."))
|
|
140
|
+
|
|
141
|
+
release_notes = ReleaseNotes.from_string(new_body)
|
|
142
|
+
pr_start_time = time.time()
|
|
143
|
+
self._get_prs_for_lines(release_notes.lines)
|
|
144
|
+
pr_end_time = time.time()
|
|
145
|
+
self.progress.report(
|
|
146
|
+
ProgressEvent(
|
|
147
|
+
type="success",
|
|
148
|
+
message=f"Downloaded PRs in {pr_end_time - pr_start_time:.2f} seconds.",
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if self.force_use_commits or (
|
|
153
|
+
not any(line.change for line in release_notes.lines) and "Full Changelog" in new_body
|
|
154
|
+
):
|
|
155
|
+
release_notes.lines = self._get_commit_lines(tag, previous_tag_name) + release_notes.lines
|
|
156
|
+
|
|
157
|
+
process_start_time = time.time()
|
|
158
|
+
self._process_lines(release_notes.lines)
|
|
159
|
+
process_end_time = time.time()
|
|
160
|
+
self.progress.report(
|
|
161
|
+
ProgressEvent(
|
|
162
|
+
type="success",
|
|
163
|
+
message=f"Processed lines in {process_end_time - process_start_time:.2f} seconds.",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
reviewers_start_time = time.time()
|
|
168
|
+
release_notes.load_reviewers()
|
|
169
|
+
reviewers_end_time = time.time()
|
|
170
|
+
self.progress.report(
|
|
171
|
+
ProgressEvent(
|
|
172
|
+
type="success",
|
|
173
|
+
message=f"Loaded reviewers in {reviewers_end_time - reviewers_start_time:.2f} seconds.",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return release_notes.serialize(
|
|
178
|
+
self.exclude_change_types,
|
|
179
|
+
self.exclude_change_labels,
|
|
180
|
+
self.exclude_authors,
|
|
181
|
+
grouping=self.grouping,
|
|
182
|
+
model_name=format_model_name(self.llm_model),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _get_prs_for_lines(self, lines: list["ReleaseNotesLine"]) -> None:
|
|
186
|
+
"""Download info for all PRs in parallel."""
|
|
187
|
+
tasks: list[Callable[[], Any]] = [
|
|
188
|
+
partial(self._get_pr_for_line, line) for line in lines if line.pr_no and not line.is_new_contributor
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
if tasks:
|
|
192
|
+
self.execution.execute_parallel(tasks)
|
|
193
|
+
|
|
194
|
+
def _get_pr_for_line(self, line: "ReleaseNotesLine"):
|
|
195
|
+
"""Download info for a single PR."""
|
|
196
|
+
assert self.repository is not None, "Repository must be initialized"
|
|
197
|
+
if line.pr_no and not line.is_new_contributor:
|
|
198
|
+
line.change = self.github.get_pr(self.repository, line.pr_no)
|
|
199
|
+
|
|
200
|
+
def _process_lines(self, lines: list["ReleaseNotesLine"]) -> None:
|
|
201
|
+
"""Process all lines in parallel."""
|
|
202
|
+
prompt_template = self.prompt_path.read_text()
|
|
203
|
+
tasks: list[Callable[[], Any]] = [partial(self._process_line, line, prompt_template) for line in lines]
|
|
204
|
+
if tasks:
|
|
205
|
+
self.execution.execute_parallel(tasks)
|
|
206
|
+
|
|
207
|
+
def update_on_github(self, new_body: str, tag: str):
|
|
208
|
+
"""Update release notes on GitHub."""
|
|
209
|
+
assert self.repository is not None, "Repository must be initialized before updating release notes"
|
|
210
|
+
release = self._get_release(tag)
|
|
211
|
+
try:
|
|
212
|
+
self.github.update_release(self.repository, release["id"], new_body)
|
|
213
|
+
self.progress.report(ProgressEvent(type="success", message="Release notes updated successfully."))
|
|
214
|
+
except HTTPError as e:
|
|
215
|
+
if e.response.status_code != 403:
|
|
216
|
+
raise e
|
|
217
|
+
|
|
218
|
+
self.progress.report(
|
|
219
|
+
ProgressEvent(
|
|
220
|
+
type="error",
|
|
221
|
+
message="No permission to update release notes, skipping.",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _get_commit_lines(self, tag: str, previous_tag_name: str | None = None):
|
|
226
|
+
assert self.repository is not None, "Repository must be initialized"
|
|
227
|
+
if previous_tag_name:
|
|
228
|
+
commits = self.github.get_diff_commits(self.repository, tag, previous_tag_name)
|
|
229
|
+
else:
|
|
230
|
+
commits = self.github.get_tag_commits(self.repository, tag)
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
[
|
|
234
|
+
# Add the same heading line for the commits, that is automatically added by GitHub for PRs
|
|
235
|
+
ReleaseNotesLine(
|
|
236
|
+
original_line="## What's Changed",
|
|
237
|
+
)
|
|
238
|
+
]
|
|
239
|
+
+ [
|
|
240
|
+
ReleaseNotesLine(
|
|
241
|
+
original_line="",
|
|
242
|
+
change=commit,
|
|
243
|
+
)
|
|
244
|
+
for commit in commits
|
|
245
|
+
]
|
|
246
|
+
+ [
|
|
247
|
+
# Add an empty line to separate commits from full changelog
|
|
248
|
+
ReleaseNotesLine(
|
|
249
|
+
original_line="",
|
|
250
|
+
)
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _process_line(self, line: "ReleaseNotesLine", prompt_template: str):
|
|
255
|
+
assert self.repository is not None, "Repository must be initialized"
|
|
256
|
+
needs_db = self.db_read_enabled or self.db_write_enabled
|
|
257
|
+
db = get_db(self.db_type, self.db_name) if needs_db else None
|
|
258
|
+
|
|
259
|
+
if not line.change or line.is_new_contributor:
|
|
260
|
+
self.progress.report(ProgressEvent(type="markdown", message=str(line)))
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if line.change.conventional_type in self.exclude_change_types:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if line.change.labels and line.change.labels & self.exclude_change_labels:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
change_id = line.change.get_summary_key()
|
|
270
|
+
if db and self.db_read_enabled:
|
|
271
|
+
stored_sentence = db.get_sentence(self.repository, change_id)
|
|
272
|
+
if stored_sentence:
|
|
273
|
+
line.sentence = stored_sentence
|
|
274
|
+
self.progress.report(ProgressEvent(type="markdown", message=str(line)))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
prompt = line.change.get_prompt(
|
|
278
|
+
prompt_template=prompt_template,
|
|
279
|
+
max_patch_size=self.max_patch_size,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
change_summary = get_chat_response(
|
|
284
|
+
content=prompt,
|
|
285
|
+
model=self.llm_model,
|
|
286
|
+
api_key=self.llm_api_key,
|
|
287
|
+
reasoning_effort=self.reasoning_effort,
|
|
288
|
+
)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
error_msg = (
|
|
291
|
+
f"LLM API error ({format_model_name(self.llm_model)}) "
|
|
292
|
+
f"for {_describe_change(line.change)}: {_format_error(e)}"
|
|
293
|
+
)
|
|
294
|
+
self.progress.report(ProgressEvent(type="error", message=error_msg))
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if not change_summary:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
change_summary = change_summary.lstrip(" -")
|
|
301
|
+
if db and self.db_write_enabled:
|
|
302
|
+
db.store_sentence(self.repository, change_id, change_summary)
|
|
303
|
+
line.sentence = change_summary
|
|
304
|
+
self.progress.report(ProgressEvent(type="markdown", message=str(line)))
|
|
305
|
+
|
|
306
|
+
def _get_release(self, tag: str):
|
|
307
|
+
assert self.repository is not None, "Repository must be initialized"
|
|
308
|
+
return self.github.get_release(self.repository, tag)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_previous_tag_name(release_notes_body: str, repository: Repository) -> str | None:
|
|
312
|
+
compare_url = f"https://github.com/{repository.owner}/{repository.name}/compare/"
|
|
313
|
+
pos = release_notes_body.find(compare_url)
|
|
314
|
+
if pos != -1:
|
|
315
|
+
return release_notes_body[pos + len(compare_url) :].split("...")[0]
|
|
316
|
+
|
|
317
|
+
return None
|