763 lines
24 KiB
Python
763 lines
24 KiB
Python
# Copyright (c) 2022 PaddlePaddle Authors. All Rights Reserved.
|
|
#
|
|
# 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from dataclasses import asdict, dataclass, fields
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from urllib import error, request
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
DEFAULT_OWNER = "PaddlePaddle"
|
|
DEFAULT_REPO = "Paddle"
|
|
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
PR_NUMBER_RE = re.compile(r"\(#(\d+)\)")
|
|
HEADING_RE = re.compile(r"(?m)^###\s*(.+?)\s*$")
|
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
|
|
PR_CATEGORIES = [
|
|
'User Experience',
|
|
'Execute Infrastructure',
|
|
'Operator Mechanism',
|
|
'CINN',
|
|
'Custom Device',
|
|
'Performance Optimization',
|
|
'Distributed Strategy',
|
|
'Parameter Server',
|
|
'Communication Library',
|
|
'Auto Parallel',
|
|
'Inference',
|
|
'Environment Adaptation',
|
|
'Others',
|
|
]
|
|
|
|
PR_TYPES = [
|
|
'New features',
|
|
'Bug fixes',
|
|
'Improvements',
|
|
'Performance',
|
|
'BC Breaking',
|
|
'Deprecations',
|
|
'Docs',
|
|
'Devs',
|
|
'Not User Facing',
|
|
'Security',
|
|
'Others',
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommitRecord:
|
|
commit_hash: str
|
|
title: str
|
|
git_author: str
|
|
pr_number: int | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PullRequestRecord:
|
|
number: int
|
|
title: str
|
|
author: str
|
|
labels: list[str]
|
|
reviewers: list[str]
|
|
category: str
|
|
topic: str
|
|
description: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommitRow:
|
|
commit_hash: str
|
|
category: str
|
|
topic: str
|
|
title: str
|
|
pr_link: str
|
|
author: str
|
|
labels: str
|
|
accepter_1: str
|
|
accepter_2: str
|
|
accepter_3: str
|
|
description: str
|
|
|
|
|
|
COMMIT_FIELDS = tuple(field.name for field in fields(CommitRow))
|
|
|
|
|
|
def run_git(*args: str) -> str:
|
|
completed = subprocess.run(
|
|
["git", *args],
|
|
check=True,
|
|
text=True,
|
|
capture_output=True,
|
|
)
|
|
return completed.stdout.strip()
|
|
|
|
|
|
def normalize_whitespace(text: str) -> str:
|
|
return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
def strip_html_comments(text: str) -> str:
|
|
return HTML_COMMENT_RE.sub("", text or "")
|
|
|
|
|
|
def cleanup_markdown_text(text: str) -> str:
|
|
cleaned_lines = []
|
|
for line in strip_html_comments(text).replace("\r\n", "\n").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
line = re.sub(r"^[-*+]\s*", "", line)
|
|
line = re.sub(r"^\d+\.\s*", "", line)
|
|
cleaned_lines.append(line)
|
|
return normalize_whitespace(" ".join(cleaned_lines))
|
|
|
|
|
|
def cleanup_title(title: str) -> str:
|
|
return normalize_whitespace(PR_NUMBER_RE.sub("", title or ""))
|
|
|
|
|
|
def extract_pr_number(title: str) -> int | None:
|
|
matches = PR_NUMBER_RE.findall(title or "")
|
|
return int(matches[-1]) if matches else None
|
|
|
|
|
|
def split_known_choice(raw_text: str, choices: list[str], default: str) -> str:
|
|
parts = [
|
|
cleanup_markdown_text(part)
|
|
for part in re.split(r'[,|\n]+', raw_text or '')
|
|
]
|
|
parts = [part for part in parts if part]
|
|
if not parts:
|
|
return default
|
|
for part in parts:
|
|
for choice in choices:
|
|
if part.casefold() == choice.casefold():
|
|
return choice
|
|
for part in parts:
|
|
lowered = part.casefold()
|
|
for choice in choices:
|
|
if choice.casefold() in lowered:
|
|
return choice
|
|
return default
|
|
|
|
|
|
def label_value(labels: Iterable[str], prefix: str) -> str | None:
|
|
prefix_lower = prefix.casefold()
|
|
for label in labels:
|
|
if label.casefold().startswith(prefix_lower):
|
|
_, value = label.split(':', 1)
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
def markdown_sections(body: str) -> dict[str, str]:
|
|
cleaned = (
|
|
strip_html_comments(body).replace("\r\n", "\n").replace("\r", "\n")
|
|
)
|
|
matches = list(HEADING_RE.finditer(cleaned))
|
|
sections: dict[str, str] = {}
|
|
for index, match in enumerate(matches):
|
|
start = match.end()
|
|
end = (
|
|
matches[index + 1].start()
|
|
if index + 1 < len(matches)
|
|
else len(cleaned)
|
|
)
|
|
sections[normalize_whitespace(match.group(1))] = cleaned[
|
|
start:end
|
|
].strip()
|
|
return sections
|
|
|
|
|
|
def section_value(sections: dict[str, str], name: str) -> str:
|
|
for key, value in sections.items():
|
|
if key.casefold() == name.casefold():
|
|
return value
|
|
return ""
|
|
|
|
|
|
def resolve_category(labels: list[str], body: str) -> str:
|
|
value = label_value(labels, 'release notes:')
|
|
if value:
|
|
return split_known_choice(value, PR_CATEGORIES, 'Others')
|
|
sections = markdown_sections(body)
|
|
return split_known_choice(
|
|
section_value(sections, 'PR Category'), PR_CATEGORIES, 'Others'
|
|
)
|
|
|
|
|
|
def resolve_topic(labels: list[str], body: str) -> str:
|
|
value = label_value(labels, 'topic:')
|
|
if value:
|
|
return split_known_choice(value, PR_TYPES, 'Others')
|
|
sections = markdown_sections(body)
|
|
return split_known_choice(
|
|
section_value(sections, 'PR Types'), PR_TYPES, 'Others'
|
|
)
|
|
|
|
|
|
def resolve_description(body: str) -> str:
|
|
sections = markdown_sections(body)
|
|
return cleanup_markdown_text(section_value(sections, 'Description'))
|
|
|
|
|
|
def load_token(explicit_token: str | None) -> str | None:
|
|
if explicit_token:
|
|
return explicit_token
|
|
for env_name in ('GITHUB_API_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'):
|
|
value = os.getenv(env_name)
|
|
if value:
|
|
return value
|
|
token_file = Path('~/.gh_tokenrc').expanduser()
|
|
if token_file.exists():
|
|
match = re.search(r'github_oauth\s*=\s*(\S+)', token_file.read_text())
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def collect_commits(
|
|
base_ref: str, head_ref: str, use_merge_base: bool
|
|
) -> list[CommitRecord]:
|
|
start_ref = base_ref
|
|
if use_merge_base:
|
|
start_ref = run_git('merge-base', base_ref, head_ref)
|
|
raw_log = run_git(
|
|
'log',
|
|
'--reverse',
|
|
'--pretty=format:%H%x1f%s%x1f%an%x1e',
|
|
f'{start_ref}..{head_ref}',
|
|
)
|
|
if not raw_log:
|
|
return []
|
|
|
|
commits = []
|
|
for record in raw_log.split('\x1e'):
|
|
record = record.strip()
|
|
if not record:
|
|
continue
|
|
commit_hash, title, author = record.split('\x1f')
|
|
commits.append(
|
|
CommitRecord(
|
|
commit_hash=commit_hash,
|
|
title=title,
|
|
git_author=author,
|
|
pr_number=extract_pr_number(title),
|
|
)
|
|
)
|
|
return commits
|
|
|
|
|
|
class PullRequestCache:
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._data: dict[str, dict[str, object]] = {}
|
|
if self.path.exists():
|
|
self._data = json.loads(self.path.read_text())
|
|
|
|
def get_many(self, numbers: Iterable[int]) -> dict[int, PullRequestRecord]:
|
|
records = {}
|
|
for number in numbers:
|
|
cached = self._data.get(str(number))
|
|
if cached is not None:
|
|
cached = dict(cached)
|
|
cached.setdefault('title', f'PR #{number}')
|
|
records[number] = PullRequestRecord(**cached)
|
|
return records
|
|
|
|
def update_many(self, records: Iterable[PullRequestRecord]) -> None:
|
|
changed = False
|
|
for record in records:
|
|
self._data[str(record.number)] = asdict(record)
|
|
changed = True
|
|
if changed:
|
|
self.path.write_text(
|
|
json.dumps(self._data, indent=2, sort_keys=True) + '\n'
|
|
)
|
|
|
|
|
|
class GitHubClient:
|
|
def __init__(self, token: str | None, owner: str, repo: str):
|
|
self.owner = owner
|
|
self.repo = repo
|
|
self.headers = {'Accept': 'application/vnd.github+json'}
|
|
if token:
|
|
self.headers['Authorization'] = f'bearer {token}'
|
|
|
|
def _request_graphql(self, query: str) -> dict[str, object]:
|
|
last_error: Exception | None = None
|
|
for attempt in range(3):
|
|
try:
|
|
payload = json.dumps({'query': query}).encode()
|
|
http_request = request.Request(
|
|
'https://api.github.com/graphql',
|
|
data=payload,
|
|
headers={
|
|
**self.headers,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
method='POST',
|
|
)
|
|
with request.urlopen(http_request, timeout=60) as response:
|
|
payload = json.loads(response.read().decode())
|
|
if payload.get('errors'):
|
|
raise RuntimeError(
|
|
f"GitHub GraphQL query failed: {payload['errors']}"
|
|
)
|
|
return payload
|
|
except error.HTTPError as http_error:
|
|
last_error = http_error
|
|
if http_error.code in RETRYABLE_STATUS_CODES and attempt < 2:
|
|
time.sleep(1 + attempt)
|
|
continue
|
|
raise
|
|
except (error.URLError, ValueError, RuntimeError) as request_error:
|
|
last_error = request_error
|
|
if attempt == 2:
|
|
raise
|
|
time.sleep(1 + attempt)
|
|
assert last_error is not None
|
|
raise last_error
|
|
|
|
def _fetch_batch(self, batch: list[int]) -> dict[int, PullRequestRecord]:
|
|
query_parts = []
|
|
for number in batch:
|
|
query_parts.append(
|
|
f'''
|
|
pr_{number}: pullRequest(number: {number}) {{
|
|
title
|
|
author {{ login }}
|
|
body
|
|
labels(first: 50) {{
|
|
nodes {{ name }}
|
|
}}
|
|
reviews(first: 100, states: APPROVED) {{
|
|
nodes {{
|
|
author {{ login }}
|
|
}}
|
|
}}
|
|
}}
|
|
'''
|
|
)
|
|
payload = self._request_graphql(
|
|
f'''
|
|
query {{
|
|
repository(owner: "{self.owner}", name: "{self.repo}") {{
|
|
{''.join(query_parts)}
|
|
}}
|
|
}}
|
|
'''
|
|
)
|
|
repository = payload['data']['repository']
|
|
results: dict[int, PullRequestRecord] = {}
|
|
for number in batch:
|
|
node = repository.get(f'pr_{number}')
|
|
if node is None:
|
|
continue
|
|
labels = [item['name'] for item in node['labels']['nodes']]
|
|
reviewers = sorted(
|
|
{
|
|
review['author']['login']
|
|
for review in node['reviews']['nodes']
|
|
if review.get('author')
|
|
}
|
|
)
|
|
body = node.get('body') or ''
|
|
results[number] = PullRequestRecord(
|
|
number=number,
|
|
title=node.get('title') or f'PR #{number}',
|
|
author=(node.get('author') or {}).get('login', ''),
|
|
labels=labels,
|
|
reviewers=reviewers,
|
|
category=resolve_category(labels, body),
|
|
topic=resolve_topic(labels, body),
|
|
description=resolve_description(body),
|
|
)
|
|
return results
|
|
|
|
def fetch_pull_requests(
|
|
self, numbers: list[int], batch_size: int, workers: int
|
|
) -> dict[int, PullRequestRecord]:
|
|
unique_numbers = sorted(set(numbers))
|
|
if not unique_numbers:
|
|
return {}
|
|
batches = [
|
|
unique_numbers[index : index + batch_size]
|
|
for index in range(0, len(unique_numbers), batch_size)
|
|
]
|
|
results: dict[int, PullRequestRecord] = {}
|
|
if workers <= 1 or len(batches) == 1:
|
|
for batch in batches:
|
|
results.update(self._fetch_batch(batch))
|
|
return results
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
future_to_batch = {
|
|
executor.submit(self._fetch_batch, batch): batch
|
|
for batch in batches
|
|
}
|
|
for future in as_completed(future_to_batch):
|
|
results.update(future.result())
|
|
return results
|
|
|
|
|
|
def build_commit_rows(
|
|
commits: list[CommitRecord],
|
|
pull_requests: dict[int, PullRequestRecord],
|
|
owner: str,
|
|
repo: str,
|
|
) -> tuple[list[CommitRow], list[int]]:
|
|
rows: list[CommitRow] = []
|
|
missing_prs: set[int] = set()
|
|
|
|
for commit in commits:
|
|
if commit.pr_number is None:
|
|
rows.append(
|
|
CommitRow(
|
|
commit_hash=commit.commit_hash,
|
|
category='Others',
|
|
topic='Others',
|
|
title=commit.title,
|
|
pr_link='',
|
|
author=commit.git_author,
|
|
labels='',
|
|
accepter_1='',
|
|
accepter_2='',
|
|
accepter_3='',
|
|
description='',
|
|
)
|
|
)
|
|
continue
|
|
|
|
pr_record = pull_requests.get(commit.pr_number)
|
|
if pr_record is None:
|
|
missing_prs.add(commit.pr_number)
|
|
rows.append(
|
|
CommitRow(
|
|
commit_hash=commit.commit_hash,
|
|
category='Others',
|
|
topic='Others',
|
|
title=commit.title,
|
|
pr_link=f'https://github.com/{owner}/{repo}/pull/{commit.pr_number}',
|
|
author=commit.git_author,
|
|
labels='',
|
|
accepter_1='',
|
|
accepter_2='',
|
|
accepter_3='',
|
|
description='',
|
|
)
|
|
)
|
|
continue
|
|
|
|
accepters = [*pr_record.reviewers, '', '', ''][:3]
|
|
rows.append(
|
|
CommitRow(
|
|
commit_hash=commit.commit_hash,
|
|
category=pr_record.category,
|
|
topic=pr_record.topic,
|
|
title=pr_record.title or commit.title,
|
|
pr_link=f'https://github.com/{owner}/{repo}/pull/{commit.pr_number}',
|
|
author=pr_record.author or commit.git_author,
|
|
labels=','.join(pr_record.labels),
|
|
accepter_1=accepters[0],
|
|
accepter_2=accepters[1],
|
|
accepter_3=accepters[2],
|
|
description=pr_record.description,
|
|
)
|
|
)
|
|
|
|
return rows, sorted(missing_prs)
|
|
|
|
|
|
def ordered_values(
|
|
items: Iterable[str], preferred_order: list[str]
|
|
) -> list[str]:
|
|
values = {item for item in items if item}
|
|
ordered = [value for value in preferred_order if value in values]
|
|
extras = sorted(values - set(preferred_order), key=str.casefold)
|
|
return ordered + extras
|
|
|
|
|
|
def get_hash_or_pr_url(commit: CommitRow) -> str:
|
|
if not commit.pr_link:
|
|
return commit.commit_hash
|
|
matches = re.findall(
|
|
r'https://github.com/[^/]+/[^/]+/pull/(\d+)',
|
|
commit.pr_link,
|
|
)
|
|
if not matches:
|
|
return commit.commit_hash
|
|
return f'[#{matches[0]}]({commit.pr_link})'
|
|
|
|
|
|
def markdown_entry_text(commit: CommitRow) -> str:
|
|
return cleanup_title(commit.title)
|
|
|
|
|
|
def category_output_name(category: str) -> str:
|
|
cleaned = re.sub(r'[^0-9A-Za-z._-]+', '_', category.strip())
|
|
cleaned = cleaned.strip('._')
|
|
return cleaned or 'Others'
|
|
|
|
|
|
def get_markdown_header(category: str) -> str:
|
|
return (
|
|
f'# Release Notes worksheet {category}\n'
|
|
'- polish PR title to make it human read friendly.\n'
|
|
'- edit, delete, merge multiple PRs.\n'
|
|
'- summarize notes for this category.\n\n'
|
|
)
|
|
|
|
|
|
def render_markdown(
|
|
commits: list[CommitRow], base_ref: str, head_ref: str
|
|
) -> str:
|
|
lines = [
|
|
'# Release Notes\n\n',
|
|
f'- Range: `{base_ref}..{head_ref}`\n',
|
|
f'- Commits: {len(commits)}\n\n',
|
|
]
|
|
|
|
categories = ordered_values(
|
|
(commit.category for commit in commits), PR_CATEGORIES
|
|
)
|
|
for category in categories:
|
|
lines.append(f'## {category}\n\n')
|
|
category_commits = [
|
|
commit for commit in commits if commit.category == category
|
|
]
|
|
topics = ordered_values(
|
|
(commit.topic for commit in category_commits), PR_TYPES
|
|
)
|
|
for topic in topics:
|
|
topic_commits = [
|
|
commit for commit in category_commits if commit.topic == topic
|
|
]
|
|
if not topic_commits:
|
|
continue
|
|
lines.append(f'### {topic}\n\n')
|
|
for commit in topic_commits:
|
|
lines.append(
|
|
f'- {markdown_entry_text(commit)} ({get_hash_or_pr_url(commit)})\n'
|
|
)
|
|
lines.append('\n')
|
|
return ''.join(lines)
|
|
|
|
|
|
def render_category_markdown(category: str, commits: list[CommitRow]) -> str:
|
|
lines = [get_markdown_header(category), f'## {category}\n\n']
|
|
topics = ordered_values((commit.topic for commit in commits), PR_TYPES)
|
|
for topic in topics:
|
|
topic_commits = [commit for commit in commits if commit.topic == topic]
|
|
if not topic_commits:
|
|
continue
|
|
lines.append(f'### {topic}\n\n')
|
|
for commit in topic_commits:
|
|
lines.append(
|
|
f'- {markdown_entry_text(commit)} ({get_hash_or_pr_url(commit)})\n'
|
|
)
|
|
lines.append('\n')
|
|
return ''.join(lines)
|
|
|
|
|
|
def write_csv(path: Path, rows: list[CommitRow]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open('w', newline='') as csv_file:
|
|
writer = csv.writer(csv_file)
|
|
writer.writerow(COMMIT_FIELDS)
|
|
for row in rows:
|
|
writer.writerow([getattr(row, field) for field in COMMIT_FIELDS])
|
|
|
|
|
|
def write_lines(path: Path, lines: Iterable[str]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(''.join(f'{line}\n' for line in lines))
|
|
|
|
|
|
def write_category_exports(
|
|
export_dir: Path, rows: list[CommitRow]
|
|
) -> list[Path]:
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
written_files: list[Path] = []
|
|
categories = ordered_values((row.category for row in rows), PR_CATEGORIES)
|
|
for category in categories:
|
|
category_rows = [row for row in rows if row.category == category]
|
|
if not category_rows:
|
|
continue
|
|
category_name = category_output_name(category)
|
|
category_dir = export_dir / category_name
|
|
markdown_path = category_dir / f'result_{category_name}.md'
|
|
csv_path = category_dir / f'result_{category_name}.csv'
|
|
markdown_path.parent.mkdir(parents=True, exist_ok=True)
|
|
markdown_path.write_text(
|
|
render_category_markdown(category, category_rows)
|
|
)
|
|
write_csv(csv_path, category_rows)
|
|
written_files.extend([markdown_path, csv_path])
|
|
return written_files
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description='Generate release-note CSV and Markdown between two commits in one command.'
|
|
)
|
|
parser.add_argument('base_ref', help='Base git ref or commit')
|
|
parser.add_argument('head_ref', help='Head git ref or commit')
|
|
parser.add_argument(
|
|
'--output-dir',
|
|
default='results',
|
|
help='Directory for generated files (default: results)',
|
|
)
|
|
parser.add_argument(
|
|
'--owner',
|
|
default=DEFAULT_OWNER,
|
|
help=f'GitHub owner (default: {DEFAULT_OWNER})',
|
|
)
|
|
parser.add_argument(
|
|
'--repo',
|
|
default=DEFAULT_REPO,
|
|
help=f'GitHub repo (default: {DEFAULT_REPO})',
|
|
)
|
|
parser.add_argument(
|
|
'--token',
|
|
help='GitHub token; falls back to env vars or ~/.gh_tokenrc',
|
|
)
|
|
parser.add_argument(
|
|
'--cache-path',
|
|
default='results/pr_cache.json',
|
|
help='PR metadata cache path (default: results/pr_cache.json)',
|
|
)
|
|
parser.add_argument(
|
|
'--batch-size',
|
|
type=int,
|
|
default=25,
|
|
help='How many PRs to fetch per GitHub GraphQL request (default: 25)',
|
|
)
|
|
parser.add_argument(
|
|
'--workers',
|
|
type=int,
|
|
default=4,
|
|
help='How many GraphQL batches to fetch concurrently (default: 4)',
|
|
)
|
|
parser.add_argument(
|
|
'--direct-range',
|
|
action='store_true',
|
|
help='Use base_ref..head_ref directly instead of merge-base(base_ref, head_ref)..head_ref',
|
|
)
|
|
parser.add_argument(
|
|
'--local-only',
|
|
action='store_true',
|
|
help='Skip GitHub API calls and only use local git metadata',
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
output_dir = Path(args.output_dir)
|
|
csv_path = output_dir / 'commitlist.csv'
|
|
export_dir = output_dir / 'export'
|
|
contributors_path = output_dir / 'contributors.txt'
|
|
|
|
commits = collect_commits(
|
|
args.base_ref,
|
|
args.head_ref,
|
|
use_merge_base=not args.direct_range,
|
|
)
|
|
if not commits:
|
|
raise SystemExit(
|
|
f'No commits found between {args.base_ref} and {args.head_ref}.'
|
|
)
|
|
|
|
pull_requests: dict[int, PullRequestRecord] = {}
|
|
pr_numbers = sorted(
|
|
{commit.pr_number for commit in commits if commit.pr_number is not None}
|
|
)
|
|
if args.local_only and pr_numbers:
|
|
print(
|
|
'Warning: running in --local-only mode. PR metadata will not be fetched, '
|
|
'so category/topic fall back to Others and descriptions stay empty.',
|
|
file=sys.stderr,
|
|
)
|
|
if not args.local_only and pr_numbers:
|
|
token = load_token(args.token)
|
|
if not token:
|
|
print(
|
|
'Warning: GitHub token not found. Falling back to unauthenticated '
|
|
'GitHub API requests; this may hit rate limits on large ranges.',
|
|
file=sys.stderr,
|
|
)
|
|
cache = PullRequestCache(Path(args.cache_path))
|
|
cached = cache.get_many(pr_numbers)
|
|
missing = [
|
|
number
|
|
for number in pr_numbers
|
|
if number not in cached or cached[number].title == f'PR #{number}'
|
|
]
|
|
fetched = GitHubClient(
|
|
args.token or token, args.owner, args.repo
|
|
).fetch_pull_requests(
|
|
missing,
|
|
batch_size=max(1, args.batch_size),
|
|
workers=max(1, args.workers),
|
|
)
|
|
cache.update_many(fetched.values())
|
|
pull_requests = {**cached, **fetched}
|
|
|
|
rows, missing_prs = build_commit_rows(
|
|
commits, pull_requests, args.owner, args.repo
|
|
)
|
|
contributors = sorted(
|
|
{commit.git_author for commit in commits if commit.git_author},
|
|
key=str.casefold,
|
|
)
|
|
|
|
write_csv(csv_path, rows)
|
|
exported_files = write_category_exports(export_dir, rows)
|
|
write_lines(contributors_path, contributors)
|
|
|
|
if missing_prs and not args.local_only:
|
|
print(
|
|
'Warning: failed to fetch metadata for PRs '
|
|
+ ', '.join(f'#{number}' for number in missing_prs)
|
|
+ '. Fallback commit metadata was used.',
|
|
file=sys.stderr,
|
|
)
|
|
|
|
print(f'Generated {len(rows)} rows from {len(commits)} commits.')
|
|
print(f'CSV: {csv_path}')
|
|
print(f'Export dir: {export_dir}')
|
|
print(f'Category exports: {len(exported_files)} files')
|
|
print(f'Contributors: {contributors_path}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|