From 1c874d109878e82fd9f148f3ed4cb26e4dfb6d64 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 May 2025 01:30:15 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E3=83=AA=E3=83=95=E3=82=A1=E3=82=AF?= =?UTF-8?q?=E3=82=BF=E3=83=AA=E3=83=B3=E3=82=B0:=20PR=E5=88=86=E6=9E=90?= =?UTF-8?q?=E3=83=84=E3=83=BC=E3=83=AB=E3=82=92=E3=83=A2=E3=82=B8=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E6=A7=8B=E9=80=A0=E3=81=AB=E5=86=8D=E7=B7=A8?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHubのAPI操作を専用モジュールに分離 - PRデータ取得機能をfetcherモジュールに整理 - データ分析機能をanalyzerモジュールに整理 - レポート生成機能をreporterモジュールに整理 - 共通ユーティリティをutilsモジュールに抽出 - コマンドラインインターフェースをcliモジュールに整理 - 型アノテーションを追加して可読性を向上 - エラー処理を一貫して実装 Co-Authored-By: NISHIO Hirokazu --- pr-analysis/.gitignore | 54 +++ pr-analysis/README.md | 91 ++++ pr-analysis/pyproject.toml | 67 +++ pr-analysis/src/pr_analysis/__init__.py | 43 ++ .../src/pr_analysis/analyzer/__init__.py | 10 + .../analyzer/content_classifier.py | 172 +++++++ .../src/pr_analysis/analyzer/pr_analyzer.py | 329 +++++++++++++ pr-analysis/src/pr_analysis/api/__init__.py | 9 + pr-analysis/src/pr_analysis/api/github.py | 311 ++++++++++++ pr-analysis/src/pr_analysis/cli/__init__.py | 11 + pr-analysis/src/pr_analysis/cli/analyzer.py | 62 +++ pr-analysis/src/pr_analysis/cli/fetcher.py | 80 ++++ pr-analysis/src/pr_analysis/cli/reporter.py | 116 +++++ .../src/pr_analysis/fetcher/__init__.py | 9 + .../src/pr_analysis/fetcher/pr_fetcher.py | 451 ++++++++++++++++++ .../src/pr_analysis/reporter/__init__.py | 21 + .../src/pr_analysis/reporter/csv_generator.py | 163 +++++++ .../reporter/markdown_generator.py | 322 +++++++++++++ pr-analysis/src/pr_analysis/utils/__init__.py | 10 + .../src/pr_analysis/utils/date_utils.py | 156 ++++++ .../src/pr_analysis/utils/file_utils.py | 132 +++++ 21 files changed, 2619 insertions(+) create mode 100644 pr-analysis/.gitignore create mode 100644 pr-analysis/README.md create mode 100644 pr-analysis/pyproject.toml create mode 100644 pr-analysis/src/pr_analysis/__init__.py create mode 100644 pr-analysis/src/pr_analysis/analyzer/__init__.py create mode 100644 pr-analysis/src/pr_analysis/analyzer/content_classifier.py create mode 100644 pr-analysis/src/pr_analysis/analyzer/pr_analyzer.py create mode 100644 pr-analysis/src/pr_analysis/api/__init__.py create mode 100644 pr-analysis/src/pr_analysis/api/github.py create mode 100644 pr-analysis/src/pr_analysis/cli/__init__.py create mode 100644 pr-analysis/src/pr_analysis/cli/analyzer.py create mode 100644 pr-analysis/src/pr_analysis/cli/fetcher.py create mode 100644 pr-analysis/src/pr_analysis/cli/reporter.py create mode 100644 pr-analysis/src/pr_analysis/fetcher/__init__.py create mode 100644 pr-analysis/src/pr_analysis/fetcher/pr_fetcher.py create mode 100644 pr-analysis/src/pr_analysis/reporter/__init__.py create mode 100644 pr-analysis/src/pr_analysis/reporter/csv_generator.py create mode 100644 pr-analysis/src/pr_analysis/reporter/markdown_generator.py create mode 100644 pr-analysis/src/pr_analysis/utils/__init__.py create mode 100644 pr-analysis/src/pr_analysis/utils/date_utils.py create mode 100644 pr-analysis/src/pr_analysis/utils/file_utils.py diff --git a/pr-analysis/.gitignore b/pr-analysis/.gitignore new file mode 100644 index 0000000..41e0748 --- /dev/null +++ b/pr-analysis/.gitignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.env/ +.venv/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.json +*.csv +*.md +!README.md +!CONTRIBUTING.md +!LICENSE.md diff --git a/pr-analysis/README.md b/pr-analysis/README.md new file mode 100644 index 0000000..324d1cc --- /dev/null +++ b/pr-analysis/README.md @@ -0,0 +1,91 @@ +# PR Analysis Tool + +GitHub Pull Requestを分析するためのツールです。PRの内容を取得し、分析レポートを生成します。 + +## 機能 + +- GitHub APIを使用したPRデータの取得 +- PRデータの分析と統計情報の生成 +- マークダウン形式のレポート生成 +- CSVへのデータエクスポート +- コンテンツの自動分類 + +## インストール + +```bash +pip install pr-analysis +``` + +または、ソースからインストール: + +```bash +git clone https://github.com/team-mirai/pr-analysis.git +cd pr-analysis +pip install -e . +``` + +## 使用方法 + +### コマンドライン + +```bash +# PRデータを取得 +pr-fetcher --owner team-mirai --repo policy --output prs_data.json + +# PRデータを分析してレポートを生成 +pr-analyzer --input prs_data.json --output report.md + +# CSVにエクスポート +pr-reporter --input prs_data.json --format csv --output prs_data.csv +``` + +### Pythonコード + +```python +from pr_analysis.fetcher import fetch_prs +from pr_analysis.analyzer import analyze_prs +from pr_analysis.reporter import generate_report + +# PRデータを取得 +prs_data = fetch_prs(owner="team-mirai", repo="policy") + +# PRデータを分析 +analysis_results = analyze_prs(prs_data) + +# レポートを生成 +generate_report(analysis_results, output_format="markdown", output_file="report.md") +``` + +## 開発 + +### 環境設定 + +```bash +# 開発用依存関係をインストール +pip install -e ".[dev]" + +# テストを実行 +pytest +``` + +### プロジェクト構造 + +``` +pr-analysis/ +├── src/ +│ ├── pr_analysis/ +│ │ ├── __init__.py +│ │ ├── api/ # GitHub API操作 +│ │ ├── fetcher/ # PRデータ取得 +│ │ ├── analyzer/ # データ分析 +│ │ ├── reporter/ # レポート生成 +│ │ └── utils/ # 共通ユーティリティ +├── tests/ # テストコード +├── scripts/ # コマンドラインスクリプト +├── docs/ # ドキュメント +└── examples/ # 使用例 +``` + +## ライセンス + +MIT diff --git a/pr-analysis/pyproject.toml b/pr-analysis/pyproject.toml new file mode 100644 index 0000000..e17602c --- /dev/null +++ b/pr-analysis/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pr_analysis" +version = "0.1.0" +description = "A tool for analyzing GitHub Pull Requests" +readme = "README.md" +authors = [ + {name = "team-mirai", email = "info@example.com"} +] +license = {text = "MIT"} +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +requires-python = ">=3.8" +dependencies = [ + "requests>=2.25.0", + "PyGithub>=1.55", + "pandas>=1.3.0", + "matplotlib>=3.4.0", + "openai>=0.27.0", + "backoff>=2.0.0", + "tqdm>=4.62.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=6.0", + "pytest-cov>=2.12", + "black>=21.5b2", + "isort>=5.9.1", + "flake8>=3.9.2", + "mypy>=0.812", +] + +[project.scripts] +pr-analyzer = "pr_analysis.cli.analyzer:main" +pr-fetcher = "pr_analysis.cli.fetcher:main" +pr-reporter = "pr_analysis.cli.reporter:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.black] +line-length = 88 +target-version = ["py38"] +include = '\.pyi?$' + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_functions = "test_*" diff --git a/pr-analysis/src/pr_analysis/__init__.py b/pr-analysis/src/pr_analysis/__init__.py new file mode 100644 index 0000000..7a6a467 --- /dev/null +++ b/pr-analysis/src/pr_analysis/__init__.py @@ -0,0 +1,43 @@ +""" +PR Analysis パッケージ + +GitHub Pull Requestを分析するためのツールです。 +""" + +from .api.github import GitHubAPIClient +from .fetcher import PRFetcher, fetch_prs +from .analyzer import ContentClassifier, classify_content, is_readme_pr, PRAnalyzer, analyze_prs +from .reporter import ( + generate_markdown, + generate_summary_markdown, + generate_issues_and_diffs_markdown, + generate_file_based_markdown, + convert_json_to_csv, +) +from .utils import load_json_file, save_json_file, parse_datetime, format_datetime + +__version__ = "0.1.0" + +__all__ = [ + "GitHubAPIClient", + + "PRFetcher", + "fetch_prs", + + "ContentClassifier", + "classify_content", + "is_readme_pr", + "PRAnalyzer", + "analyze_prs", + + "generate_markdown", + "generate_summary_markdown", + "generate_issues_and_diffs_markdown", + "generate_file_based_markdown", + "convert_json_to_csv", + + "load_json_file", + "save_json_file", + "parse_datetime", + "format_datetime", +] diff --git a/pr-analysis/src/pr_analysis/analyzer/__init__.py b/pr-analysis/src/pr_analysis/analyzer/__init__.py new file mode 100644 index 0000000..c59c7d3 --- /dev/null +++ b/pr-analysis/src/pr_analysis/analyzer/__init__.py @@ -0,0 +1,10 @@ +""" +PR Analyzer モジュール + +GitHub Pull Requestデータを分析するための機能を提供します。 +""" + +from .content_classifier import ContentClassifier, classify_content, is_readme_pr +from .pr_analyzer import PRAnalyzer, analyze_prs + +__all__ = ["ContentClassifier", "classify_content", "is_readme_pr", "PRAnalyzer", "analyze_prs"] diff --git a/pr-analysis/src/pr_analysis/analyzer/content_classifier.py b/pr-analysis/src/pr_analysis/analyzer/content_classifier.py new file mode 100644 index 0000000..c34baac --- /dev/null +++ b/pr-analysis/src/pr_analysis/analyzer/content_classifier.py @@ -0,0 +1,172 @@ +""" +コンテンツ分類モジュール + +Pull Requestの内容を分析し、分類するための機能を提供します。 +""" + +import os +import re +from typing import Dict, List, Optional, Any, Union, Tuple + +import backoff +import requests + + +class ContentClassifier: + """Pull Requestの内容を分類するためのクラス""" + + def __init__(self, api_key: Optional[str] = None): + """ + ContentClassifierを初期化する + + Args: + api_key: OpenRouter APIキー(指定がない場合は環境変数から取得) + """ + self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY") + if not self.api_key: + print("警告: OPENROUTER_API_KEYが設定されていません。分類機能は利用できません。") + + @backoff.on_exception( + backoff.expo, + requests.exceptions.RequestException, + max_tries=3, + ) + def classify_content(self, content: str, categories: List[str]) -> Dict[str, float]: + """ + コンテンツを指定されたカテゴリに分類する + + Args: + content: 分類するコンテンツ + categories: 分類カテゴリのリスト + + Returns: + カテゴリごとの確率を含む辞書 + """ + if not self.api_key: + print("エラー: OPENROUTER_API_KEYが設定されていません。分類を実行できません。") + return {category: 0.0 for category in categories} + + categories_str = ", ".join(categories) + + prompt = f""" + 以下のテキストを次のカテゴリに分類してください: {categories_str} + + 各カテゴリについて、テキストがそのカテゴリに属する確率を0から1の間で評価してください。 + 回答は以下の形式で返してください: + カテゴリ1: 0.X + カテゴリ2: 0.Y + ... + + テキスト: + {content} + """ + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + data = { + "model": "anthropic/claude-3-opus", + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": 0.0, + } + + try: + response = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=data, + ) + response.raise_for_status() + + result = response.json() + content = result["choices"][0]["message"]["content"] + + scores = {} + for line in content.strip().split("\n"): + if ":" in line: + category, score_str = line.split(":", 1) + category = category.strip() + try: + score = float(score_str.strip()) + if category in categories: + scores[category] = score + except ValueError: + pass + + for category in categories: + if category not in scores: + scores[category] = 0.0 + + return scores + + except Exception as e: + print(f"分類中にエラーが発生しました: {e}") + return {category: 0.0 for category in categories} + + def is_readme_pr(self, pr_data: Dict[str, Any]) -> bool: + """ + PRがREADMEの変更かどうかを判定する + + Args: + pr_data: PR情報の辞書 + + Returns: + READMEの変更の場合はTrue、それ以外はFalse + """ + files = pr_data.get("files", []) + for file in files: + filename = file.get("filename", "").lower() + if "readme" in filename or "documentation" in filename: + return True + + commits = pr_data.get("commits", []) + for commit in commits: + message = commit.get("commit", {}).get("message", "").lower() + if "readme" in message or "documentation" in message or "docs" in message: + return True + + basic_info = pr_data.get("basic_info", {}) + title = basic_info.get("title", "").lower() + body = basic_info.get("body", "").lower() + + if "readme" in title or "documentation" in title or "docs" in title: + return True + + if body and ("readme" in body or "documentation" in body or "docs:" in body): + return True + + return False + + +def classify_content(content: str, categories: List[str], api_key: Optional[str] = None) -> Dict[str, float]: + """ + コンテンツを指定されたカテゴリに分類する便利関数 + + Args: + content: 分類するコンテンツ + categories: 分類カテゴリのリスト + api_key: OpenRouter APIキー(指定がない場合は環境変数から取得) + + Returns: + カテゴリごとの確率を含む辞書 + """ + classifier = ContentClassifier(api_key) + return classifier.classify_content(content, categories) + + +def is_readme_pr(pr_data: Dict[str, Any]) -> bool: + """ + PRがREADMEの変更かどうかを判定する便利関数 + + Args: + pr_data: PR情報の辞書 + + Returns: + READMEの変更の場合はTrue、それ以外はFalse + """ + classifier = ContentClassifier() + return classifier.is_readme_pr(pr_data) diff --git a/pr-analysis/src/pr_analysis/analyzer/pr_analyzer.py b/pr-analysis/src/pr_analysis/analyzer/pr_analyzer.py new file mode 100644 index 0000000..23f8fe6 --- /dev/null +++ b/pr-analysis/src/pr_analysis/analyzer/pr_analyzer.py @@ -0,0 +1,329 @@ +""" +PR Analyzer モジュール + +GitHub Pull Requestデータを分析するための機能を提供します。 +""" + +import datetime +import json +import os +from pathlib import Path +from typing import Dict, List, Optional, Any, Union, Tuple + +from ..api.github import GitHubAPIClient +from .content_classifier import ContentClassifier, is_readme_pr + + +class PRAnalyzer: + """Pull Requestデータを分析するためのクラス""" + + def __init__(self, repo_owner: str, repo_name: str): + """ + PRAnalyzerを初期化する + + Args: + repo_owner: リポジトリのオーナー名 + repo_name: リポジトリ名 + """ + self.repo_owner = repo_owner + self.repo_name = repo_name + self.github_client = GitHubAPIClient(repo_owner, repo_name) + self.content_classifier = ContentClassifier() + + def analyze_pr(self, pr_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 1つのPRを分析する + + Args: + pr_data: 分析するPR情報 + + Returns: + 分析結果を含む辞書 + """ + analysis_result = { + "pr_id": pr_data.get("basic_info", {}).get("number"), + "is_readme_pr": is_readme_pr(pr_data), + "file_stats": self._analyze_files(pr_data.get("files", [])), + "commit_stats": self._analyze_commits(pr_data.get("commits", [])), + "comment_stats": self._analyze_comments( + pr_data.get("comments", []), pr_data.get("review_comments", []) + ), + "labels": [label.get("name") for label in pr_data.get("labels", [])], + "created_at": pr_data.get("basic_info", {}).get("created_at"), + "updated_at": pr_data.get("basic_info", {}).get("updated_at"), + "state": pr_data.get("basic_info", {}).get("state"), + "user": pr_data.get("basic_info", {}).get("user", {}).get("login"), + } + + basic_info = pr_data.get("basic_info", {}) + title = basic_info.get("title", "") + body = basic_info.get("body", "") + content = f"{title}\n\n{body}" + + if content.strip(): + categories = ["バグ修正", "機能追加", "リファクタリング", "ドキュメント", "テスト", "設定変更"] + try: + classification = self.content_classifier.classify_content(content, categories) + analysis_result["classification"] = classification + except Exception as e: + print(f"PR #{analysis_result['pr_id']} の内容分類中にエラーが発生しました: {e}") + + return analysis_result + + def _analyze_files(self, files: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + PRの変更ファイルを分析する + + Args: + files: 変更ファイル情報のリスト + + Returns: + ファイル分析結果 + """ + if not files: + return {"total_files": 0} + + extensions = {} + total_additions = 0 + total_deletions = 0 + total_changes = 0 + file_paths = [] + + for file in files: + filename = file.get("filename", "") + file_paths.append(filename) + + ext = os.path.splitext(filename)[1].lower() + if ext: + extensions[ext] = extensions.get(ext, 0) + 1 + + additions = file.get("additions", 0) + deletions = file.get("deletions", 0) + changes = file.get("changes", 0) + + total_additions += additions + total_deletions += deletions + total_changes += changes + + return { + "total_files": len(files), + "extensions": extensions, + "total_additions": total_additions, + "total_deletions": total_deletions, + "total_changes": total_changes, + "file_paths": file_paths, + } + + def _analyze_commits(self, commits: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + PRのコミットを分析する + + Args: + commits: コミット情報のリスト + + Returns: + コミット分析結果 + """ + if not commits: + return {"total_commits": 0} + + commit_messages = [] + authors = set() + + for commit in commits: + message = commit.get("commit", {}).get("message", "") + if message: + commit_messages.append(message) + + author = commit.get("author", {}) + if author and "login" in author: + authors.add(author["login"]) + + return { + "total_commits": len(commits), + "commit_messages": commit_messages, + "unique_authors": list(authors), + } + + def _analyze_comments( + self, comments: List[Dict[str, Any]], review_comments: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + PRのコメントを分析する + + Args: + comments: コメント情報のリスト + review_comments: レビューコメント情報のリスト + + Returns: + コメント分析結果 + """ + total_comments = len(comments) + len(review_comments) + if total_comments == 0: + return {"total_comments": 0} + + comment_authors = set() + review_comment_authors = set() + + for comment in comments: + author = comment.get("user", {}).get("login") + if author: + comment_authors.add(author) + + for comment in review_comments: + author = comment.get("user", {}).get("login") + if author: + review_comment_authors.add(author) + + return { + "total_comments": total_comments, + "issue_comments": len(comments), + "review_comments": len(review_comments), + "comment_authors": list(comment_authors), + "review_comment_authors": list(review_comment_authors), + "all_comment_authors": list(comment_authors.union(review_comment_authors)), + } + + def analyze_prs(self, prs_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 複数のPRを分析する + + Args: + prs_data: 分析するPR情報のリスト + + Returns: + 分析結果のリスト + """ + analysis_results = [] + for pr_data in prs_data: + try: + result = self.analyze_pr(pr_data) + analysis_results.append(result) + except Exception as e: + pr_id = pr_data.get("basic_info", {}).get("number", "不明") + print(f"PR #{pr_id} の分析中にエラーが発生しました: {e}") + + return analysis_results + + def save_analysis_results(self, results: List[Dict[str, Any]], output_file: str) -> None: + """ + 分析結果をJSON形式で保存する + + Args: + results: 保存する分析結果 + output_file: 保存先ファイル名 + """ + os.makedirs(os.path.dirname(output_file), exist_ok=True) + with open(output_file, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"分析結果を {output_file} に保存しました") + + def generate_summary_stats(self, analysis_results: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + 分析結果から要約統計を生成する + + Args: + analysis_results: 分析結果のリスト + + Returns: + 要約統計情報 + """ + if not analysis_results: + return {"total_prs": 0} + + state_counts = {"open": 0, "closed": 0, "merged": 0} + + user_pr_counts = {} + + extension_counts = {} + + total_additions = 0 + total_deletions = 0 + total_changes = 0 + + total_files = 0 + + total_commits = 0 + + total_comments = 0 + + label_counts = {} + + date_pr_counts = {} + + for result in analysis_results: + state = result.get("state", "") + if state == "closed" and result.get("basic_info", {}).get("merged", False): + state = "merged" + state_counts[state] = state_counts.get(state, 0) + 1 + + user = result.get("user", "") + if user: + user_pr_counts[user] = user_pr_counts.get(user, 0) + 1 + + file_stats = result.get("file_stats", {}) + extensions = file_stats.get("extensions", {}) + for ext, count in extensions.items(): + extension_counts[ext] = extension_counts.get(ext, 0) + count + + total_additions += file_stats.get("total_additions", 0) + total_deletions += file_stats.get("total_deletions", 0) + total_changes += file_stats.get("total_changes", 0) + total_files += file_stats.get("total_files", 0) + + commit_stats = result.get("commit_stats", {}) + total_commits += commit_stats.get("total_commits", 0) + + comment_stats = result.get("comment_stats", {}) + total_comments += comment_stats.get("total_comments", 0) + + labels = result.get("labels", []) + for label in labels: + label_counts[label] = label_counts.get(label, 0) + 1 + + created_at = result.get("created_at", "") + if created_at: + date = created_at.split("T")[0] # YYYY-MM-DD形式 + date_pr_counts[date] = date_pr_counts.get(date, 0) + 1 + + return { + "total_prs": len(analysis_results), + "state_counts": state_counts, + "user_pr_counts": user_pr_counts, + "extension_counts": extension_counts, + "total_additions": total_additions, + "total_deletions": total_deletions, + "total_changes": total_changes, + "total_files": total_files, + "total_commits": total_commits, + "total_comments": total_comments, + "label_counts": label_counts, + "date_pr_counts": date_pr_counts, + } + + +def analyze_prs( + prs_data: List[Dict[str, Any]], + repo_owner: str, + repo_name: str, + output_file: Optional[str] = None, +) -> List[Dict[str, Any]]: + """ + Pull Requestデータを分析する便利関数 + + Args: + prs_data: 分析するPR情報のリスト + repo_owner: リポジトリのオーナー名 + repo_name: リポジトリ名 + output_file: 分析結果の保存先ファイル名 + + Returns: + 分析結果のリスト + """ + analyzer = PRAnalyzer(repo_owner, repo_name) + results = analyzer.analyze_prs(prs_data) + + if output_file: + analyzer.save_analysis_results(results, output_file) + + return results diff --git a/pr-analysis/src/pr_analysis/api/__init__.py b/pr-analysis/src/pr_analysis/api/__init__.py new file mode 100644 index 0000000..3ecd602 --- /dev/null +++ b/pr-analysis/src/pr_analysis/api/__init__.py @@ -0,0 +1,9 @@ +""" +GitHub API モジュール + +GitHub APIを使用してPull Requestデータを取得するための機能を提供します。 +""" + +from .github import GitHubAPIClient + +__all__ = ["GitHubAPIClient"] diff --git a/pr-analysis/src/pr_analysis/api/github.py b/pr-analysis/src/pr_analysis/api/github.py new file mode 100644 index 0000000..70f37d4 --- /dev/null +++ b/pr-analysis/src/pr_analysis/api/github.py @@ -0,0 +1,311 @@ +""" +GitHub API操作モジュール + +GitHubのAPIを使用してPull Requestデータを取得するための機能を提供します。 +""" + +import datetime +import os +import time +from typing import Dict, List, Optional, Union, Any + +import backoff +import requests +from tqdm import tqdm + + +class GitHubAPIClient: + """GitHubのAPIを操作するためのクライアントクラス""" + + def __init__(self, repo_owner: str, repo_name: str, api_base_url: str = "https://api.github.com"): + """ + GitHubAPIClientを初期化する + + Args: + repo_owner: リポジトリのオーナー名 + repo_name: リポジトリ名 + api_base_url: GitHub APIのベースURL + """ + self.repo_owner = repo_owner + self.repo_name = repo_name + self.api_base_url = api_base_url + self.headers = self._get_headers() + + def _get_github_token(self) -> Optional[str]: + """環境変数からGitHubトークンを取得する""" + token = os.environ.get("GITHUB_TOKEN") + if not token: + try: + import subprocess + + result = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True) + if result.returncode == 0: + token = result.stdout.strip() + except Exception as e: + print(f"gh CLIからトークンを取得できませんでした: {e}") + + return token + + def _get_headers(self) -> Dict[str, str]: + """APIリクエスト用のヘッダーを取得する""" + token = self._get_github_token() + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + + return headers + + @backoff.on_exception( + backoff.expo, + (requests.exceptions.RequestException, requests.exceptions.HTTPError), + max_tries=5, # 最大5回再試行 + max_time=30, # 最大30秒 + giveup=lambda e: isinstance(e, requests.exceptions.HTTPError) + and e.response.status_code in [401, 403, 404], # 認証エラーやリソースが存在しない場合は再試行しない + ) + def make_api_request(self, url: str, params: Optional[Dict[str, Any]] = None) -> Any: + """ + GitHubのAPIリクエストを実行し、再試行ロジックを適用する + + Args: + url: リクエスト先のURL + params: リクエストパラメータ + + Returns: + APIレスポンスのJSONデータ + """ + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + return response.json() + + def check_rate_limit(self) -> tuple: + """ + GitHub APIのレート制限状況を確認する + + Returns: + (残りリクエスト数, リセット時間)のタプル + """ + url = f"{self.api_base_url}/rate_limit" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + + rate_limit_data = response.json() + core_rate = rate_limit_data["resources"]["core"] + + remaining = core_rate["remaining"] + reset_time = datetime.datetime.fromtimestamp(core_rate["reset"]) + now = datetime.datetime.now() + + print(f"API制限: 残り {remaining} リクエスト") + print(f"制限リセット時間: {reset_time} (あと {(reset_time - now).total_seconds() / 60:.1f} 分)") + + return remaining, reset_time + + def get_pull_requests( + self, + limit: Optional[int] = None, + sort_by: str = "updated", + direction: str = "desc", + last_updated_at: Optional[datetime.datetime] = None, + state: str = "open", + ) -> List[Dict[str, Any]]: + """ + Pull Requestを取得する + + Args: + limit: 取得するPRの最大数 + sort_by: ソート基準 ("created", "updated", "popularity", "long-running") + direction: ソート方向 ("asc" or "desc") + last_updated_at: 前回実行時の最新更新日時(この日時以降のPRのみ取得) + state: PRの状態 ("open", "closed", "all") + + Returns: + Pull Requestのリスト + """ + all_prs = [] + page = 1 + per_page = 100 + + while True: + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/pulls" + params = {"state": state, "per_page": per_page, "page": page, "sort": sort_by, "direction": direction} + + try: + prs = self.make_api_request(url, params=params) + if not prs: + break + + if last_updated_at: + new_prs = [] + for pr in prs: + pr_updated_at = datetime.datetime.fromisoformat(pr["updated_at"].replace("Z", "+00:00")) + if pr_updated_at <= last_updated_at: + print(f"前回処理済みのPR #{pr['number']} に到達しました。処理を終了します。") + break + new_prs.append(pr) + + if len(new_prs) < len(prs): + all_prs.extend(new_prs) + break + + all_prs.extend(new_prs) + else: + all_prs.extend(prs) + + page += 1 + + if limit and len(all_prs) >= limit: + all_prs = all_prs[:limit] + break + + if page > 1: + time.sleep(0.5) # APIレート制限を考慮して少し待機 + except requests.exceptions.HTTPError as e: + if e.response.status_code == 403 and "API rate limit exceeded" in e.response.text: + print("GitHubのAPIレート制限に達しました。処理を終了します。") + break + print(f"PRリスト取得中にエラーが発生しました (ページ {page}): {e}") + break + except Exception as e: + print(f"PRリスト取得中にエラーが発生しました (ページ {page}): {e}") + break + + return all_prs + + def get_pr_by_number(self, pr_number: int) -> Optional[Dict[str, Any]]: + """ + PR番号を指定してPRを取得する + + Args: + pr_number: PR番号 + + Returns: + PR情報の辞書、存在しない場合はNone + """ + try: + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/pulls/{pr_number}" + return self.make_api_request(url) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + print(f"PR #{pr_number} は存在しません") + return None + raise + + def get_pr_comments(self, pr_number: int) -> List[Dict[str, Any]]: + """ + PRのコメントを取得する + + Args: + pr_number: PR番号 + + Returns: + コメントのリスト + """ + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/issues/{pr_number}/comments" + return self.make_api_request(url) + + def get_pr_review_comments(self, pr_number: int) -> List[Dict[str, Any]]: + """ + PRのレビューコメントを取得する + + Args: + pr_number: PR番号 + + Returns: + レビューコメントのリスト + """ + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/pulls/{pr_number}/comments" + return self.make_api_request(url) + + def get_pr_commits(self, pr_number: int) -> List[Dict[str, Any]]: + """ + PRのコミット情報を取得する + + Args: + pr_number: PR番号 + + Returns: + コミット情報のリスト + """ + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/pulls/{pr_number}/commits" + return self.make_api_request(url) + + def get_pr_files(self, pr_number: int) -> List[Dict[str, Any]]: + """ + PRの変更ファイル情報を取得する + + Args: + pr_number: PR番号 + + Returns: + 変更ファイル情報のリスト + """ + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/pulls/{pr_number}/files" + return self.make_api_request(url) + + def get_pr_labels(self, pr_number: int) -> List[Dict[str, Any]]: + """ + PRのラベル情報を取得する + + Args: + pr_number: PR番号 + + Returns: + ラベル情報のリスト + """ + url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/issues/{pr_number}/labels" + return self.make_api_request(url) + + def get_pr_details( + self, + pr_number: int, + include_comments: bool = True, + include_review_comments: bool = True, + include_commits: bool = True, + include_files: bool = True, + include_labels: bool = True, + ) -> Dict[str, Any]: + """ + PRの詳細情報を取得する + + Args: + pr_number: PR番号 + include_comments: コメントを含めるかどうか + include_review_comments: レビューコメントを含めるかどうか + include_commits: コミット情報を含めるかどうか + include_files: 変更ファイル情報を含めるかどうか + include_labels: ラベル情報を含めるかどうか + + Returns: + PR詳細情報の辞書 + """ + pr_data = self.get_pr_by_number(pr_number) + if not pr_data: + return {} + + pr_details = { + "basic_info": pr_data, + "state": pr_data["state"], # open または closed + "updated_at": pr_data["updated_at"], # 更新日時を保存 + } + + if include_labels: + try: + pr_details["labels"] = self.get_pr_labels(pr_number) + except Exception as e: + print(f"PR #{pr_number} のラベル取得中にエラーが発生しました: {str(e)[:200]}") + pr_details["labels"] = [] + + if include_comments: + pr_details["comments"] = self.get_pr_comments(pr_number) + + if include_review_comments: + pr_details["review_comments"] = self.get_pr_review_comments(pr_number) + + if include_commits: + pr_details["commits"] = self.get_pr_commits(pr_number) + + if include_files: + pr_details["files"] = self.get_pr_files(pr_number) + + return pr_details diff --git a/pr-analysis/src/pr_analysis/cli/__init__.py b/pr-analysis/src/pr_analysis/cli/__init__.py new file mode 100644 index 0000000..ddc458d --- /dev/null +++ b/pr-analysis/src/pr_analysis/cli/__init__.py @@ -0,0 +1,11 @@ +""" +コマンドラインインターフェースモジュール + +PR分析ツールのコマンドラインインターフェースを提供します。 +""" + +from .analyzer import main as analyzer_main +from .fetcher import main as fetcher_main +from .reporter import main as reporter_main + +__all__ = ["analyzer_main", "fetcher_main", "reporter_main"] diff --git a/pr-analysis/src/pr_analysis/cli/analyzer.py b/pr-analysis/src/pr_analysis/cli/analyzer.py new file mode 100644 index 0000000..34d3139 --- /dev/null +++ b/pr-analysis/src/pr_analysis/cli/analyzer.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +PR Analyzer CLI + +GitHub Pull Requestデータを分析するためのコマンドラインインターフェース。 +""" + +import argparse +import os +import sys +from typing import Optional + +from ..analyzer import analyze_prs +from ..utils import load_json_file + + +def main() -> int: + """ + PR Analyzerのメイン関数 + + Returns: + 終了コード(成功: 0, 失敗: 1) + """ + parser = argparse.ArgumentParser(description="GitHub Pull Requestデータを分析するツール") + + parser.add_argument("--input", required=True, help="入力JSONファイルパス") + + parser.add_argument("--owner", required=True, help="リポジトリのオーナー名") + parser.add_argument("--repo", required=True, help="リポジトリ名") + + parser.add_argument("--output", help="分析結果の出力ファイルパス") + + args = parser.parse_args() + + try: + prs_data = load_json_file(args.input) + if not prs_data: + print(f"エラー: 入力ファイル {args.input} が空か、読み込めませんでした", file=sys.stderr) + return 1 + + print(f"{len(prs_data)} 件のPull Requestデータを読み込みました") + + results = analyze_prs( + prs_data=prs_data, + repo_owner=args.owner, + repo_name=args.repo, + output_file=args.output, + ) + + print(f"{len(results)} 件のPull Requestデータを分析しました") + + if args.output: + print(f"分析結果を {args.output} に保存しました") + + return 0 + except Exception as e: + print(f"エラーが発生しました: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pr-analysis/src/pr_analysis/cli/fetcher.py b/pr-analysis/src/pr_analysis/cli/fetcher.py new file mode 100644 index 0000000..1ba1f9d --- /dev/null +++ b/pr-analysis/src/pr_analysis/cli/fetcher.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +PR Fetcher CLI + +GitHub Pull Requestデータを取得するためのコマンドラインインターフェース。 +""" + +import argparse +import datetime +import os +import sys +from typing import Optional + +from ..fetcher import fetch_prs + + +def main() -> int: + """ + PR Fetcherのメイン関数 + + Returns: + 終了コード(成功: 0, 失敗: 1) + """ + parser = argparse.ArgumentParser(description="GitHub Pull Requestデータを取得するツール") + + parser.add_argument("--owner", required=True, help="リポジトリのオーナー名") + parser.add_argument("--repo", required=True, help="リポジトリ名") + + parser.add_argument("--limit", type=int, help="取得するPRの最大数") + parser.add_argument("--state", default="all", choices=["open", "closed", "all"], help="PRの状態") + parser.add_argument("--sort-by", default="updated", choices=["created", "updated", "popularity", "long-running"], help="ソート基準") + parser.add_argument("--direction", default="desc", choices=["asc", "desc"], help="ソート方向") + + parser.add_argument("--no-comments", action="store_true", help="コメントを含めない") + parser.add_argument("--no-review-comments", action="store_true", help="レビューコメントを含めない") + parser.add_argument("--no-commits", action="store_true", help="コミット情報を含めない") + parser.add_argument("--no-files", action="store_true", help="変更ファイル情報を含めない") + parser.add_argument("--no-labels", action="store_true", help="ラベル情報を含めない") + + parser.add_argument("--max-workers", type=int, default=4, help="並列処理の最大ワーカー数") + + parser.add_argument("--output-dir", help="出力ディレクトリ") + parser.add_argument("--incremental", action="store_true", help="増分更新を行う") + + parser.add_argument("--fetch-mode", default="api", choices=["api", "sequential", "priority"], help="取得モード") + parser.add_argument("--start-id", type=int, default=1, help="取得開始ID(sequential モードの場合)") + parser.add_argument("--max-id", type=int, help="取得終了ID(sequential モードの場合)") + + args = parser.parse_args() + + try: + prs_data = fetch_prs( + repo_owner=args.owner, + repo_name=args.repo, + limit=args.limit, + state=args.state, + sort_by=args.sort_by, + direction=args.direction, + include_comments=not args.no_comments, + include_review_comments=not args.no_review_comments, + include_commits=not args.no_commits, + include_files=not args.no_files, + include_labels=not args.no_labels, + max_workers=args.max_workers, + output_dir=args.output_dir, + incremental=args.incremental, + fetch_mode=args.fetch_mode, + start_id=args.start_id, + max_id=args.max_id, + ) + + print(f"{len(prs_data)} 件のPull Requestデータを取得しました") + return 0 + except Exception as e: + print(f"エラーが発生しました: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pr-analysis/src/pr_analysis/cli/reporter.py b/pr-analysis/src/pr_analysis/cli/reporter.py new file mode 100644 index 0000000..a2b85a1 --- /dev/null +++ b/pr-analysis/src/pr_analysis/cli/reporter.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +PR Reporter CLI + +GitHub Pull Requestデータからレポートを生成するためのコマンドラインインターフェース。 +""" + +import argparse +import os +import sys +from typing import Optional + +from ..reporter import ( + generate_markdown, + generate_summary_markdown, + generate_issues_and_diffs_markdown, + generate_file_based_markdown, + convert_json_to_csv, +) +from ..utils import load_json_file + + +def main() -> int: + """ + PR Reporterのメイン関数 + + Returns: + 終了コード(成功: 0, 失敗: 1) + """ + parser = argparse.ArgumentParser(description="GitHub Pull Requestデータからレポートを生成するツール") + + parser.add_argument("--input", required=True, help="入力JSONファイルパス") + + parser.add_argument( + "--format", + default="markdown", + choices=["markdown", "summary", "issues", "files", "csv", "id_comment", "stats"], + help="レポート形式", + ) + + parser.add_argument("--output", help="出力ファイルパスまたはディレクトリ") + + args = parser.parse_args() + + try: + prs_data = load_json_file(args.input) + if not prs_data: + print(f"エラー: 入力ファイル {args.input} が空か、読み込めませんでした", file=sys.stderr) + return 1 + + print(f"{len(prs_data)} 件のPull Requestデータを読み込みました") + + output_file = args.output + if not output_file: + input_dir = os.path.dirname(args.input) + input_name = os.path.splitext(os.path.basename(args.input))[0] + + if args.format == "markdown": + output_file = os.path.join(input_dir, f"{input_name}_report.md") + elif args.format == "summary": + output_file = os.path.join(input_dir, f"{input_name}_summary.md") + elif args.format == "issues": + output_file = os.path.join(input_dir, f"{input_name}_issues_diffs.md") + elif args.format == "files": + output_file = os.path.join(input_dir, f"{input_name}_files") + elif args.format in ["csv", "id_comment", "stats"]: + output_file = os.path.join(input_dir, f"{input_name}.csv") + + if args.format == "markdown": + generate_markdown(prs_data, output_file) + elif args.format == "summary": + generate_summary_markdown(prs_data, output_file) + elif args.format == "issues": + generate_issues_and_diffs_markdown(prs_data, output_file) + elif args.format == "files": + generate_file_based_markdown(prs_data, output_file) + elif args.format == "csv": + convert_json_to_csv(prs_data, output_file) + elif args.format == "id_comment": + convert_json_to_csv( + prs_data, + output_file, + columns=["id", "comment"], + extract_fields={ + "id": "basic_info.number", + "comment": "basic_info.body", + }, + ) + elif args.format == "stats": + convert_json_to_csv( + prs_data, + output_file, + columns=[ + "id", "title", "state", "user", "created_at", "updated_at", + "commits", "comments", "review_comments", "files", + "additions", "deletions", "changes", "labels", + ], + extract_fields={ + "id": "basic_info.number", + "title": "basic_info.title", + "state": "basic_info.state", + "user": "basic_info.user.login", + "created_at": "basic_info.created_at", + "updated_at": "basic_info.updated_at", + }, + ) + + print(f"レポートを {output_file} に生成しました") + return 0 + except Exception as e: + print(f"エラーが発生しました: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pr-analysis/src/pr_analysis/fetcher/__init__.py b/pr-analysis/src/pr_analysis/fetcher/__init__.py new file mode 100644 index 0000000..472cc9c --- /dev/null +++ b/pr-analysis/src/pr_analysis/fetcher/__init__.py @@ -0,0 +1,9 @@ +""" +PR Fetcher モジュール + +GitHub Pull Requestデータを取得するための機能を提供します。 +""" + +from .pr_fetcher import PRFetcher, fetch_prs + +__all__ = ["PRFetcher", "fetch_prs"] diff --git a/pr-analysis/src/pr_analysis/fetcher/pr_fetcher.py b/pr-analysis/src/pr_analysis/fetcher/pr_fetcher.py new file mode 100644 index 0000000..1ec56ea --- /dev/null +++ b/pr-analysis/src/pr_analysis/fetcher/pr_fetcher.py @@ -0,0 +1,451 @@ +""" +PR Fetcher モジュール + +GitHub Pull Requestデータを取得するための機能を提供します。 +""" + +import concurrent.futures +import datetime +import json +import os +import time +from pathlib import Path +from typing import Dict, List, Optional, Any, Union + +from tqdm import tqdm + +from ..api.github import GitHubAPIClient + + +class PRFetcher: + """Pull Requestデータを取得するためのクラス""" + + def __init__(self, repo_owner: str, repo_name: str, output_dir: str = "pr_analysis_results"): + """ + PRFetcherを初期化する + + Args: + repo_owner: リポジトリのオーナー名 + repo_name: リポジトリ名 + output_dir: 出力ディレクトリ + """ + self.repo_owner = repo_owner + self.repo_name = repo_name + self.output_dir = output_dir + self.github_client = GitHubAPIClient(repo_owner, repo_name) + + def process_pr( + self, + pr: Dict[str, Any], + include_comments: bool = True, + include_review_comments: bool = True, + include_commits: bool = True, + include_files: bool = True, + include_labels: bool = True, + ) -> Optional[Dict[str, Any]]: + """ + 1つのPRを処理する(並列処理用) + + Args: + pr: 処理するPR情報 + include_comments: コメントを含めるかどうか + include_review_comments: レビューコメントを含めるかどうか + include_commits: コミット情報を含めるかどうか + include_files: 変更ファイル情報を含めるかどうか + include_labels: ラベル情報を含めるかどうか + + Returns: + 処理されたPR詳細情報 + """ + try: + pr_number = pr["number"] + try: + return self.github_client.get_pr_details( + pr_number, + include_comments=include_comments, + include_review_comments=include_review_comments, + include_commits=include_commits, + include_files=include_files, + include_labels=include_labels, + ) + except Exception as e: + print(f"PR #{pr_number} の処理中にエラーが発生しました: {str(e)[:200]}") + return None + except Exception as e: + print(f"PRのbasic_info取得中にエラーが発生しました: {str(e)[:200]}") + return None + + def save_to_json(self, data: List[Dict[str, Any]], filename: str) -> None: + """ + データをJSON形式で保存する + + Args: + data: 保存するデータ + filename: 保存先ファイル名 + """ + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"JSONデータを {filename} に保存しました") + + def save_last_run_info(self, output_dir: str, last_updated_at: datetime.datetime) -> None: + """ + 最後の実行情報を保存する + + Args: + output_dir: 出力ディレクトリ + last_updated_at: 最終更新日時 + """ + last_run_info = { + "last_updated_at": last_updated_at.isoformat(), + "timestamp": datetime.datetime.now().isoformat() + } + + last_run_file = Path(output_dir) / "last_run_info.json" + with open(last_run_file, "w", encoding="utf-8") as f: + json.dump(last_run_info, f, ensure_ascii=False, indent=2) + print(f"最後の実行情報を {last_run_file} に保存しました") + + def load_last_run_info(self, base_output_dir: str) -> Optional[datetime.datetime]: + """ + 最後の実行情報を読み込む + + Args: + base_output_dir: 基本出力ディレクトリ + + Returns: + 最終更新日時 + """ + last_run_file = Path(base_output_dir) / "last_run_info.json" + + if last_run_file.exists(): + try: + with open(last_run_file, encoding="utf-8") as f: + last_run_info = json.load(f) + + last_updated_at = datetime.datetime.fromisoformat(last_run_info["last_updated_at"]) + print(f"前回の実行情報を読み込みました: 最終更新日時 = {last_updated_at}") + return last_updated_at + except Exception as e: + print(f"前回の実行情報の読み込み中にエラーが発生しました: {e}") + + print("前回の実行情報が見つかりませんでした") + return None + + def load_previous_prs_data(self, base_output_dir: str) -> List[Dict[str, Any]]: + """ + 前回のPRデータを読み込む + + Args: + base_output_dir: 基本出力ディレクトリ + + Returns: + 前回のPRデータ + """ + dirs = [d for d in Path(base_output_dir).glob("*") if d.is_dir() and d.name[0].isdigit()] + if not dirs: + print("前回のPRデータが見つかりませんでした") + return [] + + latest_dir = max(dirs, key=lambda d: d.stat().st_mtime) + json_file = latest_dir / "prs_data.json" + + if json_file.exists(): + try: + with open(json_file, encoding="utf-8") as f: + prs_data = json.load(f) + print(f"前回のPRデータを読み込みました: {len(prs_data)}件 ({json_file})") + return prs_data + except Exception as e: + print(f"前回のPRデータの読み込み中にエラーが発生しました: {e}") + + print("前回のPRデータが見つかりませんでした") + return [] + + def load_pr_status_data(self, base_output_dir: str) -> Dict[str, Any]: + """ + PRの取得状況データを読み込む + + Args: + base_output_dir: 基本出力ディレクトリ + + Returns: + PRの取得状況データ + """ + status_file = Path(base_output_dir) / "pr_status.json" + + if status_file.exists(): + try: + with open(status_file, encoding="utf-8") as f: + status_data = json.load(f) + print(f"PRの取得状況データを読み込みました: {len(status_data)}件のPR") + return status_data + except Exception as e: + print(f"PRの取得状況データの読み込み中にエラーが発生しました: {e}") + + return {} + + def save_pr_status_data(self, base_output_dir: str, status_data: Dict[str, Any]) -> None: + """ + PRの取得状況データを保存する + + Args: + base_output_dir: 基本出力ディレクトリ + status_data: PRの取得状況データ + """ + status_file = Path(base_output_dir) / "pr_status.json" + with open(status_file, "w", encoding="utf-8") as f: + json.dump(status_data, f, ensure_ascii=False, indent=2) + print(f"PRの取得状況データを {status_file} に保存しました") + + def fetch_prs( + self, + limit: Optional[int] = None, + state: str = "all", + sort_by: str = "updated", + direction: str = "desc", + include_comments: bool = True, + include_review_comments: bool = True, + include_commits: bool = True, + include_files: bool = True, + include_labels: bool = True, + max_workers: int = 4, + output_dir: Optional[str] = None, + incremental: bool = False, + fetch_mode: str = "api", + start_id: int = 1, + max_id: Optional[int] = None, + ) -> List[Dict[str, Any]]: + """ + Pull Requestデータを取得する + + Args: + limit: 取得するPRの最大数 + state: PRの状態 ("open", "closed", "all") + sort_by: ソート基準 ("created", "updated", "popularity", "long-running") + direction: ソート方向 ("asc" or "desc") + include_comments: コメントを含めるかどうか + include_review_comments: レビューコメントを含めるかどうか + include_commits: コミット情報を含めるかどうか + include_files: 変更ファイル情報を含めるかどうか + include_labels: ラベル情報を含めるかどうか + max_workers: 並列処理の最大ワーカー数 + output_dir: 出力ディレクトリ + incremental: 増分更新を行うかどうか + fetch_mode: 取得モード ("api", "sequential", "priority") + start_id: 取得開始ID(sequential モードの場合) + max_id: 取得終了ID(sequential モードの場合) + + Returns: + 取得したPRデータのリスト + """ + if output_dir is None: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = os.path.join(self.output_dir, timestamp) + + os.makedirs(output_dir, exist_ok=True) + print(f"出力ディレクトリ: {output_dir}") + + remaining, reset_time = self.github_client.check_rate_limit() + if remaining < 10: + wait_time = (reset_time - datetime.datetime.now()).total_seconds() + if wait_time > 0: + print(f"APIレート制限に近づいています。{wait_time:.1f}秒待機します...") + time.sleep(wait_time + 5) # 少し余裕を持たせる + + last_updated_at = None + if incremental: + last_updated_at = self.load_last_run_info(self.output_dir) + + status_data = {} + if fetch_mode == "priority": + status_data = self.load_pr_status_data(self.output_dir) + + print(f"Pull Requestの基本情報を取得しています...") + basic_prs = [] + + if fetch_mode == "api": + basic_prs = self.github_client.get_pull_requests( + limit=limit, sort_by=sort_by, direction=direction, last_updated_at=last_updated_at, state=state + ) + elif fetch_mode == "sequential": + current_id = start_id + count = 0 + + print(f"PR番号 #{start_id} から順にPRを取得します...") + + while True: + try: + pr = self.github_client.get_pr_by_number(current_id) + if pr: + basic_prs.append(pr) + print(f"PR #{current_id} を取得しました") + count += 1 + else: + print(f"PR #{current_id} は存在しないためスキップします") + if current_id > 100: # 一定数以上のPRが存在しない場合は終了 + print(f"PR #{current_id} が存在しないため、これ以上のPRは存在しないと判断して処理を終了します。") + break + + current_id += 1 + + if max_id and current_id > max_id: + print(f"最大ID #{max_id} に到達しました。処理を終了します。") + break + + if limit and count >= limit: + print(f"最大取得数 {limit} に到達しました。処理を終了します。") + break + + time.sleep(0.5) + + except Exception as e: + print(f"PR #{current_id} の取得中にエラーが発生しました: {e}") + current_id += 1 # エラーが発生したら次のIDに進む + elif fetch_mode == "priority": + none_ids = [int(pr_id) for pr_id, fetch_time in status_data.items() if fetch_time is None] + none_ids.sort() # ID順にソート + + print(f"{len(none_ids)}件の未取得PRを優先的に取得します...") + + count = 0 + for pr_id in none_ids: + try: + pr = self.github_client.get_pr_by_number(pr_id) + if pr: + basic_prs.append(pr) + print(f"PR #{pr_id} を取得しました") + count += 1 + else: + print(f"PR #{pr_id} は存在しないためスキップします") + + if limit and count >= limit: + print(f"最大取得数 {limit} に到達しました。処理を終了します。") + break + + time.sleep(0.5) + + except Exception as e: + print(f"PR #{pr_id} の取得中にエラーが発生しました: {e}") + + if limit and count < limit: + remaining_limit = limit - count + print(f"未取得PRの取得が完了しました。残り {remaining_limit} 件を更新日時順で取得します...") + + additional_prs = self.github_client.get_pull_requests( + limit=remaining_limit, sort_by=sort_by, direction=direction, last_updated_at=last_updated_at, state=state + ) + basic_prs.extend(additional_prs) + + print(f"{len(basic_prs)} 件のPull Requestの基本情報を取得しました") + + if not basic_prs: + print("取得するPull Requestがありませんでした") + return [] + + print(f"Pull Requestの詳細情報を取得しています...") + prs_data = [] + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [] + for pr in basic_prs: + future = executor.submit( + self.process_pr, + pr, + include_comments=include_comments, + include_review_comments=include_review_comments, + include_commits=include_commits, + include_files=include_files, + include_labels=include_labels, + ) + futures.append(future) + + for future in tqdm(concurrent.futures.as_completed(futures), total=len(futures), desc="PRs"): + result = future.result() + if result: + prs_data.append(result) + + print(f"{len(prs_data)} 件のPull Requestの詳細情報を取得しました") + + output_file = os.path.join(output_dir, "prs_data.json") + self.save_to_json(prs_data, output_file) + + if prs_data and incremental: + latest_updated_at = max( + datetime.datetime.fromisoformat(pr["updated_at"].replace("Z", "+00:00")) + for pr in basic_prs + if "updated_at" in pr + ) + self.save_last_run_info(self.output_dir, latest_updated_at) + + if fetch_mode == "priority" or fetch_mode == "sequential": + now_str = datetime.datetime.now().isoformat() + for pr in prs_data: + pr_id = str(pr["basic_info"]["number"]) + status_data[pr_id] = now_str + self.save_pr_status_data(self.output_dir, status_data) + + return prs_data + + +def fetch_prs( + repo_owner: str, + repo_name: str, + limit: Optional[int] = None, + state: str = "all", + sort_by: str = "updated", + direction: str = "desc", + include_comments: bool = True, + include_review_comments: bool = True, + include_commits: bool = True, + include_files: bool = True, + include_labels: bool = True, + max_workers: int = 4, + output_dir: Optional[str] = None, + incremental: bool = False, + fetch_mode: str = "api", + start_id: int = 1, + max_id: Optional[int] = None, +) -> List[Dict[str, Any]]: + """ + Pull Requestデータを取得する便利関数 + + Args: + repo_owner: リポジトリのオーナー名 + repo_name: リポジトリ名 + limit: 取得するPRの最大数 + state: PRの状態 ("open", "closed", "all") + sort_by: ソート基準 ("created", "updated", "popularity", "long-running") + direction: ソート方向 ("asc" or "desc") + include_comments: コメントを含めるかどうか + include_review_comments: レビューコメントを含めるかどうか + include_commits: コミット情報を含めるかどうか + include_files: 変更ファイル情報を含めるかどうか + include_labels: ラベル情報を含めるかどうか + max_workers: 並列処理の最大ワーカー数 + output_dir: 出力ディレクトリ + incremental: 増分更新を行うかどうか + fetch_mode: 取得モード ("api", "sequential", "priority") + start_id: 取得開始ID(sequential モードの場合) + max_id: 取得終了ID(sequential モードの場合) + + Returns: + 取得したPRデータのリスト + """ + fetcher = PRFetcher(repo_owner, repo_name) + return fetcher.fetch_prs( + limit=limit, + state=state, + sort_by=sort_by, + direction=direction, + include_comments=include_comments, + include_review_comments=include_review_comments, + include_commits=include_commits, + include_files=include_files, + include_labels=include_labels, + max_workers=max_workers, + output_dir=output_dir, + incremental=incremental, + fetch_mode=fetch_mode, + start_id=start_id, + max_id=max_id, + ) diff --git a/pr-analysis/src/pr_analysis/reporter/__init__.py b/pr-analysis/src/pr_analysis/reporter/__init__.py new file mode 100644 index 0000000..40e103d --- /dev/null +++ b/pr-analysis/src/pr_analysis/reporter/__init__.py @@ -0,0 +1,21 @@ +""" +PR Reporter モジュール + +GitHub Pull Requestデータのレポートを生成するための機能を提供します。 +""" + +from .markdown_generator import ( + generate_markdown, + generate_summary_markdown, + generate_issues_and_diffs_markdown, + generate_file_based_markdown, +) +from .csv_generator import convert_json_to_csv + +__all__ = [ + "generate_markdown", + "generate_summary_markdown", + "generate_issues_and_diffs_markdown", + "generate_file_based_markdown", + "convert_json_to_csv", +] diff --git a/pr-analysis/src/pr_analysis/reporter/csv_generator.py b/pr-analysis/src/pr_analysis/reporter/csv_generator.py new file mode 100644 index 0000000..0cfd50a --- /dev/null +++ b/pr-analysis/src/pr_analysis/reporter/csv_generator.py @@ -0,0 +1,163 @@ +""" +CSVレポート生成モジュール + +Pull RequestデータからCSV形式のレポートを生成するための機能を提供します。 +""" + +import csv +import os +from pathlib import Path +from typing import Dict, List, Optional, Any, Union + + +def convert_json_to_csv( + data: List[Dict[str, Any]], + output_file: Optional[str] = None, + columns: Optional[List[str]] = None, + extract_fields: Optional[Dict[str, str]] = None +) -> str: + """ + Pull Requestデータからカスタム列を持つCSVファイルを生成する + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス(指定がない場合は入力ファイルと同じディレクトリに生成) + columns: CSVに含める列名のリスト + extract_fields: データから抽出するフィールドのマッピング(列名: JSONパス) + + Returns: + 生成されたCSVファイルのパス + """ + if not data: + print("データが空です。CSVレポートを生成できません。") + return "" + + if columns is None: + columns = ["id", "title", "state", "user", "created_at", "updated_at", "comment"] + + if extract_fields is None: + extract_fields = { + "id": "basic_info.number", + "title": "basic_info.title", + "state": "basic_info.state", + "user": "basic_info.user.login", + "created_at": "basic_info.created_at", + "updated_at": "basic_info.updated_at", + "comment": "basic_info.body", + } + + if output_file is None: + output_file = "prs_data.csv" + + os.makedirs(os.path.dirname(output_file), exist_ok=True) + + with open(output_file, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + + writer.writerow(columns) + + count = 0 + for pr in data: + row = [] + for column in columns: + field_path = extract_fields.get(column, "") + if not field_path: + row.append("") + continue + + value = pr + for key in field_path.split("."): + if isinstance(value, dict) and key in value: + value = value[key] + else: + value = "" + break + + row.append(value) + + writer.writerow(row) + count += 1 + + print(f"{count}行のデータをCSVファイル {output_file} に書き込みました") + return output_file + + +def convert_pr_to_id_comment_csv(data: List[Dict[str, Any]], output_file: Optional[str] = None) -> str: + """ + Pull RequestデータからID-コメントのCSVファイルを生成する(json_to_csv.pyの代替) + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス(指定がない場合は入力ファイルと同じディレクトリに生成) + + Returns: + 生成されたCSVファイルのパス + """ + if output_file is None: + output_file = "prs_id_comment.csv" + + columns = ["id", "comment"] + extract_fields = { + "id": "basic_info.number", + "comment": "basic_info.body", + } + + return convert_json_to_csv(data, output_file, columns, extract_fields) + + +def convert_pr_to_stats_csv(data: List[Dict[str, Any]], output_file: Optional[str] = None) -> str: + """ + Pull RequestデータからPR統計情報のCSVファイルを生成する + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス(指定がない場合は入力ファイルと同じディレクトリに生成) + + Returns: + 生成されたCSVファイルのパス + """ + if output_file is None: + output_file = "prs_stats.csv" + + with open(output_file, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + + writer.writerow([ + "id", "title", "state", "user", "created_at", "updated_at", + "commits", "comments", "review_comments", "files", + "additions", "deletions", "changes", "labels" + ]) + + count = 0 + for pr in data: + basic_info = pr.get("basic_info", {}) + + pr_id = basic_info.get("number", "") + title = basic_info.get("title", "") + state = basic_info.get("state", "") + user = basic_info.get("user", {}).get("login", "") + created_at = basic_info.get("created_at", "") + updated_at = basic_info.get("updated_at", "") + + commits = len(pr.get("commits", [])) + comments = len(pr.get("comments", [])) + review_comments = len(pr.get("review_comments", [])) + + files = pr.get("files", []) + file_count = len(files) + + additions = sum(file.get("additions", 0) for file in files) + deletions = sum(file.get("deletions", 0) for file in files) + changes = additions + deletions + + labels = ", ".join([label.get("name", "") for label in pr.get("labels", [])]) + + writer.writerow([ + pr_id, title, state, user, created_at, updated_at, + commits, comments, review_comments, file_count, + additions, deletions, changes, labels + ]) + count += 1 + + print(f"{count}行のデータをCSVファイル {output_file} に書き込みました") + return output_file diff --git a/pr-analysis/src/pr_analysis/reporter/markdown_generator.py b/pr-analysis/src/pr_analysis/reporter/markdown_generator.py new file mode 100644 index 0000000..1ba47e0 --- /dev/null +++ b/pr-analysis/src/pr_analysis/reporter/markdown_generator.py @@ -0,0 +1,322 @@ +""" +マークダウンレポート生成モジュール + +Pull Requestデータからマークダウン形式のレポートを生成するための機能を提供します。 +""" + +import os +from pathlib import Path +from typing import Dict, List, Optional, Any, Union + + +def generate_markdown(data: List[Dict[str, Any]], output_file: str) -> None: + """ + Pull Requestデータからマークダウンレポートを生成する + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス + """ + if not data: + print("データが空です。マークダウンレポートを生成できません。") + return + + os.makedirs(os.path.dirname(output_file), exist_ok=True) + + with open(output_file, "w", encoding="utf-8") as f: + f.write("# Pull Request分析レポート\n\n") + + f.write("## 概要\n\n") + f.write(f"- 分析対象PR数: {len(data)}\n") + f.write(f"- 生成日時: {os.path.basename(output_file).split('_')[0]}\n\n") + + f.write("## Pull Requestリスト\n\n") + + for pr in data: + basic_info = pr.get("basic_info", {}) + pr_number = basic_info.get("number") + pr_title = basic_info.get("title") + pr_state = basic_info.get("state") + pr_user = basic_info.get("user", {}).get("login") + pr_created_at = basic_info.get("created_at") + pr_updated_at = basic_info.get("updated_at") + pr_html_url = basic_info.get("html_url") + + if not pr_number or not pr_title: + continue + + f.write(f"### PR #{pr_number}: {pr_title}\n\n") + f.write(f"- 状態: {pr_state}\n") + f.write(f"- 作成者: {pr_user}\n") + f.write(f"- 作成日時: {pr_created_at}\n") + f.write(f"- 更新日時: {pr_updated_at}\n") + f.write(f"- URL: {pr_html_url}\n\n") + + labels = pr.get("labels", []) + if labels: + f.write("#### ラベル\n\n") + for label in labels: + label_name = label.get("name") + if label_name: + f.write(f"- {label_name}\n") + f.write("\n") + + commits = pr.get("commits", []) + if commits: + f.write("#### コミット\n\n") + f.write(f"コミット数: {len(commits)}\n\n") + for commit in commits[:5]: # 最初の5件のみ表示 + commit_info = commit.get("commit", {}) + commit_message = commit_info.get("message", "").split("\n")[0] # 1行目のみ + commit_author = commit_info.get("author", {}).get("name") + commit_date = commit_info.get("author", {}).get("date") + f.write(f"- {commit_message} (by {commit_author} on {commit_date})\n") + if len(commits) > 5: + f.write(f"- ... 他 {len(commits) - 5} 件のコミット\n") + f.write("\n") + + files = pr.get("files", []) + if files: + f.write("#### 変更ファイル\n\n") + f.write(f"変更ファイル数: {len(files)}\n\n") + for file in files[:10]: # 最初の10件のみ表示 + filename = file.get("filename") + status = file.get("status") + additions = file.get("additions", 0) + deletions = file.get("deletions", 0) + f.write(f"- {filename} ({status}, +{additions}, -{deletions})\n") + if len(files) > 10: + f.write(f"- ... 他 {len(files) - 10} 件のファイル\n") + f.write("\n") + + comments = pr.get("comments", []) + review_comments = pr.get("review_comments", []) + if comments or review_comments: + f.write("#### コメント\n\n") + f.write(f"コメント数: {len(comments)}, レビューコメント数: {len(review_comments)}\n\n") + f.write("\n") + + f.write("---\n\n") + + print(f"マークダウンレポートを {output_file} に生成しました") + + +def generate_summary_markdown(data: List[Dict[str, Any]], output_file: str) -> None: + """ + Pull Requestデータから要約マークダウンレポートを生成する + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス + """ + if not data: + print("データが空です。要約マークダウンレポートを生成できません。") + return + + os.makedirs(os.path.dirname(output_file), exist_ok=True) + + state_counts = {"open": 0, "closed": 0} + + user_pr_counts = {} + + label_counts = {} + + for pr in data: + basic_info = pr.get("basic_info", {}) + + state = basic_info.get("state") + if state: + state_counts[state] = state_counts.get(state, 0) + 1 + + user = basic_info.get("user", {}).get("login") + if user: + user_pr_counts[user] = user_pr_counts.get(user, 0) + 1 + + labels = pr.get("labels", []) + for label in labels: + label_name = label.get("name") + if label_name: + label_counts[label_name] = label_counts.get(label_name, 0) + 1 + + with open(output_file, "w", encoding="utf-8") as f: + f.write("# Pull Request概要\n\n") + + f.write("## 統計情報\n\n") + f.write(f"- 分析対象PR数: {len(data)}\n") + f.write(f"- オープンなPR: {state_counts.get('open', 0)}\n") + f.write(f"- クローズされたPR: {state_counts.get('closed', 0)}\n\n") + + f.write("## ユーザー別PR数\n\n") + for user, count in sorted(user_pr_counts.items(), key=lambda x: x[1], reverse=True): + f.write(f"- {user}: {count}\n") + f.write("\n") + + if label_counts: + f.write("## ラベル別PR数\n\n") + for label, count in sorted(label_counts.items(), key=lambda x: x[1], reverse=True): + f.write(f"- {label}: {count}\n") + f.write("\n") + + f.write("## オープンなPull Requestの統計\n\n") + open_prs = [pr for pr in data if pr.get("basic_info", {}).get("state") == "open"] + if open_prs: + open_prs.sort(key=lambda x: x.get("basic_info", {}).get("created_at", "")) + + f.write("### 最も古いオープンなPR\n\n") + oldest_pr = open_prs[0] + oldest_pr_info = oldest_pr.get("basic_info", {}) + f.write(f"- PR #{oldest_pr_info.get('number')}: {oldest_pr_info.get('title')}\n") + f.write(f"- 作成者: {oldest_pr_info.get('user', {}).get('login')}\n") + f.write(f"- 作成日時: {oldest_pr_info.get('created_at')}\n") + f.write(f"- URL: {oldest_pr_info.get('html_url')}\n\n") + + open_prs.sort(key=lambda x: x.get("basic_info", {}).get("updated_at", ""), reverse=True) + + f.write("### 最近更新されたオープンなPR\n\n") + recent_pr = open_prs[0] + recent_pr_info = recent_pr.get("basic_info", {}) + f.write(f"- PR #{recent_pr_info.get('number')}: {recent_pr_info.get('title')}\n") + f.write(f"- 作成者: {recent_pr_info.get('user', {}).get('login')}\n") + f.write(f"- 更新日時: {recent_pr_info.get('updated_at')}\n") + f.write(f"- URL: {recent_pr_info.get('html_url')}\n\n") + else: + f.write("オープンなPRはありません。\n\n") + + print(f"要約マークダウンレポートを {output_file} に生成しました") + + +def generate_issues_and_diffs_markdown(data: List[Dict[str, Any]], output_file: str) -> None: + """ + Pull Requestデータからissues内容と変更差分のマークダウンレポートを生成する + + Args: + data: PRデータのリスト + output_file: 出力ファイルパス + """ + if not data: + print("データが空です。issues内容と変更差分のマークダウンレポートを生成できません。") + return + + os.makedirs(os.path.dirname(output_file), exist_ok=True) + + with open(output_file, "w", encoding="utf-8") as f: + f.write("# Pull Request内容と変更差分\n\n") + + for pr in data: + basic_info = pr.get("basic_info", {}) + pr_number = basic_info.get("number") + pr_title = basic_info.get("title") + pr_body = basic_info.get("body", "") + pr_html_url = basic_info.get("html_url") + + if not pr_number or not pr_title: + continue + + f.write(f"## PR #{pr_number}: {pr_title}\n\n") + f.write(f"URL: {pr_html_url}\n\n") + + if pr_body: + f.write("### PR内容\n\n") + f.write(f"{pr_body}\n\n") + else: + f.write("PR本文はありません。\n\n") + + f.write("### 変更内容\n\n") + + files = pr.get("files", []) + if files: + for file in files: + filename = file.get("filename") + status = file.get("status") + additions = file.get("additions", 0) + deletions = file.get("deletions", 0) + patch = file.get("patch") + + f.write(f"#### {filename}\n\n") + f.write(f"- 状態: {status}\n") + f.write(f"- 追加行数: {additions}\n") + f.write(f"- 削除行数: {deletions}\n") + + if patch: + f.write("\n```diff\n") + f.write(patch) + f.write("\n```\n\n") + else: + f.write("\n変更差分は利用できません。\n\n") + else: + f.write("変更ファイルはありません。\n\n") + + f.write("---\n\n") + + print(f"issues内容と変更差分のマークダウンレポートを {output_file} に生成しました") + + +def generate_file_based_markdown(data: List[Dict[str, Any]], output_dir: str) -> None: + """ + Pull Requestデータからファイルごとのマークダウンレポートを生成する + + Args: + data: PRデータのリスト + output_dir: 出力ディレクトリ + """ + if not data: + print("データが空です。ファイルごとのマークダウンレポートを生成できません。") + return + + os.makedirs(output_dir, exist_ok=True) + + file_prs = {} + + for pr in data: + basic_info = pr.get("basic_info", {}) + pr_number = basic_info.get("number") + pr_title = basic_info.get("title") + pr_html_url = basic_info.get("html_url") + + if not pr_number or not pr_title: + continue + + files = pr.get("files", []) + for file in files: + filename = file.get("filename") + if not filename: + continue + + if filename not in file_prs: + file_prs[filename] = [] + + file_prs[filename].append({ + "pr_number": pr_number, + "pr_title": pr_title, + "pr_url": pr_html_url, + "status": file.get("status"), + "additions": file.get("additions", 0), + "deletions": file.get("deletions", 0), + "patch": file.get("patch"), + }) + + for filename, prs in file_prs.items(): + file_path = Path(output_dir) / f"{filename.replace('/', '_')}.md" + os.makedirs(file_path.parent, exist_ok=True) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(f"# ファイル: {filename}\n\n") + f.write(f"このファイルに影響を与えたPR数: {len(prs)}\n\n") + + for pr_info in prs: + f.write(f"## PR #{pr_info['pr_number']}: {pr_info['pr_title']}\n\n") + f.write(f"- URL: {pr_info['pr_url']}\n") + f.write(f"- 状態: {pr_info['status']}\n") + f.write(f"- 追加行数: {pr_info['additions']}\n") + f.write(f"- 削除行数: {pr_info['deletions']}\n\n") + + patch = pr_info.get("patch") + if patch: + f.write("### 変更差分\n\n") + f.write("```diff\n") + f.write(patch) + f.write("\n```\n\n") + + f.write("---\n\n") + + print(f"ファイルごとのマークダウンレポートを {output_dir} に生成しました") diff --git a/pr-analysis/src/pr_analysis/utils/__init__.py b/pr-analysis/src/pr_analysis/utils/__init__.py new file mode 100644 index 0000000..e990840 --- /dev/null +++ b/pr-analysis/src/pr_analysis/utils/__init__.py @@ -0,0 +1,10 @@ +""" +ユーティリティモジュール + +PR分析ツールで使用される共通ユーティリティ機能を提供します。 +""" + +from .file_utils import load_json_file, save_json_file +from .date_utils import parse_datetime, format_datetime + +__all__ = ["load_json_file", "save_json_file", "parse_datetime", "format_datetime"] diff --git a/pr-analysis/src/pr_analysis/utils/date_utils.py b/pr-analysis/src/pr_analysis/utils/date_utils.py new file mode 100644 index 0000000..6c2867c --- /dev/null +++ b/pr-analysis/src/pr_analysis/utils/date_utils.py @@ -0,0 +1,156 @@ +""" +日付ユーティリティモジュール + +日付と時刻の解析と書式設定のためのユーティリティを提供します。 +""" + +import datetime +from typing import Optional, Union + + +def parse_datetime( + date_string: str, + formats: Optional[list] = None +) -> Optional[datetime.datetime]: + """ + 文字列を日時オブジェクトに変換する + + Args: + date_string: 変換する日時文字列 + formats: 試行する日時フォーマットのリスト + + Returns: + 変換された日時オブジェクト、変換できない場合はNone + """ + if not formats: + formats = [ + "%Y-%m-%dT%H:%M:%SZ", # ISO 8601 (GitHub API) + "%Y-%m-%dT%H:%M:%S.%fZ", # ISO 8601 with microseconds + "%Y-%m-%dT%H:%M:%S%z", # ISO 8601 with timezone + "%Y-%m-%d %H:%M:%S", # 標準的な日時形式 + "%Y-%m-%d", # 日付のみ + ] + + for fmt in formats: + try: + dt = datetime.datetime.strptime(date_string, fmt) + return dt + except ValueError: + continue + + try: + return datetime.datetime.fromisoformat(date_string.replace("Z", "+00:00")) + except ValueError: + pass + + return None + + +def format_datetime( + dt: Union[datetime.datetime, str], + output_format: str = "%Y-%m-%d %H:%M:%S" +) -> str: + """ + 日時オブジェクトまたは日時文字列を指定された形式にフォーマットする + + Args: + dt: フォーマットする日時オブジェクトまたは日時文字列 + output_format: 出力形式 + + Returns: + フォーマットされた日時文字列 + """ + if isinstance(dt, str): + parsed_dt = parse_datetime(dt) + if not parsed_dt: + return dt # 解析できない場合は元の文字列を返す + dt = parsed_dt + + return dt.strftime(output_format) + + +def get_relative_time_description(dt: Union[datetime.datetime, str]) -> str: + """ + 日時オブジェクトまたは日時文字列から相対的な時間説明を取得する + + Args: + dt: 日時オブジェクトまたは日時文字列 + + Returns: + 相対的な時間説明(例: "3日前", "1時間前") + """ + if isinstance(dt, str): + parsed_dt = parse_datetime(dt) + if not parsed_dt: + return "不明な日時" + dt = parsed_dt + + now = datetime.datetime.now(dt.tzinfo) if dt.tzinfo else datetime.datetime.now() + diff = now - dt + + if diff.days < 0: + return "未来の日時" + elif diff.days == 0: + hours = diff.seconds // 3600 + if hours == 0: + minutes = diff.seconds // 60 + if minutes == 0: + return "たった今" + elif minutes == 1: + return "1分前" + else: + return f"{minutes}分前" + elif hours == 1: + return "1時間前" + else: + return f"{hours}時間前" + elif diff.days == 1: + return "昨日" + elif diff.days < 7: + return f"{diff.days}日前" + elif diff.days < 30: + weeks = diff.days // 7 + if weeks == 1: + return "1週間前" + else: + return f"{weeks}週間前" + elif diff.days < 365: + months = diff.days // 30 + if months == 1: + return "1ヶ月前" + else: + return f"{months}ヶ月前" + else: + years = diff.days // 365 + if years == 1: + return "1年前" + else: + return f"{years}年前" + + +def get_date_range( + start_date: Union[datetime.datetime, str], + end_date: Union[datetime.datetime, str] +) -> int: + """ + 2つの日時間の日数を計算する + + Args: + start_date: 開始日時 + end_date: 終了日時 + + Returns: + 日数 + """ + if isinstance(start_date, str): + start_date = parse_datetime(start_date) + if not start_date: + raise ValueError("開始日時を解析できませんでした") + + if isinstance(end_date, str): + end_date = parse_datetime(end_date) + if not end_date: + raise ValueError("終了日時を解析できませんでした") + + delta = end_date - start_date + return delta.days diff --git a/pr-analysis/src/pr_analysis/utils/file_utils.py b/pr-analysis/src/pr_analysis/utils/file_utils.py new file mode 100644 index 0000000..2a5fd4a --- /dev/null +++ b/pr-analysis/src/pr_analysis/utils/file_utils.py @@ -0,0 +1,132 @@ +""" +ファイルユーティリティモジュール + +JSONファイルの読み込みと保存などのファイル操作ユーティリティを提供します。 +""" + +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def load_json_file(file_path: Union[str, Path]) -> Any: + """ + JSONファイルを読み込む + + Args: + file_path: JSONファイルのパス + + Returns: + 読み込んだJSONデータ + """ + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"Error loading {file_path}: {e}") + return [] + + +def save_json_file(data: Any, file_path: Union[str, Path]) -> bool: + """ + JSONファイルを保存する + + Args: + data: 保存するデータ + file_path: 保存先ファイルパス + + Returns: + 保存に成功した場合はTrue、失敗した場合はFalse + """ + try: + os.makedirs(os.path.dirname(str(file_path)), exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"Saved data to {file_path}") + return True + except Exception as e: + logger.error(f"Error saving to {file_path}: {e}") + return False + + +def ensure_directory(directory_path: Union[str, Path]) -> bool: + """ + ディレクトリが存在することを確認し、存在しない場合は作成する + + Args: + directory_path: 確認/作成するディレクトリパス + + Returns: + ディレクトリが存在する(または作成された)場合はTrue、失敗した場合はFalse + """ + try: + os.makedirs(str(directory_path), exist_ok=True) + return True + except Exception as e: + logger.error(f"Error creating directory {directory_path}: {e}") + return False + + +def get_file_extension(file_path: Union[str, Path]) -> str: + """ + ファイルの拡張子を取得する + + Args: + file_path: ファイルパス + + Returns: + ファイルの拡張子(ドットを含む) + """ + return os.path.splitext(str(file_path))[1].lower() + + +def list_files( + directory_path: Union[str, Path], + pattern: str = "*", + recursive: bool = False +) -> List[Path]: + """ + ディレクトリ内のファイルを一覧表示する + + Args: + directory_path: 検索するディレクトリパス + pattern: 検索パターン(glob形式) + recursive: サブディレクトリも検索するかどうか + + Returns: + ファイルパスのリスト + """ + path = Path(directory_path) + if recursive: + return list(path.glob(f"**/{pattern}")) + else: + return list(path.glob(pattern)) + + +def get_latest_file( + directory_path: Union[str, Path], + pattern: str = "*" +) -> Optional[Path]: + """ + ディレクトリ内の最新のファイルを取得する + + Args: + directory_path: 検索するディレクトリパス + pattern: 検索パターン(glob形式) + + Returns: + 最新のファイルパス、ファイルが見つからない場合はNone + """ + files = list_files(directory_path, pattern) + if not files: + return None + + return max(files, key=lambda f: f.stat().st_mtime) From 71bc2e4b236f818a36ee9fe64495f6f97ac858e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 May 2025 01:44:17 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88:=20=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E6=A7=8B=E9=80=A0=E3=81=AE=E8=A9=B3=E7=B4=B0=E3=81=AA?= =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: NISHIO Hirokazu --- pr-analysis/README.md | 156 +++++++++++++----- .../src/pr_analysis/analyzer/README.md | 45 +++++ pr-analysis/src/pr_analysis/api/README.md | 31 ++++ pr-analysis/src/pr_analysis/cli/README.md | 43 +++++ pr-analysis/src/pr_analysis/fetcher/README.md | 35 ++++ .../src/pr_analysis/reporter/README.md | 62 +++++++ pr-analysis/src/pr_analysis/utils/README.md | 46 ++++++ 7 files changed, 373 insertions(+), 45 deletions(-) create mode 100644 pr-analysis/src/pr_analysis/analyzer/README.md create mode 100644 pr-analysis/src/pr_analysis/api/README.md create mode 100644 pr-analysis/src/pr_analysis/cli/README.md create mode 100644 pr-analysis/src/pr_analysis/fetcher/README.md create mode 100644 pr-analysis/src/pr_analysis/reporter/README.md create mode 100644 pr-analysis/src/pr_analysis/utils/README.md diff --git a/pr-analysis/README.md b/pr-analysis/README.md index 324d1cc..738ef0c 100644 --- a/pr-analysis/README.md +++ b/pr-analysis/README.md @@ -1,60 +1,136 @@ -# PR Analysis Tool +# PR分析ツール -GitHub Pull Requestを分析するためのツールです。PRの内容を取得し、分析レポートを生成します。 +GitHub Pull Requestデータを取得、分析、レポート生成するためのモジュール化されたツールセットです。 ## 機能 - GitHub APIを使用したPRデータの取得 -- PRデータの分析と統計情報の生成 -- マークダウン形式のレポート生成 -- CSVへのデータエクスポート -- コンテンツの自動分類 +- PRデータの分析と分類 +- 様々な形式(マークダウン、CSV)でのレポート生成 +- コマンドラインインターフェース ## インストール ```bash -pip install pr-analysis +# リポジトリをクローン +git clone https://github.com/team-mirai/random.git +cd random/pr-analysis + +# 依存関係のインストール +pip install -e . ``` -または、ソースからインストール: +## 使用方法 + +### コマンドラインから + +#### PRデータの取得 ```bash -git clone https://github.com/team-mirai/pr-analysis.git -cd pr-analysis -pip install -e . +python -m pr_analysis.cli.fetcher --owner <リポジトリオーナー> --repo <リポジトリ名> --output-dir <出力ディレクトリ> ``` -## 使用方法 +オプション: +- `--limit`: 取得するPRの最大数 +- `--state`: PRの状態(open, closed, all) +- `--sort-by`: ソート基準(created, updated, popularity, long-running) +- `--direction`: ソート方向(asc, desc) +- `--no-comments`: コメントを含めない +- `--no-review-comments`: レビューコメントを含めない +- `--no-commits`: コミット情報を含めない +- `--no-files`: 変更ファイル情報を含めない +- `--no-labels`: ラベル情報を含めない -### コマンドライン +#### PRデータの分析 ```bash -# PRデータを取得 -pr-fetcher --owner team-mirai --repo policy --output prs_data.json +python -m pr_analysis.cli.analyzer --input <入力JSONファイル> --owner <リポジトリオーナー> --repo <リポジトリ名> --output <出力ファイル> +``` -# PRデータを分析してレポートを生成 -pr-analyzer --input prs_data.json --output report.md +#### レポート生成 -# CSVにエクスポート -pr-reporter --input prs_data.json --format csv --output prs_data.csv +```bash +python -m pr_analysis.cli.reporter --input <入力JSONファイル> --format <レポート形式> --output <出力ファイル> ``` -### Pythonコード +レポート形式: +- `markdown`: 詳細なマークダウンレポート +- `summary`: サマリーマークダウン +- `issues`: Issues内容と変更差分マークダウン +- `files`: ファイルごとのマークダウン +- `csv`: CSV形式 +- `id_comment`: ID-コメントのみのCSV +- `stats`: 統計情報CSV + +### Pythonコードから ```python from pr_analysis.fetcher import fetch_prs from pr_analysis.analyzer import analyze_prs -from pr_analysis.reporter import generate_report - -# PRデータを取得 -prs_data = fetch_prs(owner="team-mirai", repo="policy") +from pr_analysis.reporter import generate_markdown + +# PRデータの取得 +prs_data = fetch_prs( + repo_owner="owner", + repo_name="repo", + limit=100, + state="all" +) + +# PRデータの分析 +analyzed_data = analyze_prs( + prs_data=prs_data, + repo_owner="owner", + repo_name="repo" +) + +# レポート生成 +generate_markdown(analyzed_data, "report.md") +``` -# PRデータを分析 -analysis_results = analyze_prs(prs_data) +## モジュール構造 -# レポートを生成 -generate_report(analysis_results, output_format="markdown", output_file="report.md") ``` +pr-analysis/ +├── src/ +│ ├── pr_analysis/ +│ │ ├── __init__.py +│ │ ├── api/ # GitHub API操作 +│ │ ├── fetcher/ # PRデータ取得 +│ │ ├── analyzer/ # データ分析 +│ │ ├── reporter/ # レポート生成 +│ │ ├── cli/ # コマンドラインインターフェース +│ │ └── utils/ # 共通ユーティリティ +├── tests/ # テストコード +├── pyproject.toml # プロジェクト設定 +└── README.md # ドキュメント +``` + +## 各モジュールの説明 + +### api + +GitHub APIとの通信を担当するモジュールです。認証、レート制限の処理、APIエンドポイントへのアクセスを提供します。 + +### fetcher + +GitHub PRデータを取得するためのモジュールです。増分更新、並列処理、様々な取得モードをサポートしています。 + +### analyzer + +PRデータを分析するためのモジュールです。コンテンツの分類、統計情報の計算、パターン検出などの機能を提供します。 + +### reporter + +分析結果をさまざまな形式(マークダウン、CSV)でレポート生成するためのモジュールです。 + +### cli + +コマンドラインインターフェースを提供するモジュールです。各機能へのアクセスを簡素化します。 + +### utils + +ファイル操作、日付処理などの共通ユーティリティ関数を提供するモジュールです。 ## 開発 @@ -68,24 +144,14 @@ pip install -e ".[dev]" pytest ``` -### プロジェクト構造 +## 貢献 -``` -pr-analysis/ -├── src/ -│ ├── pr_analysis/ -│ │ ├── __init__.py -│ │ ├── api/ # GitHub API操作 -│ │ ├── fetcher/ # PRデータ取得 -│ │ ├── analyzer/ # データ分析 -│ │ ├── reporter/ # レポート生成 -│ │ └── utils/ # 共通ユーティリティ -├── tests/ # テストコード -├── scripts/ # コマンドラインスクリプト -├── docs/ # ドキュメント -└── examples/ # 使用例 -``` +1. リポジトリをフォーク +2. 機能ブランチを作成 (`git checkout -b feature/amazing-feature`) +3. 変更をコミット (`git commit -m 'Add some amazing feature'`) +4. ブランチをプッシュ (`git push origin feature/amazing-feature`) +5. Pull Requestを作成 ## ライセンス -MIT +このプロジェクトはMITライセンスの下で公開されています。 diff --git a/pr-analysis/src/pr_analysis/analyzer/README.md b/pr-analysis/src/pr_analysis/analyzer/README.md new file mode 100644 index 0000000..15c1b68 --- /dev/null +++ b/pr-analysis/src/pr_analysis/analyzer/README.md @@ -0,0 +1,45 @@ +# Analyzer モジュール + +PRデータを分析するためのモジュールです。 + +## 主な機能 + +- コンテンツの分類 +- 統計情報の計算 +- パターン検出 +- 傾向分析 + +## 主要コンポーネント + +### content_classifier.py + +PRの内容を分類するための関数を提供します。 + +```python +from pr_analysis.analyzer.content_classifier import classify_content, is_readme_pr + +# コンテンツの分類 +category = classify_content(pr_title, pr_body, files) + +# READMEに関するPRかどうかの判定 +is_readme = is_readme_pr(files) +``` + +### pr_analyzer.py + +PRデータを分析するための関数を提供します。 + +```python +from pr_analysis.analyzer.pr_analyzer import analyze_prs + +# PRデータの分析 +analysis_results = analyze_prs( + prs_data=prs_data, + repo_owner="team-mirai", + repo_name="random" +) +``` + +## 依存関係 + +- pr_analysis.utils: ファイル操作、日付処理 diff --git a/pr-analysis/src/pr_analysis/api/README.md b/pr-analysis/src/pr_analysis/api/README.md new file mode 100644 index 0000000..41b1ca4 --- /dev/null +++ b/pr-analysis/src/pr_analysis/api/README.md @@ -0,0 +1,31 @@ +# API モジュール + +GitHub APIとの通信を担当するモジュールです。 + +## 主な機能 + +- GitHub APIへの認証 +- レート制限の処理 +- APIエンドポイントへのアクセス +- エラーハンドリング + +## 主要コンポーネント + +### github.py + +GitHub APIとの通信を行うクラスと関数を提供します。 + +```python +from pr_analysis.api.github import GitHubClient + +# クライアントの初期化 +client = GitHubClient(token="your_github_token") + +# PRデータの取得 +pr_data = client.get_pull_request(owner="team-mirai", repo="random", pr_number=123) +``` + +## 依存関係 + +- requests: HTTPリクエスト +- backoff: レート制限時の再試行 diff --git a/pr-analysis/src/pr_analysis/cli/README.md b/pr-analysis/src/pr_analysis/cli/README.md new file mode 100644 index 0000000..bfd9def --- /dev/null +++ b/pr-analysis/src/pr_analysis/cli/README.md @@ -0,0 +1,43 @@ +# CLI モジュール + +コマンドラインインターフェースを提供するモジュールです。 + +## 主な機能 + +- コマンドライン引数の解析 +- 各機能へのアクセス +- ヘルプメッセージの表示 +- エラーハンドリング + +## 主要コンポーネント + +### fetcher.py + +PRデータ取得のためのCLIを提供します。 + +```bash +python -m pr_analysis.cli.fetcher --owner team-mirai --repo random --output-dir ./data +``` + +### analyzer.py + +PRデータ分析のためのCLIを提供します。 + +```bash +python -m pr_analysis.cli.analyzer --input ./data/prs_data.json --owner team-mirai --repo random --output ./data/analysis_results.json +``` + +### reporter.py + +レポート生成のためのCLIを提供します。 + +```bash +python -m pr_analysis.cli.reporter --input ./data/prs_data.json --format markdown --output ./reports/report.md +``` + +## 依存関係 + +- argparse: コマンドライン引数の解析 +- pr_analysis.fetcher: PRデータ取得 +- pr_analysis.analyzer: PRデータ分析 +- pr_analysis.reporter: レポート生成 diff --git a/pr-analysis/src/pr_analysis/fetcher/README.md b/pr-analysis/src/pr_analysis/fetcher/README.md new file mode 100644 index 0000000..e615bb7 --- /dev/null +++ b/pr-analysis/src/pr_analysis/fetcher/README.md @@ -0,0 +1,35 @@ +# Fetcher モジュール + +GitHub PRデータを取得するためのモジュールです。 + +## 主な機能 + +- PRデータの取得 +- 増分更新 +- 並列処理 +- 様々な取得モード(最新、人気、長期実行中など) + +## 主要コンポーネント + +### pr_fetcher.py + +PRデータを取得するための関数を提供します。 + +```python +from pr_analysis.fetcher.pr_fetcher import fetch_prs + +# PRデータの取得 +prs_data = fetch_prs( + repo_owner="team-mirai", + repo_name="random", + limit=100, + state="all", + sort_by="updated", + direction="desc" +) +``` + +## 依存関係 + +- pr_analysis.api: GitHub API操作 +- pr_analysis.utils: ファイル操作、日付処理 diff --git a/pr-analysis/src/pr_analysis/reporter/README.md b/pr-analysis/src/pr_analysis/reporter/README.md new file mode 100644 index 0000000..3be0bd6 --- /dev/null +++ b/pr-analysis/src/pr_analysis/reporter/README.md @@ -0,0 +1,62 @@ +# Reporter モジュール + +分析結果をさまざまな形式でレポート生成するためのモジュールです。 + +## 主な機能 + +- マークダウンレポート生成 +- CSVエクスポート +- 統計情報レポート +- ファイルごとのレポート + +## 主要コンポーネント + +### markdown_generator.py + +マークダウン形式のレポートを生成するための関数を提供します。 + +```python +from pr_analysis.reporter.markdown_generator import ( + generate_markdown, + generate_summary_markdown, + generate_issues_and_diffs_markdown, + generate_file_based_markdown +) + +# 詳細なマークダウンレポートの生成 +generate_markdown(prs_data, "report.md") + +# サマリーマークダウンの生成 +generate_summary_markdown(prs_data, "summary.md") + +# Issues内容と変更差分マークダウンの生成 +generate_issues_and_diffs_markdown(prs_data, "issues_diffs.md") + +# ファイルごとのマークダウンの生成 +generate_file_based_markdown(prs_data, "files_dir") +``` + +### csv_generator.py + +CSV形式のレポートを生成するための関数を提供します。 + +```python +from pr_analysis.reporter.csv_generator import ( + generate_csv, + generate_id_comment_csv, + generate_stats_csv +) + +# 詳細なCSVレポートの生成 +generate_csv(prs_data, "report.csv") + +# ID-コメントのみのCSVの生成 +generate_id_comment_csv(prs_data, "id_comment.csv") + +# 統計情報CSVの生成 +generate_stats_csv(analysis_results, "stats.csv") +``` + +## 依存関係 + +- pr_analysis.utils: ファイル操作、日付処理 diff --git a/pr-analysis/src/pr_analysis/utils/README.md b/pr-analysis/src/pr_analysis/utils/README.md new file mode 100644 index 0000000..da3c99a --- /dev/null +++ b/pr-analysis/src/pr_analysis/utils/README.md @@ -0,0 +1,46 @@ +# Utils モジュール + +共通ユーティリティ関数を提供するモジュールです。 + +## 主な機能 + +- ファイル操作 +- 日付処理 +- データ変換 +- ヘルパー関数 + +## 主要コンポーネント + +### file_utils.py + +ファイル操作に関する関数を提供します。 + +```python +from pr_analysis.utils.file_utils import load_json_file, save_json_file + +# JSONファイルの読み込み +data = load_json_file("input.json") + +# JSONファイルの保存 +save_json_file(data, "output.json") +``` + +### date_utils.py + +日付処理に関する関数を提供します。 + +```python +from pr_analysis.utils.date_utils import format_date, calculate_duration + +# 日付のフォーマット +formatted_date = format_date("2023-01-01T00:00:00Z") + +# 期間の計算 +duration = calculate_duration("2023-01-01T00:00:00Z", "2023-01-02T00:00:00Z") +``` + +## 依存関係 + +- datetime: 日付処理 +- json: JSONデータ処理 +- pathlib: パス操作